From 2e7881613872541b4e6adcbcdb59b98540419586 Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:59:37 -0500 Subject: [PATCH 1/3] docs: state that human authorization is organization-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four pages describe a project dimension on the human path that the data model does not have. There is no `project_members` table, `roles` is `(orgId, name, permissions[])` with no project column, and permission strings carry no project either — so a role granting `workspaces:execute` grants it in every project in the organization, and "maintainer on one repository" cannot be written down at all. The claims corrected: - `self-host/authentication.md` — "Any active project maintainer can read and continue the same story. Every project operation checks organization and project scope." - `self-host/authentication.md` — the preview proxy "revalidates the user, project membership, ...". `WorkspacePreviewService.assertMembership` queries `org_members`; the check is organization membership. - `contributors/architecture.md` — the request path "checks project scope and permissions". - `guides/operate-story.md` — the UI "can continue it under the current user's project membership". `reference/security.md` was already the accurate page. Its sentence is now the canonical one and says plainly that a project boundary does not contain a person, and that a project-scoped API key is the only mechanism that contains a principal to one project. This matters because the pages sit next to a genuinely careful piece of work — a scoped key asking for another project gets 404, not 403, so it cannot be used as an enumeration oracle. A reader who has just read that will reasonably assume the same containment applies to people. It does not, and nothing in the model made the asymmetry visible. Provisioning against the stronger reading is a security expectation set by documentation. Documentation only: no behaviour changes, and no position is taken on whether the model should gain a project dimension. That question stays open in #325. Refs #325. --- apps/docs/docs/contributors/architecture.md | 3 ++- apps/docs/docs/guides/operate-story.md | 2 +- apps/docs/docs/reference/security.md | 10 ++++++---- apps/docs/docs/self-host/authentication.md | 20 +++++++++++++------- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/apps/docs/docs/contributors/architecture.md b/apps/docs/docs/contributors/architecture.md index 2379297f..252f56ed 100644 --- a/apps/docs/docs/contributors/architecture.md +++ b/apps/docs/docs/contributors/architecture.md @@ -47,7 +47,8 @@ similar handwritten versions. ## Main request path -A UI or MCP request enters the API, resolves a principal, checks project scope and permissions, and +A UI or MCP request enters the API, resolves a principal, checks its organization-wide role +permissions and — for a project-scoped API key — that the key's project matches the request, and calls a domain service. Starting or continuing a story persists its message and turn in PostgreSQL. The worker claims the turn, resolves an exact agent manifest, wakes the workspace provider, prepares `.facility.yml`, issues repository credentials, and starts or resumes Claude Code or diff --git a/apps/docs/docs/guides/operate-story.md b/apps/docs/docs/guides/operate-story.md index 82de8076..36a67838 100644 --- a/apps/docs/docs/guides/operate-story.md +++ b/apps/docs/docs/guides/operate-story.md @@ -37,7 +37,7 @@ Messages sent while a turn is active wait in order. Use `facility_get_story` to inspect status and `next_operations`. Use `facility_get_conversation` with its cursor for durable message history. The UI renders the same -conversation and can continue it under the current user's project membership. +conversation and can continue it under the current user's organization role. The story timeline is the review path across the whole delivery. It shows which agent, model, session, workspace, branch, and initial SHA started each turn; the final SHA, commits, files, and diff --git a/apps/docs/docs/reference/security.md b/apps/docs/docs/reference/security.md index 7fe4a7a3..7cdcfafb 100644 --- a/apps/docs/docs/reference/security.md +++ b/apps/docs/docs/reference/security.md @@ -37,10 +37,12 @@ Facility still enforces: - secret redaction from persisted command events and API responses; and - explicit confirmation and idempotency for durable workspace deletion. -Authorization uses organization membership, roles, and route permissions. Project-scoped API keys -are pinned to one project and cannot enumerate others. Organization administration rejects scoped -keys. Audit events record successful privileged mutations with actor, target, project, and request -id. +Authorization uses organization membership, roles, and route permissions. A human role is +organization-wide: its permissions apply to every project in the organization, and a project +boundary does not contain a person. Project-scoped API keys are pinned to one project and cannot +enumerate others, so a scoped key is the only mechanism that contains a principal to one project. +Organization administration rejects scoped keys. Audit events record successful privileged +mutations with actor, target, project, and request id. GitHub webhook signatures are verified over the raw body before JSON parsing. The installation id must map to one active organization, and delivery ids are deduplicated within that installation. diff --git a/apps/docs/docs/self-host/authentication.md b/apps/docs/docs/self-host/authentication.md index ba99e8ea..9cc80c68 100644 --- a/apps/docs/docs/self-host/authentication.md +++ b/apps/docs/docs/self-host/authentication.md @@ -97,9 +97,15 @@ the API from the deployment secret store. ## Authorization behavior -Any active project maintainer can read and continue the same story. Every project operation checks -organization and project scope. Cross-project lookups return 404 so scoped credentials cannot use -the API as an enumeration oracle. +Human authorization is organization-wide. A role grants its permissions across every project in the +organization, so any active member holding `workspaces:execute` can read and continue any story in +it. There is no project membership and no project dimension on a role: "maintainer on one +repository" cannot be expressed. + +The project dimension exists for API keys. A project-scoped key is pinned to one project, and a +cross-project lookup returns 404 rather than 403 so a key cannot use the API as an enumeration +oracle. Reach for a scoped key when automation should be contained to one project, and note the +trade-off: audit events then record the key rather than a person. Roles and route permissions govern humans and API keys. They do not change an agent manifest into a restricted workspace profile: once a principal is allowed to execute an agent in a project, the @@ -111,13 +117,13 @@ requests remain available in structured service logs and can be correlated with ## Preview authentication Preview cookies are separate from control-plane sessions. A one-time handoff issues an expiring -preview cookie; each proxied request revalidates the user, project membership, workspace, service, -expiry, and revocation status. +preview cookie; each proxied request revalidates the user, organization membership, workspace, +service, expiry, and revocation status. The preview origin must be on a different registered site from Facility control origins. Opening a service creates a one-time handoff; consuming it sets a host-only preview cookie. The preview proxy -rechecks membership on each HTTP request and WebSocket upgrade, so revoking membership, the -session, or the workspace stops continued access. +rechecks organization membership on each HTTP request and WebSocket upgrade, so removing the member, +revoking the session, or deleting the workspace stops continued access. Never send a Facility API key, OAuth token, or control-plane session cookie to the application running inside a preview. From bb854790a3853a8d8dc38fc05d11eb29496ca095 Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 15 Sep 2026 06:14:36 -0500 Subject: [PATCH 2/3] docs(concepts): a role, not project membership, continues another member's story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stories-and-workspaces.md` landed in #368 with the same claim this branch corrects elsewhere: "A project member can continue a story created by another member." There is no project membership. `orgMembers` is `(orgId, userId, roleId)` and permission strings carry no project, so what allows continuing someone else's story is an organization role that grants it — in every project in the organization, not in one. Same correction as the other four pages, on a page that did not exist when this branch was opened. --- apps/docs/docs/concepts/stories-and-workspaces.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/docs/concepts/stories-and-workspaces.md b/apps/docs/docs/concepts/stories-and-workspaces.md index 779aefff..2794d230 100644 --- a/apps/docs/docs/concepts/stories-and-workspaces.md +++ b/apps/docs/docs/concepts/stories-and-workspaces.md @@ -14,8 +14,8 @@ scheduled activation to the same story. An idempotency key protects individual s requests from network retries. These are different concerns: the external id identifies the body of work, while the key identifies one requested operation. -A project member can continue a story created by another member. The conversation is shared -project state rather than a private chat transcript. +Any organization member whose role grants it can continue a story created by another member. The +conversation is shared project state rather than a private chat transcript. ## Durable and replaceable state From 9e5ccd88fd36367f7d4e6d6e8285c660cbbb225b Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 15 Sep 2026 08:12:09 -0500 Subject: [PATCH 3/3] docs(authentication): separate the read grant from the execute grant `workspaces:execute` was described as if it also granted read. It does not. Every `app.get` on a story or its conversation in `routes/v1/story-workspaces.ts` is guarded by `projects:read`; `workspaces:execute` guards continuing a story, opening its preview and suspending it. A role needs both to follow a story and act on it. Naming only one of them is the same failure this change set out to fix: prose that implies a containment the model does not have. --- apps/docs/docs/self-host/authentication.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/docs/docs/self-host/authentication.md b/apps/docs/docs/self-host/authentication.md index 9cc80c68..c5bf79a1 100644 --- a/apps/docs/docs/self-host/authentication.md +++ b/apps/docs/docs/self-host/authentication.md @@ -98,8 +98,10 @@ the API from the deployment secret store. ## Authorization behavior Human authorization is organization-wide. A role grants its permissions across every project in the -organization, so any active member holding `workspaces:execute` can read and continue any story in -it. There is no project membership and no project dimension on a role: "maintainer on one +organization, and reading and acting are separate grants: `projects:read` is what reads a story and +its conversation, while `workspaces:execute` is what continues one, opens its preview, or suspends +it. `workspaces:execute` does not imply read access, so a role needs both to follow a story and act +on it. There is no project membership and no project dimension on either: "maintainer on one repository" cannot be expressed. The project dimension exists for API keys. A project-scoped key is pinned to one project, and a