Workspace Access¶
A workspace owns a set of workflows. Who may see, author, and run those
workflows is decided by workspace access grants, stored in the
project_membership table and managed from Workspaces → \<workspace> →
Access in the console.
Grants never replace platform permissions. Every request is checked twice: the
route's requirePermission guard confirms the caller's access token carries the
permission (workflow:read, workflow:update, workflow:start, …), and the
workspace gate confirms the caller reaches that workspace. Both must pass. A
member with write access but without workflow:update still gets a 403.
The three levels¶
| Level | How it is granted | What it allows |
|---|---|---|
| Read | Implied by any grant | See the workspace and its workflows in lists, open a workflow, export it, read its generated docs |
| Write | canWrite on the grant |
Create, edit, publish, deprecate, archive, delete, re-tag, and schedule the workspace's workflows |
| Execute | canExecute on the grant |
Start a workflow run, and keep the caller's existing schedules firing |
Write and execute are independent. A reviewer can hold execute without write; a workflow author can hold write without execute.
Write is checked against every workspace a request touches, not just the
obvious one. Re-tagging a workflow into another workspace needs write on both
ends, and publishing with deprecatePreviousVersionId needs write on the
previous version's workspace — and that version must be the one the draft was
created from, so the field cannot be used to retire an unrelated workflow.
Two things always bypass grants:
- The workspace owner (
project.owner_id) has all three levels. - Console administrators (
system:administeron thefloh-clientchannel) have all three levels on every workspace. A portal or MCP token does not inherit this, even when itsscopeclaim listssystem:administer.
Principals¶
A grant targets either a single user or the all users principal.
The all-users grant extends its levels to everyone who already holds the matching platform permission. It is how a workspace is opened up to a whole tenant without enumerating people. When a user has both an all-users grant and their own grant, the effective level is the union of the two — the per-user row can add write on top of a read-only all-users grant, but cannot subtract from it.
Groups are not yet a grant principal.
What non-members see¶
Nothing. A workspace the caller has no grant on is filtered out of
GET /api/projects, and everything that hangs off it — workflows, runs, and
schedules — is filtered out of its listing in SQL, including the pagination
total, so the count cannot be used to infer that hidden rows exist. Fetching
such a workflow by id returns 403 rather than 404 so ids stay non-enumerable.
Run history is visible to the caller who initiated the run, and otherwise only
for workspaces the caller can read. Every run surface keyed by an id enforces
that same rule, not just GET /api/runs/:id: the nested /tasks and
/diagnose reads return the same variables and step configuration; the
mutations (PUT /api/runs/:id, /cancel, /retry, and the three
/steps/:stepId/* routes) refuse before they write, so a run:update or
run:cancel holder cannot act on a run they may not read; and the
execution-state WebSocket refuses before it sends its snapshot, closing with
application code 4403. The rule itself lives in one module,
modules/workflows/run-visibility.ts, with an architecture test
(run-visibility-boundaries.test.ts) asserting every one of those surfaces
consults it — the WebSocket originally drifted because the rule was a closure
inside the HTTP route file.
A run whose workflow predates the workspace column has no workspace to grant against. Those runs stay reachable to their initiator and to administrators, matching what the listings show, and stay invisible to everyone else.
One deliberate exception: an owner still sees their own soft-deleted
workspaces under GET /api/projects?includeDeleted=true, since the restore
route authorizes exactly that owner. includeDeleted widens the owner branch
only — a member's grant, including the everyone row the upgrade backfills
onto every existing workspace, never surfaces a trashed workspace. Deleted
workspaces are excluded from the workflow, run, and schedule listings
regardless, for administrators as well: parent liveness is a property of the
row, not of the caller. Workflows predating the workspace column have no
workspace at all; they stay visible to an unscoped caller and remain invisible
to a scoped one, since no workspace means no grant to match.
A workspace that is unknown or in the trash has no grants for anyone, administrators included. Creating a workflow against an unknown workspace id is refused with 403 rather than reaching the foreign-key write, and mutations of existing workflows under a trashed workspace are refused until it is restored.
The Default workspace¶
The seeded Default workspace ships with an all-users grant carrying write
and execute. That is what makes it the shared authoring pool it has always
been: any holder of workflow:create can author there without being assigned.
Unlike the previous hardcoded behavior, this is data. An operator who wants Default locked down can edit or remove that grant from the Access card like any other, and the removal sticks: the grant is created once, on the boot that first seeds the Default workspace, and is never re-asserted afterward. That first seed writes the workspace row and its grant in one transaction, so a crash mid-seed cannot leave Default present but permanently ungranted.
Upgrading an existing deployment¶
The migration gives every pre-existing workspace an all-users grant so nobody loses access at deploy time:
- Default — write and execute (preserves shared authoring).
- Every other workspace — execute only (preserves the ability to run, while authoring stays owner-or-admin exactly as before).
No operator action is required. To tighten a workspace afterward, remove its all-users grant and assign the individuals who need it.
Managing grants¶
Membership management is deliberately narrower than write: only the workspace owner and console administrators can change grants. A member with write access cannot widen their own access or anyone else's.
| Endpoint | Permission | Additional check |
|---|---|---|
GET /api/projects/:id/members |
project:read |
Read grant |
GET /api/projects/:id/members/candidates |
project:update |
Owner or admin |
POST /api/projects/:id/members |
project:update |
Owner or admin |
PATCH /api/projects/:id/members/:userId |
project:update |
Owner or admin |
DELETE /api/projects/:id/members/:userId |
project:update |
Owner or admin |
PUT /api/projects/:id/members/everyone |
project:update |
Owner or admin |
DELETE /api/projects/:id/members/everyone |
project:update |
Owner or admin |
The candidates endpoint backs the console's recipient picker. It exists so an
owner can pick someone to grant without also holding user:read — it is
authorized by workspace ownership, and returns only an id, display name, and
email for active users who do not already hold a grant. The workspace owner is
excluded from it, and POST /members refuses the owner outright: ownership is
implicit, so a stored row would list, toggle, and delete successfully while the
owner kept full access — a management API reporting revocations it cannot
perform. Transferring ownership to an existing member removes that member's
grant for the same reason.
Each grant mutation and its audit entry are written in one transaction, which
also re-reads the workspace row with FOR UPDATE and re-runs the owner check
against it. That serializes grant changes against an ownership transfer — a
former owner cannot pass the pre-flight check and commit a grant afterwards —
and means a failure in the audit hash chain rolls the grant back rather than
leaving it active and unrecorded. The transfer itself is one transaction too:
the owner change, its audit entry, and the removal of the new owner's
now-redundant grant land together.
Every change emits an audit entry — project.member_added,
project.member_updated, or project.member_removed — recording the workspace
id, the principal type, the target user id, and the resulting flags. Display
names and email addresses are deliberately excluded from audit metadata.
Revocation timing¶
Revocation takes effect on the next request. Scheduled triggers re-check the schedule creator's execute grant on every fire rather than trusting a snapshot taken when the schedule was created, so revoking execute stops that user's schedules on the next tick.
Editing a schedule takes it over: the accepted-permission snapshot and the recorded creator move to the editor together, so the fire-time check evaluates the live access of whoever last configured it. Replacing only the snapshot let a member's permissions outlive their own grant inside an owner's schedule. The takeover reaches no state a write member could not already reach by deleting the schedule and recreating it.
The fire-time check reads stored access only — workspace ownership and grant rows. Platform administrator status is not considered, because a background job has no live token to read it from. An administrator who schedules a workflow in a workspace they neither own nor hold an execute grant on will see the schedule skipped with a warning; grant them execute access, or transfer ownership, to keep it firing.
Soft-deleting a user stops their grants from being honored immediately. The rows
are retained, so restoring the user restores their access. Deactivating or
soft-deleting an account also stops its background work: the fire-time check
resolves the principal before ownership or the everyone grant, neither of
which reads the principal's own row. On the request path the same guarantee
comes from authenticate, which refuses an inactive or deleted user before any
handler runs.
Configuration export¶
GET and POST /api/config/export run under the caller's grants unless they
hold system:administer. A workflow definition in the export carries its
variables, steps, and connector wiring — a strictly richer read than
GET /api/workflows — and config:export is held by roles without console
admin (the built-in MCP author role), so an unscoped export would be a way
around a withheld grant. Workspaces, workflows, and schedules are filtered to
the caller's readable workspaces; the other entity types in the document are
not workspace-scoped and are unchanged.
Configuration import¶
POST /api/config/import and its preview run under the caller's grants
unless they hold system:administer. An import resolves a workspace by name
and then inserts or overwrites its workflow definitions and schedules — the
same writes the API gates — so a config:import holder without a write grant
would otherwise have a way around every workflow mutation gate. Rows destined
for a workspace the caller cannot write are reported as error with
"No write access to the destination workspace" and skipped; the rest of the
bundle still applies. Creating a new workspace is unaffected: the row lands
owned by the importer, and later rows in the same bundle may write into it —
including during a preview, which never inserts the row but must still report
the outcome the execution would produce. Updating an existing workspace
requires write access to it.
Importing a schedule that already exists moves its created_by to the
importer along with the permission snapshot. The two are read together at
fire time — the gate re-checks created_by's live execute grant while each
run copies accepted_permissions — so leaving the previous creator in place
would keep the importer's privileges firing under someone else's grant.
What grants do not affect¶
The request catalog and the portal are self-service by design. Which requests a person can browse and submit is governed by the workflow's publication state and its submission groups, not by workspace grants. Adding a grant does not widen catalog access, and removing one does not narrow it.