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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <title>Workspace Invitation</title>
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <style>
+        :root {
+            --vscode-foreground: #CCCCCC;
+            --vscode-editor-background: #1F1F1F;
+            --vscode-textLink-foreground: #4daafc;
+        }
+        @media (prefers-color-scheme: light) {
+            :root {
+                --vscode-foreground: #3B3B3B;
+                --vscode-editor-background: #FFFFFF;
+                --vscode-textLink-foreground: #005FB8;
+            }
+        }
+        html { height: 100%; }
+        body {
+            box-sizing: border-box;
+            min-height: 100%;
+            margin: 0;
+            padding: 15px 30px;
+            display: flex;
+            flex-direction: column;
+            color: var(--vscode-foreground);
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", system-ui, "Ubuntu", "Droid Sans", sans-serif;
+            background-color: var(--vscode-editor-background);
+        }
+        a { color: var(--vscode-textLink-foreground); text-decoration: none; }
+        a:hover, a:focus { text-decoration: underline; }
+        .container {
+            height: 100vh;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            text-align: center;
+        }
+        .title {
+            font-size: 28px;
+            font-weight: 300;
+            margin: 0 0 12px 0;
+            color: var(--vscode-foreground);
+        }
+        .subtitle {
+            font-size: 18px;
+            font-weight: 300;
+            color: var(--vscode-foreground);
+            opacity: 0.7;
+            margin: 0 0 36px 0;
+        }
+        .detail {
+            font-size: 14px;
+            color: var(--vscode-foreground);
+            opacity: 0.7;
+            margin: 0 0 8px 0;
+        }
+        .icon-container {
+            margin-bottom: 24px;
+        }
+        @keyframes rise-and-glow {
+            0% { opacity: 0; transform: translateY(15px) scale(0.95); }
+            50% { opacity: 0.8; transform: translateY(-2px) scale(1.02); }
+            70% { opacity: 1; transform: translateY(1px) scale(0.99); }
+            100% { opacity: 1; transform: translateY(0) scale(1); }
+        }
+        .drycc-icon {
+            width: 128px;
+            height: 128px;
+            animation: rise-and-glow 1.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
+        }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <div class="content">
+            <div class="icon-container">
+                <svg class="drycc-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+                    <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+                        <circle stroke="#FF2F57" fill="#FF2F57" cx="3" cy="3" r="2"/>
+                        <circle stroke="#FF2F57" fill="#FF2F57" cx="3" cy="13" r="2"/>
+                        <circle stroke="#FF2F57" fill="#FF2F57" cx="13" cy="13" r="2"/>
+                        <circle stroke="#FF2F57" fill="#FF2F57" cx="13" cy="3" r="2"/>
+                        <rect stroke="#3FB1E5" fill="#3FB1E5" x="6.5" y="6.5" width="3" height="3"/>
+                        <polygon stroke="#38BB4C" fill="#38BB4C" stroke-linejoin="round" points="8 11 10 15 6 15"/>
+                        <polygon stroke="#38BB4C" fill="#38BB4C" stroke-linejoin="round" transform="translate(3,8) rotate(90) translate(-3,-8)" points="3 6 5 10 1 10"/>
+                        <polygon stroke="#38BB4C" fill="#38BB4C" stroke-linejoin="round" transform="translate(8,3) rotate(180) translate(-8,-3)" points="8 1 10 5 6 5"/>
+                        <polygon stroke="#38BB4C" fill="#38BB4C" stroke-linejoin="round" transform="translate(13,8) rotate(270) translate(-13,-8)" points="13 6 15 10 11 10"/>
+                    </g>
+                </svg>
+            </div>
+            {% if user_exists %}
+            <h1 class="title">Invitation Accepted</h1>
+            <p class="subtitle">You have been added to workspace <strong>{{ workspace_name }}</strong>.</p>
+            <p class="detail">Use the Drycc CLI to log in: <code>drycc login</code></p>
+            {% else %}
+            <h1 class="title">You're Invited to <strong>{{ workspace_name }}</strong></h1>
+            <p class="subtitle">You need an account to join this workspace.</p>
+            <p class="detail">First, <a href="{{ register_url }}">sign up on Drycc Passport</a>.</p>
+            <p class="detail">Then use the Drycc CLI to log in: <code>drycc login</code></p>
+            {% endif %}
+        </div>
+    </div>
+</body>
+</html>
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<key>[-_\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<name>[-_\w]+)/?$',
+            views.WorkspaceViewSet.as_view(
+                {'get': 'retrieve', 'patch': 'partial_update', 'delete': 'destroy'}),
+            name='workspace_detail'),
+    re_path(r'^workspaces/(?P<name>[-_\w]+)/members/?$',
+            views.WorkspaceMemberViewSet.as_view({'get': 'list'}),
+            name='workspace_member_list'),
+    re_path(r'^workspaces/(?P<name>[-_\w]+)/members/(?P<user>[-_\w]+)/?$',
+            views.WorkspaceMemberViewSet.as_view(
+                {'get': 'retrieve', 'patch': 'partial_update', 'delete': 'destroy'}),
+            name='workspace_member_detail'),
+    re_path(r'^workspaces/(?P<name>[-_\w]+)/invitations/?$',
+            views.WorkspaceInvitationViewSet.as_view({'get': 'list', 'post': 'create'}),
+            name='workspace_invitation_list'),
+    re_path(r'^workspaces/(?P<name>[-_\w]+)/invitations/(?P<uid>[-_\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<id>{})/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<id>{})/releases/v(?P<version>[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<id>{})/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<id>{})/pods/(?P<name>{})/describe/?$".format(
@@ -92,11 +113,7 @@
     re_path(
         r"^apps/(?P<id>{})/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<id>{})/run/?$".format(settings.APP_URL_REGEX),
-        views.AppViewSet.as_view({'post': 'run'})),
+                                     'get': 'list', 'delete': 'destroy'})),
     # application settings
     re_path(
         r"^apps/(?P<id>{})/settings/?$".format(settings.APP_URL_REGEX),
@@ -143,17 +160,18 @@
     re_path(
         r'^apps/(?P<id>{})/certs/(?P<name>{})/?$'.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<id>{})/certs/?$'.format(settings.APP_URL_REGEX),
         views.CertificateViewSet.as_view({'get': 'list', 'post': 'create'})),
