diff --git a/rootfs/api/admin.py b/rootfs/api/admin.py
index c3710a944..056a3207f 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,25 +14,13 @@
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.
"""
date_hierarchy = 'created'
- list_display = ('created', 'owner', 'app')
- list_filter = ('owner', 'app')
+ list_display = ('created', 'app',)
+ list_filter = ('app',)
admin.site.register(Build, BuildAdmin)
@@ -45,8 +31,8 @@ class ConfigAdmin(admin.ModelAdmin):
in the Django admin.
"""
date_hierarchy = 'created'
- list_display = ('created', 'owner', 'app')
- list_filter = ('owner', 'app')
+ list_display = ('created', 'app',)
+ list_filter = ('app',)
admin.site.register(Config, ConfigAdmin)
@@ -57,8 +43,8 @@ class DomainAdmin(admin.ModelAdmin):
in the Django admin.
"""
date_hierarchy = 'created'
- list_display = ('owner', 'app', 'domain')
- list_filter = ('owner', 'app')
+ list_display = ('app', 'domain')
+ list_filter = ('app',)
admin.site.register(Domain, DomainAdmin)
@@ -81,9 +67,9 @@ class ReleaseAdmin(admin.ModelAdmin):
in the Django admin.
"""
date_hierarchy = 'created'
- list_display = ('created', 'version', 'owner', 'app')
+ list_display = ('created', 'version', 'app')
list_display_links = ('created', 'version')
- list_filter = ('owner', 'app')
+ list_filter = ('app',)
admin.site.register(Release, ReleaseAdmin)
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/migrations/0029_key_api_key_fingerp_4e77c5_idx_and_more.py b/rootfs/api/migrations/0029_key_api_key_fingerp_4e77c5_idx_and_more.py
new file mode 100644
index 000000000..454a81db8
--- /dev/null
+++ b/rootfs/api/migrations/0029_key_api_key_fingerp_4e77c5_idx_and_more.py
@@ -0,0 +1,25 @@
+# Generated by Django 5.2.9 on 2026-03-26 08:26
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0028_workspace_remove_app_owner_remove_appsettings_owner_and_more'),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name='key',
+ index=models.Index(fields=['fingerprint'], name='api_key_fingerp_4e77c5_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='limitplan',
+ index=models.Index(fields=['spec', 'cpu', 'memory'], name='api_limitpl_spec_id_f95ef2_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='release',
+ index=models.Index(fields=['app', 'failed', 'state'], name='api_release_app_id_9849f2_idx'),
+ ),
+ ]
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..15778f0aa 100644
--- a/rootfs/api/models/certificate.py
+++ b/rootfs/api/models/certificate.py
@@ -1,10 +1,8 @@
import logging
-from OpenSSL import crypto
-from pyasn1.codec.der import decoder as der_decoder
-from pyasn1.type import univ, constraint
-from ndg.httpsclient.ssl_peer_verification import SUBJ_ALT_NAME_SUPPORT
-from ndg.httpsclient.subj_alt_name import SubjectAltName as BaseSubjectAltName
-from datetime import datetime
+
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.x509.oid import ExtensionOID, NameOID
from pytz import utc
from django.shortcuts import get_object_or_404
@@ -22,59 +20,57 @@
logger = logging.getLogger(__name__)
-# Note: This is a slightly bug-fixed version of same from ndg-httpsclient.
-class SubjectAltName(BaseSubjectAltName):
- '''ASN.1 implementation for subjectAltNames support'''
+def get_subj_alt_name(peer_cert):
+ try:
+ san_extension = peer_cert.extensions.get_extension_for_oid(
+ ExtensionOID.SUBJECT_ALTERNATIVE_NAME
+ )
+ except x509.ExtensionNotFound:
+ return []
+
+ return san_extension.value.get_values_for_type(x509.DNSName)
+
- # There is no limit to how many SAN certificates a certificate may have,
- # however this needs to have some limit so we'll set an arbitrarily high
- # limit.
- sizeSpec = univ.SequenceOf.sizeSpec + \
- constraint.ValueSizeConstraint(1, 1024)
+def public_keys_match(cert_public_key, private_key):
+ def _public_key_der(key):
+ """Serialize any public key to DER SubjectPublicKeyInfo bytes."""
+ return key.public_bytes(
+ serialization.Encoding.DER,
+ serialization.PublicFormat.SubjectPublicKeyInfo,
+ )
+ return _public_key_der(cert_public_key) == _public_key_der(
+ private_key.public_key()
+ )
-# Note: This is a slightly bug-fixed version of same from ndg-httpsclient.
-def get_subj_alt_name(peer_cert):
- # Search through extensions
- dns_name = []
- if not SUBJ_ALT_NAME_SUPPORT:
- return dns_name
-
- general_names = SubjectAltName()
- for i in range(peer_cert.get_extension_count()):
- ext = peer_cert.get_extension(i)
- ext_name = ext.get_short_name()
- if ext_name != b'subjectAltName':
- continue
-
- # PyOpenSSL returns extension data in ASN.1 encoded form
- ext_dat = ext.get_data()
- decoded_dat = der_decoder.decode(ext_dat,
- asn1Spec=general_names)
-
- for name in decoded_dat:
- if not isinstance(name, SubjectAltName):
- continue
- for entry in range(len(name)):
- component = name.getComponentByPosition(entry)
- if component.getName() != 'dNSName':
- continue
- dns_name.append(str(component.getComponent()))
-
- return dns_name
+
+def get_certificate_not_valid_after(certificate):
+ if hasattr(certificate, 'not_valid_after_utc'):
+ return certificate.not_valid_after_utc
+
+ return certificate.not_valid_after.replace(tzinfo=utc)
+
+
+def get_certificate_not_valid_before(certificate):
+ if hasattr(certificate, 'not_valid_before_utc'):
+ return certificate.not_valid_before_utc
+
+ return certificate.not_valid_before.replace(tzinfo=utc)
def validate_certificate(value):
try:
- return crypto.load_certificate(crypto.FILETYPE_PEM, value)
- except crypto.Error as e:
+ certificate_bytes = value.encode('utf-8') if isinstance(value, str) else value
+ return x509.load_pem_x509_certificate(certificate_bytes)
+ except (TypeError, ValueError) as e:
raise ValidationError('Could not load certificate: {}'.format(e))
def validate_private_key(value):
try:
- return crypto.load_privatekey(crypto.FILETYPE_PEM, value)
- except crypto.Error as e:
+ private_key_bytes = value.encode('utf-8') if isinstance(value, str) else value
+ return serialization.load_pem_private_key(private_key_bytes, password=None)
+ except (TypeError, ValueError) as e:
raise ValidationError('Could not load private key: {}'.format(e))
@@ -87,12 +83,8 @@ def validate_cert_pair(certificate, private_key):
# The certificate and key should already have been validated
raise SuspiciousOperation(e)
- if pkey.type() == crypto.TYPE_RSA:
- # Compare modulus n, to the factors p and q
- priv_numbers = pkey.to_cryptography_key().private_numbers()
- pub_modulus = cert.get_pubkey().to_cryptography_key().public_numbers().n
- if pub_modulus != (priv_numbers.p * priv_numbers.q):
- raise ValidationError('Certificate and private key do not match!')
+ if not public_keys_match(cert.public_key(), pkey):
+ raise ValidationError('Certificate and private key do not match!')
# Return tuple if everything went ok
return (cert, pkey)
@@ -102,7 +94,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
@@ -144,34 +135,26 @@ def save(self, *args, **kwargs):
certificate, _ = validate_cert_pair(self.certificate, self.key)
if not self.common_name:
- self.common_name = certificate.get_subject().CN
+ common_names = certificate.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
+ self.common_name = common_names[0].value if common_names else None
# Grab expire date of the certificate
if not self.expires:
- # https://pyopenssl.readthedocs.org/en/latest/api/crypto.html#OpenSSL.crypto.X509.get_notAfter
- # Convert bytes to string
- timestamp = certificate.get_notAfter().decode(encoding='UTF-8')
- # convert openssl's expiry date format to Django's DateTimeField format
- self.expires = datetime.strptime(timestamp, '%Y%m%d%H%M%SZ').replace(tzinfo=utc)
+ self.expires = get_certificate_not_valid_after(certificate)
# Grab the start date of the certificate
if not self.starts:
- # https://pyopenssl.readthedocs.org/en/latest/api/crypto.html#OpenSSL.crypto.X509.get_notBefore
- # Convert bytes to string
- timestamp = certificate.get_notBefore().decode(encoding='UTF-8')
- # convert openssl's starts date format to Django's DateTimeField format
- self.starts = datetime.strptime(timestamp, '%Y%m%d%H%M%SZ').replace(tzinfo=utc)
+ self.starts = get_certificate_not_valid_before(certificate)
# process issuers - separate each key/value with a slash
- issuer = certificate.get_issuer().get_components()
- self.issuer = '/' + '/'.join('%s=%s' % (key.decode(encoding='UTF-8'), value.decode(encoding='UTF-8')) for key, value in issuer) # noqa
+ self.issuer = certificate.issuer.rfc4514_string()
# process subject - separate each key/value with a slash
- subject = certificate.get_subject().get_components()
- self.subject = '/' + '/'.join('%s=%s' % (key.decode(encoding='UTF-8'), value.decode(encoding='UTF-8')) for key, value in subject) # noqa
+ self.subject = certificate.subject.rfc4514_string()
# public fingerprint of certificate
- self.fingerprint = certificate.digest('sha256').decode()
+ fingerprint = certificate.fingerprint(hashes.SHA256()).hex().upper()
+ self.fingerprint = ':'.join(fingerprint[i:i + 2] for i in range(0, len(fingerprint), 2))
# SubjectAltName from the certificate - return a list
self.san = get_subj_alt_name(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..7f2d43b88 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
@@ -426,6 +423,10 @@ def randstr(k):
# Oauth settings
DRYCC_PASSPORT_URL = os.environ.get('DRYCC_PASSPORT_URL', 'https://127.0.0.1:8000')
+DRYCC_REGISTER_URL = os.environ.get(
+ 'DRYCC_REGISTER_URL',
+ f'{DRYCC_PASSPORT_URL}/user/registration'
+)
LOGIN_REDIRECT_URL = os.environ.get(
'LOGIN_REDIRECT_URL',
@@ -459,6 +460,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/templates/workspace/workspace_invitation_accept.html b/rootfs/api/templates/workspace/workspace_invitation_accept.html
new file mode 100644
index 000000000..94c5c1893
--- /dev/null
+++ b/rootfs/api/templates/workspace/workspace_invitation_accept.html
@@ -0,0 +1,107 @@
+
+
+
+
+ Workspace Invitation
+
+
+
+
+
+
+
+
+
+ {% if user_exists %}
+
Invitation Accepted
+
You have been added to workspace {{ workspace_name }}.
+
Use the Drycc CLI to log in: drycc login
+ {% else %}
+
You're Invited to {{ workspace_name }}
+
You need an account to join this workspace.
+
First, sign up on Drycc Passport.
+
Then use the Drycc CLI to log in: drycc login
+ {% endif %}
+
+
+
+
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..38a58d904 100644
--- a/rootfs/api/tests/test_app.py
+++ b/rootfs/api/tests/test_app.py
@@ -15,13 +15,13 @@
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
-from api.tests import adapter, DryccTestCase
+from api.tests import adapter, DryccTestCase, DryccTransactionTestCase
import requests_mock
User = get_user_model()
@@ -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)
+
+ # owner adds collaborator to workspace
+ WorkspaceMember.objects.get_or_create(
+ workspace=workspace,
+ user=collaborator,
+ defaults={'role': 'member'},
+ )
- # New owner can access it
- self.client.credentials(HTTP_AUTHORIZATION='Token ' + new_owner_token)
+ # 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"
@@ -677,10 +712,292 @@ def test_app_name_bad_regex(self, mock_requests):
self.assertEqual(response.status_code, 200, response)
self.assertEqual(response.data['count'], 1, response.data)
+ def test_app_workspace_isolation(self, mock_requests):
+ """
+ Apps in different workspaces should be isolated.
+ A user who is not a member of a workspace should not see its apps.
+ """
+ # user1 creates workspace and app
+ response = self.client.post('/v2/workspaces', {
+ 'name': 'wsisolate1',
+ 'email': 'isolate1@example.com',
+ })
+ self.assertEqual(response.status_code, 201, response.data)
+
+ response = self.client.post('/v2/apps', {
+ 'id': 'app-isolate01',
+ 'workspace': 'wsisolate1',
+ })
+ self.assertEqual(response.status_code, 201, response.data)
+
+ # user2 creates a different workspace and app
+ user2 = User.objects.get(username='autotest2')
+ token2 = self.get_or_create_token(user2)
+ self.client.credentials(HTTP_AUTHORIZATION='Token ' + token2)
+
+ response = self.client.post('/v2/workspaces', {
+ 'name': 'wsisolate2',
+ 'email': 'isolate2@example.com',
+ })
+ self.assertEqual(response.status_code, 201, response.data)
+
+ response = self.client.post('/v2/apps', {
+ 'id': 'app-isolate02',
+ 'workspace': 'wsisolate2',
+ })
+ self.assertEqual(response.status_code, 201, response.data)
+
+ # user2 cannot see user1's app
+ response = self.client.get('/v2/apps/app-isolate01')
+ self.assertEqual(response.status_code, 404, response.data)
+
+ # user2 can see their own app
+ response = self.client.get('/v2/apps/app-isolate02')
+ self.assertEqual(response.status_code, 200, response.data)
+
+ # user2's app list should only contain their own app
+ response = self.client.get('/v2/apps')
+ self.assertEqual(response.status_code, 200, response.data)
+ app_ids = [app['id'] for app in response.data['results']]
+ self.assertIn('app-isolate02', app_ids)
+ self.assertNotIn('app-isolate01', app_ids)
+
+ def test_app_workspace_member_can_see_app(self, mock_requests):
+ """
+ When a user is added as a member to a workspace,
+ they should be able to see apps in that workspace.
+ """
+ # user1 creates workspace and app
+ response = self.client.post('/v2/workspaces', {
+ 'name': 'wsmember1',
+ 'email': 'member1@example.com',
+ })
+ self.assertEqual(response.status_code, 201, response.data)
+
+ response = self.client.post('/v2/apps', {
+ 'id': 'app-member01',
+ 'workspace': 'wsmember1',
+ })
+ self.assertEqual(response.status_code, 201, response.data)
+
+ # user2 cannot see the app initially
+ user2 = User.objects.get(username='autotest2')
+ token2 = self.get_or_create_token(user2)
+ self.client.credentials(HTTP_AUTHORIZATION='Token ' + token2)
+
+ response = self.client.get('/v2/apps/app-member01')
+ self.assertEqual(response.status_code, 404, response.data)
+
+ # add user2 as a viewer to the workspace
+ workspace = Workspace.objects.get(name='wsmember1')
+ WorkspaceMember.objects.create(user=user2, workspace=workspace, role='viewer')
-FAKE_LOG_DATA = bytes("""
-2013-08-15 12:41:25 [33454] [INFO] Starting gunicorn 17.5
-2013-08-15 12:41:25 [33454] [INFO] Listening at: http://0.0.0.0:5000 (33454)
-2013-08-15 12:41:25 [33454] [INFO] Using worker: sync
-2013-08-15 12:41:25 [33457] [INFO] Booting worker with pid 33457
-""", 'utf-8')
+ # now user2 can see the app
+ response = self.client.get('/v2/apps/app-member01')
+ self.assertEqual(response.status_code, 200, response.data)
+
+ def test_app_response_has_workspace_not_owner(self, mock_requests):
+ """
+ App API response should contain 'workspace' field, not 'owner'.
+ """
+ app_id = self.create_app()
+
+ response = self.client.get(f'/v2/apps/{app_id}')
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertIn('workspace', response.data)
+ self.assertNotIn('owner', response.data)
+
+ # app list should also have workspace, not owner
+ response = self.client.get('/v2/apps')
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertGreater(len(response.data['results']), 0)
+ app_data = response.data['results'][0]
+ self.assertIn('workspace', app_data)
+ self.assertNotIn('owner', app_data)
+
+ def test_app_subresources_have_no_owner_field(self, mock_requests):
+ """
+ App sub-resources (build, config, release, domain, etc.) should
+ not contain an 'owner' field in their API responses since the
+ owner field has been removed from these models.
+ """
+ app_id = self.create_app()
+
+ # Create a build to generate a release
+ url = f'/v2/apps/{app_id}/build'
+ body = {'image': 'autotest/example', 'stack': 'container'}
+ response = self.client.post(url, body)
+ self.assertEqual(response.status_code, 201, response.data)
+
+ # Build should not have 'owner'
+ self.assertNotIn('owner', response.data)
+
+ # Config should not have 'owner'
+ url = f'/v2/apps/{app_id}/config'
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertNotIn('owner', response.data)
+
+ # Releases should not have 'owner'
+ url = f'/v2/apps/{app_id}/releases'
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200, response.data)
+ for release in response.data['results']:
+ self.assertNotIn('owner', release)
+
+ def test_key_and_token_still_have_owner(self, mock_requests):
+ """
+ Key and Token models should still contain 'owner' field
+ since they are user personal assets, not workspace resources.
+ """
+ # Key should have 'owner'
+ body = {'id': str(self.user), 'public': (
+ 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCfQkkUUoxpvcNMkvv7jqnfodgs37M2eBO'
+ 'APgLK+KNBMaZaaKB4GF1QhTCMfFhoiTW3rqa0J75bHJcdkoobtTHlK8XUrFqsquWyg3XhsT'
+ 'Yr/3RQQXvO86e2sF7SVDJqVtpnbQGc5SgNrHCeHJmf5HTbXSIjCO/AJSvIjnituT/SIAMGe'
+ 'Bw0Nq/iSltwYAek1hiKO7wSmLcIQ8U4A00KEUtalaumf2aHOcfjgPfzlbZGP0S0cuBwSqLr'
+ '8b5XGPmkASNdUiuJY4MJOce7bFU14B7oMAy2xacODUs1momUeYtGI9T7X2WMowJaO7tP3Gl'
+ 'sgBMP81VfYTfYChAyJpKp2yoP autotest@autotesting'
+ )}
+ response = self.client.post('/v2/keys', body)
+ self.assertEqual(response.status_code, 201, response.data)
+ self.assertIn('owner', response.data)
+
+ # Token should have 'owner'
+ response = self.client.get('/v2/tokens')
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertGreater(len(response.data['results']), 0)
+ self.assertIn('owner', response.data['results'][0])
+
+ def test_app_list_filters_by_workspace_membership(self, mock_requests):
+ """
+ App list API should only return apps in workspaces where the
+ authenticated user is a member.
+ """
+ # user1 creates workspace1 with app1
+ response = self.client.post('/v2/workspaces', {
+ 'name': 'wslist01',
+ 'email': 'wslist1@example.com',
+ })
+ self.assertEqual(response.status_code, 201, response.data)
+
+ response = self.client.post('/v2/apps', {
+ 'id': 'app-list01',
+ 'workspace': 'wslist01',
+ })
+ self.assertEqual(response.status_code, 201, response.data)
+
+ # user2 creates workspace2 with app2
+ user2 = User.objects.get(username='autotest2')
+ token2 = self.get_or_create_token(user2)
+ self.client.credentials(HTTP_AUTHORIZATION='Token ' + token2)
+
+ response = self.client.post('/v2/workspaces', {
+ 'name': 'wslist02',
+ 'email': 'wslist2@example.com',
+ })
+ self.assertEqual(response.status_code, 201, response.data)
+
+ response = self.client.post('/v2/apps', {
+ 'id': 'app-list02',
+ 'workspace': 'wslist02',
+ })
+ self.assertEqual(response.status_code, 201, response.data)
+
+ # user2 should only see their own app
+ response = self.client.get('/v2/apps')
+ self.assertEqual(response.status_code, 200, response.data)
+ app_ids = [app['id'] for app in response.data['results']]
+ self.assertIn('app-list02', app_ids)
+ self.assertNotIn('app-list01', app_ids)
+
+ # Switch back to user1
+ self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+
+ # user1 should only see their own app
+ response = self.client.get('/v2/apps')
+ self.assertEqual(response.status_code, 200, response.data)
+ app_ids = [app['id'] for app in response.data['results']]
+ self.assertIn('app-list01', app_ids)
+ self.assertNotIn('app-list02', app_ids)
+
+
+class AppWorkspaceModelTest(DryccTransactionTestCase):
+ """
+ Test App model workspace-related behavior without K8s dependency.
+
+ These tests verify the workspace-based permission model at the ORM level,
+ bypassing App.save() which requires K8s API access.
+ Uses bulk_create to insert App records directly into the database.
+ """
+
+ fixtures = ['tests.json']
+
+ def setUp(self):
+ self.user1 = User.objects.get(username='autotest')
+ self.token1 = self.get_or_create_token(self.user1)
+ self.user2 = User.objects.get(username='autotest2')
+ self.token2 = self.get_or_create_token(self.user2)
+
+ def tearDown(self):
+ cache.clear()
+
+ def test_app_model_has_workspace_no_owner(self):
+ """
+ App model should have a 'workspace' field and no 'owner' field.
+ """
+ # App has workspace field
+ self.assertTrue(hasattr(App, 'workspace'))
+ # App does NOT have owner field
+ self.assertFalse(hasattr(App, 'owner'))
+
+ def test_app_queryset_filters_by_workspace_membership(self):
+ """
+ App.objects.filter(workspace__workspacemember__user__username=...)
+ should return only apps in workspaces where the user is a member.
+ """
+ # Create workspace1 with user1 as admin
+ ws1 = Workspace.objects.create(name='wsamodel01', email='ws1@example.com')
+ WorkspaceMember.objects.create(workspace=ws1, user=self.user1, role='admin')
+
+ # Create workspace2 with user2 as admin
+ ws2 = Workspace.objects.create(name='wsamodel02', email='ws2@example.com')
+ WorkspaceMember.objects.create(workspace=ws2, user=self.user2, role='admin')
+
+ # Create apps directly in DB (bypass K8s)
+ App.objects.bulk_create([
+ App(id='app-model01', workspace=ws1),
+ App(id='app-model02', workspace=ws2),
+ ])
+
+ # user1 should only see app-model01
+ user1_apps = list(
+ App.objects.filter(
+ workspace__workspacemember__user__username=self.user1.username
+ ).values_list('id', flat=True)
+ )
+ self.assertIn('app-model01', user1_apps)
+ self.assertNotIn('app-model02', user1_apps)
+
+ # user2 should only see app-model02
+ user2_apps = list(
+ App.objects.filter(
+ workspace__workspacemember__user__username=self.user2.username
+ ).values_list('id', flat=True)
+ )
+ self.assertIn('app-model02', user2_apps)
+ self.assertNotIn('app-model01', user2_apps)
+
+ def test_key_model_still_has_owner(self):
+ """
+ Key model should still have 'owner' field since it's a user personal asset.
+ """
+ from api.models.key import Key
+ self.assertTrue(hasattr(Key, 'owner'))
+
+ def test_token_model_still_has_owner(self):
+ """
+ Token model should still have 'owner' field since it's a user personal asset.
+ """
+ from api.models.base import Token
+ self.assertTrue(hasattr(Token, 'owner'))
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..ccb9d5446 100644
--- a/rootfs/api/tests/test_certificate.py
+++ b/rootfs/api/tests/test_certificate.py
@@ -5,6 +5,12 @@
from api.models.app import App
from api.models.certificate import Certificate
from api.tests import TEST_ROOT, DryccTestCase
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.x509.oid import NameOID
+from datetime import datetime, timedelta
+from pytz import utc
User = get_user_model()
@@ -19,7 +25,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'
@@ -33,6 +40,34 @@ def tearDown(self):
# make sure every test has a clean slate for k8s mocking
cache.clear()
+ def _create_certificate_pem(self, common_name, san_names=None):
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ subject = issuer = x509.Name([
+ x509.NameAttribute(NameOID.COUNTRY_NAME, 'US'),
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'Drycc'),
+ x509.NameAttribute(NameOID.COMMON_NAME, common_name),
+ ])
+ builder = x509.CertificateBuilder().subject_name(subject).issuer_name(issuer)
+ builder = builder.public_key(private_key.public_key())
+ builder = builder.serial_number(x509.random_serial_number())
+ builder = builder.not_valid_before(datetime.now(utc) - timedelta(minutes=1))
+ builder = builder.not_valid_after(datetime.now(utc) + timedelta(days=30))
+
+ if san_names:
+ builder = builder.add_extension(
+ x509.SubjectAlternativeName([x509.DNSName(name) for name in san_names]),
+ critical=False,
+ )
+
+ certificate = builder.sign(private_key=private_key, algorithm=hashes.SHA256())
+ cert_pem = certificate.public_bytes(serialization.Encoding.PEM).decode('utf-8')
+ key_pem = private_key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
+ encryption_algorithm=serialization.NoEncryption(),
+ ).decode('utf-8')
+ return cert_pem, key_pem, certificate
+
def test_create_certificate_with_domain(self):
"""Tests creating a certificate."""
response = self.client.post(
@@ -146,7 +181,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
@@ -156,7 +190,7 @@ def test_delete_certificate(self):
self.assertEqual(response.status_code, 204, response.data)
def test_create_invalid_cert(self):
- """Upload a cert that can't be loaded by pyopenssl"""
+ """Upload a cert that can't be parsed."""
response = self.client.post(
self.url,
{
@@ -166,15 +200,14 @@ def test_create_invalid_cert(self):
}
)
self.assertEqual(response.status_code, 400, response.data)
- # match partial since right now the rest is pyopenssl errors
+ # Match partial since parser details may vary.
self.assertIn('Could not load certificate', response.data['certificate'][0])
def test_load_invalid_cert(self):
- """Inject a cert that can't be loaded by pyopenssl"""
+ """Inject a cert that can't be parsed."""
with self.assertRaises(SuspiciousOperation):
Certificate.objects.create(
- owner=self.user,
app=self.app,
name='random-test-cert',
certificate='i am bad data',
@@ -182,7 +215,7 @@ def test_load_invalid_cert(self):
)
def test_create_invalid_key(self):
- """Upload a private key that can't be loaded by pyopenssl"""
+ """Upload a private key that can't be parsed."""
response = self.client.post(
self.url,
{
@@ -192,20 +225,71 @@ def test_create_invalid_key(self):
}
)
self.assertEqual(response.status_code, 400, response.data)
- # match partial since right now the rest is pyopenssl errors
+ # Match partial since parser details may vary.
self.assertIn('Could not load private key', response.data['key'][0])
def test_load_invalid_key(self):
- """Inject a private key that can't be loaded by pyopenssl"""
+ """Inject a private key that can't be parsed."""
with self.assertRaises(SuspiciousOperation):
Certificate.objects.create(
- owner=self.user,
+ app=self.app,
name='random-test-cert',
certificate=self.cert,
key='I am Groot.'
)
+ def test_create_certificate_with_san(self):
+ """Certificates with SAN should expose DNS names."""
+ cert, key, _ = self._create_certificate_pem(
+ 'autotest.example.com',
+ san_names=['autotest.example.com', 'www.autotest.example.com'],
+ )
+
+ response = self.client.post(
+ self.url,
+ {
+ 'name': 'san-test-cert',
+ 'certificate': cert,
+ 'key': key,
+ }
+ )
+ self.assertEqual(response.status_code, 201, response.data)
+ self.assertEqual(
+ response.data['san'],
+ ['autotest.example.com', 'www.autotest.example.com'],
+ )
+
+ def test_create_certificate_with_mismatched_key(self):
+ """Certificate and private key must match."""
+ cert, _, _ = self._create_certificate_pem('autotest.example.com')
+ _, other_key, _ = self._create_certificate_pem('other.example.com')
+
+ response = self.client.post(
+ self.url,
+ {
+ 'name': 'mismatch-test-cert',
+ 'certificate': cert,
+ 'key': other_key,
+ }
+ )
+ self.assertEqual(response.status_code, 400, response.data)
+ self.assertIn('Certificate and private key do not match!', response.data[0])
+
+ def test_certificate_persists_subject_and_issuer(self):
+ """Persisted certificate metadata should reflect x509 subject and issuer."""
+ cert, key, parsed = self._create_certificate_pem('autotest.example.com')
+
+ certificate = Certificate.objects.create(
+ name='metadata-test-cert',
+ app=self.app,
+ certificate=cert,
+ key=key,
+ )
+
+ self.assertEqual(certificate.subject, parsed.subject.rfc4514_string())
+ self.assertEqual(certificate.issuer, parsed.issuer.rfc4514_string())
+
def test_certs_fetch_limit(self):
"""
When a user retrieves a certificate, make sure limits work
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_proxy.py b/rootfs/api/tests/test_proxy.py
new file mode 100644
index 000000000..0d192ccaf
--- /dev/null
+++ b/rootfs/api/tests/test_proxy.py
@@ -0,0 +1,195 @@
+"""
+Tests for QuickwitProxyView and PrometheusProxyView workspace-based queries.
+
+These tests verify that app lookups use workspace membership
+instead of the legacy owner field (which no longer exists on App).
+"""
+from django.contrib.auth import get_user_model
+from django.core.cache import cache
+
+from api.models.app import App
+from api.models.workspace import Workspace, WorkspaceMember
+from api.tests import DryccTransactionTestCase
+
+User = get_user_model()
+
+
+def _create_app_directly(app_id, workspace):
+ """Create an App record directly in DB, bypassing K8s checks in App.save().
+
+ App.save() connects to K8s to verify namespaces, which fails in local tests.
+ We use bulk_create which calls INSERT directly without triggering save().
+ """
+ app = App(id=app_id, workspace=workspace)
+ App.objects.bulk_create([app])
+ return App.objects.get(id=app_id)
+
+
+class QuickwitAppIndexesQueryTest(DryccTransactionTestCase):
+ """
+ Test the ORM query used by QuickwitProxyView.get_app_indexes.
+
+ The original code used:
+ App.objects.filter(owner__username=username)
+ which is broken since App no longer has an 'owner' field.
+ The fixed query uses:
+ App.objects.filter(workspace__workspacemember__user__username=username)
+
+ Since App.save() requires K8s, we create app records directly in the DB.
+ """
+
+ fixtures = ['tests.json']
+
+ def setUp(self):
+ self.user1 = User.objects.get(username='autotest')
+ self.token1 = self.get_or_create_token(self.user1)
+
+ self.user2 = User.objects.get(username='autotest2')
+ self.token2 = self.get_or_create_token(self.user2)
+
+ def tearDown(self):
+ cache.clear()
+
+ def test_query_returns_apps_for_workspace_member(self):
+ """
+ A user who is a workspace member should see apps in that workspace
+ via the workspace__workspacemember query.
+ """
+ ws = Workspace.objects.create(name='wsqp01', email='ws1@example.com')
+ WorkspaceMember.objects.create(workspace=ws, user=self.user1, role='admin')
+ _create_app_directly('app-qp01', ws)
+
+ app_ids = list(
+ App.objects.filter(
+ workspace__workspacemember__user__username=self.user1.username
+ ).values_list('id', flat=True)
+ )
+ self.assertIn('app-qp01', app_ids)
+
+ def test_query_excludes_apps_for_non_member(self):
+ """
+ A user who is NOT a workspace member should NOT see apps
+ via the workspace__workspacemember query.
+ """
+ ws = Workspace.objects.create(name='wsqp02', email='ws2@example.com')
+ WorkspaceMember.objects.create(workspace=ws, user=self.user1, role='admin')
+ _create_app_directly('app-qp02', ws)
+
+ app_ids = list(
+ App.objects.filter(
+ workspace__workspacemember__user__username=self.user2.username
+ ).values_list('id', flat=True)
+ )
+ self.assertNotIn('app-qp02', app_ids)
+
+ def test_query_includes_apps_after_membership_added(self):
+ """
+ After adding a user as a workspace member, the query should
+ return apps in that workspace for the new member.
+ """
+ ws = Workspace.objects.create(name='wsqp03', email='ws3@example.com')
+ WorkspaceMember.objects.create(workspace=ws, user=self.user1, role='admin')
+ _create_app_directly('app-qp03', ws)
+
+ # user2 is not a member yet
+ app_ids_before = list(
+ App.objects.filter(
+ workspace__workspacemember__user__username=self.user2.username
+ ).values_list('id', flat=True)
+ )
+ self.assertNotIn('app-qp03', app_ids_before)
+
+ # Add user2 as member
+ WorkspaceMember.objects.create(workspace=ws, user=self.user2, role='viewer')
+
+ # Now user2 should see the app
+ app_ids_after = list(
+ App.objects.filter(
+ workspace__workspacemember__user__username=self.user2.username
+ ).values_list('id', flat=True)
+ )
+ self.assertIn('app-qp03', app_ids_after)
+
+ def test_query_excludes_apps_after_membership_removed(self):
+ """
+ After removing a user from a workspace, the query should
+ no longer return apps in that workspace for the removed member.
+ """
+ ws = Workspace.objects.create(name='wsqp04', email='ws4@example.com')
+ WorkspaceMember.objects.create(workspace=ws, user=self.user1, role='admin')
+ WorkspaceMember.objects.create(workspace=ws, user=self.user2, role='viewer')
+ _create_app_directly('app-qp04', ws)
+
+ # user2 can see the app
+ app_ids_before = list(
+ App.objects.filter(
+ workspace__workspacemember__user__username=self.user2.username
+ ).values_list('id', flat=True)
+ )
+ self.assertIn('app-qp04', app_ids_before)
+
+ # Remove user2 from workspace
+ WorkspaceMember.objects.filter(workspace=ws, user=self.user2).delete()
+
+ # user2 can no longer see the app
+ app_ids_after = list(
+ App.objects.filter(
+ workspace__workspacemember__user__username=self.user2.username
+ ).values_list('id', flat=True)
+ )
+ self.assertNotIn('app-qp04', app_ids_after)
+
+ def test_query_across_multiple_workspaces(self):
+ """
+ A user who is a member of multiple workspaces should see
+ apps from all their workspaces.
+ """
+ ws1 = Workspace.objects.create(name='wsqp05a', email='ws5a@example.com')
+ ws2 = Workspace.objects.create(name='wsqp05b', email='ws5b@example.com')
+ WorkspaceMember.objects.create(workspace=ws1, user=self.user1, role='admin')
+ WorkspaceMember.objects.create(workspace=ws2, user=self.user1, role='member')
+ _create_app_directly('app-qp05a', ws1)
+ _create_app_directly('app-qp05b', ws2)
+
+ app_ids = list(
+ App.objects.filter(
+ workspace__workspacemember__user__username=self.user1.username
+ ).values_list('id', flat=True)
+ )
+ self.assertIn('app-qp05a', app_ids)
+ self.assertIn('app-qp05b', app_ids)
+
+ def test_query_distinct_prevents_duplicates(self):
+ """
+ When a user has multiple roles or memberships that could cause
+ duplicate results, .distinct() should prevent duplicates.
+ """
+ ws = Workspace.objects.create(name='wsqp06', email='ws6@example.com')
+ WorkspaceMember.objects.create(workspace=ws, user=self.user1, role='admin')
+ _create_app_directly('app-qp06', ws)
+
+ app_ids = list(
+ App.objects.filter(
+ workspace__workspacemember__user__username=self.user1.username
+ ).distinct().values_list('id', flat=True)
+ )
+ # Should appear exactly once, not multiple times
+ self.assertEqual(app_ids.count('app-qp06'), 1)
+
+ def test_superuser_service_account_sees_all(self):
+ """
+ The 'drycc' service account bypasses the workspace member query
+ in QuickwitProxyView.get_app_indexes (handled by username == "drycc" check).
+ This test verifies the query itself is correct for normal users.
+ """
+ ws = Workspace.objects.create(name='wsqp07', email='ws7@example.com')
+ WorkspaceMember.objects.create(workspace=ws, user=self.user1, role='admin')
+ _create_app_directly('app-qp07', ws)
+
+ # Normal user query only returns their workspace's apps
+ app_ids = list(
+ App.objects.filter(
+ workspace__workspacemember__user__username=self.user1.username
+ ).values_list('id', flat=True)
+ )
+ self.assertIn('app-qp07', app_ids)
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..39476bd52
--- /dev/null
+++ b/rootfs/api/tests/test_workspace.py
@@ -0,0 +1,196 @@
+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 via API (JSON response)
+ response = self.client.get(f'/v2/workspaces/testinvite/invitations/{invitation.token}')
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertIn('workspace_name', response.data)
+ self.assertEqual(response.data['workspace_name'], 'testinvite')
+ self.assertIn('user_exists', response.data)
+ self.assertIn('register_url', 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)
+
+ @mock.patch('api.models.workspace.send_mail')
+ def test_invitation_accept_browser(self, mock_send_mail):
+ """Test accepting invitation via browser returns HTML"""
+ response = self.client.post(
+ '/v2/workspaces/testinvite/invitations', {'email': self.user2.email}
+ )
+ self.assertEqual(response.status_code, 201, response.data)
+ invitation = WorkspaceInvitation.objects.get(email=self.user2.email)
+
+ # Accept invitation via browser (HTML response)
+ response = self.client.get(
+ f'/v2/workspaces/testinvite/invitations/{invitation.token}',
+ HTTP_ACCEPT='text/html',
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response['Content-Type'], 'text/html; charset=utf-8')
+ self.assertIn(b'testinvite', response.content)
+
+ # Verify user became a member
+ self.assertTrue(
+ WorkspaceMember.objects.filter(
+ workspace=self.workspace,
+ user=self.user2
+ ).exists()
+ )
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..0dfd46c8d 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
@@ -20,22 +21,20 @@
from django.http import Http404, HttpResponse
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.shortcuts import get_object_or_404, redirect, render
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 rest_framework.renderers import JSONRenderer, TemplateHTMLRenderer
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 +167,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 +246,234 @@ 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_permissions(self):
+ """
+ Allow anyone to accept an invitation.
+ Only authenticated users can create or list invitations.
+ """
+ if self.action == 'retrieve':
+ return [AllowAny()]
+ return super().get_permissions()
+
+ def get_renderers(self):
+ if self.action == 'retrieve':
+ return [JSONRenderer(), TemplateHTMLRenderer()]
+ return super().get_renderers()
+
+ 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()
+ user_exists = User.objects.filter(email=instance.email).exists()
+ data = {
+ 'workspace_name': instance.workspace.name,
+ 'user_exists': user_exists,
+ 'register_url': settings.DRYCC_REGISTER_URL,
+ }
+ if isinstance(request.accepted_renderer, TemplateHTMLRenderer):
+ return render(request, 'workspace/workspace_invitation_accept.html', data)
+ return Response(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 +485,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 +519,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 +547,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 +573,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 +596,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 +627,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 +639,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 +661,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 +698,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 +827,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 +878,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 +945,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 +957,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 +991,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,11 +1020,9 @@ 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)
# return the application databag
response = {
@@ -857,7 +1032,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 +1046,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 +1200,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 +1270,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 +1302,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 +1327,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):
@@ -1419,7 +1492,8 @@ async def get_app_indexes(self, username, index):
app_ids = self.app_ids
else:
app_ids = [
- app.id async for app in models.app.App.objects.filter(owner__username=username)]
+ app.id async for app in models.app.App.objects.filter(
+ workspace__workspacemember__user__username=username).distinct()]
setattr(self, "app_ids", app_ids)
for app_id in app_ids:
app_index = f"{log_index_prefix}{app_id}"
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..0c80ff269 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,12 +14,9 @@ idna==3.11
jsonschema==4.25.1
morph==0.1.5
packaging==25.0
-pyasn1==0.6.2
psycopg[binary]==3.2.12
-pyOpenSSL==25.3.0
-ndg-httpsclient==0.5.1
pytz==2025.2
-requests==2.32.5
+requests==2.33.0
requests-toolbelt==1.0.0
celery[redis]==5.5.3
hiredis==3.3.0