Skip to content

docs: state that human authorization is organization-wide - #335

Open
Lob26 wants to merge 4 commits into
theam:mainfrom
Lob26:docs/human-authorization-is-org-wide
Open

Lob26 wants to merge 4 commits into
theam:mainfrom
Lob26:docs/human-authorization-is-org-wide

Conversation

@Lob26

@Lob26 Lob26 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

What changes

Four documentation pages describe a project dimension on the human authorization
path that the data model does not have. They now say what the code does.

  • 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 becomes the
canonical one and now 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.

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.

Why

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. "Maintainer on one repository" cannot be written down, so it cannot
be reviewed, audited, or refused.

This matters because the pages sit next to a genuinely careful piece of work: a
project-scoped API 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, which is why
this half is worth fixing regardless of how the model question in #325 is
answered.

Out of scope, deliberately. #325 also notes that the bundled owner and
maintainer roles are both ["*"] — identical grants under different names, with
nothing between "read everything" and "administer the organization". Narrowing
maintainer removes access from existing instances on deploy, so it belongs in
its own change with an expand → migrate → contract path, not in a documentation
correction. reference/hardening.md already recommends project-scoped keys for
automation that needs only one project, so no change was needed there.

One claim listed in #325 is already gone: the README no longer says a "project
member" can open an authenticated local preview. Nothing to correct there.

Verification

Each claim was checked against the code before rewriting it, not against the
issue text:

grep projectMembers|project_members packages/db/src/schema.ts   → no matches
roles           = (id, orgId, name, description, permissions[])
orgMembers      = (id, orgId, userId, roleId)
BUNDLED_ROLES   → owner ["*"], maintainer ["*"], viewer allReads
preview.ts:221  → assertMembership(orgId, userId) selects from org_members
permissions.ts  → SPECIAL_PERMISSIONS includes "workspaces:execute"
node guards/run.mjs                                     2 guards, 0 failed
pnpm --filter @facility/docs build                      [SUCCESS]
  • pnpm verify passes locally
  • Behaviour verified beyond the test suite (say how)
  • Documentation updated, or no user-facing change

pnpm verify does not pass on this machine and not because of this change:
test:dev reports 116 tests, 92 pass, 24 fail here, and the identical 92/24
on a clean main — Windows noise unrelated to documentation. The meaningful
gates for a docs change are the link guard and the Docusaurus build, both green
above; the substance was verified by reading the schema, the role table and the
preview membership query rather than by any test.

Refs #325.

🤖 Claude Code helped

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 theam#325.

Refs theam#325.
# Conflicts:
#	apps/docs/docs/guides/operate-story.md
…ber's story

`stories-and-workspaces.md` landed in theam#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.

@adrian-lorenzo adrian-lorenzo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying the organization-wide authorization model and updating the newer story docs.

One detail to correct: reading a story or conversation requires projects:read; workspaces:execute permits continuing it and does not imply read access. Please name both permissions in the authentication guide.

`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.
@Lob26

Lob26 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Addressed at 9e5ccd8. You are right, and naming only one of them was the same failure this PR set out to fix: prose implying a containment the model does not have. The guide now says projects:read reads a story and its conversation, workspaces:execute continues it, opens its preview or suspends it, and that execute does not imply read, so a role needs both. Checked against every app.get in routes/v1/story-workspaces.ts rather than inferred. Thanks for catching it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants