Skip to content

Commit 1402b5f

Browse files
committed
feat(perms): refine the permission model
1 parent 2e196bf commit 1402b5f

23 files changed

Lines changed: 368 additions & 272 deletions

rootfs/api/consumers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from channels.generic.websocket import AsyncWebsocketConsumer
2121

2222
from .models.app import App
23-
from .permissions import has_object_permission
23+
from .permissions import has_app_permission
2424

2525

2626
class BaseAppConsumer(AsyncWebsocketConsumer):
@@ -35,7 +35,7 @@ def has_perm(self):
3535
if permission is None:
3636
try:
3737
app = App.objects.get(id=self.id)
38-
permission = has_object_permission(self.scope["user"], app, "GET")
38+
permission = has_app_permission(self.scope["user"], app, "GET")
3939
if permission[0]:
4040
cache.set(key, permission, timeout=self.timeout)
4141
except App.DoesNotExist:

rootfs/api/migrations/0010_alter_certificate_options.py

Lines changed: 0 additions & 17 deletions
This file was deleted.

rootfs/api/migrations/0012_appsettings_autodeploy.py renamed to rootfs/api/migrations/0011_appsettings_autodeploy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def migration_dryccfile(apps, schema_editor):
1414
class Migration(migrations.Migration):
1515

1616
dependencies = [
17-
('api', '0011_merge_20240815_0955'),
17+
('api', '0010_appsettings_autorollback'),
1818
]
1919

2020
operations = [

rootfs/api/migrations/0011_merge_20240815_0955.py

Lines changed: 0 additions & 14 deletions
This file was deleted.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Generated by Django 4.2.15 on 2024-08-30 00:53
2+
3+
import api.utils
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
from guardian.shortcuts import assign_perm, get_users_with_perms, remove_perm
7+
from api.models.app import App, VIEW_APP_PERMISSION, CHANGE_APP_PERMISSION
8+
from api.models.domain import Domain
9+
10+
11+
def migration_permission(apps, schema_editor):
12+
for app in App.objects.all():
13+
for user in get_users_with_perms(app):
14+
remove_perm('use_app', user, app)
15+
assign_perm(VIEW_APP_PERMISSION.codename, user, app)
16+
assign_perm(CHANGE_APP_PERMISSION.codename, user, app)
17+
18+
19+
def migration_certificate(apps, schema_editor):
20+
for domain in Domain.objects.all():
21+
if domain.certificate:
22+
if domain.certificate.app == None:
23+
domain.certificate.app = domain.app
24+
domain.certificate.save()
25+
else:
26+
certificate = domain.certificate
27+
certificate.pk = None
28+
certificate.app = domain.app
29+
certificate.save()
30+
domain.certificate = certificate
31+
domain.save()
32+
33+
34+
class Migration(migrations.Migration):
35+
36+
dependencies = [
37+
('api', '0011_appsettings_autodeploy'),
38+
]
39+
40+
operations = [
41+
migrations.AlterModelOptions(
42+
name='app',
43+
options={'ordering': ['id'], 'verbose_name': 'Application'},
44+
),
45+
migrations.AddField(
46+
model_name='certificate',
47+
name='app',
48+
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to='api.app'),
49+
preserve_default=False,
50+
),
51+
migrations.AddField(
52+
model_name='release',
53+
name='conditions',
54+
field=models.JSONField(default=list),
55+
),
56+
migrations.AlterField(
57+
model_name='certificate',
58+
name='name',
59+
field=models.CharField(max_length=253, validators=[api.utils.validate_label]),
60+
),
61+
migrations.AlterUniqueTogether(
62+
name='certificate',
63+
unique_together={('app', 'name')},
64+
),
65+
migrations.RunPython(migration_permission),
66+
migrations.RunPython(migration_certificate),
67+
]

rootfs/api/models/app.py

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import socket
1111
from contextlib import closing
1212
from urllib.parse import urljoin
13-
from collections import OrderedDict
13+
from collections import OrderedDict, namedtuple
1414
from datetime import datetime, timezone
1515

