# Authorisation

## Authorisation is the URL

A nested URL is a **claim about ownership**, and the server verifies it once, centrally:
`/v1/projects/{projectId}/items/{itemId}` resolves the item, derives its project from the
record, and **404s when the pair disagrees**.

Not a per-handler check. A single middleware over every nested route, so a new endpoint is
bound the day it is written and cannot opt out.

This is the HTTP form of the fix that shipped as RUL-158. The eight defects RUL-157 and
RUL-162 repaired were all one shape — **a guard checking one identifier while the query used
another** — and in a URL that shape does not exist to be got wrong.

**404, never 403.** A 403 confirms the record exists, which turns a URL into a probe across a
tenancy boundary. `nesting/404-never-403` in the conformance suite asserts it on every nested
operation.

**Studio-level resources are not nested.** Suppliers, units, codes, clients and addresses
belong to the studio, not to a project. Nesting them under a project would be a false claim
about ownership, and pretending otherwise is how `supplierId` reached the `NO-RECORD-YET`
list.

## Scopes are named, not discovered by 403

Every operation carries `x-rulework-scope`, and the vocabulary is the `StudioPermission`
union in `packages/trpc/src/contracts-a.ts` — **eleven** members, singular nouns.

This document does not list them, and neither should the spec's prose. The conformance suite
**imports the union** and checks every operation's scope against it. An enumerating regex,
`"[a-z]+:[a-z-]+"`, once returned ten of the eleven because it disallows a hyphen in the
first segment, so `client-decision:write` — the one scope RUL-184 is built on — could never
match, and a whole ticket was written around its absence. Enumerate a closed set from its
source or not at all.

Schedule writes require both `project:write` and `project:read`: their responses include the readable line. The server checks both before mutation, and each operation declares the extra permission in `x-rulework-additional-scopes`.

**Never add a scope the app cannot enforce.** A scope in a spec that nothing checks is worse
than no scope, because it reads as a guarantee.

## Caller kinds

Three exist today.

**Practitioner** — the workbench's WorkOS session. Permissions come off its validated claims;
membership changes take effect when that session next refreshes.

**Capability holder** — `capabilityToken`. **It names a document, never a person.** Without a
WorkOS session the actor stays null even for a perfectly valid token, and that is deliberate:
a link can be forwarded, so treating the holder as a principal would let whoever received the
email bind the client commercially and write an audit row attributing the decision to nobody
real (SEC-008).

**Integration** — `bearerAuth` again, but a **studio access token** rather than a session:
`Authorization: Bearer rwk_live_…`, forty-three base64url characters after the prefix, minted
by a studio owner at Settings → Connections (RUL-286). This is the kind RUL-173 described and,
until 13 September, this document said did not exist; it exists now, and it is what an agent
or an integration presents to `/v1` and to `/mcp`.

- **Minting is behind `integration:manage`**, and a token is **capped at the minter's
  permissions**: it may name any subset of what the minter holds and nothing outside it. The
  vocabulary it draws from is the same `StudioPermission` union.
- **Shown once.** The secret is stored as a SHA-256 hash and compared as one, on the share
  token's reasoning: 256 bits from a CSPRNG need no salt, and the lookup is by exact hash.
- **Membership and current permissions are rechecked on every hosted request.** WorkOS resolves the member's active organization roles; the saved token grant is intersected with those current permissions. Removal fails closed and demotion removes authority on the next request. Provider role definitions are used directly, without a local role map.
- **The saved grant is a ceiling.** A later promotion cannot add permissions the token was never granted. Revocation and expiry remain independent checks.
- **Project grants still apply.** A read-only token sees projects explicitly assigned to its owner. `workspace:manage` also grants access across the studio, but includes member administration; it is never silently added to make reading work.
- **Expiry and revocation** are honoured on every request. Revocation is an append, never a
  delete, so the row that minted the token survives as audit.
- **Indistinguishable failures.** Unknown, malformed, expired, revoked, minted by a member who
  has since left: one `401 UNAUTHORIZED` problem document, on the rule below. A well-formed
  bearer that the verifier does not believe is refused outright and never falls through to a
  session cookie.

**OAuth integrations** also use `bearerAuth`. WorkOS Connect handles sign-in, organization
selection, consent, PKCE, token issuance and refresh. Grok Bot can connect by entering
`https://rulework.app/mcp` and choosing Authenticate; no API token needs to enter a chat.

When `WORKOS_AUTHKIT_ISSUER` is configured, RFC 9728 metadata at
`/.well-known/oauth-protected-resource` advertises that authorization server and its OIDC
scopes (`openid profile email offline_access`). These scopes govern the OAuth exchange;
they are not Rulework permissions. The signed token must target `https://rulework.app/mcp`,
identify a user, organization and application consent, and pass signature, issuer and time
checks. ID tokens, browser session tokens and machine identities are refused. The compatibility
endpoint `/.well-known/oauth-authorization-server` forwards WorkOS's discovery document.

The selected organization's active membership and current role permissions are read from
WorkOS on each API request, including custom and multiple roles. There is no local role map
or default owner grant. The normal studio mapping, permission checks and project grants then
apply. Membership removal and role changes take effect on the next request. OAuth consent
revocation stops refresh; an already issued JWT remains usable until its expiry. OAuth
credentials are never accepted as a share capability or allowed to fall through to a cookie.
Expired OAuth tokens receive an HTTP 401 challenge at `/mcp`, enabling the client to refresh.

Without the issuer setting, studio API tokens remain supported and OAuth is not advertised.

## What a capability may do, and what it may not

- **Header only, never a query string** — query strings survive in access logs, referrers and
  browser history. Checked by `auth/no-credential-in-a-query-string`.
- **Minting is immutable.** Revocation is an append to `share_capability_revocations`, checked
  on every read. Archiving the project renders every capability on it unavailable, and is
  currently the only bulk off switch.
- **Nothing records an open.** No `lastAccessedAt`, no `openCount`, nothing that implies a
  read receipt — there is no access table and `document_deliveries` and `communication_events`
  are empty (RUL-170). If that capability is ever wanted it is a feature with a schema change,
  not a field. Checked by `auth/nothing-implies-a-read-receipt`, on words rather than raw
  strings so a verb buried mid-name cannot slip through.

## The capability reads anonymously and never writes anonymously

**Settled (RUL-221).** A valid token alone gets the read-only immutable projection, through
`external-share.ts`, which never produces an actor. **The estimate decision is not part of
that.** It is an `authenticatedProcedure`, both branches of `authorizeEstimateDecision` read
`context.actor.actorId`, and `resolveActiveDecision` is keyed on it — so the write is
unreachable without a session, by construction rather than by omission.

**This is a refusal, not a gap.** SEC-008 is the reason: a share link can be forwarded, so
treating its holder as a principal would let whoever received the email bind the client
commercially, and would write an audit row attributing a commercial decision to nobody real.
The capability branch instead records **both** — the verified person and the capability they
used — which is strictly more accountable than either alone.

The product already works this way and says so. A recipient who opens a share link and tries
to approve is shown a state titled *"Approving needs a verified identity"* and sent to
`/sign-in` with a return path back to that document, because "the token is not something they
can retype".

So the capability is **a second factor, not a credential**: it scopes a verified actor to one
revision, binding on `scopeType === "estimate_revision"` and `scopeId === revisionId`, which
is tighter than any project grant. Note that `client-decision:write` gates the **staff**
branch only — the capability branch never checks it.

**Do not describe the client as writing without a session.** Any future anonymous write path
is a new decision with a security argument to overturn, not an implementation detail; it
would also inherit the RUL-173 rule that only what is decided before the token resolves may
be distinguished.

## One indistinguishable failure, and the rule underneath it

Every reason a capability fails to resolve returns **one identical response** (SEC-002,
SEC-009, SYS-009). Revoked, expired, archived, malformed, never existed: the same body.
Distinguishing them is an oracle for which tokens exist.

The rule that generalises, and the one to apply to anything new on this route:

> **Distinguish only what is decided before the token resolves.**

That is why rate limiting can answer honestly — the limiter runs on the raw credential before
anything is looked up — and why nothing after it can.

## Rate limits

Real, enforced, and in the spec as `x-rulework-rate-limits`, generated from
`RATE_LIMIT_POLICIES` in `packages/db/src/rate-limit-adapter.ts` and checked against it. An
operation that consumes a bucket names it in `x-rulework-rate-limit` and must answer `429`
with `Retry-After` — **an agent that cannot see the limit will find it by hitting it**, and on
a write path that is expensive.

The limits are per **action**, not per credential kind. RUL-173 asks for the latter; the
deployment does not have it, and inventing the table would be advertising a policy nothing
applies. The one place the two ideas meet is the share route, where `public.share.read` is per
credential and `public.share.read.any` is a single anonymous bucket across every visitor of
every studio — three orders of magnitude larger because it is crude volume control, not what
makes a token unguessable. Entropy is that.

## Discovery

An agent that knows only the site can find everything above:

| URL | What it returns |
| --- | --- |
| `/mcp` | The remote MCP endpoint (Streamable HTTP, stateless). Bearer required; without one it answers `401` with `WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource"`. |
| `/.well-known/oauth-protected-resource` | RFC 9728 metadata for `/mcp`: the resource, WorkOS authorization server and OAuth scopes when enabled, and bearer-header transport. |
| `/.well-known/oauth-authorization-server` | WorkOS discovery proxy for clients that discover OAuth at the resource host. |
| `/.well-known/mcp/server-card.json` | The server card: `serverInfo`, the transport and endpoint, the protocol version, how to authenticate. |
| `/.well-known/openapi` | The contract, `application/yaml`. |
| `/.well-known/api-catalog` | RFC 9727 linkset tying the contract, these docs and the metadata together. |
| `/docs/api/<name>.md` | This directory, served as `text/markdown`. |
| `/llms.txt`, `/llms-full.txt` | The short index and the four API pages in one file. |

The MCP server also exposes the contract as the `rulework://openapi` resource and this page as
`rulework://docs/authorisation`. Every tool is one operation from the contract; a refusal
from `/v1` is returned inside the tool result verbatim, as the problem document, so `code` is
readable there exactly as it is over HTTP.

## Unchecked, and why

- **The nesting middleware itself.** There are no `/v1` routes yet, so the suite asserts the
  spec's shape — every nested operation answers 404 and none answers 403 — and cannot assert
  that one middleware enforces it. That check arrives with the routes.
- **That a 404 body reveals nothing.** The shape is checked (`Problem`, with a `code` from the
  closed vocabulary and no field outside RFC 9457's members); the `detail` string is free text
  and cannot be checked statically.

MCP conditional reads return `{data: {kind: "not_modified", etag}}` when the API answers 304. Throttled tool errors include the API's `Retry-After` value. When the limiter cannot determine its window, the server recommends a 60-second retry delay.


For `create_item`, `duplicate_item` and `add_photograph`, the saved HTTP response and the
change commit in one database transaction. Repeating the same key and request returns the
original status, body, location and version after later edits or removal. Project and scope
permissions are checked again before any replay. Reusing a key for a different request is
`IDEMPOTENCY_CONFLICT`.
