feat(workspaces): per-resource access grants — per-site ACL backend (#33)

An owner/admin can share a specific application (transitively its WordPress
site / DB / domains) with a specific user who doesn't own it. Access-level for
now; per-grant view/edit roles are reserved via a role column.

- New ResourceGrant model (Alembic 016, unique per user x resource) +
  ResourceGrantService (grant/revoke/list/user_has_grant/granted_ids).
- Enforcement at the cleanest seams: scope_query gained grant_resource_type so
  granted apps appear in the apps list (own OR granted, then workspace-narrowed);
  get_app and 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 (GET/POST/DELETE /apps/<id>/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). Uses user.id (int), not the stringified get_jwt_identity().

Verified: 4 new ACL tests (visibility, 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 stay owner-or-admin; delete stays owner-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Juan DenisandClaude Opus 4.8 committed 2026-06-01 11:59:34 -04:00
1 parent 35c7a73d8c
commit d9a5ec7e92
9 files changed
+378 -23

No files matched your search

+71 -4
View File
@@ -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('/<int:app_id>', 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('/<int:app_id>/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('/<int:app_id>/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('/<int:app_id>/grants/<int:grant_id>', 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():
+9 -6
View File
@@ -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
+2 -2
View File
@@ -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',
+39
View File
@@ -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,
}
@@ -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
+17 -10
View File
@@ -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
@@ -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')
+123
View File
@@ -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
+2 -1
View File
@@ -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/<id>/workspace` (owner-or-admin on the app) and `PUT /servers/<id>/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/<id>/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).