1616
from docker import auth as docker_auth
@@ -31,12 +31,51 @@
3131
from .tls import TLS
3232
from .appsettings import AppSettings
3333
from .volume import Volume
34-
from .base import UuidAuditedModel, PROCFILE_TYPE_WEB, PROCFILE_TYPE_RUN, DEFAULT_HTTP_PORT, \
35-
ObjectPolicy, object_policy_registry
34+
from .base import UuidAuditedModel, PROCFILE_TYPE_WEB, PROCFILE_TYPE_RUN, DEFAULT_HTTP_PORT
3635

3736
User = get_user_model()
3837
logger = logging.getLogger(__name__)
39-
app_policy = ObjectPolicy('id', 'use_app', 'Can use app') # query field, policy, description
38+
AppPermission = namedtuple('AppPermission', ['shortname', 'codename', 'description'])
39+
VIEW_APP_PERMISSION = AppPermission("view", "view_app", "can view app")
40+
CHANGE_APP_PERMISSION = AppPermission("change", "change_app", "can change app")
41+
DELETE_APP_PERMISSION = AppPermission("delete", "delete_app", "can delete app")
42+
43+
44+
class AppPermissionRegistry(object):
45+
46+
def __init__(self):
47+
self.tags = {}
48+
self.permissions = set()
49+
50+
def get(self, q):
51+
permissions = [
52+
permission for permission in self.permissions
53+
if q == permission.shortname or q == permission.codename
54+
]
55+
permissions.extend([
56+
permission for permission in self.permissions
57+
if permission in self.tags and q in self.tags[permission]
58+
])
59+
return permissions[0] if permissions else None
60+
61+
def register(self, permission, tags=None):
62+
if tags:
63+
self.tags[permission] = tags
64+
self.permissions.add(permission)
65+
66+
@property
67+
def codenames(self):
68+
return [permission[1] for permission in self.permissions]
69+
70+
@property
71+
def shortnames(self):
72+
return [permission[0] for permission in self.permissions]
73+
74+
75+
app_permission_registry = AppPermissionRegistry()
76+
app_permission_registry.register(VIEW_APP_PERMISSION, ["GET", "HEAD", "OPTION"])
77+
app_permission_registry.register(CHANGE_APP_PERMISSION, ["POST", "PUT", "PATCH"])
78+
app_permission_registry.register(DELETE_APP_PERMISSION, ["DELETE"])
4079

4180

4281
# http://kubernetes.io/v1.1/docs/design/identifiers.html
@@ -79,7 +118,6 @@ class App(UuidAuditedModel):
79118

80119
class Meta:
81120
verbose_name = 'Application'
82-
permissions = (app_policy[1:], )
83121
ordering = ['id']
84122

85123
def save(self, *args, **kwargs):
@@ -1168,7 +1206,3 @@ def _gather_app_settings(self, release, app_settings, procfile_type, replicas, v
11681206
'pod_security_context': limit_plan.pod_security_context,
11691207
'container_security_context': limit_plan.container_security_context,
11701208
}
1171-
1172-
1173-
# Register policy
1174-
object_policy_registry.register(App, app_policy)

rootfs/api/models/base.py

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import importlib
55
from datetime import timedelta
66
from functools import partial
7-
from collections import namedtuple
87
from django.db import models
98
from django.conf import settings
109
from django.utils import timezone
@@ -39,38 +38,6 @@
3938
def get_anonymous_user_instance(user): return user(id=-1, username=settings.ANONYMOUS_USER_NAME)
4039

4140

42-
ObjectPolicy = namedtuple('ObjectPolicy', ['unique', 'codename', 'description'])
43-
44-
45-
class ObjectPolicyRegistry(object):
46-
47-
def __init__(self):
48-
self.object_permission_policy_table = dict[type, ObjectPolicy]()
49-
50-
def all(self) -> list[type, ObjectPolicy]:
51-
return self.object_permission_policy_table.items()
52-
53-
def get(self, query) -> tuple[type, ObjectPolicy]:
54-
if isinstance(query, str):
55-
for key, value in self.object_permission_policy_table.items():
56-
if value.codename == query:
57-
return key, value
58-
elif isinstance(query, models.Model):
59-
query = type(query)
60-
value = self.object_permission_policy_table.get(query)
61-
return query if value else None, value
62-
elif isinstance(query, type):
63-
value = self.object_permission_policy_table.get(query)
64-
return query if value else None, value
65-
return None, None
66-
67-
def register(self, cls: type, object_policy: ObjectPolicy) -> None:
68-
self.object_permission_policy_table[cls] = object_policy
69-
70-
71-
object_policy_registry = ObjectPolicyRegistry()
72-
73-
7441
class AuditedModel(models.Model):
7542
"""Add created and updated fields to a model."""
7643

