diff --git a/backend/app/api/apps.py b/backend/app/api/apps.py index f1a43262..43ac1bbf 100644 --- a/backend/app/api/apps.py +++ b/backend/app/api/apps.py @@ -318,7 +318,8 @@ def get_apps(): ws_id = WorkspaceService.resolve_workspace_id( user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id')) query = WorkspaceService.scope_query(Application.query, Application, user, - workspace_id=ws_id, owner_attr='user_id') + workspace_id=ws_id, owner_attr='user_id', + grant_resource_type='application') if environment_filter: query = query.filter_by(environment_type=environment_filter) @@ -366,23 +367,89 @@ def set_app_workspace(app_id): return jsonify({'message': 'Workspace updated', 'app': app.to_dict()}), 200 +def _can_access_app(user, app): + """True if the user owns the app, is admin, or was granted access (#33 ACL). + Uses user.id (int) — get_jwt_identity() is the stringified token id.""" + if user.is_admin or app.user_id == user.id: + return True + from app.services.resource_grant_service import ResourceGrantService + return ResourceGrantService.user_has_grant(user.id, 'application', app.id) + + @apps_bp.route('/', methods=['GET']) @jwt_required() def get_app(app_id): - current_user_id = get_jwt_identity() - user = User.query.get(current_user_id) + user = User.query.get(get_jwt_identity()) app = Application.query.get(app_id) if not app: return jsonify({'error': 'Application not found'}), 404 - if user.role != 'admin' and app.user_id != current_user_id: + if not _can_access_app(user, app): return jsonify({'error': 'Access denied'}), 403 # Single app requests include linked app info by default return jsonify({'app': _attach_deploy_config(app.to_dict(include_linked=True))}), 200 +# ---- Per-resource access grants (#33 per-site ACL): share an app with a user ---- + +@apps_bp.route('//grants', methods=['GET']) +@jwt_required() +def list_app_grants(app_id): + """List who has been granted access to this app (owner-or-admin).""" + from app.services.resource_grant_service import ResourceGrantService + user = User.query.get(get_jwt_identity()) + app = Application.query.get(app_id) + if not app: + return jsonify({'error': 'Application not found'}), 404 + if user.role != 'admin' and app.user_id != user.id: + return jsonify({'error': 'Access denied'}), 403 + grants = ResourceGrantService.list_for_resource('application', app.id) + return jsonify({'grants': [g.to_dict() for g in grants]}), 200 + + +@apps_bp.route('//grants', methods=['POST']) +@jwt_required() +def grant_app_access(app_id): + """Grant a user access to this app (owner-or-admin). Body: {user_id, role?}.""" + from app.services.resource_grant_service import ResourceGrantService + user = User.query.get(get_jwt_identity()) + app = Application.query.get(app_id) + if not app: + return jsonify({'error': 'Application not found'}), 404 + if user.role != 'admin' and app.user_id != user.id: + return jsonify({'error': 'Access denied'}), 403 + data = request.get_json() or {} + grantee_id = data.get('user_id') + if not grantee_id: + return jsonify({'error': 'user_id is required'}), 400 + grantee = User.query.get(grantee_id) + if not grantee: + return jsonify({'error': 'User not found'}), 404 + if grantee.id == app.user_id: + return jsonify({'error': 'The owner already has access'}), 400 + grant = ResourceGrantService.grant(user_id=grantee.id, resource_type='application', + resource_id=app.id, granted_by=user.id, + role=data.get('role') or 'editor') + return jsonify({'grant': grant.to_dict()}), 201 + + +@apps_bp.route('//grants/', methods=['DELETE']) +@jwt_required() +def revoke_app_access(app_id, grant_id): + """Revoke a grant on this app (owner-or-admin).""" + from app.services.resource_grant_service import ResourceGrantService + user = User.query.get(get_jwt_identity()) + app = Application.query.get(app_id) + if not app: + return jsonify({'error': 'Application not found'}), 404 + if user.role != 'admin' and app.user_id != user.id: + return jsonify({'error': 'Access denied'}), 403 + ok = ResourceGrantService.revoke(grant_id, resource_type='application', resource_id=app.id) + return jsonify({'success': ok}), (200 if ok else 404) + + @apps_bp.route('/from-repository', methods=['POST']) @jwt_required() def create_app_from_repository(): diff --git a/backend/app/api/wordpress.py b/backend/app/api/wordpress.py index bf0c1f8f..e8d25250 100644 --- a/backend/app/api/wordpress.py +++ b/backend/app/api/wordpress.py @@ -558,15 +558,18 @@ def scan_site_vulnerabilities(app_id): # ---- Per-site security depth (#30): file integrity, WP_DEBUG, WP-Cron ---- def _owner_or_admin_app(app_id): - """Resolve an app and enforce the owner-or-admin read guard. Returns - (app, None) or (None, (response, status)).""" - current_user_id = get_jwt_identity() - user = User.query.get(current_user_id) + """Resolve an app and enforce the access guard: owner, admin, or a user the + app was shared with via a per-resource grant (#33). Returns (app, None) or + (None, (response, status)).""" + user = User.query.get(get_jwt_identity()) app = _resolve_app(app_id) if not app: return None, (jsonify({'error': 'Application not found'}), 404) - if user.role != 'admin' and app.user_id != current_user_id: - return None, (jsonify({'error': 'Access denied'}), 403) + # user.id (int), not the stringified token id; honor per-resource grants. + if not user.is_admin and app.user_id != user.id: + from app.services.resource_grant_service import ResourceGrantService + if not ResourceGrantService.user_has_grant(user.id, 'application', app.id): + return None, (jsonify({'error': 'Access denied'}), 403) return app, None diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5b632b22..1daba4e7 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -26,7 +26,7 @@ from app.models.invitation import Invitation from app.models.metric_alert import ServerAlertThreshold, MetricAlert from app.models.agent_plugin import AgentPlugin, AgentPluginInstall from app.models.server_template import ServerTemplate, ServerTemplateAssignment -from app.models.workspace import Workspace, WorkspaceMember, WorkspaceApiKey +from app.models.workspace import Workspace, WorkspaceMember, WorkspaceApiKey, ResourceGrant from app.models.dns_zone import DNSZone, DNSRecord from app.models.status_page import StatusPage, StatusComponent, HealthCheck, StatusIncident, StatusIncidentUpdate from app.models.cloud_server import CloudProvider, CloudServer, CloudSnapshot @@ -48,7 +48,7 @@ __all__ = [ 'ServerAlertThreshold', 'MetricAlert', 'AgentPlugin', 'AgentPluginInstall', 'ServerTemplate', 'ServerTemplateAssignment', - 'Workspace', 'WorkspaceMember', 'WorkspaceApiKey', + 'Workspace', 'WorkspaceMember', 'WorkspaceApiKey', 'ResourceGrant', 'DNSZone', 'DNSRecord', 'StatusPage', 'StatusComponent', 'HealthCheck', 'StatusIncident', 'StatusIncidentUpdate', 'CloudProvider', 'CloudServer', 'CloudSnapshot', diff --git a/backend/app/models/workspace.py b/backend/app/models/workspace.py index 8e154f52..cb7d1487 100644 --- a/backend/app/models/workspace.py +++ b/backend/app/models/workspace.py @@ -139,3 +139,42 @@ class WorkspaceApiKey(db.Model): 'created_at': self.created_at.isoformat() if self.created_at else None, 'last_used_at': self.last_used_at.isoformat() if self.last_used_at else None, } + + +class ResourceGrant(db.Model): + """Per-resource access grant (#33 — per-site ACL): grants a user access to a + specific resource they don't own (e.g. an application and, transitively, its + WordPress site / databases / domains). + + Access-level for now — a granted user can see and operate the resource like an + owner. Per-grant view/edit roles are a future refinement; the `role` column is + reserved for it but not yet enforced. + """ + __tablename__ = 'resource_grants' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + resource_type = db.Column(db.String(32), nullable=False) # 'application' + resource_id = db.Column(db.Integer, nullable=False, index=True) + role = db.Column(db.String(16), default='editor') # reserved for view/edit + granted_by = db.Column(db.Integer, db.ForeignKey('users.id')) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + __table_args__ = ( + db.UniqueConstraint('user_id', 'resource_type', 'resource_id', name='uq_resource_grant'), + ) + + user = db.relationship('User', foreign_keys=[user_id]) + + def to_dict(self): + return { + 'id': self.id, + 'user_id': self.user_id, + 'username': self.user.username if self.user else None, + 'email': self.user.email if self.user else None, + 'resource_type': self.resource_type, + 'resource_id': self.resource_id, + 'role': self.role, + 'granted_by': self.granted_by, + 'created_at': self.created_at.isoformat() if self.created_at else None, + } diff --git a/backend/app/services/resource_grant_service.py b/backend/app/services/resource_grant_service.py new file mode 100644 index 00000000..516c21d1 --- /dev/null +++ b/backend/app/services/resource_grant_service.py @@ -0,0 +1,71 @@ +"""Per-resource access grants (#33 — per-site ACL). + +A grant gives a user access to a specific resource (currently an application, +which transitively covers its WordPress site / databases / domains) without +owning it. This service is the single seam for creating, revoking, querying, and +checking grants; enforcement lives in WorkspaceService.scope_query (lists) and +the per-resource access helpers in the API layer. +""" + +RESOURCE_APPLICATION = 'application' + + +class ResourceGrantService: + + @staticmethod + def granted_ids(user_id, resource_type): + """The resource_ids of `resource_type` this user has been granted.""" + from app.models.workspace import ResourceGrant + rows = (ResourceGrant.query + .with_entities(ResourceGrant.resource_id) + .filter_by(user_id=user_id, resource_type=resource_type) + .all()) + return [r[0] for r in rows] + + @staticmethod + def user_has_grant(user_id, resource_type, resource_id): + from app.models.workspace import ResourceGrant + return ResourceGrant.query.filter_by( + user_id=user_id, resource_type=resource_type, resource_id=resource_id + ).first() is not None + + @staticmethod + def list_for_resource(resource_type, resource_id): + from app.models.workspace import ResourceGrant + return ResourceGrant.query.filter_by( + resource_type=resource_type, resource_id=resource_id + ).all() + + @staticmethod + def grant(user_id, resource_type, resource_id, granted_by=None, role='editor'): + """Create (or update) a grant. Idempotent on (user, resource).""" + from app import db + from app.models.workspace import ResourceGrant + existing = ResourceGrant.query.filter_by( + user_id=user_id, resource_type=resource_type, resource_id=resource_id + ).first() + if existing: + existing.role = role + db.session.commit() + return existing + row = ResourceGrant(user_id=user_id, resource_type=resource_type, + resource_id=resource_id, role=role, granted_by=granted_by) + db.session.add(row) + db.session.commit() + return row + + @staticmethod + def revoke(grant_id, resource_type=None, resource_id=None): + """Delete a grant by id. When resource_type/resource_id are given, the + grant must belong to that resource (so a caller can't revoke another + resource's grant by id).""" + from app import db + from app.models.workspace import ResourceGrant + row = ResourceGrant.query.get(grant_id) + if row is None: + return False + if resource_type is not None and (row.resource_type != resource_type or row.resource_id != resource_id): + return False + db.session.delete(row) + db.session.commit() + return True diff --git a/backend/app/services/workspace_service.py b/backend/app/services/workspace_service.py index 8b6282bf..aebb35e9 100644 --- a/backend/app/services/workspace_service.py +++ b/backend/app/services/workspace_service.py @@ -73,23 +73,30 @@ class WorkspaceService: return ws_id @staticmethod - def scope_query(query, model, user, workspace_id=None, owner_attr=None): - """Apply scoping to a resource list query. Critically, this can only ever - NARROW what a user sees — it never broadens access — so activating a - workspace can't reveal another user's resources. + def scope_query(query, model, user, workspace_id=None, owner_attr=None, grant_resource_type=None): + """Apply scoping to a resource list query. - - non-admin + owner_attr -> always restricted to the user's own rows - (preserves the existing per-user ownership model, e.g. applications). - A workspace context then narrows *within* those rows. + - non-admin + owner_attr -> restricted to the user's own rows, PLUS any + rows explicitly shared with them via a per-resource grant (#33) when + grant_resource_type is given (e.g. 'application'). A workspace context + then narrows *within* that set. - admin, or a global resource (owner_attr=None, e.g. servers) -> no owner filter; today's behavior (admin sees all; servers are global). - workspace context active -> additionally filter to that workspace. - Broader "every member sees every resource in the workspace" visibility is - deliberately NOT done here — that needs the workspace-role/ACL step. + It still only ever NARROWS the global/admin view; for non-admins it widens + ONLY by explicit grants the user was given, never by workspace membership + alone. """ if owner_attr and not user.is_admin: - query = query.filter(getattr(model, owner_attr) == user.id) + own = getattr(model, owner_attr) == user.id + if grant_resource_type: + from app import db + from app.services.resource_grant_service import ResourceGrantService + granted = ResourceGrantService.granted_ids(user.id, grant_resource_type) + query = query.filter(db.or_(own, model.id.in_(granted))) if granted else query.filter(own) + else: + query = query.filter(own) if workspace_id is not None: query = query.filter(model.workspace_id == workspace_id) return query diff --git a/backend/migrations/versions/016_resource_grants.py b/backend/migrations/versions/016_resource_grants.py new file mode 100644 index 00000000..92187416 --- /dev/null +++ b/backend/migrations/versions/016_resource_grants.py @@ -0,0 +1,44 @@ +"""Per-resource access grants (#33 — per-site ACL). + +Adds: +- table resource_grants (one row per user-x-resource grant; resource_type + + resource_id identify the shared resource, e.g. an application) + +Idempotent: MigrationService._fix_missing_columns runs db.create_all() on boot +before Alembic (creating the table), so guard on the live schema like 009..015. + +Revision ID: 016_resource_grants +Revises: 015_workspace_scope +Create Date: 2026-06-01 +""" +from alembic import op +import sqlalchemy as sa + +revision = '016_resource_grants' +down_revision = '015_workspace_scope' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'resource_grants' not in inspector.get_table_names(): + op.create_table( + 'resource_grants', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False, index=True), + sa.Column('resource_type', sa.String(length=32), nullable=False), + sa.Column('resource_id', sa.Integer(), nullable=False, index=True), + sa.Column('role', sa.String(length=16)), + sa.Column('granted_by', sa.Integer(), sa.ForeignKey('users.id')), + sa.Column('created_at', sa.DateTime()), + sa.UniqueConstraint('user_id', 'resource_type', 'resource_id', name='uq_resource_grant'), + ) + + +def downgrade(): + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'resource_grants' in inspector.get_table_names(): + op.drop_table('resource_grants') diff --git a/backend/tests/test_resource_grants.py b/backend/tests/test_resource_grants.py new file mode 100644 index 00000000..2a91e03b --- /dev/null +++ b/backend/tests/test_resource_grants.py @@ -0,0 +1,123 @@ +"""Tests for per-resource access grants (#33 — per-site ACL).""" + + +def _mk_user(db, username, role='developer'): + from app.models import User + from werkzeug.security import generate_password_hash + u = User(email=f'{username}@t.local', username=username, + password_hash=generate_password_hash('x'), role=role, is_active=True) + db.session.add(u) + db.session.commit() + return u + + +def _token(user_id): + from flask_jwt_extended import create_access_token + return {'Authorization': f'Bearer {create_access_token(identity=user_id)}'} + + +def test_grant_makes_app_visible_and_accessible(app, client): + from app import db + from app.models import Application + + owner = _mk_user(db, 'g_owner') + grantee = _mk_user(db, 'g_grantee') + a = Application(name='shared-app', app_type='php', user_id=owner.id) + db.session.add(a) + db.session.commit() + app_id = a.id + + # Before the grant: the grantee neither sees nor can open the app. + r = client.get('/api/v1/apps', headers=_token(grantee.id)) + assert 'shared-app' not in {x['name'] for x in r.get_json()['apps']} + r = client.get(f'/api/v1/apps/{app_id}', headers=_token(grantee.id)) + assert r.status_code == 403 + + # Owner shares it. + r = client.post(f'/api/v1/apps/{app_id}/grants', json={'user_id': grantee.id}, headers=_token(owner.id)) + assert r.status_code == 201 + + # Now the grantee sees it in their list and can open it. + r = client.get('/api/v1/apps', headers=_token(grantee.id)) + assert 'shared-app' in {x['name'] for x in r.get_json()['apps']} + r = client.get(f'/api/v1/apps/{app_id}', headers=_token(grantee.id)) + assert r.status_code == 200 + + # An unrelated user still can't. + other = _mk_user(db, 'g_other') + r = client.get(f'/api/v1/apps/{app_id}', headers=_token(other.id)) + assert r.status_code == 403 + + +def test_grant_management_permissions(app, client): + from app import db + from app.models import Application + + owner = _mk_user(db, 'm_owner') + grantee = _mk_user(db, 'm_grantee') + stranger = _mk_user(db, 'm_stranger') + a = Application(name='m-app', app_type='php', user_id=owner.id) + db.session.add(a) + db.session.commit() + app_id = a.id + + # A non-owner non-admin can't manage sharing. + r = client.post(f'/api/v1/apps/{app_id}/grants', json={'user_id': grantee.id}, headers=_token(stranger.id)) + assert r.status_code == 403 + + # Owner grants, lists, and the owner can't be granted to themselves. + r = client.post(f'/api/v1/apps/{app_id}/grants', json={'user_id': grantee.id}, headers=_token(owner.id)) + assert r.status_code == 201 + grant_id = r.get_json()['grant']['id'] + + r = client.get(f'/api/v1/apps/{app_id}/grants', headers=_token(owner.id)) + assert r.status_code == 200 and len(r.get_json()['grants']) == 1 + + r = client.post(f'/api/v1/apps/{app_id}/grants', json={'user_id': owner.id}, headers=_token(owner.id)) + assert r.status_code == 400 + + # Revoke -> the grantee loses access. + r = client.delete(f'/api/v1/apps/{app_id}/grants/{grant_id}', headers=_token(owner.id)) + assert r.status_code == 200 + r = client.get(f'/api/v1/apps/{app_id}', headers=_token(grantee.id)) + assert r.status_code == 403 + + +def test_grant_is_idempotent(app, client): + from app import db + from app.models import Application + from app.models.workspace import ResourceGrant + + owner = _mk_user(db, 'i_owner') + grantee = _mk_user(db, 'i_grantee') + a = Application(name='i-app', app_type='php', user_id=owner.id) + db.session.add(a) + db.session.commit() + + client.post(f'/api/v1/apps/{a.id}/grants', json={'user_id': grantee.id}, headers=_token(owner.id)) + client.post(f'/api/v1/apps/{a.id}/grants', json={'user_id': grantee.id}, headers=_token(owner.id)) + assert ResourceGrant.query.filter_by(resource_type='application', resource_id=a.id, user_id=grantee.id).count() == 1 + + +def test_grant_enables_wordpress_per_site_routes(app, client): + from app import db + from app.models import Application, WordPressSite + from app.services.resource_grant_service import ResourceGrantService + + owner = _mk_user(db, 'wpg_owner') + grantee = _mk_user(db, 'wpg_grantee') + a = Application(name='wpg-app', app_type='wordpress', user_id=owner.id) + db.session.add(a) + db.session.commit() + db.session.add(WordPressSite(application_id=a.id, is_production=True)) + db.session.commit() + + # A WP per-site route (guarded by _owner_or_admin_app) is denied before the grant. + r = client.get(f'/api/v1/wordpress/sites/{a.id}/updates', headers=_token(grantee.id)) + assert r.status_code == 403 + + # After a grant, the same route is reachable — one helper covers every WP route. + ResourceGrantService.grant(user_id=grantee.id, resource_type='application', + resource_id=a.id, granted_by=owner.id) + r = client.get(f'/api/v1/wordpress/sites/{a.id}/updates', headers=_token(grantee.id)) + assert r.status_code == 200 diff --git a/docs/WORDPRESS_ROADMAP.md b/docs/WORDPRESS_ROADMAP.md index 39bc2ef7..1bc3e04f 100644 --- a/docs/WORDPRESS_ROADMAP.md +++ b/docs/WORDPRESS_ROADMAP.md @@ -291,7 +291,7 @@ Highest-effort, broadest surface — but by now the `WordPressSite` is a first-c - **Done when:** Each site exposes scoped SFTP credentials. - **Assessment / why deferred:** The roadmap's host-sshd-chroot approach doesn't fit the Docker model — a managed site's files live in the `wordpress_html` **named volume**, not a host path, so a host-level chroot would target the wrong place. The **Docker-correct** design is a per-site **SFTP sidecar container** (e.g. `atmoz/sftp`) mounting `wordpress_html`, injected into the compose stack exactly like #23's redis (`_ensure_*_in_stack` + additive `compose up -d`), with a generated credential surfaced once and a new mapped port (via the template's port-allocation). That is genuinely implementable on the proven pattern — but it (a) opens a **publicly-exposed, security-sensitive file-access surface** that should be firewalled and that I cannot runtime-verify from the dev host, and (b) consumes a port + a container per site. Shipping security-sensitive infra blind violates the quality bar, so this is deferred for a focused pass on a real Docker host (where the sidecar, chroot perms, and firewall can be exercised). No code shipped. -### #33 — Agency scale (workspaces, RBAC, white-label, reports) `[XL]` 🟡 — 🟡 In progress (reports slice ✅ + `workspace_id` scoping foundation ✅ shipped; broad route adoption + per-site ACL + workspace↔User role reconciliation + server-side branding + frontend workspace selector still deferred) +### #33 — Agency scale (workspaces, RBAC, white-label, reports) `[XL]` 🟡 — 🟡 In progress (✅ reports · workspace_id foundation · selector · brand color · resource reassignment · WP-hub scoping · **per-site ACL backend**; remaining: ACL sharing UI, broad operate-endpoint adoption, workspace↔User role reconciliation) - **Today:** No `workspace_id` FK on `Application`/`WordPressSite`/`Server`; permissions are global with no per-site ACL; white-label lives in `localStorage` and never reads `Workspace.logo_url`/`primary_color`. - **Do:** Add `workspace_id` FKs + per-site ACL rows + environment-tier RBAC in promote/sync/bulk endpoints; server-side branding scoped to workspace; monthly client reports aggregating uptime/updates/backups/security. - **Reuse:** `workspace_service`, `permission_service`, `audit_service`. @@ -303,6 +303,7 @@ Highest-effort, broadest surface — but by now the `WordPressSite` is a first-c - **Landed (workspace-scoped brand color):** the visible payoff — selecting a client workspace now recolors the whole panel to that client's brand. A workspace's `primary_color` (already on the model; now editable via a color picker added to the Workspaces create modal) is mirrored by the `WorkspaceSwitcher` into a `workspace_accent` localStorage key, and `ThemeContext` applies it via the existing `applyAccentToDOM` with **precedence over the user's personal accent** (`workspace_accent || accentColor`) — recoloring the logo gradient, buttons, and highlights. Deterministic (no effect-ordering races) because switching reloads and the value is read once on mount; cleared on logout and on a stale selection; reverts to the user accent on "All workspaces". Default color is the app accent, so a workspace is unbranded unless a custom color is chosen. Verified: frontend lint-clean (no new warnings) + production build. *(Deferred: per-workspace logo swap in the sidebar brand — the accent recolor is the high-impact piece and the default logo already recolors with the accent.)* - **Landed (resource reassignment):** completes the organizing loop — existing resources were stuck wherever the backfill put them (Default); now they can be moved into client workspaces. New endpoints `PUT /apps//workspace` (owner-or-admin on the app) and `PUT /servers//workspace` (developer+), both requiring the **target** be a workspace the caller can access (member or admin); a null target moves a resource back to Default. UI: an **Apps** management modal on each Workspaces card (mirrors the Members modal) listing the workspace's apps with move-in/move-out — it fetches with `allWorkspaces` (an empty `X-Workspace-Id` header) so it can see apps in other workspaces to move them in, sidestepping the ambient scope. **Fixed a real bug** caught by tests: the ownership check compared `app.user_id` to the *stringified* `get_jwt_identity()` (int≠str → spurious 403); now uses `user.id`. Verified: backend `py_compile`, **2 new endpoint tests** (ownership + target-membership + null→Default + role gates), full suite **122 passed**, frontend lint-clean + production build. The management modal now handles **both apps and servers** (a "Resources" modal on each Workspaces card), each with move-in/move-out; servers fetch with the same `allWorkspaces` header bypass. - **Landed (WordPress hub workspace scoping):** the active-workspace switcher now also filters the **WordPress sites hub**, not just apps/servers (it was the inconsistent gap). WP sites carry no `workspace_id` of their own, so `WordPressService.get_sites(workspace_id=…)` scopes **join-based** through the site's parent `Application.workspace_id`; `list_sites` resolves the active workspace and passes it. The hub is global today, so with no workspace context behavior is unchanged (additive, narrow-only). **No frontend change** — the WP hub already fetches through the header-injecting `api.request`, so it auto-scopes on the ambient `X-Workspace-Id`. Verified: backend `py_compile`, a new API test (global vs workspace-scoped), full suite **123 passed**. *(Follow-on for broad adoption: databases/domains are murkier — server-level vs app-child — and need their own analysis before scoping.)* +- **Landed (per-site ACL — backend):** per-resource **sharing** — an owner/admin can grant a specific user access to a specific application (transitively its WordPress site / DB / domains) without that user owning it (access-level; per-grant view/edit roles reserved for later via a `role` column). New `ResourceGrant` model (Alembic **016**, unique per user×resource) + `ResourceGrantService` (grant/revoke/list/`user_has_grant`/`granted_ids`). **Enforcement at the cleanest seams:** `WorkspaceService.scope_query` gained `grant_resource_type` so granted apps appear in the apps list (own **OR** granted, then workspace-narrowed); `get_app` and — crucially — the WordPress `_owner_or_admin_app` helper became grant-aware, so **one change opens every WP per-site route** to a grantee. Grant-management API on `apps.py` (`GET/POST/DELETE /apps//grants`, owner-or-admin; can't grant to the owner; revoke is resource-scoped so you can't revoke another app's grant by id). Also uses `user.id` (int) not the stringified `get_jwt_identity()`. Verified: **4 new ACL tests** (visibility via list, get_app, WP route access, management perms + idempotency + revoke), full suite **127 passed**, end-to-end `alembic upgrade` to `016`. *(Deferred: a **Sharing UI** (next), and broadening grant-access to the generic app **operate** endpoints — start/stop/update — which stay owner-or-admin for now; delete stays owner-only by design.)* ### #34 — Horizontal scaling `[XL]` ❌ — ⏸️ Deferred (XL + blocked on the same per-site reverse-proxy/upstream layer as #21) - **Today:** `nginx_advanced_service` has load-balancing primitives (round-robin/least-conn/IP-hash) but WP containers are stateful (local uploads, no shared object cache).