+    # application actions
+    re_path(
+        r"^apps/(?P<id>{})/run/?$".format(settings.APP_URL_REGEX),
+        views.AppViewSet.as_view({'post': 'run'})),
     # apps base endpoint
     re_path(
         r"^apps/(?P<id>{})/?$".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<id>{})/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<id>{})/routes/(?P<name>{})?/?$".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<id>{})/routes/(?P<name>{})/attach/?$".format(
             settings.APP_URL_REGEX, settings.NAME_REGEX),
@@ -207,14 +225,6 @@
         r"^apps/(?P<id>{})/routes/(?P<name>{})/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<username>[\w.@+-]+)/enable/?$',
-        views.UserView.as_view({'patch': 'enable'})),
-    re_path(
-        r'^users/(?P<username>[\w.@+-]+)/disable/?$',
-        views.UserView.as_view({'patch': 'disable'})),
     re_path(
         r'^apps/(?P<id>{})/metrics/?$'.format(settings.APP_URL_REGEX),
         views.MetricView.as_view({'get': 'metric'})),
@@ -228,13 +238,6 @@
     re_path(
         r'^manager/(?P<type>[\w.@+-]+)s/(?P<id>{})/unblock/?$'.format(settings.APP_URL_REGEX),
         views.WorkflowManagerViewset.as_view({'delete': 'unblock'})),
-    # user perms
-    re_path(
-        r"^apps/(?P<id>{})/perms/?$".format(settings.APP_URL_REGEX),
-        views.AppPermViewSet.as_view({'get': 'list', 'post': 'create'})),
-    re_path(
-        r"^apps/(?P<id>{})/perms/(?P<username>[\w.@+-]+)/?$".format(settings.APP_URL_REGEX),
-        views.AppPermViewSet.as_view({'put': 'update', 'delete': 'destroy'})),
     # quickwit
     re_path(
         r'^quickwit/(?P<username>[\w.@+-]+)/(?P<path>.+)/?$', 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