rootfs/api/models/certificate.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,11 @@
1515
from api.utils import validate_label
1616
from api.exceptions import AlreadyExists, ServiceUnavailable
1717
from scheduler import KubeException
18-
from .base import AuditedModel, ObjectPolicy, object_policy_registry
18+
from .base import AuditedModel
1919
from .domain import Domain
2020

2121
User = get_user_model()
2222
logger = logging.getLogger(__name__)
23-
cert_policy = ObjectPolicy('name', 'use_cert', 'Can use cert')
2423

2524

2625
# Note: This is a slightly bug-fixed version of same from ndg-httpsclient.
@@ -104,7 +103,8 @@ class Certificate(AuditedModel):
104103
Public and private key pair used to secure application traffic at the router.
105104
"""
106105
owner = models.ForeignKey(User, on_delete=models.PROTECT)
107-
name = models.CharField(max_length=253, unique=True, validators=[validate_label])
106+
app = models.ForeignKey('App', on_delete=models.CASCADE)
107+
name = models.CharField(max_length=253, validators=[validate_label])
108108
# there is no upper limit on the size of an x.509 certificate
109109
certificate = models.TextField(validators=[validate_certificate])
110110
key = models.TextField(validators=[validate_private_key])
@@ -121,8 +121,8 @@ class Certificate(AuditedModel):
121121
subject = models.TextField(editable=False)
122122

123123
class Meta:
124-
permissions = (cert_policy[1:], )
125124
ordering = ['name', 'common_name', 'expires']
125+
unique_together = ('app', 'name')
126126

127127
@property
128128
def domains(self):
@@ -240,7 +240,3 @@ def detach(self, *args, **kwargs):
240240
raise ServiceUnavailable(
241241
"Could not delete certificate secret {} for application {}".format(
242242
self.certname, namespace)) from e
243-
244-
245-
# Register policy
246-
object_policy_registry.register(Certificate, cert_policy)

rootfs/api/permissions.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from api import manager
66
from api.models import app
77
from api.models import blocklist
8-
from api.models.base import object_policy_registry
98

109

1110
def get_app_status(app):
@@ -19,20 +18,20 @@ def get_app_status(app):
1918
return True, None
2019

2120

22-
def has_object_permission(user, obj, method):
21+
def has_app_permission(user, obj, method):
2322
obj = getattr(obj, 'app', obj)
24-
object_policy = object_policy_registry.get(obj)[1]
2523
has_permission, message = False, f"{obj} object does not exist or does not have permission."
2624
if user.is_superuser:
2725
has_permission, message = True, None
2826
elif getattr(obj, "owner", None) == user:
2927
has_permission, message = True, None
30-
elif user.is_staff or (object_policy and user.has_perm(object_policy.codename, obj)):
31-
if method != 'DELETE':
28+
elif user.is_staff:
29+
has_permission, message = True, None
30+
else:
31+
permission = app.app_permission_registry.get(method)
32+
if permission and user.has_perm(permission.codename, obj):
3233
has_permission, message = True, None
33-
else:
34-
has_permission, message = False, "{user} does not have permission to delete."
35-
elif has_permission and isinstance(obj, app.App):
34+
if has_permission and isinstance(obj, app.App):
3635
return get_app_status(obj)
3736
return has_permission, message
3837

@@ -82,7 +81,7 @@ class IsObjectUser(permissions.BasePermission):
8281
an app-related model.
8382
"""
8483
def has_object_permission(self, request, view, obj):
85-
return has_object_permission(request.user, obj, request.method)[0]
84+
return has_app_permission(request.user, obj, request.method)[0]
8685

8786

8887
class IsAdmin(permissions.BasePermission):

rootfs/api/serializers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,7 @@ def validate_target_port(cls, value):
506506
class CertificateSerializer(serializers.ModelSerializer):
507507
"""Serialize a :class:`~api.models.certificate.Certificate` model."""
508508

509+
app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
509510
owner = serializers.ReadOnlyField(source='owner.username')
510511
domains = serializers.ReadOnlyField()
511512
san = serializers.ListField(

0 commit comments

Comments
 (0)