From a2d208267de650bc6c127f55a1bcfac35efeec37 Mon Sep 17 00:00:00 2001 From: duanhongyi Date: Tue, 17 Mar 2026 08:28:48 +0800 Subject: [PATCH] feat(workspace): migrate controller from owner-based auth to workspace model --- rootfs/api/admin.py | 14 - rootfs/api/consumers.py | 11 +- rootfs/api/exceptions.py | 2 + .../commands/upload_network_usage.py | 2 +- .../commands/upload_volume_usage.py | 2 +- rootfs/api/manager.py | 16 +- ..._migration_permissions_and_certificates.py | 12 - ...owner_remove_appsettings_owner_and_more.py | 119 +++++ rootfs/api/models/app.py | 74 +-- rootfs/api/models/appsettings.py | 11 +- rootfs/api/models/base.py | 9 +- rootfs/api/models/blocklist.py | 14 +- rootfs/api/models/build.py | 8 +- rootfs/api/models/certificate.py | 1 - rootfs/api/models/config.py | 1 - rootfs/api/models/domain.py | 1 - rootfs/api/models/gateway.py | 6 +- rootfs/api/models/key.py | 3 + rootfs/api/models/limit.py | 3 + rootfs/api/models/release.py | 25 +- rootfs/api/models/resource.py | 3 +- rootfs/api/models/service.py | 1 - rootfs/api/models/tls.py | 5 +- rootfs/api/models/volume.py | 3 +- rootfs/api/models/workspace.py | 108 ++++ rootfs/api/permissions.py | 98 +--- rootfs/api/serializers/__init__.py | 65 ++- rootfs/api/settings/celery.py | 4 - rootfs/api/settings/production.py | 9 +- rootfs/api/signals.py | 11 +- rootfs/api/tasks.py | 24 +- .../workspace/workspace_invitation.html | 7 + rootfs/api/tests/__init__.py | 49 +- rootfs/api/tests/test_app.py | 159 +++--- rootfs/api/tests/test_build.py | 3 +- rootfs/api/tests/test_certificate.py | 7 +- .../api/tests/test_certificate_use_case_1.py | 6 +- .../api/tests/test_certificate_use_case_2.py | 8 +- .../api/tests/test_certificate_use_case_3.py | 8 +- .../api/tests/test_certificate_use_case_4.py | 10 +- .../api/tests/test_certificate_use_case_5.py | 10 +- rootfs/api/tests/test_config.py | 27 +- rootfs/api/tests/test_consumers.py | 1 - rootfs/api/tests/test_domain.py | 3 +- rootfs/api/tests/test_event.py | 6 +- rootfs/api/tests/test_gateway.py | 12 +- rootfs/api/tests/test_hooks.py | 10 - rootfs/api/tests/test_perm.py | 406 +++++--------- rootfs/api/tests/test_pods.py | 18 +- rootfs/api/tests/test_ptypes.py | 6 +- rootfs/api/tests/test_release.py | 12 +- rootfs/api/tests/test_resource.py | 3 +- rootfs/api/tests/test_users.py | 47 -- rootfs/api/tests/test_volume.py | 4 +- rootfs/api/tests/test_workflow_manager.py | 6 +- rootfs/api/tests/test_workspace.py | 166 ++++++ rootfs/api/urls.py | 63 +-- rootfs/api/utils.py | 5 + rootfs/api/views.py | 496 ++++++++++-------- rootfs/api/viewsets.py | 34 +- rootfs/requirements.txt | 5 +- 61 files changed, 1236 insertions(+), 1026 deletions(-) create mode 100644 rootfs/api/migrations/0028_workspace_remove_app_owner_remove_appsettings_owner_and_more.py create mode 100644 rootfs/api/models/workspace.py create mode 100644 rootfs/api/templates/workspace/workspace_invitation.html delete mode 100644 rootfs/api/tests/test_users.py create mode 100644 rootfs/api/tests/test_workspace.py diff --git a/rootfs/api/admin.py b/rootfs/api/admin.py index c3710a944..199161af6 100644 --- a/rootfs/api/admin.py +++ b/rootfs/api/admin.py @@ -6,9 +6,7 @@ from django.contrib import admin -from guardian.admin import GuardedModelAdmin -from .models import App from .models import Build from .models import Config from .models import Domain @@ -16,18 +14,6 @@ from .models import Release -class AppAdmin(GuardedModelAdmin): - """Set presentation options for :class:`~api.models.App` models - in the Django admin. - """ - date_hierarchy = 'created' - list_display = ('id', 'owner') - list_filter = ('owner',) - - -admin.site.register(App, AppAdmin) - - class BuildAdmin(admin.ModelAdmin): """Set presentation options for :class:`~api.models.Build` models in the Django admin. diff --git a/rootfs/api/consumers.py b/rootfs/api/consumers.py index e3995d771..47d680a83 100644 --- a/rootfs/api/consumers.py +++ b/rootfs/api/consumers.py @@ -4,6 +4,7 @@ import ssl import aiohttp import asyncio +from collections import namedtuple from urllib.parse import urljoin from django.conf import settings from django.core.cache import cache @@ -22,11 +23,12 @@ from .models.app import App from .models.volume import Volume -from .permissions import has_app_permission +from .permissions import IsAppUser, get_app_status class AppPermChecker(object): timeout = 60 * 60 + check_permission = IsAppUser().has_object_permission def __init__(self, scope): self.scope = scope @@ -40,8 +42,11 @@ async def has_perm(self): if permission is None: try: app = await App.objects.aget(id=app_id) - permission = await sync_to_async(has_app_permission)( - self.scope["user"], app, "GET") + request = namedtuple("Request", ["user", "method"])(self.scope["user"], "GET") + if await sync_to_async(self.check_permission)(request, None, app): + permission = await sync_to_async(get_app_status)(app) + else: + permission = (False, "permission denied") if permission[0]: await cache.aset(key, permission, timeout=self.timeout) except App.DoesNotExist: diff --git a/rootfs/api/exceptions.py b/rootfs/api/exceptions.py index bf6ce99c5..38db7b9b9 100644 --- a/rootfs/api/exceptions.py +++ b/rootfs/api/exceptions.py @@ -42,6 +42,8 @@ def custom_exception_handler(exc, context): # No response means DRF couldn't handle it # Output a generic 500 in a JSON format if response is None: + import traceback + traceback.print_exc() logging.exception('Uncaught Exception', exc_info=exc) set_rollback() return Response({'detail': 'Server Error'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/rootfs/api/management/commands/upload_network_usage.py b/rootfs/api/management/commands/upload_network_usage.py index c24465655..fd80ea387 100644 --- a/rootfs/api/management/commands/upload_network_usage.py +++ b/rootfs/api/management/commands/upload_network_usage.py @@ -27,7 +27,7 @@ def _upload_network_usage(self, start_time, app_map, timestamp): pod_name = metric['pod'] networks.append({ "app_id": str(app_map[metric['namespace']].uuid), - "owner": app_map[metric['namespace']].owner_id, + "workspace": app_map[metric['namespace']].workspace_id, "type": "network", "unit": "bytes", "name": metric['direction'], diff --git a/rootfs/api/management/commands/upload_volume_usage.py b/rootfs/api/management/commands/upload_volume_usage.py index 1d4b80e57..9852e42ff 100644 --- a/rootfs/api/management/commands/upload_volume_usage.py +++ b/rootfs/api/management/commands/upload_volume_usage.py @@ -27,7 +27,7 @@ def _upload_volume_usage(self, start_time, app_map, timestamp): pvc_name = metric['persistentvolumeclaim'] volumes.append({ "app_id": str(app_map[metric['namespace']].uuid), - "owner": app_map[metric['namespace']].owner_id, + "workspace": app_map[metric['namespace']].workspace_id, "type": "volume", "unit": "bytes", "name": metric["storageclass"], diff --git a/rootfs/api/manager.py b/rootfs/api/manager.py index a7a178db2..53b22eb7f 100644 --- a/rootfs/api/manager.py +++ b/rootfs/api/manager.py @@ -54,23 +54,23 @@ def delete(self, url, **kwargs): return self.request('delete', url, **kwargs) -class UserAPI(ManagerAPI): +class WorkspaceAPI(ManagerAPI): - def get_status(self, id): + def get_status(self, workspace_id): """ { "is_active": False, "message": "The user is in arrears" } """ - key = f"user:status:{id}" + key = f"workspace:status:{workspace_id}" status = cache.get(key) if not status: - url = f"{settings.WORKFLOW_MANAGER_URL}/users/{id}/status/" + url = f"{settings.WORKFLOW_MANAGER_URL}/workspaces/{workspace_id}/status/" try: status = self.get(url=url, timeout=self.timeout).json() except requests.exceptions.Timeout as ex: - msg = f"request user {id} timeout, skipping verification." + msg = f"request workspace {workspace_id} timeout, skipping verification." status = {"is_active": True, "message": msg} logger.error(msg) logger.exception(ex) @@ -78,6 +78,10 @@ def get_status(self, id): return status +class UserAPI(WorkspaceAPI): + """Backward-compatible alias for legacy call sites.""" + + class UsageAPI(ManagerAPI): def post(self, usages: List[Dict[str, str]]): @@ -85,7 +89,7 @@ def post(self, usages: List[Dict[str, str]]): [ { "app_id": "test", - "owner": "test", + "workspace": "test", "name": "web", "type": "limits", "unit": "std1.large.c1m1", diff --git a/rootfs/api/migrations/0013_migration_permissions_and_certificates.py b/rootfs/api/migrations/0013_migration_permissions_and_certificates.py index f8704081b..63e87c4ab 100644 --- a/rootfs/api/migrations/0013_migration_permissions_and_certificates.py +++ b/rootfs/api/migrations/0013_migration_permissions_and_certificates.py @@ -1,21 +1,10 @@ # Generated by Django 4.2.15 on 2024-09-03 03:48 from django.db import migrations -from guardian.shortcuts import assign_perm, get_users_with_perms, remove_perm -from api.models.app import VIEW_APP_PERMISSION, CHANGE_APP_PERMISSION from api.models.domain import Domain from api.models.certificate import Certificate -def migration_permission(apps, schema_editor): - App = apps.get_model('api', 'App') - for app in App.objects.all(): - for user in get_users_with_perms(app): - remove_perm('use_app', user, app) - assign_perm(VIEW_APP_PERMISSION.codename, user, app) - assign_perm(CHANGE_APP_PERMISSION.codename, user, app) - - def migration_certificate(apps, schema_editor): for domain in Domain.objects.all(): if domain.certificate: @@ -38,6 +27,5 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RunPython(migration_permission), migrations.RunPython(migration_certificate), ] diff --git a/rootfs/api/migrations/0028_workspace_remove_app_owner_remove_appsettings_owner_and_more.py b/rootfs/api/migrations/0028_workspace_remove_app_owner_remove_appsettings_owner_and_more.py new file mode 100644 index 000000000..5dfb1ee61 --- /dev/null +++ b/rootfs/api/migrations/0028_workspace_remove_app_owner_remove_appsettings_owner_and_more.py @@ -0,0 +1,119 @@ +# Generated by Django 5.2.9 on 2026-03-23 05:42 + +import api.models.workspace +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0027_rename_lifecycle_post_start_config_lifecycle_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Workspace', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.SlugField(max_length=150, unique=True, validators=[api.models.workspace.validate_workspace_name], verbose_name='workspace name')), + ('email', models.EmailField(max_length=254, verbose_name='email address')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ], + ), + migrations.RemoveField( + model_name='app', + name='owner', + ), + migrations.RemoveField( + model_name='appsettings', + name='owner', + ), + migrations.RemoveField( + model_name='build', + name='owner', + ), + migrations.RemoveField( + model_name='certificate', + name='owner', + ), + migrations.RemoveField( + model_name='config', + name='owner', + ), + migrations.RemoveField( + model_name='domain', + name='owner', + ), + migrations.RemoveField( + model_name='gateway', + name='owner', + ), + migrations.RemoveField( + model_name='release', + name='owner', + ), + migrations.RemoveField( + model_name='route', + name='owner', + ), + migrations.RemoveField( + model_name='resource', + name='owner', + ), + migrations.RemoveField( + model_name='service', + name='owner', + ), + migrations.RemoveField( + model_name='tls', + name='owner', + ), + migrations.RemoveField( + model_name='volume', + name='owner', + ), + migrations.AlterField( + model_name='blocklist', + name='type', + field=models.PositiveIntegerField(choices=[(1, 'app'), (2, 'workspace')]), + ), + migrations.AddField( + model_name='app', + name='workspace', + field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to='api.workspace'), + preserve_default=False, + ), + migrations.CreateModel( + name='WorkspaceInvitation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, verbose_name='email address')), + ('token', models.CharField(max_length=128, unique=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('accepted', models.BooleanField(default=False, verbose_name='accepted')), + ('inviter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.workspace')), + ], + options={ + 'unique_together': {('email', 'workspace')}, + }, + ), + migrations.CreateModel( + name='WorkspaceMember', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('admin', 'Admin'), ('member', 'Member'), ('viewer', 'Viewer')], max_length=50)), + ('alerts', models.BooleanField(default=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.workspace')), + ], + options={ + 'unique_together': {('user', 'workspace')}, + }, + ), + ] diff --git a/rootfs/api/models/app.py b/rootfs/api/models/app.py index 8da9a0d8b..3e7914035 100644 --- a/rootfs/api/models/app.py +++ b/rootfs/api/models/app.py @@ -10,7 +10,7 @@ import socket from contextlib import closing from urllib.parse import urljoin -from collections import OrderedDict, namedtuple +from collections import OrderedDict from datetime import datetime, timezone from docker import auth as docker_auth @@ -38,47 +38,6 @@ User = get_user_model() logger = logging.getLogger(__name__) -AppPermission = namedtuple('AppPermission', ['shortname', 'codename', 'description']) -VIEW_APP_PERMISSION = AppPermission("view", "view_app", "can view app") -CHANGE_APP_PERMISSION = AppPermission("change", "change_app", "can change app") -DELETE_APP_PERMISSION = AppPermission("delete", "delete_app", "can delete app") - - -class AppPermissionRegistry(object): - - def __init__(self): - self.tags = {} - self.permissions = set() - - def get(self, q): - permissions = [ - permission for permission in self.permissions - if q == permission.shortname or q == permission.codename - ] - permissions.extend([ - permission for permission in self.permissions - if permission in self.tags and q in self.tags[permission] - ]) - return permissions[0] if permissions else None - - def register(self, permission, tags=None): - if tags: - self.tags[permission] = tags - self.permissions.add(permission) - - @property - def codenames(self): - return [permission[1] for permission in self.permissions] - - @property - def shortnames(self): - return [permission[0] for permission in self.permissions] - - -app_permission_registry = AppPermissionRegistry() -app_permission_registry.register(VIEW_APP_PERMISSION, ["GET", "HEAD", "OPTION"]) -app_permission_registry.register(CHANGE_APP_PERMISSION, ["POST", "PUT", "PATCH"]) -app_permission_registry.register(DELETE_APP_PERMISSION, ["DELETE"]) # http://kubernetes.io/v1.1/docs/design/identifiers.html @@ -109,9 +68,9 @@ class App(UuidAuditedModel): Application used to service requests on behalf of end-users """ - owner = models.ForeignKey(User, on_delete=models.PROTECT) id = models.SlugField(max_length=63, unique=True, null=True, validators=[validate_app_id]) + workspace = models.ForeignKey('Workspace', on_delete=models.CASCADE) structure = models.JSONField( default=dict, blank=True, validators=[validate_app_structure]) suspended_state = models.JSONField(default=dict, blank=True) @@ -150,6 +109,12 @@ def save(self, *args, **kwargs): def lock(self): return CacheLock(f"app:lock:{self.id}") + def _default_actor(self): + member = self.workspace.workspacemember_set.select_related('user').first() + if member: + return member.user + raise DryccException("workspace has no members") + @property def ptypes(self): return list(self.structure.keys()) @@ -162,7 +127,8 @@ def scheduler(self): directly reference using ID instead. """ scheduler = super(App, self).scheduler - scheduler.metadata["annotations"]["drycc.cc/project_id"] = str(self.id) + scheduler.metadata["annotations"]["drycc.cc/app_id"] = str(self.id) + scheduler.metadata["annotations"]["drycc.cc/workspace_id"] = str(self.workspace_id) return scheduler def check_ptypes(self, ptypes: set): @@ -201,7 +167,7 @@ def create(self, *args, **kwargs): # noqa self.release_set.latest() except Release.DoesNotExist: Release.objects.create( - version=1, owner=self.owner, app=self, + version=1, app=self, config=cfg, build=None ) @@ -220,11 +186,11 @@ def create(self, *args, **kwargs): # noqa self.appsettings_set.latest() except AppSettings.DoesNotExist: AppSettings.objects.create( - owner=self.owner, app=self, routable=True, autodeploy=True, autorollback=True) + app=self, routable=True, autodeploy=True, autorollback=True) try: self.tls_set.latest() except TLS.DoesNotExist: - TLS.objects.create(owner=self.owner, app=self) + TLS.objects.create(app=self) def delete(self, *args, **kwargs): """Delete this application including all containers""" @@ -297,7 +263,7 @@ def pipeline(self, release, ptypes, force_deploy=False): for run in release.get_runners(ptypes): self.log(f"{prefix} starts running pipeline.run {run['image']}") job_name = self.run( - self.owner, run['image'], command=run['command'], + self._default_actor(), run['image'], command=run['command'], args=run['args'], timeout=run['timeout'], expires=run['timeout'], envs=self._build_env_vars(release, run['ptype']), ) @@ -374,7 +340,7 @@ def clean(self, release=None, ptypes=None): if ptype not in release.ptypes and self.structure.get(ptype, 0) > 0: if ptypes is None or ptype in ptypes: removed[ptype] = 0 - self.scale(self.owner, removed) + self.scale(self._default_actor(), removed) self._merge_structure(release, pre_release) # clean k8s resources, ptype not in structure labels = {'heritage': 'drycc'} @@ -732,7 +698,7 @@ def to_usages(self, timestamp: float): plan = config.limits.get(ptype) usage.append({ "app_id": str(self.uuid), - "owner": self.owner_id, + "workspace": self.workspace_id, "name": plan, "type": "limits", "unit": "number", @@ -936,7 +902,7 @@ def _set_default_config(self): limits[PTYPE_WEB] = config.limits.get(PTYPE_WEB, plan.id) limits[PTYPE_RUN] = config.limits.get(PTYPE_RUN, plan.id) except Config.DoesNotExist: - config = Config.objects.create(owner=self.owner, app=self, limits=limits) + config = Config.objects.create(app=self, limits=limits) for ptype in self.ptypes: limits[ptype] = config.limits.get(ptype, plan.id) if limits != config.limits: @@ -949,7 +915,7 @@ def _create_default_ingress(self, target_port): try: service = self.service_set.filter(ptype=PTYPE_WEB).latest() except Service.DoesNotExist: - service = Service(owner=self.owner, app=self, ptype=PTYPE_WEB) + service = Service(app=self, ptype=PTYPE_WEB) service.add_port(DEFAULT_HTTP_PORT, "TCP", target_port) service.save() else: @@ -961,7 +927,7 @@ def _create_default_ingress(self, target_port): if gateway.change_default_tls(): gateway.save() except Gateway.DoesNotExist: - gateway = Gateway(app=self, owner=self.owner, name=self.id) + gateway = Gateway(app=self, name=self.id) added, msg = gateway.add(DEFAULT_HTTP_PORT, "HTTP") if not added: raise DryccException(msg) @@ -972,7 +938,7 @@ def _create_default_ingress(self, target_port): if route.change_default_tls(): route.save() except Route.DoesNotExist: - route = Route(app=self, owner=self.owner, kind="HTTPRoute", name=self.id, + route = Route(app=self, kind="HTTPRoute", name=self.id, rules=[{"backendRefs": [{"kind": "Service", "name": service.name, "port": DEFAULT_HTTP_PORT, "weight": 100}]}]) attached, msg = route.attach(gateway.name, DEFAULT_HTTP_PORT) diff --git a/rootfs/api/models/appsettings.py b/rootfs/api/models/appsettings.py index 4f9066afe..32c1da8f7 100644 --- a/rootfs/api/models/appsettings.py +++ b/rootfs/api/models/appsettings.py @@ -20,7 +20,6 @@ class AppSettings(UuidAuditedModel): Instance of Application settings used by scheduler """ - owner = models.ForeignKey(User, on_delete=models.PROTECT) app = models.ForeignKey('App', on_delete=models.CASCADE) routable = models.BooleanField(default=None) autodeploy = models.BooleanField(default=None) @@ -77,7 +76,9 @@ def _update_field(self, field, previous_settings): elif new is None and isinstance(self._meta.get_field(field), models.BooleanField): setattr(self, field, True) elif old != new: - self.summary += ["{} changed {} from {} to {}".format(self.owner, field, old, new)] + self.summary += [ + "{} changed {} from {} to {}".format(self.app.workspace.name, field, old, new) + ] def _update_autoscale(self, previous_settings): data = getattr(previous_settings, 'autoscale', {}).copy() @@ -117,7 +118,7 @@ def _update_autoscale(self, previous_settings): deleted = 'deleted autoscale for process type ' + deleted if deleted else '' changes = ', '.join(i for i in (added, changed, deleted) if i) if changes: - self.summary += ["{} {}".format(self.owner, changes)] + self.summary += ["{} {}".format(self.app.workspace.name, changes)] def _update_label(self, previous_settings): data = getattr(previous_settings, 'label', {}).copy() @@ -149,7 +150,7 @@ def _update_label(self, previous_settings): if changes: if self.summary: self.summary += ' and ' - self.summary += ["{} {}".format(self.owner, changes)] + self.summary += ["{} {}".format(self.app.workspace.name, changes)] @transaction.atomic def save(self, ignore_update_fields=None, *args, **kwargs): @@ -175,7 +176,7 @@ def save(self, ignore_update_fields=None, *args, **kwargs): if not self.summary and previous_settings: self.delete() - raise AlreadyExists("{} changed nothing".format(self.owner)) + raise AlreadyExists("{} changed nothing".format(self.app.workspace.name)) super(AppSettings, self).save(**kwargs) summary = ' '.join(self.summary) self.log('summary of app setting changes: {}'.format(summary), logging.DEBUG) diff --git a/rootfs/api/models/base.py b/rootfs/api/models/base.py index 67baa73c5..f3310d3dc 100644 --- a/rootfs/api/models/base.py +++ b/rootfs/api/models/base.py @@ -5,7 +5,6 @@ from datetime import timedelta from functools import partial from django.db import models -from django.conf import settings from django.utils import timezone from django.contrib.auth.models import AbstractUser from django.utils.translation import gettext_lazy as _ @@ -35,9 +34,6 @@ PTYPE_MAX_LENGTH = 63 -def get_anonymous_user_instance(user): return user(id=-1, username=settings.ANONYMOUS_USER_NAME) - - class AuditedModel(models.Model): """Add created and updated fields to a model.""" @@ -66,9 +62,8 @@ def app_log(self, app_id, msg): def scheduler(self): annotations = {} if hasattr(self, 'app'): - annotations["drycc.cc/project_id"] = str(self.app.id) - if hasattr(self, 'owner'): - annotations["drycc.cc/account_id"] = str(self.owner.id) + annotations["drycc.cc/app_id"] = str(self.app.id) + annotations["drycc.cc/workspace_id"] = str(self.app.workspace_id) return get_scheduler(metadata={"annotations": annotations}) diff --git a/rootfs/api/models/blocklist.py b/rootfs/api/models/blocklist.py index 306aa4d09..e51a2b613 100644 --- a/rootfs/api/models/blocklist.py +++ b/rootfs/api/models/blocklist.py @@ -1,7 +1,6 @@ import logging import functools from django.db import models -from django.contrib.auth import get_user_model from scheduler import KubeHTTPException from api.exceptions import ServiceUnavailable from api.utils import apply_tasks @@ -9,17 +8,15 @@ from .base import UuidAuditedModel -User = get_user_model() logger = logging.getLogger(__name__) class Blocklist(UuidAuditedModel): """ - You can block apps or users. - If a user is blocked, all apps owned by the user will be stopped. - The apps managed by the user will not be affected. + You can block apps or workspaces. + If a workspace is blocked, all apps under that workspace will be stopped. """ - type_choices = [(1, "app", ), (2, "user")] + type_choices = [(1, "app", ), (2, "workspace")] id = models.CharField(max_length=128, db_index=True) type = models.PositiveIntegerField(choices=type_choices) remark = models.TextField(blank=True, null=True, default="Blocked for unknown reason") @@ -27,8 +24,7 @@ class Blocklist(UuidAuditedModel): @property def related_apps(self): if self.type == 2: - user = User.objects.get(id=self.id) - return App.objects.filter(owner=user) + return App.objects.filter(workspace_id=self.id) else: return App.objects.filter(id=self.id) @@ -42,7 +38,7 @@ def get_type(cls, name: str): @classmethod def get_blocklist(cls, app: App): return cls.objects.filter( - models.Q(id=app.id, type=1) | models.Q(id=app.owner_id, type=2) + models.Q(id=app.id, type=1) | models.Q(id=app.workspace_id, type=2) ).first() class Meta: diff --git a/rootfs/api/models/build.py b/rootfs/api/models/build.py index 2b91d755c..96bee571b 100644 --- a/rootfs/api/models/build.py +++ b/rootfs/api/models/build.py @@ -17,7 +17,6 @@ class Build(UuidAuditedModel): Instance of a software build used by runtime nodes """ - owner = models.ForeignKey(User, on_delete=models.PROTECT) app = models.ForeignKey('App', on_delete=models.CASCADE) image = models.TextField() stack = models.CharField(max_length=32) @@ -79,7 +78,7 @@ def merge(self, config): pipeline['env'] = ptype_env[pipeline['ptype']] pipeline['config'] = config.values_refs.get(pipeline['ptype'], []) return Build.objects.create( - owner=config.owner, app=self.app, image=self.image, stack=self.stack, sha=self.sha, + app=self.app, image=self.image, stack=self.stack, sha=self.sha, procfile=self.procfile, dryccfile=dryccfile, dockerfile=self.dockerfile, ) return self @@ -125,7 +124,7 @@ def create_release(self, user, *args, **kwargs): if new_release.summary: new_release.summary += " " new_release.summary += "{} deployed {} which failed".format( - self.owner, str(self.uuid)[:7]) + user, str(self.uuid)[:7]) # Get the exception that has occured new_release.exception = "error: {}".format(str(e)) # avoid overwriting other fields @@ -157,7 +156,6 @@ def _get_or_create_config(self): values_ref[pipeline['ptype']] = [config_ref] else: values_ref[pipeline['ptype']].append(config_ref) - config = Config( - owner=self.owner, app=self.app, values=values, values_refs=values_ref) + config = Config(app=self.app, values=values, values_refs=values_ref) config.save(ignore_update_fields=["values", "values_refs"]) return config diff --git a/rootfs/api/models/certificate.py b/rootfs/api/models/certificate.py index 43b862427..28184856e 100644 --- a/rootfs/api/models/certificate.py +++ b/rootfs/api/models/certificate.py @@ -102,7 +102,6 @@ class Certificate(AuditedModel): """ Public and private key pair used to secure application traffic at the router. """ - owner = models.ForeignKey(User, on_delete=models.PROTECT) app = models.ForeignKey('App', on_delete=models.CASCADE) name = models.CharField(max_length=253, validators=[validate_label]) # there is no upper limit on the size of an x.509 certificate diff --git a/rootfs/api/models/config.py b/rootfs/api/models/config.py index 649434b76..51bc0cf9f 100644 --- a/rootfs/api/models/config.py +++ b/rootfs/api/models/config.py @@ -20,7 +20,6 @@ class Config(UuidAuditedModel): "termination_grace_period", "registry") allof_fields = ("values", ) + ptype_fields - owner = models.ForeignKey(User, on_delete=models.PROTECT) app = models.ForeignKey('App', on_delete=models.CASCADE) values = models.JSONField(default=list, blank=True) values_refs = models.JSONField(default=dict, blank=True) diff --git a/rootfs/api/models/domain.py b/rootfs/api/models/domain.py index bb5e8db71..a1caff610 100644 --- a/rootfs/api/models/domain.py +++ b/rootfs/api/models/domain.py @@ -7,7 +7,6 @@ class Domain(AuditedModel): - owner = models.ForeignKey(User, on_delete=models.PROTECT) app = models.ForeignKey('App', on_delete=models.CASCADE) domain = models.TextField( blank=False, null=False, unique=True, diff --git a/rootfs/api/models/gateway.py b/rootfs/api/models/gateway.py index 71dfef91b..c4e68c323 100644 --- a/rootfs/api/models/gateway.py +++ b/rootfs/api/models/gateway.py @@ -3,7 +3,6 @@ import threading from django.db import models from django.conf import settings -from django.contrib.auth import get_user_model from django.db.models import Q from django.http import Http404 @@ -15,7 +14,6 @@ from .base import AuditedModel, DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT -User = get_user_model() logger = logging.getLogger(__name__) TLS_PROTOCOLS = ("HTTPS", "TLS") @@ -24,7 +22,6 @@ class Gateway(AuditedModel): app = models.ForeignKey('App', on_delete=models.CASCADE) - owner = models.ForeignKey(User, on_delete=models.PROTECT) name = models.CharField(max_length=63, db_index=True, validators=[validate_label]) ports = models.JSONField(default=list) @@ -150,7 +147,7 @@ def delete(self, *args, **kwargs): def to_usages(self, timestamp: float): return [{ "app_id": str(self.app_id), - "owner": self.owner_id, + "workspace": self.app.workspace_id, "name": settings.DRYCC_APP_GATEWAY_CLASS, "type": "gateway", "unit": "number", @@ -196,7 +193,6 @@ class Route(AuditedModel): } app = models.ForeignKey('App', on_delete=models.CASCADE) - owner = models.ForeignKey(User, on_delete=models.PROTECT) kind = models.CharField(max_length=15, choices=[ (key, '/'.join(value)) for key, value in PROTOCOLS_CHOICES.items()]) name = models.CharField(max_length=63, db_index=True) diff --git a/rootfs/api/models/key.py b/rootfs/api/models/key.py index b94867e59..08e94cdf8 100644 --- a/rootfs/api/models/key.py +++ b/rootfs/api/models/key.py @@ -33,6 +33,9 @@ class Meta: verbose_name = 'SSH Key' unique_together = (('owner', 'fingerprint')) ordering = ['public'] + indexes = [ + models.Index(fields=['fingerprint']), + ] def __str__(self): return "{}...{}".format(self.public[:18], self.public[-31:]) diff --git a/rootfs/api/models/limit.py b/rootfs/api/models/limit.py index 616c29268..a72eda1a4 100644 --- a/rootfs/api/models/limit.py +++ b/rootfs/api/models/limit.py @@ -124,6 +124,9 @@ class LimitPlan(AuditedModel): class Meta: get_latest_by = 'created' ordering = ['priority'] + indexes = [ + models.Index(fields=['spec', 'cpu', 'memory']), + ] def __str__(self): return self.name diff --git a/rootfs/api/models/release.py b/rootfs/api/models/release.py index 7cf8c750f..8a9f8c2f8 100644 --- a/rootfs/api/models/release.py +++ b/rootfs/api/models/release.py @@ -32,7 +32,6 @@ class Release(UuidAuditedModel): ("crashed", "Release pipeline runtime crashed"), ("succeed", "Release pipeline runtime succeed"), ) - owner = models.ForeignKey(User, on_delete=models.PROTECT) app = models.ForeignKey('App', on_delete=models.CASCADE) state = models.TextField(choices=STATE_CHOICES, default=STATE_CHOICES[0][0]) version = models.PositiveIntegerField() @@ -49,6 +48,9 @@ class Meta: get_latest_by = 'created' ordering = ['-created'] unique_together = (('app', 'version'),) + indexes = [ + models.Index(fields=['app', 'failed', 'state']), + ] def __str__(self): return "{0}-{1}".format(self.app.id, self.version_name) @@ -143,8 +145,7 @@ def new(self, user, config, build, summary=None): new_version = self.app.release_set.latest().version + 1 # create new release and auto-increment version return Release.objects.create( - owner=user, app=self.app, config=config, - build=build, version=new_version, summary=summary + app=self.app, config=config, build=build, version=new_version, summary=summary ) def get_port(self, ptype): @@ -263,7 +264,7 @@ def rollback(self, user, ptypes=None, version=None): if new_release.summary: new_release.summary += " " new_release.summary += "{} performed roll back to a release that failed".format( - self.owner) + user) # Get the exception that has occured new_release.exception = "error: {}".format(str(e)) # avoid overwriting other fields @@ -424,12 +425,14 @@ def save(self, *args, **kwargs): # noqa old_config = prev_release.config if prev_release else None # if the build changed, log it and who pushed it if self.version == 1: - self.summary += "{} created initial release".format(self.app.owner) + self.summary += "{} created initial release".format(self.app.workspace.name) elif self.build != old_build: if self.build.sha: - self.summary += "{} deployed {}".format(self.build.owner, self.build.sha[:7]) + self.summary += "{} deployed {}".format( + self.app.workspace.name, self.build.sha[:7]) else: - self.summary += "{} deployed {}".format(self.build.owner, self.build.image) + self.summary += "{} deployed {}".format( + self.app.workspace.name, self.build.image) elif self.config != old_config: for field, diff in self.config.diff(old_config).items(): diff_list = [] @@ -437,11 +440,13 @@ def save(self, *args, **kwargs): # noqa diff_list.append(f'{diff_type} {field} {", ".join(values)}') if diff_list: changes = ', '.join(diff_list) - self.summary += "{} {}".format(self.config.owner, changes) + self.summary += "{} {}".format(self.app.workspace.name, changes) if not self.summary: if self.version == 1: - self.summary = "{} created the initial release".format(self.owner) + self.summary = "{} created the initial release".format(self.app.workspace.name) else: # There were no changes to this release - raise AlreadyExists("{} changed nothing - release stopped".format(self.owner)) + raise AlreadyExists( + "{} changed nothing - release stopped".format(self.app.workspace.name) + ) super(Release, self).save(*args, **kwargs) diff --git a/rootfs/api/models/resource.py b/rootfs/api/models/resource.py index 42cdb346f..63ef0b8c2 100644 --- a/rootfs/api/models/resource.py +++ b/rootfs/api/models/resource.py @@ -13,7 +13,6 @@ class Resource(UuidAuditedModel): - owner = models.ForeignKey(User, on_delete=models.PROTECT) app = models.ForeignKey('App', on_delete=models.CASCADE) name = models.CharField(max_length=63, validators=[validate_label]) plan = models.CharField(max_length=128) @@ -210,7 +209,7 @@ def retrieve(self, *args, **kwargs): def to_usages(self, timestamp: float): return [{ "app_id": str(self.app_id), - "owner": self.owner_id, + "workspace": self.app.workspace_id, "name": self.plan, "type": "resource", "unit": "number", diff --git a/rootfs/api/models/service.py b/rootfs/api/models/service.py index 1651d3110..6b1c2711f 100644 --- a/rootfs/api/models/service.py +++ b/rootfs/api/models/service.py @@ -29,7 +29,6 @@ class Service(AuditedModel): - owner = models.ForeignKey(User, on_delete=models.PROTECT) app = models.ForeignKey('App', on_delete=models.CASCADE) ports = models.JSONField( default=list, validators=[partial(validate_json, schema=service_ports_schema)]) diff --git a/rootfs/api/models/tls.py b/rootfs/api/models/tls.py index 9eaa6a081..b6a5b9262 100644 --- a/rootfs/api/models/tls.py +++ b/rootfs/api/models/tls.py @@ -24,7 +24,6 @@ def default_issuer(): class TLS(UuidAuditedModel): - owner = models.ForeignKey(User, on_delete=models.PROTECT) app = models.ForeignKey('App', on_delete=models.CASCADE) issuer = models.JSONField(default=default_issuer) https_enforced = models.BooleanField(null=True) @@ -134,12 +133,12 @@ def _check_previous_tls_settings(self): if self.https_enforced is not None: if previous_tls_settings.https_enforced == self.https_enforced: raise AlreadyExists( - "{} changed nothing".format(self.owner)) + "{} changed nothing".format(self.app.workspace.name)) self.certs_auto_enabled = previous_tls_settings.certs_auto_enabled elif self.certs_auto_enabled is not None: if previous_tls_settings.certs_auto_enabled == self.certs_auto_enabled: raise AlreadyExists( - "{} changed nothing".format(self.owner)) + "{} changed nothing".format(self.app.workspace.name)) self.https_enforced = previous_tls_settings.https_enforced previous_tls_settings.delete() except TLS.DoesNotExist: diff --git a/rootfs/api/models/volume.py b/rootfs/api/models/volume.py index 09d45813c..01a41b54c 100644 --- a/rootfs/api/models/volume.py +++ b/rootfs/api/models/volume.py @@ -23,7 +23,6 @@ class Volume(UuidAuditedModel): ("nfs", "network file system"), ("oss", "object storage service file"), ) - owner = models.ForeignKey(User, on_delete=models.PROTECT) app = models.ForeignKey('App', on_delete=models.CASCADE) name = models.CharField(max_length=63, validators=[validate_label]) size = models.CharField(default='0G', max_length=128) @@ -75,7 +74,7 @@ def log(self, message, level=logging.INFO): def to_usages(self, timestamp: float): return [{ "app_id": str(self.app_id), - "owner": self.owner_id, + "workspace": self.app.workspace_id, "name": self.type, "type": "volume", "unit": "bytes", diff --git a/rootfs/api/models/workspace.py b/rootfs/api/models/workspace.py new file mode 100644 index 000000000..7e5b6a689 --- /dev/null +++ b/rootfs/api/models/workspace.py @@ -0,0 +1,108 @@ +import re +import logging +from django.db import models +from django.core.mail import send_mail +from django.utils.translation import gettext_lazy as _ + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.template.loader import render_to_string + +from rest_framework.exceptions import ValidationError +from api.utils import validate_reserved_names, get_local_host + +User = get_user_model() +logger = logging.getLogger(__name__) + + +def validate_workspace_name(value): + """ + Check that the value follows the kubernetes name constraints + """ + match = re.match(r'^[0-9a-z]{5,}$', value) + if not match: + raise ValidationError("App name must start with an alphabetic character, cannot end with a" + + " hyphen and can only contain a-z (lowercase), 0-9 and hyphens.") + validate_reserved_names(value) + + +class Workspace(models.Model): + name = models.SlugField( + _("workspace name"), + max_length=150, + unique=True, + validators=[validate_workspace_name], + ) + email = models.EmailField(_("email address")) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + def has_member(self, user, role=None): + kwargs = {'user': user, 'workspace': self} + if role: + kwargs['role'] = role + return WorkspaceMember.objects.filter(**kwargs).exists() + + def __str__(self): + return self.name + + +class WorkspaceMember(models.Model): + role_choices = [ + ('admin', 'Admin'), + ('member', 'Member'), + ('viewer', 'Viewer'), + ] + + user = models.ForeignKey(User, on_delete=models.CASCADE) + role = models.CharField(max_length=50, choices=role_choices) + alerts = models.BooleanField(default=True) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE) + + def __str__(self): + return f"{self.user.username} - {self.workspace.name} ({self.role})" + + class Meta: + unique_together = ('user', 'workspace') + + +class WorkspaceInvitation(models.Model): + email = models.EmailField(_("email address")) + token = models.CharField(max_length=128, unique=True) + inviter = models.ForeignKey(User, on_delete=models.CASCADE) + created = models.DateTimeField(auto_now_add=True) + accepted = models.BooleanField(_("accepted"), default=False) + workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE) + + class Meta: + unique_together = ('email', 'workspace') + + def accept(self): + if not self.accepted: + self.accepted = True + self.save() + user = User.objects.filter(email=self.email).first() + if not user: + return + WorkspaceMember.objects.get_or_create( + user=user, workspace=self.workspace, defaults={'role': 'member'}) + + def send_email(self, request): + cache_key = f"invitation:email:{self.email}" + cache.add(cache_key, 0, timeout=settings.DRYCC_INVITATION_EMAIL_TIMEOUT) + count = cache.incr(cache_key) + if count > settings.DRYCC_INVITATION_EMAIL_LIMIT: + raise ValidationError("Too many invitation emails, please try again later") + domain = get_local_host(request) + mail_subject = f'We Invite You to Join the {self.workspace.name} Workspace.' + message = render_to_string( + 'workspace/workspace_invitation.html', + {'domain': domain, 'invitation': self} + ) + send_mail(mail_subject, message, None, [self.email], fail_silently=True) + + def __str__(self): + return f"Invitation for {self.email} to join {self.workspace.name}" diff --git a/rootfs/api/permissions.py b/rootfs/api/permissions.py index 208df5baf..4bd7f90d1 100644 --- a/rootfs/api/permissions.py +++ b/rootfs/api/permissions.py @@ -1,10 +1,9 @@ import base64 from rest_framework import permissions from django.conf import settings -from django.contrib.auth.models import AnonymousUser from api import manager -from api.models import app from api.models import blocklist +from api.models.workspace import Workspace, WorkspaceMember def get_app_status(app): @@ -12,42 +11,12 @@ def get_app_status(app): if block: return False, block.remark if settings.WORKFLOW_MANAGER_URL: - status = manager.UserAPI().get_status(app.owner.pk) + status = manager.WorkspaceAPI().get_status(app.workspace_id) if not status["is_active"]: return False, status["message"] return True, None -def has_app_permission(user, obj, method): - obj = getattr(obj, 'app', obj) - has_permission, message = False, f"{obj} object does not exist or does not have permission." - if user.is_superuser: - has_permission, message = True, None - elif getattr(obj, "owner", None) == user: - has_permission, message = True, None - elif user.is_staff: - has_permission, message = True, None - else: - permission = app.app_permission_registry.get(method) - if permission and user.has_perm(permission.codename, obj): - has_permission, message = True, None - if has_permission and isinstance(obj, app.App): - return get_app_status(obj) - return has_permission, message - - -class IsAnonymous(permissions.BasePermission): - """ - View permission to allow anonymous users. - """ - - def has_permission(self, request, view): - """ - Return `True` if permission is granted, `False` otherwise. - """ - return type(request.user) is AnonymousUser - - class IsOwner(permissions.BasePermission): """ Object-level permission to allow only owners of an object to access it. @@ -61,54 +30,29 @@ def has_object_permission(self, request, view, obj): return False -class IsOwnerOrAdmin(permissions.BasePermission): +class IsAppUser(permissions.BasePermission): """ - Object-level permission to allow only owners of an object or administrators to access it. - Assumes the model instance has an `owner` attribute. + Object-level permission to allow only users who are owners + or collaborators of an app to access it. """ - def has_object_permission(self, request, view, obj): - if request.user.is_superuser: - return True - if hasattr(obj, 'owner'): - return obj.owner == request.user - else: - return False - -class IsObjectUser(permissions.BasePermission): - """ - Object-level permission to allow owners or collaborators to access - an app-related model. - """ def has_object_permission(self, request, view, obj): - return has_app_permission(request.user, obj, request.method)[0] - - -class IsAdmin(permissions.BasePermission): - """ - View permission to allow only admins. - """ - - def has_permission(self, request, view): - """ - Return `True` if permission is granted, `False` otherwise. - """ - return request.user.is_superuser - - -class IsAdminOrSafeMethod(permissions.BasePermission): - """ - View permission to allow only admins to use unsafe methods - including POST, PUT, DELETE. - - This allows - """ - - def has_permission(self, request, view): - """ - Return `True` if permission is granted, `False` otherwise. - """ - return request.method in permissions.SAFE_METHODS or request.user.is_superuser + if request.user.is_superuser or request.user.is_staff: + return True + elif getattr(obj, "user", None) == request.user: + return True + elif isinstance(obj, Workspace) or hasattr(obj, 'workspace'): + workspace = obj if isinstance(obj, Workspace) else obj.workspace + if request.method in ["GET", "HEAD", "OPTIONS"]: + allowed_roles = ["viewer", "member", "admin"] + elif request.method in ["POST", "PUT", "PATCH"]: + allowed_roles = ["member", "admin"] + else: + allowed_roles = ["admin"] + return WorkspaceMember.objects.filter( + workspace=workspace, user=request.user, role__in=allowed_roles, + ).exists() + return False class IsServiceToken(permissions.BasePermission): diff --git a/rootfs/api/serializers/__init__.py b/rootfs/api/serializers/__init__.py index 6bd1e624f..146736d93 100644 --- a/rootfs/api/serializers/__init__.py +++ b/rootfs/api/serializers/__init__.py @@ -201,27 +201,29 @@ class Meta: class AppSerializer(serializers.ModelSerializer): """Serialize a :class:`~api.models.app.App` model.""" - owner = serializers.ReadOnlyField(source='owner.username') + workspace = serializers.SlugRelatedField( + slug_field='name', + queryset=models.workspace.Workspace.objects.all(), + ) structure = serializers.JSONField(required=False) class Meta: """Metadata options for a :class:`AppSerializer`.""" model = models.app.App - fields = ['uuid', 'id', 'owner', 'structure', 'created', 'updated'] + fields = ['uuid', 'id', 'workspace', 'structure', 'created', 'updated'] class BuildSerializer(serializers.ModelSerializer): """Serialize a :class:`~api.models.build.Build` model.""" app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all()) - owner = serializers.ReadOnlyField(source='owner.username') procfile = serializers.JSONField(required=False) dryccfile = serializers.JSONField(required=False) class Meta: """Metadata options for a :class:`BuildSerializer`.""" model = models.build.Build - fields = ['owner', 'app', 'image', 'stack', 'sha', 'procfile', 'dryccfile', + fields = ['app', 'image', 'stack', 'sha', 'procfile', 'dryccfile', 'dockerfile', 'created', 'updated', 'uuid'] @staticmethod @@ -277,7 +279,6 @@ class ConfigSerializer(serializers.ModelSerializer): """Serialize a :class:`~api.models.config.Config` model.""" app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all()) - owner = serializers.ReadOnlyField(source='owner.username') values = JSONFieldSerializer(required=False, binary=True) limits = JSONFieldSerializer(required=False, binary=True) lifecycle = JSONFieldSerializer(convert_to_str=False, required=False, binary=True) @@ -419,7 +420,6 @@ class ReleaseSerializer(serializers.ModelSerializer): """Serialize a :class:`~api.models.release.Release` model.""" app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all()) - owner = serializers.ReadOnlyField(source='owner.username') class Meta: """Metadata options for a :class:`ReleaseSerializer`.""" @@ -442,12 +442,11 @@ class DomainSerializer(serializers.ModelSerializer): """Serialize a :class:`~api.models.domain.Domain` model.""" app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all()) - owner = serializers.ReadOnlyField(source='owner.username') class Meta: """Metadata options for a :class:`DomainSerializer`.""" model = models.domain.Domain - fields = ['owner', 'created', 'updated', 'app', 'domain', 'ptype'] + fields = ['created', 'updated', 'app', 'domain', 'ptype'] read_only_fields = ['uuid'] @staticmethod @@ -504,7 +503,6 @@ class ServiceSerializer(serializers.ModelSerializer): """Serialize a :class:`~api.models.service.Service` model.""" app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all()) - owner = serializers.ReadOnlyField(source='owner.username') port = serializers.IntegerField(required=True) protocol = serializers.CharField(required=True) target_port = serializers.IntegerField(default=DEFAULT_CONTAINER_PORT) @@ -513,7 +511,7 @@ class ServiceSerializer(serializers.ModelSerializer): class Meta: """Metadata options for a :class:`ServiceSerializer`.""" model = models.service.Service - fields = ['owner', 'created', 'updated', 'app', 'ptype'] + fields = ['created', 'updated', 'app', 'ptype'] read_only_fields = ['uuid'] @staticmethod @@ -536,7 +534,6 @@ class CertificateSerializer(serializers.ModelSerializer): """Serialize a :class:`~api.models.certificate.Certificate` model.""" app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all()) - owner = serializers.ReadOnlyField(source='owner.username') domains = serializers.ReadOnlyField() san = serializers.ListField( child=serializers.CharField(allow_blank=True, allow_null=True, required=False), @@ -592,7 +589,6 @@ class AppSettingsSerializer(serializers.ModelSerializer): """Serialize a :class:`~api.models.appsettings.AppSettings` model.""" app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all()) - owner = serializers.ReadOnlyField(source='owner.username') autoscale = JSONFieldSerializer(convert_to_str=False, required=False, binary=True) label = JSONFieldSerializer(convert_to_str=False, required=False, binary=True) @@ -615,7 +611,6 @@ class TLSSerializer(serializers.ModelSerializer): """Serialize a :class:`~api.models.tls.TLS` model.""" app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all()) - owner = serializers.ReadOnlyField(source='owner.username') events = serializers.ReadOnlyField() class Meta: @@ -628,7 +623,6 @@ class VolumeSerializer(serializers.ModelSerializer): """Serialize a :class:`~api.models.volume.Volume` model.""" app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all()) - owner = serializers.ReadOnlyField(source='owner.username') path = JSONFieldSerializer(required=False, binary=True) parameters = serializers.JSONField(required=False) @@ -687,7 +681,6 @@ def validate_parameters(data): class ResourceSerializer(serializers.ModelSerializer): """Serialize a :class:`~api.models.resource.Resource` model.""" app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all()) - owner = serializers.ReadOnlyField(source='owner.username') name = serializers.CharField(max_length=63, required=True) plan = serializers.CharField(max_length=128, required=True) data = JSONFieldSerializer(required=False, binary=True) @@ -743,7 +736,6 @@ def validate(self, attrs): class GatewaySerializer(serializers.Serializer): app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all()) - owner = serializers.ReadOnlyField(source='owner.username') name = serializers.CharField(max_length=63, required=True) listeners = serializers.JSONField(required=False) addresses = serializers.JSONField(read_only=True) @@ -767,7 +759,6 @@ def validate_protocol(value): class RouteSerializer(serializers.ModelSerializer): app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all()) - owner = serializers.ReadOnlyField(source='owner.username') kind = serializers.CharField(max_length=15, required=True) name = serializers.CharField(max_length=63, required=True) rules = serializers.JSONField(required=False) @@ -793,3 +784,43 @@ def validate_rules(self, value): else: schema = rules.SCHEMA return validate_json(value, schema, serializers.ValidationError) + + +class WorkspaceSerializer(serializers.ModelSerializer): + """Serialize Workspace model.""" + + class Meta: + model = models.workspace.Workspace + fields = '__all__' + read_only_fields = ['id', 'created', 'updated'] + extra_kwargs = { + 'name': {'validators': [models.workspace.validate_workspace_name]}, + } + + def update(self, instance, validated_data): + # Workspace name cannot be modified; the name field is read-only after creation. + validated_data.pop('name', None) + return super().update(instance, validated_data) + + +class WorkspaceMemberSerializer(serializers.ModelSerializer): + """Serialize WorkspaceMember model.""" + user = serializers.ReadOnlyField(source='user.username') + email = serializers.ReadOnlyField(source='user.email') + workspace = serializers.ReadOnlyField(source='workspace.name') + + class Meta: + model = models.workspace.WorkspaceMember + fields = '__all__' + read_only_fields = ['id', 'created', 'updated'] + + +class WorkspaceInvitationSerializer(serializers.ModelSerializer): + """Serialize WorkspaceInvitation model.""" + inviter = serializers.ReadOnlyField(source='inviter.username') + workspace = serializers.ReadOnlyField(source='workspace.name') + + class Meta: + model = models.workspace.WorkspaceInvitation + fields = '__all__' + read_only_fields = ['id', 'token', 'created'] diff --git a/rootfs/api/settings/celery.py b/rootfs/api/settings/celery.py index e323267c9..4a9183d94 100644 --- a/rootfs/api/settings/celery.py +++ b/rootfs/api/settings/celery.py @@ -54,10 +54,6 @@ class Config(object): 'queue': 'controller.high', 'exchange': 'controller.priority', 'routing_key': 'controller.priority.high', }, - 'api.tasks.downstream_model_owner': { - 'queue': 'controller.high', - 'exchange': 'controller.priority', 'routing_key': 'controller.priority.high', - }, 'api.tasks.send_app_log': { 'queue': 'controller.middle', 'exchange': 'controller.priority', 'routing_key': 'controller.priority.middle', diff --git a/rootfs/api/settings/production.py b/rootfs/api/settings/production.py index 304bc416d..9d8ea2c06 100644 --- a/rootfs/api/settings/production.py +++ b/rootfs/api/settings/production.py @@ -72,6 +72,7 @@ def randstr(k): 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [ # insert your TEMPLATE_DIRS here + os.path.join(BASE_DIR, "templates"), ], 'APP_DIRS': True, 'OPTIONS': { @@ -113,7 +114,6 @@ def randstr(k): 'django.contrib.sessions', # Third-party apps 'corsheaders', - 'guardian', 'gunicorn', 'rest_framework', 'social_django', @@ -126,11 +126,8 @@ def randstr(k): AUTHENTICATION_BACKENDS = ( "django.contrib.auth.backends.ModelBackend", - "guardian.backends.ObjectPermissionBackend", "api.apps_extra.social_core.backends.DryccOIDC", ) -GUARDIAN_GET_INIT_ANONYMOUS_USER = 'api.models.base.get_anonymous_user_instance' -ANONYMOUS_USER_NAME = os.environ.get('ANONYMOUS_USER_NAME', 'AnonymousUser') LOGIN_URL = '/v2/auth/login/' # Security settings @@ -459,6 +456,10 @@ def randstr(k): ) DRYCC_CACHE_USER_TIME = int(os.environ.get('DRYCC_CACHE_USER_TIME', 30 * 60)) +# Rate limit for invitation emails: max LIMIT emails per WINDOW seconds per recipient address +DRYCC_INVITATION_EMAIL_LIMIT = int(os.environ.get('DRYCC_INVITATION_EMAIL_LIMIT', 5)) +DRYCC_INVITATION_EMAIL_TIMEOUT = int(os.environ.get('DRYCC_INVITATION_EMAIL_TIMEOUT', 3600)) + # Cache Valkey Configuration CACHES = { "default": { diff --git a/rootfs/api/signals.py b/rootfs/api/signals.py index 9cd966f09..3b7e3831b 100644 --- a/rootfs/api/signals.py +++ b/rootfs/api/signals.py @@ -5,6 +5,7 @@ """ import time import hashlib + import hmac import logging import urllib.parse @@ -27,6 +28,7 @@ from api.models.tls import TLS from api.models.volume import Volume from api.models.resource import Resource +from api.models.workspace import WorkspaceInvitation User = get_user_model() @@ -93,7 +95,7 @@ def _hook_release_created(**kwargs): 'release': release.version_name, 'release_summary': release.summary, 'sha': '', - 'user': release.owner, + 'user': release.app.workspace.name, } if release.build is not None: params['sha'] = release.build.sha @@ -169,6 +171,13 @@ def tls_changed_handle(sender, instance: TLS, created=False, update_fields=None, route.refresh_to_k8s() +@receiver(post_save, sender=User) +def user_changed_handle(sender, instance: User, created=False, update_fields=None, **kwargs): + if created or (update_fields is not None and "email" in update_fields): + for invitation in WorkspaceInvitation.objects.filter(email=instance.email, accepted=True): + invitation.accept() + + @receiver(post_save, sender=Gateway) def gateway_changed_handle( sender, instance: Gateway, created=False, update_fields=None, **kwargs): diff --git a/rootfs/api/tasks.py b/rootfs/api/tasks.py index bd068c6f2..75242e79a 100644 --- a/rootfs/api/tasks.py +++ b/rootfs/api/tasks.py @@ -5,7 +5,7 @@ from django.core import signals from celery import shared_task -from api import utils, manager, models +from api import utils, manager from api.exceptions import ServiceUnavailable logger = logging.getLogger(__name__) @@ -138,28 +138,6 @@ def mount_app(app, user, volume, path): signals.request_finished.send(sender=task_id) -@shared_task( - retry_kwargs={'max_retries': 3} -) -def downstream_model_owner(app, old_owner, new_owner): - task_id = uuid.uuid4().hex - signals.request_started.send(sender=task_id) - try: - for downstream_model in [ - models.appsettings.AppSettings, models.build.Build, models.config.Config, - models.domain.Domain, models.release.Release, models.resource.Resource, - models.tls.TLS, models.service.Service, models.volume.Volume, - models.gateway.Gateway, models.gateway.Route]: - downstream_model.objects.filter(owner=old_owner, app=app).update(owner=new_owner) - app.owner = new_owner - app.save() - except Exception as e: - signals.got_request_exception.send(sender=task_id) - raise e - else: - signals.request_finished.send(sender=task_id) - - @shared_task( autoretry_for=(ServiceUnavailable, ), retry_kwargs={'max_retries': 3} diff --git a/rootfs/api/templates/workspace/workspace_invitation.html b/rootfs/api/templates/workspace/workspace_invitation.html new file mode 100644 index 000000000..fd680435b --- /dev/null +++ b/rootfs/api/templates/workspace/workspace_invitation.html @@ -0,0 +1,7 @@ +{% autoescape off %} +Hi there, + +Please click on the link below to confirm joining {{ invitation.workspace.name }} workspace: + +{{ domain }}{% url 'workspace_invitation_detail' name=invitation.workspace.name uid=invitation.token %} +{% endautoescape %} \ No newline at end of file diff --git a/rootfs/api/tests/__init__.py b/rootfs/api/tests/__init__.py index 835bb4be1..052cfe63c 100644 --- a/rootfs/api/tests/__init__.py +++ b/rootfs/api/tests/__init__.py @@ -6,9 +6,13 @@ from os.path import dirname, realpath from django.test.runner import DiscoverRunner +from django.contrib.auth import get_user_model from rest_framework.test import APITestCase, APITransactionTestCase from api.models.base import Token +from api.models.workspace import Workspace, WorkspaceMember + +User = get_user_model() # Mock out router requests and add in some jitter @@ -56,10 +60,49 @@ def run_tests(self, test_labels, **kwargs): class DryccBaseTestCase(unittest.TestCase): - def create_app(self, name=None): - body = {} + def _get_authenticated_user(self): + credentials = getattr(getattr(self, 'client', None), '_credentials', {}) + auth = credentials.get('HTTP_AUTHORIZATION', '') + if auth.startswith('Token '): + token_key = auth.split(' ', 1)[1] + token = Token.objects.filter(key=token_key).select_related('owner').first() + if token is not None: + return token.owner + return getattr(self, 'user', None) + + def _default_workspace_name(self): + user = self._get_authenticated_user() + if user and user.username: + base = ''.join(ch for ch in user.username.lower() if ch.isalnum()) + if len(base) >= 5: + return base + return 'autotest' + + def _ensure_workspace_admin(self, workspace_name): + user = self._get_authenticated_user() + if user is None: + user = User.objects.filter(username='autotest').first() + if user is None: + raise AssertionError('No test user available for workspace membership') + + workspace, _ = Workspace.objects.get_or_create( + name=workspace_name, + defaults={'email': user.email or f'{workspace_name}@example.com'}, + ) + WorkspaceMember.objects.update_or_create( + workspace=workspace, + user=user, + defaults={'role': 'admin'}, + ) + return workspace.name + + def create_app(self, name=None, workspace=None): + workspace_name = workspace or self._default_workspace_name() + workspace_name = self._ensure_workspace_admin(workspace_name) + + body = {'workspace': workspace_name} if name: - body = {'id': name} + body['id'] = name response = self.client.post('/v2/apps', body) self.assertEqual(response.status_code, 201, response.data) diff --git a/rootfs/api/tests/test_app.py b/rootfs/api/tests/test_app.py index 651156eed..162c8ae19 100644 --- a/rootfs/api/tests/test_app.py +++ b/rootfs/api/tests/test_app.py @@ -15,9 +15,9 @@ from django.core.cache import cache from django.test.utils import override_settings -from api.models.app import App, app_permission_registry +from api.models.app import App from api.models.base import PTYPE_WEB -from api.models.config import Config +from api.models.workspace import Workspace, WorkspaceMember from scheduler import KubeException, KubeHTTPException from api.exceptions import DryccException @@ -45,6 +45,18 @@ def setUp(self): self.user = User.objects.get(username='autotest') self.token = self.get_or_create_token(self.user) self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) + self.workspace_name = self._ensure_workspace_admin(self._default_workspace_name()) + + original_post = self.client.post + + def _post_with_workspace(path, data=None, *args, **kwargs): + if path == '/v2/apps': + payload = {} if data is None else dict(data) + payload.setdefault('workspace', self.workspace_name) + data = payload + return original_post(path, data, *args, **kwargs) + + self.client.post = _post_with_workspace def tearDown(self): # make sure every test has a clean slate for k8s mocking @@ -66,7 +78,8 @@ def test_app(self, mock_requests): body = {'id': 'new'} response = self.client.patch(url, body) - self.assertEqual(response.status_code, 405, response.content) + self.assertEqual(response.status_code, 400, response.data) + self.assertEqual(str(response.data['detail']), 'workspace is required') response = self.client.delete(url) self.assertEqual(response.status_code, 204, response.data) @@ -90,10 +103,10 @@ def test_response_data(self, mock_requests): body = {'id': 'app-{}'.format(random.randrange(1000, 10000))} response = self.client.post('/v2/apps', body) for key in response.data: - self.assertIn(key, ['uuid', 'created', 'updated', 'id', 'owner', 'structure']) + self.assertIn(key, ['uuid', 'created', 'updated', 'id', 'workspace', 'structure']) expected = { 'id': body['id'], - 'owner': self.user.username, + 'workspace': self.user.username, 'structure': {} } self.assertEqual(response.data, expected | response.data) @@ -194,6 +207,13 @@ def test_admin_can_manage_other_apps(self, mock_requests, mock_logger): self.client.credentials(HTTP_AUTHORIZATION='Token ' + token) app_id = self.create_app() + app = App.objects.get(id=app_id) + WorkspaceMember.objects.get_or_create( + workspace=app.workspace, + user=self.user, + defaults={'role': 'admin'}, + ) + # log in as admin, check to see if they have access self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) url = '/v2/apps/{}'.format(app_id) @@ -216,7 +236,14 @@ def test_admin_can_see_other_apps(self, mock_requests): user = User.objects.get(username='autotest2') token = self.get_or_create_token(user) self.client.credentials(HTTP_AUTHORIZATION='Token ' + token) - self.create_app() + app_id = self.create_app() + + app = App.objects.get(id=app_id) + WorkspaceMember.objects.get_or_create( + workspace=app.workspace, + user=self.user, + defaults={'role': 'viewer'}, + ) # log in as admin self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) @@ -288,7 +315,7 @@ def test_unauthorized_user_cannot_see_app(self, mock_requests): An unauthorized user should not be able to access an app's resources. Since an unauthorized user can't access the application, these - tests should return a 403, but currently return a 404. FIXME! + tests return 404 when app is filtered out from queryset. """ app_id = self.create_app() unauthorized_user = User.objects.get(username='autotest2') @@ -298,14 +325,14 @@ def test_unauthorized_user_cannot_see_app(self, mock_requests): url = '/v2/apps/{}/run'.format(app_id) body = {'command': 'foo'} response = self.client.post(url, body) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 404) url = '/v2/apps/{}'.format(app_id) response = self.client.get(url) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 404) response = self.client.delete(url) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 404) def test_app_info_not_showing_wrong_app(self, mock_requests): self.create_app() @@ -318,70 +345,78 @@ def test_app_transfer(self, mock_requests): self.client.credentials(HTTP_AUTHORIZATION='Token ' + owner_token) collaborator = User.objects.get(username='autotest3') + collab_token = self.get_or_create_token(collaborator) - app = App.objects.create(owner=owner) - - # pretend the owner and a collaborator added some config to the app to ensure - # resources owned by the owner are transferred, but not resources owned by the - # collaborator. + # create a workspace and app under owner + response = self.client.post('/v2/workspaces', { + 'name': 'wstransfer1', + 'email': 'ws-owner@example.com', + }) + self.assertEqual(response.status_code, 201, response.data) - config1 = Config.objects.create( - owner=owner, app=app, values=[{"name": "FOO", "value": "bar", "group": "global"}]) - config2 = Config.objects.create( - owner=collaborator, app=app, - values=[{"name": "CAR", "value": "star", "group": "global"}]) + response = self.client.post('/v2/apps', { + 'id': 'app-transfer01', + 'workspace': 'wstransfer1', + }) + self.assertEqual(response.status_code, 201, response.data) - # Transfer App + app = App.objects.get(id='app-transfer01') + workspace = Workspace.objects.get(name='wstransfer1') url = '/v2/apps/{}'.format(app.id) - new_owner = User.objects.get(username='autotest4') - new_owner_token = self.get_or_create_token(new_owner) - body = {'owner': new_owner.username} - response = self.client.post(url, body) - self.assertEqual(response.status_code, 200, response.data) - # Original user can no longer access it + # collaborator cannot access app before joining workspace + self.client.credentials(HTTP_AUTHORIZATION='Token ' + collab_token) response = self.client.get(url) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 404, response.data) - # New owner can access it - self.client.credentials(HTTP_AUTHORIZATION='Token ' + new_owner_token) + # owner adds collaborator to workspace + WorkspaceMember.objects.get_or_create( + workspace=workspace, + user=collaborator, + defaults={'role': 'member'}, + ) + + # collaborator can access app after membership response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) - self.assertEqual(response.data['owner'], new_owner.username) - # At this point config1.owner field is still the old owner, but the value in the database - # was updated to the new owner when we performed the transfer. The object's updated values - # needs to be reloaded from the database to get an accurate idea who owns the object. - # https://docs.djangoproject.com/en/dev/ref/models/instances/#django.db.models.Model.refresh_from_db - config1.refresh_from_db() - config2.refresh_from_db() + # collaborator cannot manage workspace members + response = self.client.patch( + f'/v2/workspaces/{workspace.name}/members/{owner.username}', + {'role': 'viewer'}, + ) + self.assertEqual(response.status_code, 403, response.data) + + def test_transfer_api_success(self, mock_requests): + app_id = self.create_app(name='appxferok') + response = self.client.post('/v2/workspaces', { + 'name': 'wstarget', + 'email': 'target@example.com', + }) + self.assertEqual(response.status_code, 201, response.data) - # New owner also is given ownership to all resources owned by the original user, but not - # resources created by other users - self.assertEqual(config1.owner, new_owner) - self.assertEqual(config2.owner, collaborator) + response = self.client.patch( + f'/v2/apps/{app_id}', + {'workspace': 'wstarget'}, + ) + self.assertEqual(response.status_code, 204, response.data) - # Collaborators can't transfer - body = { - 'username': owner.username, - 'permissions': ','.join(app_permission_registry.shortnames), - } - response = self.client.post(f'/v2/apps/{app.id}/perms/', body) - self.assertEqual(response.status_code, 201, response.data) + app = App.objects.get(id=app_id) + self.assertEqual(app.workspace.name, 'wstarget') - self.client.credentials(HTTP_AUTHORIZATION='Token ' + owner_token) - body = {'owner': self.user.username} - response = self.client.post(url, body) - self.assertEqual(response.status_code, 403) + def test_transfer_api_requires_workspace(self, mock_requests): + app_id = self.create_app(name='appxferreqws') + response = self.client.patch(f'/v2/apps/{app_id}', {}) + self.assertEqual(response.status_code, 400, response.data) + self.assertEqual(str(response.data['detail']), 'workspace is required') - # Admins can transfer - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) - body = {'owner': self.user.username} - response = self.client.post(url, body) - self.assertEqual(response.status_code, 200, response.data) - response = self.client.get(url) - self.assertEqual(response.status_code, 200, response.data) - self.assertEqual(response.data['owner'], self.user.username) + def test_transfer_api_target_workspace_not_found(self, mock_requests): + app_id = self.create_app(name='appxfer404') + response = self.client.patch( + f'/v2/apps/{app_id}', + {'workspace': 'workspace-not-exists'}, + ) + self.assertEqual(response.status_code, 404, response.data) def test_app_exists_in_kubernetes(self, mock_requests): """ @@ -585,7 +620,7 @@ def test_get_private_registry_config_bad_registry_location(self, mock_requests): self.assertEqual(create, None) def test_build_env_vars(self, mock_requests): - app = App.objects.create(owner=self.user) + app = App.objects.create(workspace=Workspace.objects.get(name=self.workspace_name)) # Make sure an exception is raised when calling without a build available with self.assertRaises(DryccException): app._build_env_vars(app.release_set.latest(), PTYPE_WEB) @@ -622,7 +657,7 @@ def test_build_env_vars(self, mock_requests): }) def test_gather_app_settings(self, mock_requests): - app = App.objects.create(owner=self.user) + app = App.objects.create(workspace=Workspace.objects.get(name=self.workspace_name)) app.save() data = {'image': 'autotest/example', 'stack': 'container'} url = f"/v2/apps/{app.id}/build" diff --git a/rootfs/api/tests/test_build.py b/rootfs/api/tests/test_build.py index 6e30e172a..11820bf19 100644 --- a/rootfs/api/tests/test_build.py +++ b/rootfs/api/tests/test_build.py @@ -88,10 +88,9 @@ def test_response_data(self, mock_requests): response = self.client.post(url, body) for key in response.data: - self.assertIn(key, ['uuid', 'owner', 'created', 'updated', 'app', 'dockerfile', + self.assertIn(key, ['uuid', 'created', 'updated', 'app', 'dockerfile', 'dryccfile', 'image', 'stack', 'procfile', 'sha']) expected = { - 'owner': self.user.username, 'app': app_id, 'dockerfile': '', 'image': 'autotest/example', diff --git a/rootfs/api/tests/test_certificate.py b/rootfs/api/tests/test_certificate.py index c7efd8f0f..741e3b09a 100644 --- a/rootfs/api/tests/test_certificate.py +++ b/rootfs/api/tests/test_certificate.py @@ -19,7 +19,8 @@ def setUp(self): self.user = User.objects.get(username='autotest') self.token = self.get_or_create_token(self.user) self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) - self.app = App.objects.create(owner=self.user, id='test-app-use-case') + app_id = self.create_app(name='test-app-use-case') + self.app = App.objects.get(id=app_id) self.url = f'/v2/apps/{self.app.id}/certs' self.domain = 'autotest.example.com' @@ -146,7 +147,6 @@ def test_delete_certificate(self): Certificate.objects.create( name='random-test-cert', app=self.app, - owner=self.user, common_name='autotest.example.com', certificate=self.cert, key=self.key @@ -174,7 +174,6 @@ def test_load_invalid_cert(self): with self.assertRaises(SuspiciousOperation): Certificate.objects.create( - owner=self.user, app=self.app, name='random-test-cert', certificate='i am bad data', @@ -200,7 +199,7 @@ def test_load_invalid_key(self): with self.assertRaises(SuspiciousOperation): Certificate.objects.create( - owner=self.user, + app=self.app, name='random-test-cert', certificate=self.cert, key='I am Groot.' diff --git a/rootfs/api/tests/test_certificate_use_case_1.py b/rootfs/api/tests/test_certificate_use_case_1.py index ea6d5a64a..604a9f4b4 100644 --- a/rootfs/api/tests/test_certificate_use_case_1.py +++ b/rootfs/api/tests/test_certificate_use_case_1.py @@ -24,10 +24,11 @@ def setUp(self): self.token = self.get_or_create_token(self.user) self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) - self.app = App.objects.create(owner=self.user, id='test-app-use-case-1') + app_id = self.create_app(name='test-app-use-case-1') + self.app = App.objects.get(id=app_id) self.url = f'/v2/apps/{self.app.id}/certs' self.domain = Domain.objects.create( - owner=self.user, app=self.app, domain='foo.com', ptype=PTYPE_WEB) + app=self.app, domain='foo.com', ptype=PTYPE_WEB) self.name = 'foo-com' # certificate name with open('{}/certs/{}.key'.format(TEST_ROOT, self.domain)) as f: @@ -147,7 +148,6 @@ def test_certficate_denied_requests(self): def test_delete_certificate(self): """Destroying a certificate should generate a 204 response""" Certificate.objects.create( - owner=self.user, app=self.app, name=self.name, certificate=self.cert, diff --git a/rootfs/api/tests/test_certificate_use_case_2.py b/rootfs/api/tests/test_certificate_use_case_2.py index 7f808f890..c74845291 100644 --- a/rootfs/api/tests/test_certificate_use_case_2.py +++ b/rootfs/api/tests/test_certificate_use_case_2.py @@ -24,13 +24,14 @@ def setUp(self): self.token = self.get_or_create_token(self.user) self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) - self.app = App.objects.create(owner=self.user, id='test-app-use-case-2') + app_id = self.create_app(name='test-app-use-case-2') + self.app = App.objects.get(id=app_id) self.url = f'/v2/apps/{self.app.id}/certs' self.domains = { 'foo.com': Domain.objects.create( - owner=self.user, app=self.app, domain='foo.com', ptype=PTYPE_WEB), + app=self.app, domain='foo.com', ptype=PTYPE_WEB), 'bar.com': Domain.objects.create( - owner=self.user, app=self.app, domain='bar.com', ptype=PTYPE_WEB), + app=self.app, domain='bar.com', ptype=PTYPE_WEB), } # only foo.com has a cert @@ -133,7 +134,6 @@ def test_delete_certificate(self): Certificate.objects.create( name=certificate['name'], app=self.app, - owner=self.user, common_name=domain, certificate=certificate['cert'], key=certificate['key'] diff --git a/rootfs/api/tests/test_certificate_use_case_3.py b/rootfs/api/tests/test_certificate_use_case_3.py index 9d17a2f97..7eedfc859 100644 --- a/rootfs/api/tests/test_certificate_use_case_3.py +++ b/rootfs/api/tests/test_certificate_use_case_3.py @@ -24,13 +24,14 @@ def setUp(self): self.token = self.get_or_create_token(self.user) self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) - self.app = App.objects.create(owner=self.user, id='test-app-use-case-3') + app_id = self.create_app(name='test-app-use-case-3') + self.app = App.objects.get(id=app_id) self.url = f'/v2/apps/{self.app.id}/certs' self.domains = { 'foo.com': Domain.objects.create( - owner=self.user, app=self.app, domain='foo.com', ptype=PTYPE_WEB), + app=self.app, domain='foo.com', ptype=PTYPE_WEB), 'bar.com': Domain.objects.create( - owner=self.user, app=self.app, domain='bar.com', ptype=PTYPE_WEB), + app=self.app, domain='bar.com', ptype=PTYPE_WEB), } self.certificates = {} @@ -135,7 +136,6 @@ def test_delete_certificate(self): Certificate.objects.create( name=certificate['name'], app=self.app, - owner=self.user, common_name=domain, certificate=certificate['cert'], key=certificate['key'] diff --git a/rootfs/api/tests/test_certificate_use_case_4.py b/rootfs/api/tests/test_certificate_use_case_4.py index f12b7634b..573cfa571 100644 --- a/rootfs/api/tests/test_certificate_use_case_4.py +++ b/rootfs/api/tests/test_certificate_use_case_4.py @@ -24,16 +24,17 @@ def setUp(self): self.token = self.get_or_create_token(self.user) self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) - self.app = App.objects.create(owner=self.user, id='test-app-use-case-3') + app_id = self.create_app(name='test-app-use-case-3') + self.app = App.objects.get(id=app_id) self.url = f'/v2/apps/{self.app.id}/certs' self.domains = { '*.foo.com': Domain.objects.create( - owner=self.user, app=self.app, domain='*.foo.com', + app=self.app, domain='*.foo.com', ptype=PTYPE_WEB), 'foo.com': Domain.objects.create( - owner=self.user, app=self.app, domain='foo.com', ptype=PTYPE_WEB), + app=self.app, domain='foo.com', ptype=PTYPE_WEB), 'bar.com': Domain.objects.create( - owner=self.user, app=self.app, domain='bar.com', ptype=PTYPE_WEB), + app=self.app, domain='bar.com', ptype=PTYPE_WEB), } self.certificates = {} @@ -218,7 +219,6 @@ def test_delete_certificate(self): for domain, certificate in self.certificates.items(): Certificate.objects.create( name=certificate['name'], - owner=self.user, app=self.app, common_name=domain, certificate=certificate['cert'], diff --git a/rootfs/api/tests/test_certificate_use_case_5.py b/rootfs/api/tests/test_certificate_use_case_5.py index 34c331e3c..1a060421b 100644 --- a/rootfs/api/tests/test_certificate_use_case_5.py +++ b/rootfs/api/tests/test_certificate_use_case_5.py @@ -24,17 +24,18 @@ def setUp(self): self.token = self.get_or_create_token(self.user) self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) - self.app = App.objects.create(owner=self.user, id='test-app-use-case-5') + app_id = self.create_app(name='test-app-use-case-5') + self.app = App.objects.get(id=app_id) self.url = f'/v2/apps/{self.app.id}/certs' # Done out of scope as it gets the same cert as the wildcard Domain.objects.create( - owner=self.user, app=self.app, domain='foo.com', ptype=PTYPE_WEB) + app=self.app, domain='foo.com', ptype=PTYPE_WEB) self.domains = { '*.foo.com': Domain.objects.create( - owner=self.user, app=self.app, domain='*.foo.com', + app=self.app, domain='*.foo.com', ptype=PTYPE_WEB), 'bar.com': Domain.objects.create( - owner=self.user, app=self.app, domain='bar.com', ptype=PTYPE_WEB), + app=self.app, domain='bar.com', ptype=PTYPE_WEB), } self.certificates = {} @@ -164,7 +165,6 @@ def test_delete_certificate(self): # Create certificate Certificate.objects.create( name=certificate['name'], - owner=self.user, app=self.app, common_name=domain, certificate=certificate['cert'], diff --git a/rootfs/api/tests/test_config.py b/rootfs/api/tests/test_config.py index 22a435fea..c9579df42 100644 --- a/rootfs/api/tests/test_config.py +++ b/rootfs/api/tests/test_config.py @@ -35,11 +35,8 @@ def setUp(self): self.user = User.objects.get(username='autotest') self.token = self.get_or_create_token(self.user) self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) - - url = '/v2/apps' - response = self.client.post(url, HTTP_AUTHORIZATION='token {}'.format(self.token)) - self.assertEqual(response.status_code, 201, response.data) - self.app = App.objects.all()[0] + app_id = self.create_app() + self.app = App.objects.get(id=app_id) def tearDown(self): # make sure every test has a clean slate for k8s mocking @@ -366,11 +363,10 @@ def test_response_data(self, mock_requests): body = {'values': [value1]} response = self.client.post(url, body) for key in response.data: - self.assertIn(key, ['uuid', 'owner', 'created', 'updated', 'app', 'values', + self.assertIn(key, ['uuid', 'created', 'updated', 'app', 'values', 'values_refs', 'limits', 'tags', 'registry', 'healthcheck', 'lifecycle', 'termination_grace_period']) expected = { - 'owner': self.user.username, 'app': app_id, 'values': [value1], 'limits': { @@ -397,11 +393,10 @@ def test_response_data_types_converted(self, mock_requests): response = self.client.post(url, body) self.assertEqual(response.status_code, 201, response.data) for key in response.data: - self.assertIn(key, ['uuid', 'owner', 'created', 'updated', 'app', 'values', 'limits', + self.assertIn(key, ['uuid', 'created', 'updated', 'app', 'values', 'limits', 'values_refs', 'tags', 'registry', 'healthcheck', 'lifecycle', 'termination_grace_period']) expected = { - 'owner': self.user.username, 'app': app_id, 'values': [value1], 'limits': { @@ -563,14 +558,6 @@ def test_admin_can_create_config_on_other_apps(self, mock_requests): self.assertEqual([value1], response.data['values']) return response - def test_config_owner_is_requesting_user(self, mock_requests): - """ - Ensure that setting the config value is owned by the requesting user - See https://github.com/drycc/drycc/issues/2650 - """ - response = self.test_admin_can_create_config_on_other_apps() - self.assertEqual(response.data['owner'], self.user.username) - def test_unauthorized_user_cannot_modify_config(self, mock_requests): """ An unauthorized user should not be able to modify other config. @@ -685,9 +672,7 @@ def test_unset_limits_error(self, mock_requests): self.assertEqual(response.status_code, 201) # dockerfile + procfile worflow app = App.objects.get(id=app_id) - user = User.objects.get(username='autotest') build = Build.objects.create( - owner=user, app=app, image="qwerty", procfile={ @@ -700,7 +685,6 @@ def test_unset_limits_error(self, mock_requests): # create an initial release release = Release.objects.create( version=3, - owner=user, app=app, config=app.config_set.latest(), build=build @@ -764,9 +748,7 @@ def test_set_config_limits_run(self, *args, **kwargs): app_id = self.create_app() # dockerfile + procfile worflow app = App.objects.get(id=app_id) - user = User.objects.get(username='autotest') build = Build.objects.create( - owner=user, app=app, image="qwerty", procfile={ @@ -779,7 +761,6 @@ def test_set_config_limits_run(self, *args, **kwargs): # create an initial release release = Release.objects.create( version=3, - owner=user, app=app, config=app.config_set.latest(), build=build diff --git a/rootfs/api/tests/test_consumers.py b/rootfs/api/tests/test_consumers.py index 244edb3e8..15d84fe34 100644 --- a/rootfs/api/tests/test_consumers.py +++ b/rootfs/api/tests/test_consumers.py @@ -421,7 +421,6 @@ def setUp(self): # We'll patch the save method to prevent k8s interactions with patch.object(Volume, 'save_to_k8s'), patch.object(Volume, 'delete_from_k8s'): self.test_volume = Volume.objects.create( - owner=self.user, app=self.app, name='test-volume', size='1G', diff --git a/rootfs/api/tests/test_domain.py b/rootfs/api/tests/test_domain.py index 7b310ca8d..918eea36d 100644 --- a/rootfs/api/tests/test_domain.py +++ b/rootfs/api/tests/test_domain.py @@ -45,10 +45,9 @@ def test_response_data(self): for key in response.data: self.assertIn( - key, ['uuid', 'owner', 'created', 'updated', 'app', 'domain', 'ptype']) + key, ['uuid', 'created', 'updated', 'app', 'domain', 'ptype']) expected = { - 'owner': self.user.username, 'app': app_id, 'domain': 'test-domain.example.com' } diff --git a/rootfs/api/tests/test_event.py b/rootfs/api/tests/test_event.py index cf44d61b2..2b3b0d717 100644 --- a/rootfs/api/tests/test_event.py +++ b/rootfs/api/tests/test_event.py @@ -25,11 +25,7 @@ def setUp(self): self.user = User.objects.get(username='autotest') self.token = self.get_or_create_token(self.user) self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) - - url = '/v2/apps' - response = self.client.post(url, HTTP_AUTHORIZATION='token {}'.format(self.token)) - self.assertEqual(response.status_code, 201, response.data) - self.app = App.objects.all()[0] + self.app = App.objects.get(id=self.create_app()) def tearDown(self): # make sure every test has a clean slate for k8s mocking diff --git a/rootfs/api/tests/test_gateway.py b/rootfs/api/tests/test_gateway.py index 39f184997..dda6e3119 100644 --- a/rootfs/api/tests/test_gateway.py +++ b/rootfs/api/tests/test_gateway.py @@ -43,13 +43,11 @@ def create_app_with_domain_and_deploy(self): self.assertEqual(len(response.data["results"]), 0, response.data) # create a release so we can scale app = App.objects.get(id=app_id) - user = User.objects.get(username='autotest') - build = Build.objects.create(owner=user, app=app, image="qwerty") + build = Build.objects.create(app=app, image="qwerty") # create an initial release release = Release.objects.create( version=2, - owner=user, app=app, config=app.config_set.latest(), build=build @@ -152,7 +150,6 @@ def test_add_listener(self): break expect = { "app": app_id, - "owner": "autotest", "name": gateway_name, "listeners": [ { @@ -195,7 +192,7 @@ def test_add_tls_listener(self): name = "tls-gateway" _, domain, secret_name, results = self.add_tls_listener(name, "TLS", port) expect = [{ - 'app': name, 'owner': 'autotest', 'name': name, + 'app': name, 'name': name, 'listeners': [{ 'allowedRoutes': {'namespaces': {'from': 'All'}}, 'name': 'tcp-443-1', 'port': 443, 'hostname': domain, @@ -213,7 +210,6 @@ def test_add_https_listener(self): _, domain, secret_name, results = self.add_tls_listener(name, "HTTPS", port) expect = [{ 'app': name, - 'owner': 'autotest', 'name': name, 'listeners': [{ 'allowedRoutes': { @@ -244,7 +240,7 @@ def test_add_http_listener(self): name = "bingo-gateway" _, domain, secret_name, results = self.add_tls_listener(name, "HTTP", port) expect = [{ - 'app': 'bingo-gateway', 'owner': 'autotest', 'name': 'bingo-gateway', + 'app': 'bingo-gateway', 'name': 'bingo-gateway', 'listeners': [ { 'allowedRoutes': {'namespaces': {'from': 'All'}}, @@ -280,7 +276,6 @@ def test_add_udp_listener(self): _, results = self.add_listener(app_id, name, "UDP", port) expect = [{ 'app': 'bingo-gateway', - 'owner': 'autotest', 'name': 'bingo-gateway', 'listeners': [{ 'allowedRoutes': { @@ -376,7 +371,6 @@ def test_change_tls(self): self.assertEqual(response.data["count"], 1, response.data) expect = [{ 'app': app_id, - 'owner': 'autotest', 'name': app_id, 'listeners': [ { diff --git a/rootfs/api/tests/test_hooks.py b/rootfs/api/tests/test_hooks.py index d528d16b2..970ec5b5d 100644 --- a/rootfs/api/tests/test_hooks.py +++ b/rootfs/api/tests/test_hooks.py @@ -8,7 +8,6 @@ from django.core.cache import cache from api.tests import adapter, DryccTransactionTestCase -from api.models.app import app_permission_registry import requests_mock User = get_user_model() @@ -70,15 +69,6 @@ def test_key_hook(self, mock_requests): # Create app to use app_id = self.create_app() - # give user permission to app - body = { - 'username': str(self.user), - 'permissions': ','.join(app_permission_registry.shortnames), - } - url = f'/v2/apps/{app_id}/perms/' - response = self.client.post(url, body) - self.assertEqual(response.status_code, 201, response.data) - # Create rsa key body = {'id': str(self.user), 'public': RSA_PUBKEY} response = self.client.post('/v2/keys', body) diff --git a/rootfs/api/tests/test_perm.py b/rootfs/api/tests/test_perm.py index 21055e2cf..96ec20e7a 100644 --- a/rootfs/api/tests/test_perm.py +++ b/rootfs/api/tests/test_perm.py @@ -1,319 +1,191 @@ from django.test import tag from django.contrib.auth import get_user_model + from api.tests import DryccTestCase -from api.models.app import App, app_permission_registry, \ - VIEW_APP_PERMISSION, CHANGE_APP_PERMISSION +from api.models.workspace import WorkspaceMember +from api.models.app import App + User = get_user_model() -class TestUserPerm(DryccTestCase): +class TestWorkspacePerm(DryccTestCase): - fixtures = ['test_sharing.json'] + fixtures = ['tests.json'] @tag('auth') def setUp(self): - self.user = User.objects.get(username='autotest-1') + self.user = User.objects.get(username='autotest') self.token = self.get_or_create_token(self.user) - # Always have first user authed coming into tests self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) - self.user2 = User.objects.get(username='autotest-2') + self.user2 = User.objects.get(username='autotest2') self.token2 = self.get_or_create_token(self.user2) - self.user3 = User.objects.get(username='autotest-3') - self.token3 = self.get_or_create_token(self.user3) @tag('auth') - def test_create(self): - # check that user 1 sees her lone app and user 2's app - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) - response = self.client.get('/v2/apps') - self.assertEqual(response.status_code, 200, response.data) - self.assertEqual(len(response.data['results']), 2) - app_id = response.data['results'][0]['id'] - - # check that user 2 can only see his app - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2) - response = self.client.get('/v2/apps') - self.assertEqual(len(response.data['results']), 1) - # check that user 2 can't see any of the app's builds, configs, - # containers, limits, or releases - for model in ['builds', 'config', 'pods', 'releases']: - response = self.client.get("/v2/apps/{}/{}/".format(app_id, model)) - msg = "Failed: status '%s', and data '%s'" % (response.status_code, response.data) - self.assertEqual(response.status_code, 403, msg=msg) - self.assertEqual(response.data['detail'], - 'You do not have permission to perform this action.', msg=msg) - - for app in App.objects.filter(owner=self.user): - body = { - 'username': self.user2.username, - 'permissions': ','.join(app_permission_registry.shortnames), - } - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) - response = self.client.post(f'/v2/apps/{app.id}/perms/', body) - self.assertEqual(response.status_code, 201, response.data) - - # check that user 2 can see the app - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2) - response = self.client.get('/v2/apps') - self.assertEqual(response.status_code, 200, response.data) - self.assertEqual(len(response.data['results']), 2) + def _create_workspace_and_app(self, ws_name='testws01', app_id='testapp01'): + response = self.client.post('/v2/workspaces', { + 'name': ws_name, + 'email': 'ws@example.com', + }) + self.assertEqual(response.status_code, 201, response.data) - # check that user 2 sees (empty) results now for builds, containers, - # and releases. (config and limit will still give 404s since we didn't - # push a build here.) - for model in ['builds', 'releases']: - response = self.client.get("/v2/apps/{}/{}/".format(app_id, model)) - self.assertEqual(len(response.data['results']), 0) - # TODO: check that user 2 can git push the app + response = self.client.post('/v2/apps', { + 'id': app_id, + 'workspace': ws_name, + }) + self.assertEqual(response.status_code, 201, response.data) + return ws_name, app_id @tag('auth') - def test_create_errors(self): - # check that user 1 sees her lone app - response = self.client.get('/v2/apps') - app_id = response.data['results'][0]['id'] - - body = { - 'username': self.user2.username, - 'permissions': ','.join(app_permission_registry.shortnames), - } + def test_workspace_member_can_access_app(self): + ws_name, app_id = self._create_workspace_and_app() - # check that user 2 can't create a permission + # non-member cannot access app self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2) - response = self.client.post(f'/v2/apps/{app_id}/perms/', body) - self.assertEqual(response.status_code, 403) + response = self.client.get(f'/v2/apps/{app_id}') + self.assertEqual(response.status_code, 404, response.data) - @tag('auth') - def test_delete(self): - # give user 2 permission to user 1's app - response = self.client.get('/v2/apps') - app_id = response.data['results'][0]['id'] - body = { - 'username': self.user2.username, - 'permissions': ','.join( - [VIEW_APP_PERMISSION.shortname, CHANGE_APP_PERMISSION.shortname]), - } - response = self.client.post(f'/v2/apps/{app_id}/perms/', body) - self.assertEqual(response.status_code, 201, response.data) + # add user2 into workspace, then user2 can access app + self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) + workspace = WorkspaceMember.objects.get(user=self.user, workspace__name=ws_name).workspace + WorkspaceMember.objects.get_or_create( + user=self.user2, + workspace=workspace, + defaults={'role': 'member'}, + ) - # check that user 2 can see the app as well as his own self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2) - response = self.client.get('/v2/apps') + response = self.client.get(f'/v2/apps/{app_id}') self.assertEqual(response.status_code, 200, response.data) - self.assertEqual(len(response.data['results']), 2) - # delete permission to user 1's app - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) - url = f"/v2/apps/{app_id}/perms/{self.user2.username}/" - response = self.client.delete(url) - self.assertEqual(response.status_code, 204, response) - self.assertIsNone(response.data) + @tag('auth') + def test_non_admin_cannot_manage_workspace_members(self): + ws_name, _ = self._create_workspace_and_app(ws_name='testws02', app_id='testapp02') - # check that user 2 can only see his app - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2) - response = self.client.get('/v2/apps') - self.assertEqual(len(response.data['results']), 1) + workspace = WorkspaceMember.objects.get(user=self.user, workspace__name=ws_name).workspace + WorkspaceMember.objects.get_or_create( + user=self.user2, + workspace=workspace, + defaults={'role': 'member'}, + ) - # delete permission to user 1's app again, expecting an error - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) - response = self.client.delete(url) - self.assertEqual(response.status_code, 204) + self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2) + response = self.client.patch( + f'/v2/workspaces/{ws_name}/members/{self.user.username}', + {'role': 'viewer'}, + ) + self.assertEqual(response.status_code, 403, response.data) @tag('auth') - def test_list(self): - # check that user 1 sees her lone app and user 2's app - response = self.client.get('/v2/apps') - self.assertEqual(response.status_code, 200, response.data) - self.assertEqual(len(response.data['results']), 2) - app_id = response.data['results'][0]['id'] - - # create a new object permission - body = { - 'username': self.user2.username, - 'permissions': ','.join(app_permission_registry.shortnames), - } - response = self.client.post(f'/v2/apps/{app_id}/perms/', body) - self.assertEqual(response.status_code, 201, response.data) + def test_admin_can_manage_workspace_members(self): + ws_name, _ = self._create_workspace_and_app(ws_name='testws03', app_id='testapp03') - # list perms on the app - response = self.client.get(f"/v2/apps/{app_id}/perms/") - self.assertEqual(response.data["count"], 1) + workspace = WorkspaceMember.objects.get(user=self.user, workspace__name=ws_name).workspace + WorkspaceMember.objects.get_or_create( + user=self.user2, + workspace=workspace, + defaults={'role': 'member'}, + ) - @tag('auth') - def test_admin_can_list(self): - """Check that an administrator can list an app's perms""" - response = self.client.get('/v2/apps') + self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) + response = self.client.patch( + f'/v2/workspaces/{ws_name}/members/{self.user2.username}', + {'role': 'viewer'}, + ) self.assertEqual(response.status_code, 200, response.data) - self.assertEqual(len(response.data['results']), 2) + self.assertEqual(response.data['role'], 'viewer') @tag('auth') - def test_list_errors(self): - response = self.client.get('/v2/apps') - # login as user 2, list perms on the app + def test_non_member_cannot_run_app(self): + _, app_id = self._create_workspace_and_app(ws_name='testws04', app_id='testapp04') + self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2) - response = self.client.get(f"/v2/apps/{self.user2.id}/perms/") - self.assertEqual(response.status_code, 404) + response = self.client.post( + f'/v2/apps/{app_id}/run', + {'command': 'echo hello'}, + ) + self.assertEqual(response.status_code, 404, response.data) @tag('auth') - def test_unauthorized_user_cannot_modify_perms(self): - """ - An unauthorized user should not be able to modify other apps' permissions. - - Since an unauthorized user should not know about the application at all, these - requests should return a 404. - """ - app_id = 'autotest' - url = '/v2/apps' - body = {'id': app_id} - response = self.client.post(url, body) - - body = { - 'username': self.user2.username, - 'permissions': ','.join(app_permission_registry.shortnames), - } + def test_workspace_member_can_run_but_without_build_gets_business_error(self): + ws_name, app_id = self._create_workspace_and_app(ws_name='testws05', app_id='testapp05') + workspace = WorkspaceMember.objects.get(user=self.user, workspace__name=ws_name).workspace + WorkspaceMember.objects.get_or_create( + user=self.user2, + workspace=workspace, + defaults={'role': 'member'}, + ) + self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2) - response = self.client.post(f'/v2/apps/{app_id}/perms/', body) - self.assertEqual(response.status_code, 403) + response = self.client.post( + f'/v2/apps/{app_id}/run', + {'command': 'echo hello'}, + ) + self.assertEqual(response.status_code, 400, response.data) + self.assertEqual( + str(response.data['detail']), + 'no build available, please deploy a release', + ) @tag('auth') - def test_collaborator_share(self): - """ - An collaborator should not be able to modify the app's permissions. - """ - app_id = "autotest-1-app" - owner_token = self.token - collab = self.user2 - collab_token = self.token2 - - # Share app with collaborator - body = { - 'username': collab.username, - 'permissions': ','.join(app_permission_registry.shortnames), - } - url = f'/v2/apps/{app_id}/perms/' - self.client.credentials(HTTP_AUTHORIZATION='Token ' + owner_token) - response = self.client.post(url, body) - self.assertEqual(response.status_code, 201, response.data) + def test_non_admin_cannot_update_workspace(self): + ws_name, _ = self._create_workspace_and_app(ws_name='testws06', app_id='testapp06') + workspace = WorkspaceMember.objects.get(user=self.user, workspace__name=ws_name).workspace + WorkspaceMember.objects.get_or_create( + user=self.user2, + workspace=workspace, + defaults={'role': 'member'}, + ) - # Collaborator can share app - self.client.credentials(HTTP_AUTHORIZATION='Token ' + collab_token) - body = { - 'username': self.user3.username, - 'permissions': ','.join(app_permission_registry.shortnames), - } - response = self.client.post(url, body) - self.assertEqual(response.status_code, 201) - - # Collaborator can list - response = self.client.get(url) - self.assertEqual(response.status_code, 200, response.data) + self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2) + response = self.client.patch( + f'/v2/workspaces/{ws_name}', + {'email': 'new@example.com'}, + ) + self.assertEqual(response.status_code, 403, response.data) - # Share app with user 3 for rest of tests - self.client.credentials(HTTP_AUTHORIZATION='Token ' + owner_token) - response = self.client.post(url, body) + @tag('auth') + def test_non_admin_cannot_transfer_app(self): + ws_name, app_id = self._create_workspace_and_app(ws_name='testws07', app_id='testapp07') + response = self.client.post('/v2/workspaces', { + 'name': 'testws08', + 'email': 'ws2@example.com', + }) self.assertEqual(response.status_code, 201, response.data) - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token3) - response = self.client.get(url) - self.assertEqual(response.status_code, 200, response.data) - self.assertEqual(response.data['count'], 2, response.data) - - # Collaborator cannot delete other collaborator - self.client.credentials(HTTP_AUTHORIZATION='Token ' + collab_token) - url += "{}/".format(self.user3.username) - response = self.client.delete(url) - self.assertEqual(response.status_code, 204) - url = f'/v2/apps/{app_id}/perms/' - response = self.client.get(url) - self.assertEqual(response.status_code, 200, response.data) - self.assertEqual(response.data['count'], 1, response.data) - - # Collaborator can delete themselves - url += "{}/".format(self.user2.username) - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) - response = self.client.delete(url) - self.assertEqual(response.status_code, 204) - url = f'/v2/apps/{app_id}/perms/' - response = self.client.get(url) - self.assertEqual(response.status_code, 200, response.data) - self.assertEqual(response.data['count'], 0, response.data) + workspace = WorkspaceMember.objects.get(user=self.user, workspace__name=ws_name).workspace + WorkspaceMember.objects.get_or_create( + user=self.user2, + workspace=workspace, + defaults={'role': 'member'}, + ) - @tag('auth') - def test_each_permission(self): - # check that user 1 sees her lone app and user 2's app - response = self.client.get('/v2/apps') - self.assertEqual(response.status_code, 200, response.data) - self.assertEqual(len(response.data['results']), 2) - app_id = response.data['results'][0]['id'] - - # list no perms on the app self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2) - response = self.client.get(f"/v2/apps/{app_id}/perms/") - self.assertEqual(response.status_code, 403) + response = self.client.patch( + f'/v2/apps/{app_id}', + {'workspace': 'testws08'}, + ) + self.assertEqual(response.status_code, 400, response.data) + self.assertEqual( + str(response.data['detail']), + 'you must be an admin of the current workspace', + ) - # create a new object permission - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) - body = { - 'username': self.user2.username, - 'permissions': ','.join([VIEW_APP_PERMISSION.shortname]), - } - response = self.client.post(f'/v2/apps/{app_id}/perms/', body) + @tag('auth') + def test_admin_can_transfer_app(self): + _, app_id = self._create_workspace_and_app(ws_name='testws09', app_id='testapp09') + response = self.client.post('/v2/workspaces', { + 'name': 'testws10', + 'email': 'ws10@example.com', + }) self.assertEqual(response.status_code, 201, response.data) - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2) - response = self.client.get(f"/v2/apps/{app_id}/perms/") - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['count'], 1, response.data) - - # test no change permission - body = { - 'username': self.user2.username, - 'permissions': ','.join([VIEW_APP_PERMISSION.shortname]), - } - response = self.client.post(f'/v2/apps/{app_id}/perms/', body) - self.assertEqual(response.status_code, 403, response.data) - # test add change permission - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) - body = { - 'username': self.user2.username, - 'permissions': ','.join( - [VIEW_APP_PERMISSION.shortname, CHANGE_APP_PERMISSION.shortname]), - } - response = self.client.post(f'/v2/apps/{app_id}/perms/', body) - self.assertEqual(response.status_code, 201) - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2) - body = { - 'username': self.user2.username, - 'permissions': ','.join( - [VIEW_APP_PERMISSION.shortname, CHANGE_APP_PERMISSION.shortname]), - } - response = self.client.post(f'/v2/apps/{app_id}/perms/', body) - self.assertEqual(response.status_code, 201) - - # test no delete permission - response = self.client.delete(f'/v2/apps/{app_id}/perms/{self.user2.username}/') - self.assertEqual(response.status_code, 403) - - # test update permisssion - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) - body = { - 'username': self.user2.username, - 'permissions': ','.join([VIEW_APP_PERMISSION.shortname]), - } - response = self.client.put(f'/v2/apps/{app_id}/perms/{self.user2.username}/', body) - self.assertEqual(response.status_code, 204) - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2) + response = self.client.patch( + f'/v2/apps/{app_id}', + {'workspace': 'testws10'}, + ) + self.assertEqual(response.status_code, 204, response.data) - # test not update permission - body = { - 'username': self.user2.username, - 'permissions': ','.join([VIEW_APP_PERMISSION.shortname]), - } - response = self.client.put(f'/v2/apps/{app_id}/perms/{self.user2.username}/', body) - self.assertEqual(response.status_code, 403) - - # has view permission - response = self.client.get(f"/v2/apps/{app_id}/perms/") - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['count'], 1, response.data) + app = App.objects.get(id=app_id) + self.assertEqual(app.workspace.name, 'testws10') diff --git a/rootfs/api/tests/test_pods.py b/rootfs/api/tests/test_pods.py index 5b8a1266a..371b8208c 100644 --- a/rootfs/api/tests/test_pods.py +++ b/rootfs/api/tests/test_pods.py @@ -265,13 +265,11 @@ def test_container_errors(self, mock_requests): # create a release so we can scale app = App.objects.get(id=app_id) - user = User.objects.get(username='autotest') - build = Build.objects.create(owner=user, app=app, image="qwerty") + build = Build.objects.create(app=app, image="qwerty") # create an initial release release = Release.objects.create( version=2, - owner=user, app=app, config=app.config_set.latest(), build=build @@ -474,9 +472,7 @@ def test_admin_can_manage_other_pods(self, mock_requests): def test_scale_without_build_should_error(self, mock_requests): """A user should not be able to scale processes unless a build is present.""" app_id = 'autotest' - url = '/v2/apps' - body = {'cluster': 'autotest', 'id': app_id} - response = self.client.post(url, body) + self.create_app(name=app_id) url = f'/v2/apps/{app_id}/ptypes/scale' body = {'web': '1'} @@ -489,11 +485,9 @@ def test_command_good(self, mock_requests): """Test the default command for each container workflow""" app_id = self.create_app() app = App.objects.get(id=app_id) - user = User.objects.get(username='autotest') # CNCF Buildpack app build = Build.objects.create( - owner=user, app=app, image="qwerty", procfile={ @@ -507,7 +501,6 @@ def test_command_good(self, mock_requests): # create an initial release release = Release.objects.create( version=2, - owner=user, app=app, config=app.config_set.latest(), build=build @@ -551,7 +544,6 @@ def test_run_command_good(self, mock_requests): # dockerfile + procfile worflow build = Build.objects.create( - owner=self.user, app=app, image="qwerty", stack="heroku-18", @@ -566,7 +558,6 @@ def test_run_command_good(self, mock_requests): # create an initial release release = Release.objects.create( version=2, - owner=self.user, app=app, config=app.config_set.latest(), build=build @@ -613,7 +604,6 @@ def test_run_not_fail_on_debug(self, mock_requests): # dockerfile + procfile worflow build = Build.objects.create( - owner=self.user, app=app, image="qwerty", procfile={ @@ -627,7 +617,6 @@ def test_run_not_fail_on_debug(self, mock_requests): # create an initial release release = Release.objects.create( version=2, - owner=self.user, app=app, config=app.config_set.latest(), build=build @@ -646,11 +635,9 @@ def test_scaling_does_not_add_run_proctypes_to_structure(self, mock_requests): """Test that app info doesn't show transient "run" proctypes.""" app_id = self.create_app() app = App.objects.get(id=app_id) - user = User.objects.get(username='autotest') # dockerfile + procfile worflow build = Build.objects.create( - owner=user, app=app, image="qwerty", procfile={ @@ -664,7 +651,6 @@ def test_scaling_does_not_add_run_proctypes_to_structure(self, mock_requests): # create an initial release release = Release.objects.create( version=2, - owner=user, app=app, config=app.config_set.latest(), build=build diff --git a/rootfs/api/tests/test_ptypes.py b/rootfs/api/tests/test_ptypes.py index 64749c3fe..15a509bee 100644 --- a/rootfs/api/tests/test_ptypes.py +++ b/rootfs/api/tests/test_ptypes.py @@ -25,11 +25,7 @@ def setUp(self): self.user = User.objects.get(username='autotest') self.token = self.get_or_create_token(self.user) self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) - - url = '/v2/apps' - response = self.client.post(url, HTTP_AUTHORIZATION='token {}'.format(self.token)) - self.assertEqual(response.status_code, 201, response.data) - self.app = App.objects.all()[0] + self.app = App.objects.get(id=self.create_app()) def tearDown(self): # make sure every test has a clean slate for k8s mocking diff --git a/rootfs/api/tests/test_release.py b/rootfs/api/tests/test_release.py index 6330ccdb7..88ef6d6e1 100644 --- a/rootfs/api/tests/test_release.py +++ b/rootfs/api/tests/test_release.py @@ -257,11 +257,10 @@ def test_response_data(self, mock_requests): url = '/v2/apps/{}/releases/v2'.format(app_id) response = self.client.get(url) for key in response.data.keys(): - self.assertIn(key, ['uuid', 'owner', 'created', 'updated', 'app', 'build', 'config', + self.assertIn(key, ['uuid', 'created', 'updated', 'app', 'build', 'config', 'summary', 'version', 'state', 'failed', 'conditions', 'exception', 'deployed_ptypes']) expected = { - 'owner': self.user.username, 'app': app_id, 'build': None, 'config': uuid.UUID(config_response.data['uuid']), @@ -735,7 +734,6 @@ def test_release_external_registry(self, mock_requests): def test_run(self, mock_requests): app_id = self.create_app() app = App.objects.get(id=app_id) - user = User.objects.get(username='autotest') dryccfile = { "pipeline": { "web.yaml": { @@ -767,10 +765,10 @@ def test_run(self, mock_requests): }, } build = Build.objects.create( - owner=user, app=app, image="qwerty", procfile={}, + app=app, image="qwerty", procfile={}, sha='african-swallow', dockerfile={}, dryccfile=dryccfile) release = Release.objects.create( - version=2, owner=user, app=app, config=app.config_set.latest(), + version=2, app=app, config=app.config_set.latest(), build=build, state="succeed") runners = release.get_runners(["web"]) self.assertEqual(len(runners), 1) @@ -786,10 +784,10 @@ def test_run(self, mock_requests): 'image': 'registry.drycc.cc/drycc/python-dev:latest', } build = Build.objects.create( - owner=user, app=app, image="qwerty", procfile={}, + app=app, image="qwerty", procfile={}, sha='african-swallow', dockerfile={}, dryccfile=dryccfile) release = Release.objects.create( - version=3, owner=user, app=app, config=app.config_set.latest(), + version=3, app=app, config=app.config_set.latest(), build=build, state="succeed") runners = release.get_runners(["web"]) self.assertEqual(len(runners), 1) diff --git a/rootfs/api/tests/test_resource.py b/rootfs/api/tests/test_resource.py index 31a1c4817..37af38ef9 100644 --- a/rootfs/api/tests/test_resource.py +++ b/rootfs/api/tests/test_resource.py @@ -61,11 +61,10 @@ def test_resources_create(self, mock_requests): for key in response.data: self.assertIn(key, - ['uuid', 'owner', 'created', 'updated', 'app', 'plan', + ['uuid', 'created', 'updated', 'app', 'plan', 'options', 'data', 'status', 'binding', 'name']) expected = { - 'owner': self.user.username, 'app': app_id, 'name': 'mysql', 'plan': 'mysql:5.6' diff --git a/rootfs/api/tests/test_users.py b/rootfs/api/tests/test_users.py deleted file mode 100644 index c8daf13cc..000000000 --- a/rootfs/api/tests/test_users.py +++ /dev/null @@ -1,47 +0,0 @@ -from django.contrib.auth import get_user_model -from api.tests import DryccTestCase - -User = get_user_model() - - -class TestUsers(DryccTestCase): - """ Tests users endpoint""" - - fixtures = ['tests.json'] - - def test_super_user_can_list(self): - user = User.objects.get(username='autotest') - token = self.get_or_create_token(user) - - for url in ['/v2/users', '/v2/users/']: - response = self.client.get(url, - HTTP_AUTHORIZATION='token {}'.format(token)) - self.assertEqual(response.status_code, 200, response.data) - self.assertEqual(len(response.data['results']), 4) - - def test_enable(self): - user = User.objects.get(username='autotest') - token = self.get_or_create_token(user) - response = self.client.patch("/v2/users/autotest2/enable/", - HTTP_AUTHORIZATION='token {}'.format(token)) - self.assertEqual(response.status_code, 204) - user = User.objects.get(username='autotest2') - self.assertEqual(user.is_active, True) - - def test_disable(self): - user = User.objects.get(username='autotest') - token = self.get_or_create_token(user) - response = self.client.patch("/v2/users/autotest2/disable/", - HTTP_AUTHORIZATION='token {}'.format(token)) - self.assertEqual(response.status_code, 204) - user = User.objects.get(username='autotest2') - self.assertEqual(user.is_active, False) - - def test_non_super_user_cannot_list(self): - user = User.objects.get(username='autotest2') - token = self.get_or_create_token(user) - - for url in ['/v2/users', '/v2/users/']: - response = self.client.get(url, - HTTP_AUTHORIZATION='token {}'.format(token)) - self.assertEqual(response.status_code, 403) diff --git a/rootfs/api/tests/test_volume.py b/rootfs/api/tests/test_volume.py index 0ce9b9f7e..20f908117 100644 --- a/rootfs/api/tests/test_volume.py +++ b/rootfs/api/tests/test_volume.py @@ -93,11 +93,10 @@ def test_volumecreate(self, mock_requests): for key in response.data: self.assertIn(key, - ['uuid', 'owner', 'created', 'updated', 'app', 'name', + ['uuid', 'created', 'updated', 'app', 'name', 'size', 'path', 'type', 'parameters']) expected = { - 'owner': self.user.username, 'app': app_id, 'name': 'myvolume', 'size': '500G' @@ -144,7 +143,6 @@ def test_volume_expand(self, mock_requests): url = '/v2/apps/{app_id}/volumes'.format(app_id=app_id) response = self.client.get(url) expected = { - 'owner': self.user.username, 'app': app_id, 'name': 'myvolume1', 'size': '1024G' diff --git a/rootfs/api/tests/test_workflow_manager.py b/rootfs/api/tests/test_workflow_manager.py index 5d655f86d..c2dd93ebc 100644 --- a/rootfs/api/tests/test_workflow_manager.py +++ b/rootfs/api/tests/test_workflow_manager.py @@ -40,18 +40,18 @@ def tearDown(self): def test_block(self, mock_requests): response = self.client.post( - '/v2/manager/{}/{}/block/'.format("users", 7), + '/v2/manager/{}/{}/block/'.format("apps", self.app_id), data={'remark': 'Arrears blockade'}, ) self.assertEqual(response.status_code, 201) def test_unblock(self, mock_requests): response = self.client.post( - '/v2/manager/{}/{}/block/'.format("users", 7), + '/v2/manager/{}/{}/block/'.format("apps", self.app_id), data={'remark': 'Arrears blockade'}, ) self.assertEqual(response.status_code, 201) response = self.client.delete( - '/v2/manager/{}/{}/unblock/'.format("users", 7), + '/v2/manager/{}/{}/unblock/'.format("apps", self.app_id), ) self.assertEqual(response.status_code, 204) diff --git a/rootfs/api/tests/test_workspace.py b/rootfs/api/tests/test_workspace.py new file mode 100644 index 000000000..60c49ff18 --- /dev/null +++ b/rootfs/api/tests/test_workspace.py @@ -0,0 +1,166 @@ +from django.contrib.auth import get_user_model +from django.core.cache import cache +from unittest import mock + +from api.models.workspace import Workspace, WorkspaceMember, WorkspaceInvitation +from api.tests import DryccTestCase + +User = get_user_model() + + +class WorkspaceTest(DryccTestCase): + """Tests creation and management of workspaces""" + + fixtures = ['tests.json'] + + def setUp(self): + self.user = User.objects.get(username='autotest') + self.token = self.get_or_create_token(self.user) + self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) + + self.user2 = User.objects.get(username='autotest2') + self.token2 = self.get_or_create_token(self.user2) + + def tearDown(self): + cache.clear() + + def test_workspace_lifecycle(self): + """Test workspace create, list, retrieve, update, delete""" + # Create + response = self.client.post( + '/v2/workspaces', {'name': 'testworkspace', 'email': 'test@example.com'} + ) + self.assertEqual(response.status_code, 201, response.data) + self.assertEqual(response.data['name'], 'testworkspace') + + # Verify admin member created + workspace = Workspace.objects.get(name='testworkspace') + self.assertTrue(workspace.has_member(self.user, role='admin')) + + # List + response = self.client.get('/v2/workspaces') + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(len(response.data['results']), 1) + + # Retrieve + response = self.client.get('/v2/workspaces/testworkspace') + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data['name'], 'testworkspace') + + # Update (personal workspace NOT allowed) + response = self.client.patch( + f'/v2/workspaces/{self.user.username}', {'email': 'new@example.com'} + ) + self.assertEqual(response.status_code, 404, response.data) + + # Update + response = self.client.patch('/v2/workspaces/testworkspace', {'email': 'new@example.com'}) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data['email'], 'new@example.com') + + # Delete + response = self.client.delete('/v2/workspaces/testworkspace') + self.assertEqual(response.status_code, 204, response.data) + + def test_workspace_isolation(self): + # User 1 creates workspace + self.client.post('/v2/workspaces', {'name': 'testisolated', 'email': 'test@example.com'}) + + # User 2 tries to access User 1's workspace + self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2) + response = self.client.get('/v2/workspaces/testisolated') + self.assertEqual(response.status_code, 404, response.data) # Not found for user2 + + +class WorkspaceMemberTest(DryccTestCase): + fixtures = ['tests.json'] + + def setUp(self): + self.user = User.objects.get(username='autotest') + self.user2 = User.objects.get(username='autotest2') + self.token = self.get_or_create_token(self.user) + self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) + + # Create a workspace + self.client.post('/v2/workspaces', {'name': 'testmembers', 'email': 'test@example.com'}) + self.workspace = Workspace.objects.get(name='testmembers') + + def test_member_management(self): + # Add member via DB (since POST /members is not supported, users join via invitations) + WorkspaceMember.objects.create(user=self.user2, workspace=self.workspace, role='member') + + # List members + response = self.client.get('/v2/workspaces/testmembers/members') + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(len(response.data['results']), 2) + + # Update member role (admin updating user2) + response = self.client.patch( + f'/v2/workspaces/testmembers/members/{self.user2.username}', {'role': 'viewer'} + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data['role'], 'viewer') + + # Non-admin user2 cannot update other members + token2 = self.get_or_create_token(self.user2) + self.client.credentials(HTTP_AUTHORIZATION='Token ' + token2) + response = self.client.patch( + f'/v2/workspaces/testmembers/members/{self.user.username}', {'role': 'viewer'} + ) + self.assertEqual(response.status_code, 403, response.data) + + # Admin delete member + self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) + response = self.client.delete(f'/v2/workspaces/testmembers/members/{self.user2.username}') + self.assertEqual(response.status_code, 204, response.data) + + +class WorkspaceInvitationTest(DryccTestCase): + fixtures = ['tests.json'] + + def setUp(self): + self.user = User.objects.get(username='autotest') + self.user2 = User.objects.get(username='autotest2') + self.token = self.get_or_create_token(self.user) + self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) + + self.client.post('/v2/workspaces', {'name': 'testinvite', 'email': 'test@example.com'}) + self.workspace = Workspace.objects.get(name='testinvite') + + @mock.patch('api.models.workspace.send_mail') + def test_invitation_lifecycle(self, mock_send_mail): + # Create invitation + response = self.client.post( + '/v2/workspaces/testinvite/invitations', {'email': self.user2.email} + ) + self.assertEqual(response.status_code, 201, response.data) + mock_send_mail.assert_called_once() + + invitation = WorkspaceInvitation.objects.get(email=self.user2.email) + + # List invitations + response = self.client.get('/v2/workspaces/testinvite/invitations') + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(len(response.data['results']), 1) + + # Accept invitation (retrieve with UID) + response = self.client.get(f'/v2/workspaces/testinvite/invitations/{invitation.token}') + self.assertEqual(response.status_code, 200, response.data) + + # Verify user became a member + self.assertTrue( + WorkspaceMember.objects.filter( + workspace=self.workspace, + user=self.user2 + ).exists() + ) + + # Test delete invitation + response = self.client.post( + '/v2/workspaces/testinvite/invitations', {'email': 'test-invite2@example.com'} + ) + invitation2 = WorkspaceInvitation.objects.get(email='test-invite2@example.com') + response = self.client.delete( + f'/v2/workspaces/testinvite/invitations/{invitation2.token}' + ) + self.assertEqual(response.status_code, 204, response.data) diff --git a/rootfs/api/urls.py b/rootfs/api/urls.py index 647b119fb..bf4baba6b 100644 --- a/rootfs/api/urls.py +++ b/rootfs/api/urls.py @@ -17,6 +17,27 @@ re_path(r'auth/login/?$', views.AuthLoginView.as_view({"post": "login"})), re_path(r'auth/token/?$', views.AuthTokenView.as_view({"post": "token"})), re_path(r'auth/token/(?P[-_\w]+)/?$', views.AuthTokenView.as_view({"get": "token"})), + # Workspaces management URLs (keep workspaces prefix, no user prefix) + re_path(r'^workspaces/?$', + views.WorkspaceViewSet.as_view({'get': 'list', 'post': 'create'}), + name='workspace_list'), + re_path(r'^workspaces/(?P[-_\w]+)/?$', + views.WorkspaceViewSet.as_view( + {'get': 'retrieve', 'patch': 'partial_update', 'delete': 'destroy'}), + name='workspace_detail'), + re_path(r'^workspaces/(?P[-_\w]+)/members/?$', + views.WorkspaceMemberViewSet.as_view({'get': 'list'}), + name='workspace_member_list'), + re_path(r'^workspaces/(?P[-_\w]+)/members/(?P[-_\w]+)/?$', + views.WorkspaceMemberViewSet.as_view( + {'get': 'retrieve', 'patch': 'partial_update', 'delete': 'destroy'}), + name='workspace_member_detail'), + re_path(r'^workspaces/(?P[-_\w]+)/invitations/?$', + views.WorkspaceInvitationViewSet.as_view({'get': 'list', 'post': 'create'}), + name='workspace_invitation_list'), + re_path(r'^workspaces/(?P[-_\w]+)/invitations/(?P[-_\w]+)/?$', + views.WorkspaceInvitationViewSet.as_view({'get': 'retrieve', 'delete': 'destroy'}), + name='workspace_invitation_detail'), # limits re_path( r'^limits/specs/?$', @@ -33,7 +54,7 @@ views.BuildViewSet.as_view({'get': 'retrieve', 'post': 'create'})), re_path( r"^apps/(?P{})/config/?$".format(settings.APP_URL_REGEX), - views.ConfigViewSet.as_view({'get': 'retrieve', 'post': 'create'})), + views.ConfigViewSet.as_view({'get': 'retrieve', 'post': 'create', 'delete': 'destroy'})), re_path( r"^apps/(?P{})/releases/v(?P[0-9]+)/?$".format(settings.APP_URL_REGEX), views.ReleaseViewSet.as_view({'get': 'retrieve'})), @@ -49,7 +70,7 @@ # list/delete pods re_path( r"^apps/(?P{})/pods/?$".format(settings.APP_URL_REGEX), - views.PodViewSet.as_view({'get': 'list', 'delete': 'delete'})), + views.PodViewSet.as_view({'get': 'list', 'delete': 'destroy'})), # describe pod re_path( r"^apps/(?P{})/pods/(?P{})/describe/?$".format( @@ -92,11 +113,7 @@ re_path( r"^apps/(?P{})/services/?$".format(settings.APP_URL_REGEX), views.ServiceViewSet.as_view({'post': 'create_or_update', - 'get': 'list', 'delete': 'delete'})), - # application actions - re_path( - r"^apps/(?P{})/run/?$".format(settings.APP_URL_REGEX), - views.AppViewSet.as_view({'post': 'run'})), + 'get': 'list', 'delete': 'destroy'})), # application settings re_path( r"^apps/(?P{})/settings/?$".format(settings.APP_URL_REGEX), @@ -143,17 +160,18 @@ re_path( r'^apps/(?P{})/certs/(?P{})/?$'.format( settings.APP_URL_REGEX, settings.NAME_REGEX), - views.CertificateViewSet.as_view({ - 'get': 'retrieve', - 'delete': 'destroy' - })), + views.CertificateViewSet.as_view({'get': 'retrieve', 'delete': 'destroy'})), re_path( r'^apps/(?P{})/certs/?$'.format(settings.APP_URL_REGEX), views.CertificateViewSet.as_view({'get': 'list', 'post': 'create'})), + # application actions + re_path( + r"^apps/(?P{})/run/?$".format(settings.APP_URL_REGEX), + views.AppViewSet.as_view({'post': 'run'})), # apps base endpoint re_path( r"^apps/(?P{})/?$".format(settings.APP_URL_REGEX), - views.AppViewSet.as_view({'get': 'retrieve', 'post': 'update', 'delete': 'destroy'})), + views.AppViewSet.as_view({'get': 'retrieve', 'patch': 'transfer', 'delete': 'destroy'})), re_path( r'^apps/?$', views.AppViewSet.as_view({'get': 'list', 'post': 'create'})), @@ -183,18 +201,18 @@ # authn / authz re_path( r'^auth/whoami/?$', - views.UserManagementViewSet.as_view({'get': 'list'})), + views.UserManagementViewSet.as_view({'get': 'whoami'})), # gateways re_path( r"^apps/(?P{})/gateways/?$".format(settings.APP_URL_REGEX), views.GatewayViewSet.as_view( - {'post': 'create_or_update', 'get': 'list', 'delete': 'delete'})), + {'post': 'create_or_update', 'get': 'list', 'delete': 'destroy'})), # routes re_path( r"^apps/(?P{})/routes/(?P{})?/?$".format( settings.APP_URL_REGEX, settings.NAME_REGEX), views.RouteViewSet.as_view( - {'post': 'create', 'get': 'list', 'delete': 'delete'})), + {'post': 'create', 'get': 'list', 'delete': 'destroy'})), re_path( r"^apps/(?P{})/routes/(?P{})/attach/?$".format( settings.APP_URL_REGEX, settings.NAME_REGEX), @@ -207,14 +225,6 @@ r"^apps/(?P{})/routes/(?P{})/rules/?$".format( settings.APP_URL_REGEX, settings.NAME_REGEX), views.RouteViewSet.as_view({'get': 'get', 'put': 'set'})), - # users - re_path(r'^users/?$', views.UserView.as_view({'get': 'list'})), - re_path( - r'^users/(?P[\w.@+-]+)/enable/?$', - views.UserView.as_view({'patch': 'enable'})), - re_path( - r'^users/(?P[\w.@+-]+)/disable/?$', - views.UserView.as_view({'patch': 'disable'})), re_path( r'^apps/(?P{})/metrics/?$'.format(settings.APP_URL_REGEX), views.MetricView.as_view({'get': 'metric'})), @@ -228,13 +238,6 @@ re_path( r'^manager/(?P[\w.@+-]+)s/(?P{})/unblock/?$'.format(settings.APP_URL_REGEX), views.WorkflowManagerViewset.as_view({'delete': 'unblock'})), - # user perms - re_path( - r"^apps/(?P{})/perms/?$".format(settings.APP_URL_REGEX), - views.AppPermViewSet.as_view({'get': 'list', 'post': 'create'})), - re_path( - r"^apps/(?P{})/perms/(?P[\w.@+-]+)/?$".format(settings.APP_URL_REGEX), - views.AppPermViewSet.as_view({'put': 'update', 'delete': 'destroy'})), # quickwit re_path( r'^quickwit/(?P[\w.@+-]+)/(?P.+)/?$', views.QuickwitProxyView.as_view()), diff --git a/rootfs/api/utils.py b/rootfs/api/utils.py index e3c40b9d4..d2aa1d519 100644 --- a/rootfs/api/utils.py +++ b/rootfs/api/utils.py @@ -240,6 +240,11 @@ def validate_json(value, schema, raise_exception=jsonschema.ValidationError): return value +def get_local_host(request): + uri = request.build_absolute_uri() + return uri[0:uri.find(request.path)] + + class CacheLock(object): def __init__(self, key): diff --git a/rootfs/api/views.py b/rootfs/api/views.py index ef30259e9..53683b147 100644 --- a/rootfs/api/views.py +++ b/rootfs/api/views.py @@ -9,6 +9,7 @@ import time import zlib import random +import secrets import aiohttp import requests import warnings @@ -21,21 +22,18 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.shortcuts import get_object_or_404, redirect -from guardian.shortcuts import assign_perm, get_objects_for_user, \ - get_users_with_perms, get_user_perms, remove_perm from django.views.generic import View from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_page from django.views.decorators.vary import vary_on_headers -from rest_framework import renderers, status, filters +from rest_framework import status, filters from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.response import Response -from rest_framework.viewsets import GenericViewSet -from rest_framework.exceptions import PermissionDenied +from rest_framework.viewsets import GenericViewSet, ModelViewSet +from rest_framework.exceptions import ValidationError from api import monitor, models, permissions, serializers, viewsets, authentication, __version__ -from api.tasks import scale_app, restart_app, mount_app, downstream_model_owner, \ - delete_pod, scale_resources +from api.tasks import scale_app, restart_app, mount_app, delete_pod, scale_resources from api.exceptions import AlreadyExists, ServiceUnavailable, DryccException from django.views.decorators.cache import never_cache @@ -168,14 +166,8 @@ def token(self, request, *args, **kwargs): class UserManagementViewSet(GenericViewSet): serializer_class = serializers.UserSerializer - def get_queryset(self): - return User.objects.filter(pk=self.request.user.pk) - - def get_object(self): - return self.get_queryset()[0] - - def list(self, request, **kwargs): - user = self.get_object() + def whoami(self, request, **kwargs): + user = get_object_or_404(User, pk=self.request.user.pk) serializer = self.get_serializer(user, many=False) return Response(serializer.data) @@ -253,10 +245,213 @@ def handle(self, request, **kwargs): }) -class TokenViewSet(viewsets.OwnerViewSet): +class WorkspaceViewSet(ModelViewSet): + """ + ViewSet for Workspace model. + """ + lookup_field = 'name' + serializer_class = serializers.WorkspaceSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return models.workspace.Workspace.objects.filter( + workspacemember__user=self.request.user + ).distinct() + + def perform_create(self, serializer): + workspace = serializer.save() + models.workspace.WorkspaceMember.objects.create( + user=self.request.user, workspace=workspace, role='admin' + ) + + def get_object(self): + """Override to get workspace by name instead of pk""" + return get_object_or_404(self.get_queryset(), name=self.kwargs['name']) + + def partial_update(self, request, *args, **kwargs): + """Only admins can update workspaces""" + workspace = self.get_object() + if not workspace.has_member(request.user, role='admin'): + return Response( + {"detail": "Only workspace admins can update workspaces"}, + status=status.HTTP_403_FORBIDDEN + ) + return super().partial_update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + """Only admins can delete workspaces""" + workspace = self.get_object() + if not workspace.has_member(request.user, role='admin'): + return Response( + {"detail": "Only workspace admins can delete workspaces"}, + status=status.HTTP_403_FORBIDDEN + ) + if models.workspace.WorkspaceMember.objects.filter(workspace=workspace).count() > 1: + return Response( + {"detail": "Cannot delete workspace with more than one member"}, + status=status.HTTP_403_FORBIDDEN + ) + return super().destroy(request, *args, **kwargs) + + +class WorkspaceMemberViewSet(ModelViewSet): + """ + ViewSet for WorkspaceMember model. + """ + serializer_class = serializers.WorkspaceMemberSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + workspace = get_object_or_404(models.workspace.Workspace, name=self.kwargs['name']) + # Check if user has access to this workspace + if workspace.has_member(self.request.user): + return models.workspace.WorkspaceMember.objects.filter(workspace=workspace) + return models.workspace.WorkspaceMember.objects.none() + + def get_object(self): + """Override to get member by username and workspace name""" + workspace = get_object_or_404(models.workspace.Workspace, name=self.kwargs['name']) + return get_object_or_404( + models.workspace.WorkspaceMember, + workspace=workspace, user__username=self.kwargs['user'] + ) + + def partial_update(self, request, *args, **kwargs): + """Update a member. Admins can update any member (role and alerts). + Non-admins can only update their own alerts field.""" + member = self.get_object() + is_admin = member.workspace.has_member(request.user, role='admin') + + # Check if workspace has only one member + member_count = models.workspace.WorkspaceMember.objects.filter( + workspace=member.workspace + ).count() + is_only_member = member_count == 1 + + # Only member cannot modify role + if is_only_member and 'role' in request.data: + return Response( + {"detail": "Cannot modify role: workspace only has one member"}, + status=status.HTTP_403_FORBIDDEN + ) + # Non-admin users restrictions + if not is_admin: + # Cannot update other members + if request.user != member.user: + return Response( + {"detail": "Only workspace admins can update other members"}, + status=status.HTTP_403_FORBIDDEN + ) + # Cannot modify own role + if 'role' in request.data: + return Response( + {"detail": "Cannot modify your own role"}, + status=status.HTTP_403_FORBIDDEN + ) + + return super().partial_update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + """Delete a member. Admins can delete any member. + Non-admins can only delete themselves (leave workspace).""" + member = self.get_object() + is_admin = member.workspace.has_member(request.user, role='admin') + + # Check if workspace has only one member + member_count = models.workspace.WorkspaceMember.objects.filter( + workspace=member.workspace + ).count() + is_only_member = member_count == 1 + + # Only member cannot delete self + if is_only_member and request.user == member.user: + return Response( + {"detail": "Cannot delete: workspace only has one member"}, + status=status.HTTP_403_FORBIDDEN + ) + + # Non-admin can delete self + if request.user == member.user: + return super().destroy(request, *args, **kwargs) + + # Admin can delete any member + if is_admin: + return super().destroy(request, *args, **kwargs) + + # Other cases forbidden + return Response( + {"detail": "Only workspace admins can remove other members"}, + status=status.HTTP_403_FORBIDDEN + ) + + +class WorkspaceInvitationViewSet(ModelViewSet): + """ + ViewSet for WorkspaceInvitation model. + """ + serializer_class = serializers.WorkspaceInvitationSerializer + + def get_queryset(self): + workspace = get_object_or_404(models.workspace.Workspace, name=self.kwargs['name']) + if workspace.has_member(self.request.user): + return models.workspace.WorkspaceInvitation.objects.filter( + workspace=workspace, accepted=False) + return models.workspace.WorkspaceInvitation.objects.none() + + def get_object(self): + """Override to get invitation by uid and workspace name""" + return get_object_or_404( + models.workspace.WorkspaceInvitation, + workspace=get_object_or_404(models.workspace.Workspace, name=self.kwargs['name']), + token=self.kwargs['uid'], + accepted=False, + ) + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + instance.accept() + serializer = self.get_serializer(instance) + return Response(serializer.data) + + def perform_create(self, serializer): + workspace = get_object_or_404(models.workspace.Workspace, name=self.kwargs['name']) + if not workspace.has_member(self.request.user, role='admin'): + raise ValidationError("Only workspace admins can create invitations") + email = serializer.validated_data['email'] + user = User.objects.filter(email=email).first() + if user and workspace.has_member(user): + raise ValidationError("User is already a member of the workspace") + invitation = models.workspace.WorkspaceInvitation.objects.filter( + email=email, workspace=workspace, accepted=False + ).first() + if not invitation: + models.workspace.WorkspaceInvitation.objects.filter( + email=email, workspace=workspace, accepted=True + ).delete() + invitation = serializer.save( + token=secrets.token_hex(64), inviter=self.request.user, workspace=workspace) + if settings.EMAIL_HOST: + invitation.send_email(self.request) + else: + invitation.accept() + + def destroy(self, request, *args, **kwargs): + """Only admins can revoke invitations""" + invitation = self.get_object() + if not invitation.workspace.has_member(request.user, role='admin'): + return Response( + {"detail": "Only workspace admins can revoke invitations"}, + status=status.HTTP_403_FORBIDDEN + ) + return super().destroy(request, *args, **kwargs) + + +class TokenViewSet(viewsets.OwnerViewSet): + """ + A viewset for interacting with Token objects. + """ serializer_class = serializers.TokenSerializer - permission_classes = [IsAuthenticated, permissions.IsOwner] def get_queryset(self): return models.base.Token.objects.filter(owner=self.request.user) @@ -268,19 +463,7 @@ def destroy(self, *args, **kwargs): return response -class BaseDryccViewSet(viewsets.OwnerViewSet): - """ - A generic ViewSet for objects related to Drycc. - - To use it, at minimum you'll need to provide the `serializer_class` attribute and - the `model` attribute shortcut. - """ - lookup_field = 'id' - permission_classes = [IsAuthenticated, permissions.IsObjectUser] - renderer_classes = [renderers.JSONRenderer] - - -class AppFilterViewSet(BaseDryccViewSet): +class AppFilterViewSet(viewsets.BaseAppViewSet): """A viewset for objects which are attached to an application.""" def get_app(self): @@ -314,34 +497,13 @@ def get_object(self): return getattr(release, self.model.__name__.lower()) -class AppViewSet(BaseDryccViewSet): +class AppViewSet(viewsets.BaseAppViewSet): """A viewset for interacting with App objects.""" model = models.app.App filter_backends = [filters.SearchFilter] search_fields = ['^id', ] serializer_class = serializers.AppSerializer - def get_queryset(self, *args, **kwargs): - return self.model.objects.all(*args, **kwargs) - - def list(self, request, *args, **kwargs): - """ - HACK: Instead of filtering by the queryset, we limit the queryset to list only the apps - which are owned by the user as well as any apps they have been given permission to - interact with. - """ - queryset = super().get_queryset(**kwargs) | \ - get_objects_for_user( - self.request.user, f'api.{models.app.VIEW_APP_PERMISSION.codename}') - instance = self.filter_queryset(queryset) - page = self.paginate_queryset(instance) - if page is not None: - serializer = self.get_serializer(page, many=True) - return self.get_paginated_response(serializer.data) - - serializer = self.get_serializer(instance, many=True) - return Response(serializer.data) - def run(self, request, **kwargs): app = self.get_object() ptype = request.data.get('ptype', 'run') @@ -363,23 +525,21 @@ def run(self, request, **kwargs): timeout=timeout, expires=expires) return Response(status=status.HTTP_204_NO_CONTENT) - @transaction.atomic - def create(self, request, *args, **kwargs): - return super().create(request, *args, **kwargs) + def perform_create(self, serializer): + workspace = serializer.validated_data['workspace'] + self.check_object_permissions(self.request, workspace) + serializer.save() - @transaction.atomic - def update(self, request, **kwargs): + def transfer(self, request, **kwargs): app = self.get_object() - old_owner = app.owner - - if request.data.get('owner'): - if self.request.user != app.owner and not self.request.user.is_superuser: - return Response(status=status.HTTP_403_FORBIDDEN) - new_owner = get_object_or_404(User, username=request.data['owner']) - # ensure all downstream objects that are owned by this user and are part of this app - # is also updated - downstream_model_owner.delay(app, old_owner, new_owner) - return Response(status=status.HTTP_200_OK) + if not app.workspace.has_member(request.user, role='admin'): + raise DryccException("you must be an admin of the current workspace") + workspace = request.data.get('workspace', '') + if not workspace: + raise DryccException("workspace is required") + app.workspace = get_object_or_404(models.workspace.Workspace, name=workspace) + app.save() + return Response(status=status.HTTP_204_NO_CONTENT) @transaction.atomic def destroy(self, request, *args, **kwargs): @@ -391,16 +551,16 @@ class BuildViewSet(ReleasableViewSet): model = models.build.Build serializer_class = serializers.BuildSerializer - def post_save(self, build): + def perform_create(self, serializer): + build = serializer.save() for ptype in build.ptypes: image = build.get_image(ptype) if is_loopback(image): raise DryccException("image must not use the loopback address") build.create_release(self.request.user) - super().post_save(build) -class LimitSpecViewSet(BaseDryccViewSet): +class LimitSpecViewSet(ModelViewSet): """A viewset for interacting with Limit objects.""" model = models.limit.LimitSpec serializer_class = serializers.LimitSpecSerializer @@ -414,7 +574,7 @@ def get_queryset(self, **kwargs): return self.model.objects.filter(q) -class LimitPlanViewSet(BaseDryccViewSet): +class LimitPlanViewSet(ModelViewSet): """A viewset for interacting with Limit objects.""" model = models.limit.LimitPlan serializer_class = serializers.LimitPlanSerializer @@ -445,7 +605,7 @@ def create(self, request, **kwargs): if self.request.query_params.get('merge', 'true').lower() == 'true': return super().create(request, **kwargs) values = self.get_serializer().validate_values(request.data.get('values')) - config = self.model(app=self.get_app(), owner=self.request.user, values=values) + config = self.model(app=self.get_app(), values=values) old_config = config.previous() if old_config and old_config.values: replace_ptypes = {v['ptype'] for v in config.values if 'ptype' in v} @@ -457,13 +617,19 @@ def create(self, request, **kwargs): headers = self.get_success_headers(data) return Response(data, status=status.HTTP_201_CREATED, headers=headers) - def delete(self, request, **kwargs): - values_refs = self.get_serializer().validate_values_refs(request.data.get('values_refs')) + def perform_create(self, serializer): + config = serializer.save() + self.post_save(config) + + def destroy(self, request, **kwargs): + values_refs = self.get_serializer().validate_values_refs( + request.data.get('values_refs', {})) if not values_refs or not values_refs.values(): raise DryccException("ptype or groups is required") - config = self.model(app=self.get_app(), owner=self.request.user, values_refs={}) - old_values_refs = config.previous().values_refs.copy() + config = self.model(app=self.get_app(), values_refs={}) + previous = config.previous() + old_values_refs = previous.values_refs.copy() if previous else {} for ptype, old_groups in old_values_refs.items(): groups_to_delete = values_refs.get(ptype, []) for group in old_groups: @@ -473,7 +639,6 @@ def delete(self, request, **kwargs): elif group not in config.values_refs[ptype]: config.values_refs[ptype].append(group) config.save(ignore_update_fields=["values_refs"]) - self.post_save(config) return Response(status=status.HTTP_200_OK) @@ -511,7 +676,7 @@ def describe(self, *args, **kwargs): pagination = {'results': data, 'count': len(data)} return Response(pagination, status=status.HTTP_200_OK) - def delete(self, request, **kwargs): + def destroy(self, request, **kwargs): pod_names = request.data.get("pod_ids") pod_names = pod_names.split(",") for pod_name in set(pod_names): @@ -640,13 +805,13 @@ def create_or_update(self, request, **kwargs): return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": "port is occupied"}) # noqa http_status = status.HTTP_204_NO_CONTENT else: - service = self.model(owner=app.owner, app=app, ptype=ptype) + service = self.model(app=app, ptype=ptype) http_status = status.HTTP_201_CREATED service.add_port(port, protocol, target_port) service.save() return Response(status=http_status) - def delete(self, request, **kwargs): + def destroy(self, request, **kwargs): port = self.get_serializer().validate_port(request.data.get('port')) protocol = self.get_serializer().validate_protocol(request.data.get('protocol')) ptype = self.get_serializer().validate_ptype( @@ -691,10 +856,10 @@ def detach(self, request, *args, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) -class KeyViewSet(BaseDryccViewSet): +class KeyViewSet(viewsets.OwnerViewSet): """A viewset for interacting with Key objects.""" + lookup_field = 'id' model = models.key.Key - permission_classes = [IsAuthenticated, permissions.IsOwner] serializer_class = serializers.KeySerializer @@ -758,7 +923,7 @@ def events(self, request, **kwargs): return Response({'results': results, 'count': len(results)}) -class BaseHookViewSet(BaseDryccViewSet): +class BaseHookViewSet(viewsets.BaseAppViewSet): permission_classes = [permissions.IsServiceToken] @@ -770,31 +935,22 @@ class KeyHookViewSet(BaseHookViewSet): def public_key(self, request, *args, **kwargs): fingerprint = kwargs['fingerprint'].strip() key = get_object_or_404(models.key.Key, fingerprint=fingerprint) - - queryset = models.app.App.objects.all() | \ - get_objects_for_user( - self.request.user, f'api.{models.app.VIEW_APP_PERMISSION.codename}') - items = self.filter_queryset(queryset) - - apps = [] - for item in items: - apps.append(item.id) - + queryset = models.app.App.objects.filter( + workspace__workspacemember__user=self.request.user + ).distinct() data = { 'username': key.owner.username, - 'apps': apps + 'apps': [item.id for item in self.filter_queryset(queryset)], } return Response(data, status=status.HTTP_200_OK) def app(self, request, *args, **kwargs): app = get_object_or_404(models.app.App, id=kwargs['id']) - usernames = [u.id for u in get_users_with_perms(app) - if u.has_perm(f"api.{models.app.VIEW_APP_PERMISSION.codename}", app)] - + usernames = app.workspace.workspacemember_set.values_list('user__username', flat=True) data = {} result = models.key.Key.objects \ - .filter(owner__in=usernames) \ + .filter(owner__username__in=usernames) \ .values('owner__username', 'public', 'fingerprint') \ .order_by('created') for info in result: @@ -813,9 +969,8 @@ def users(self, request, *args, **kwargs): app = get_object_or_404(models.app.App, id=kwargs['id']) request.user = get_object_or_404(User, username=kwargs['username']) # check the user is authorized for this app - has_permission, message = permissions.has_app_permission(request.user, app, request.method) - if not has_permission: - return Response(message, status=status.HTTP_403_FORBIDDEN) + if not permissions.IsAppUser().has_object_permission(request, self, app): + return Response(status=status.HTTP_403_FORBIDDEN) data = {request.user.username: []} keys = models.key.Key.objects \ @@ -843,9 +998,8 @@ def create(self, request, *args, **kwargs): app = get_object_or_404(models.app.App, id=request.data['receive_repo']) self.user = request.user = get_object_or_404(User, username=request.data['receive_user']) # check the user is authorized for this app - has_permission, message = permissions.has_app_permission(self.user, app, request.method) - if not has_permission: - return Response(message, status=status.HTTP_403_FORBIDDEN) + if not permissions.IsAppUser().has_object_permission(request, self, app): + return Response(status=status.HTTP_403_FORBIDDEN) request.data['app'] = app request.data['owner'] = self.user super().create(request, *args, **kwargs) @@ -857,7 +1011,8 @@ def create(self, request, *args, **kwargs): } return Response(response, status=status.HTTP_200_OK) - def post_save(self, build): + def perform_create(self, serializer): + build = serializer.save() build.create_release(self.user) @@ -870,90 +1025,13 @@ def create(self, request, *args, **kwargs): app = get_object_or_404(models.app.App, id=request.data['receive_repo']) request.user = get_object_or_404(User, username=request.data['receive_user']) # check the user is authorized for this app - has_permission, message = permissions.has_app_permission(request.user, app, request.method) - if not has_permission: - return Response(message, status=status.HTTP_403_FORBIDDEN) + if not permissions.IsAppUser().has_object_permission(request, self, app): + return Response(status=status.HTTP_403_FORBIDDEN) config = models.release.Release.latest(app).config serializer = self.get_serializer(config) return Response(serializer.data, status=status.HTTP_200_OK) -class AppPermViewSet(AppFilterViewSet): - """RESTful views for sharing apps with collaborators.""" - - def get_app(self, request): - app = get_object_or_404(models.app.App, id=self.kwargs['id']) - if not permissions.IsOwnerOrAdmin().has_object_permission(request, self, app): - raise PermissionDenied() - return app - - def list(self, request, **kwargs): - app = self.get_app(request) - results = [ - { - "app": app.id, - "username": user.username, - "permissions": [ - models.app.app_permission_registry.get(codename).shortname - for codename in get_user_perms(user, app) - ], - } - for user in get_users_with_perms(app) - ] - # fake out pagination for now - pagination = {'results': results, 'count': len(results)} - return Response(data=pagination) - - def create(self, request, **kwargs): - app = self.get_app(request) - username = request.data.get('username') - shortnames = set([perm for perm in request.data.get("permissions", "").split(",") if perm]) - all_shortnames = models.app.app_permission_registry.shortnames - if not shortnames or not shortnames.issubset(all_shortnames): - msg = "The permissions field is required and has a value range of: {}".format( - ",".join(all_shortnames) - ) - return Response(status=status.HTTP_400_BAD_REQUEST, data=msg) - user = get_object_or_404(User, username=username) - for shortname in shortnames: - permission = models.app.app_permission_registry.get(shortname) - if permission: - assign_perm(permission.codename, user, app) - app.log("User {} was granted access to {}".format(user, app)) - return Response(status=status.HTTP_201_CREATED) - - def update(self, request, **kwargs): - app = self.get_app(request) - user = get_object_or_404(User, username=kwargs['username']) - shortnames = set([perm for perm in request.data.get("permissions", "").split(",") if perm]) - all_shortnames = models.app.app_permission_registry.shortnames - if not shortnames or not shortnames.issubset(all_shortnames): - msg = "The permissions field is required and has a value range of: {}".format( - ",".join(all_shortnames) - ) - return Response(status=status.HTTP_400_BAD_REQUEST, data=msg) - for shortname in shortnames.symmetric_difference([ - models.app.app_permission_registry.get(codename).shortname - for codename in get_user_perms(user, app)]): - permission = models.app.app_permission_registry.get(shortname) - if permission: - if shortname in shortnames: - assign_perm(permission.codename, user, app) - else: - remove_perm(permission.codename, user, app) - app.log("User {} was revoked access to {}".format(user, app)) - return Response(status=status.HTTP_204_NO_CONTENT) - - def destroy(self, request, **kwargs): - app = self.get_app(request) - username = kwargs['username'] - user = get_object_or_404(User, username=username) - for codename in get_user_perms(user, app): - remove_perm(codename, user, app) - app.log("User {} was revoked access to {}".format(user, app)) - return Response(status=status.HTTP_204_NO_CONTENT) - - class AppVolumesViewSet(AppFilterViewSet): """RESTful views for volumes apps with collaborators.""" model = models.volume.Volume @@ -1101,14 +1179,14 @@ def create_or_update(self, request, *args, **kwargs): protocol = self.get_serializer().validate_protocol(request.data['protocol']) gateway = app.gateway_set.filter(name=name).first() if not gateway: - gateway = self.model(app=app, owner=app.owner, name=name) + gateway = self.model(app=app, name=name) added, msg = gateway.add(port, protocol) if not added: return Response(status=status.HTTP_400_BAD_REQUEST, data=msg) gateway.save() return Response(status=status.HTTP_201_CREATED) - def delete(self, request, **kwargs): + def destroy(self, request, **kwargs): app = self.get_app() port = self.get_serializer().validate_port(request.data.get('port')) protocol = self.get_serializer().validate_protocol(request.data['protocol']) @@ -1171,40 +1249,14 @@ def detach(self, request, *args, **kwargs): route.save() return Response(status=status.HTTP_204_NO_CONTENT) - def delete(self, request, *args, **kwargs): + def destroy(self, request, *args, **kwargs): app = self.get_app() route = get_object_or_404(self.model, app=app, name=kwargs['name']) route.delete() return Response(status=status.HTTP_204_NO_CONTENT) -class UserView(BaseDryccViewSet): - """A Viewset for interacting with User objects.""" - model = User - serializer_class = serializers.UserSerializer - permission_classes = [permissions.IsAdmin] - - def get_queryset(self): - return self.model.objects.exclude(username='AnonymousUser') - - def enable(self, request, **kwargs): - if request.user.username == kwargs['username']: - return Response(status=status.HTTP_423_LOCKED) - user = get_object_or_404(self.model, username=kwargs['username']) - user.is_active = True - user.save(update_fields=['is_active', ]) - return Response(status=status.HTTP_204_NO_CONTENT) - - def disable(self, request, **kwargs): - if request.user.username == kwargs['username']: - return Response(status=status.HTTP_423_LOCKED) - user = get_object_or_404(self.model, username=kwargs['username']) - user.is_active = False - user.save(update_fields=['is_active', ]) - return Response(status=status.HTTP_204_NO_CONTENT) - - -class MetricView(BaseDryccViewSet): +class MetricView(viewsets.BaseAppViewSet): """Getting monitoring indicators from monitor database""" def _get_app(self): @@ -1229,7 +1281,7 @@ class MetricsProxyView(View): re.compile(r'^(?:# (?:HELP|TYPE) )([a-zA-Z_][a-zA-Z0-9_:.-]*)').match) match_data = staticmethod( re.compile(r'^([a-zA-Z_][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?\s+(\S+)').match) - default_cache_value = (-1, -1) + default_cache_value = (None, -1) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1254,17 +1306,17 @@ async def sample(self, name, labels_str, value): app_id = labels.get("namespace", labels.get(settings.DRYCC_METRICS_CONFIG[name][0])) if not app_id: return None - account_id, timeout = self.cache.get(app_id, self.default_cache_value) - if (account_id < 0 and timeout < 0) or time.time() > timeout: + workspace_id, timeout = self.cache.get(app_id, self.default_cache_value) + if workspace_id is None or time.time() > timeout: if app := await models.app.App.objects.filter(id=app_id).afirst(): - account_id = app.owner_id + workspace_id = app.workspace_id else: - account_id = -1 - self.cache[app_id] = (account_id, time.time() + random.randint(600, 1200)) - if account_id < 0: + workspace_id = None + self.cache[app_id] = (workspace_id, time.time() + random.randint(600, 1200)) + if workspace_id is None: return None project_id = zlib.crc32(app_id.encode("utf-8")) - labels.update({'vm_account_id': account_id, 'vm_project_id': project_id}) + labels.update({'vm_account_id': workspace_id, 'vm_project_id': project_id}) return "%s{%s} %s\n" % (name, ",".join([f'{k}="{v}"' for k, v in labels.items()]), value) async def get(self, request): diff --git a/rootfs/api/viewsets.py b/rootfs/api/viewsets.py index 4e972fdd5..0871f5ba7 100644 --- a/rootfs/api/viewsets.py +++ b/rootfs/api/viewsets.py @@ -1,4 +1,5 @@ -from rest_framework import viewsets +from django.core.exceptions import ImproperlyConfigured +from rest_framework import viewsets, renderers from rest_framework.permissions import IsAuthenticated from api import permissions @@ -17,12 +18,25 @@ def get_queryset(self): return self.model.objects.filter(owner=self.request.user) def perform_create(self, serializer): - obj = serializer.save(owner=self.request.user) - self.post_save(obj) - - def post_save(self, obj): - """ - A post_save hook for performing actions after the object has been pushed to the database. - Leave it up to child classes to implement. - """ - pass + serializer.save(owner=self.request.user) + + +class BaseAppViewSet(viewsets.ModelViewSet): + """ + A ViewSet for the Workspace model, which filters workspaces by membership and role. + """ + lookup_field = 'id' + permission_classes = [IsAuthenticated, permissions.IsAppUser] + renderer_classes = [renderers.JSONRenderer] + + def get_queryset(self): + # Prefer direct workspace relation, then support app->workspace chain. + if hasattr(self.model, 'workspace'): + return self.model.objects.filter( + workspace__workspacemember__user=self.request.user).distinct() + elif hasattr(self.model, 'app'): + return self.model.objects.filter( + app__workspace__workspacemember__user=self.request.user).distinct() + raise ImproperlyConfigured( + f"{self.__class__.__name__} requires a model with a 'workspace' or 'app' field." + ) diff --git a/rootfs/requirements.txt b/rootfs/requirements.txt index c8ca06e04..e9d462e56 100644 --- a/rootfs/requirements.txt +++ b/rootfs/requirements.txt @@ -5,7 +5,6 @@ django==5.2.9 channels==4.3.1 aiohttp==3.13.3 django-cors-headers==4.9.0 -django-guardian==3.2.0 djangorestframework==3.16.1 docker==7.1.0 gunicorn==23.0.0 @@ -15,9 +14,9 @@ idna==3.11 jsonschema==4.25.1 morph==0.1.5 packaging==25.0 -pyasn1==0.6.2 +pyasn1==0.6.3 psycopg[binary]==3.2.12 -pyOpenSSL==25.3.0 +pyOpenSSL==26.0.0 ndg-httpsclient==0.5.1 pytz==2025.2 requests==2.32.5