POST /api/v1/workspaces/ was gated only by @jwt_required(), so any
authenticated user - including the read-only viewer role - could create
a workspace, automatically become its owner, then add arbitrary members
and mint workspace-scoped API keys (GHSA-r4q7-x795-vr4j, CWE-862).
Add @admin_required, matching the owner/admin checks every other
mutating workspace route already enforces, plus a regression test
asserting viewer and developer JWTs get 403 while an admin still
creates.
Co-authored-by: Alex <134992700+campguyuz@users.noreply.github.com>
Finish #33's two named workspace-scoping slices, backend-only (no schema
migration, no frontend changes):
- Domains: get_domains scopes through the parent Application via scope_query
(own + granted, workspace-narrowed); also surfaces granted apps' domains,
closing a gap vs the per-domain routes. Admin-no-context path unchanged.
- Databases: the app-linked /docker/databases enumeration scopes via
scope_query; host-level MySQL/PG/SQLite stay global system resources
(documented inline).
- Role reconciliation: WorkspaceService.effective_role + can_write_in_workspace
intersect global User.role with per-workspace WorkspaceMember.role,
narrow-only (a workspace role can only reduce capability; system admin never
capped). Enforced at apps/servers create + reassign seams (viewer -> 403,
only when a workspace context is active). Reconciled role surfaced on
GET /workspaces (my_role/my_effective_role).
6 new tests; full backend suite 193 passed / 12 skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the inconsistency where the workspace switcher filtered apps/servers but
not the WordPress hub.
- WP sites carry no workspace_id of their own, so 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: a new API test (global vs workspace-scoped), full suite 123 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the organizing loop — existing resources were stuck wherever the
backfill put them (Default); now they can be moved into client workspaces.
- 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 the
resource back to Default.
- UI: an 'Apps' management modal on each Workspaces card (mirrors the Members
modal) 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 app ownership check compared app.user_id
to the stringified get_jwt_identity() (int != str -> spurious 403); now uses
user.id.
Verified: 2 new endpoint tests (ownership + target-membership + null->Default +
role gates), full suite 122 passed, frontend lint-clean + production build.
Servers reassignment is API-only for now; the modal is apps-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A stale or forbidden X-Workspace-Id (deleted workspace, removed member) now degrades to no-scope instead of 403/404, so an ambient workspace-context header can never break the apps/servers lists. Safe by construction: falling back to no-scope only ever shows the user their own resources and stamps the default workspace on create — it never grants access to, or creates in, a foreign workspace. Also drops the error-tuple from the four call sites.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cross-cutting backbone the rest of the #33 agency-scale milestone needs,
landed as a contained, narrow-only foundation rather than a 36-route rewrite.
- Nullable workspace_id FK on applications + servers (Alembic 015 off 014) with
an idempotent raw-SQL backfill: create a Default workspace, attach every
workspace-less row, and make every existing user a member so nobody loses
visibility once scoping is activated. Also creates the workspace_id indexes
that upgraded installs would otherwise lack.
- Centralized scope helper in WorkspaceService: ensure_default_workspace,
resolve_workspace_id (validates a requested workspace; membership-checked with
admin bypass; rejects deactivated accounts on the workspace path), and
scope_query.
- Opt-in + narrow-only: a request filters by workspace only when it carries an
X-Workspace-Id header / ?workspace_id=. scope_query INTERSECTS the workspace
filter with the existing per-user ownership filter, so it can only narrow
visibility, never broaden it — with no context the behavior is byte-identical
to before (admin->all, owner->own, servers global). This closes the footgun
where the backfill would otherwise let a non-admin enumerate all resources via
the Default workspace.
- Wired into apps.py + servers.py (list + create): replaces the duplicated
ownership branch and stamps new resources with the active-or-default workspace.
Deferred to follow-on slices: broad route adoption, per-site ACL, the
workspace<->User role reconciliation, server-side branding, and a frontend
workspace selector.
Verified: 10 new pytest tests (scope-narrowing, a can't-broaden-access
regression, deactivated-user gate, raw-SQL backfill against real SQLite +
idempotency), full suite 120 passed, a real end-to-end alembic upgrade to 015 on
a fresh DB (columns + indexes), and a 2-lens adversarial review (3 findings, all
fixed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>