Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ their rationale inline. **Do not relitigate these.**
- **Write-only tokens (`lk_wo_*`)** — [rationale](./docs/decisions.md#write-only-tokens-lk_wo_)
- Token SHA-256 hash in DB — shown once, never stored in plain text
- **API token scoping (scopes + orgs)** — [rationale](./docs/decisions.md#api-token-scoping-scopes--orgs)
- **`orgs_only` tenancy excludes personal memories (00116)** — [rationale](./docs/decisions.md#orgs_only-the-one-tenancy-that-excludes-personal-memories)
- `AlwaysOn` OTel sampler — sampling deferred to Dash0 pipeline, never SDK-side
- `instrumentation.ts` must be `async function register()` with `NEXT_RUNTIME === 'nodejs'` guard
- **Browser RUM initialises in `lib/dash0-rum.ts`, identity set at INIT** — [rationale](./docs/decisions.md#browser-rum-init--identity-at-init)
Expand Down
41 changes: 36 additions & 5 deletions docs/api-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ token you never scope behaves exactly as it always has.
| Axis | Column | Default | Meaning |
|------|--------|---------|---------|
| Scopes | `api_tokens.scopes` | `{}` — **unrestricted** | Allowlist of scope patterns the token may touch |
| Tenancy | `api_tokens.org_access` | `all` | `all` \| `personal` \| `selected` |
| Orgs | `api_tokens.org_ids` | `{}` | The orgs a `selected` token may reach |
| Tenancy | `api_tokens.org_access` | `all` | `all` \| `personal` \| `selected` \| `orgs_only` |
| Orgs | `api_tokens.org_ids` | `{}` | The orgs a `selected` or `orgs_only` token may reach |

### Scope patterns

Expand Down Expand Up @@ -99,9 +99,17 @@ an org, and vice versa.
| `all` (default) | ✓ | Every org the owner belongs to |
| `personal` | ✓ | None |
| `selected` | ✓ | Only the orgs in `org_ids` |

Personal memories are reachable under every tenancy — `personal` narrows which
*orgs* a token reaches, it never revokes the owner's own memories. A token can
| `orgs_only` | ✗ | Only the orgs in `org_ids` |

Personal memories are reachable under every tenancy **except `orgs_only`** —
`personal` and `selected` narrow which *orgs* a token reaches and never revoke
the owner's own memories. `orgs_only` (migration 00116) is the one tenancy that
does: it reaches the orgs in `org_ids` (still intersected with the owner's
current membership) and **none** of the owner's personal memories, on every read
and write path. It exists for a token that leaves your hands — a read-only token
bundled in a public tool, a CI token shared with a team — where `selected` would
also expose everything personal you keep in the token's scopes. Like `selected`,
it requires at least one org. A token can
only be pointed at an org its **owner** is a member of; asking for any other org
is rejected when the scoping is saved, not silently ignored.

Expand All @@ -110,6 +118,29 @@ Tenancy is authoritative over
scope from a `personal` token falls back to a personal memory rather than being
routed into an org the token was never granted.

Under `orgs_only` there is nothing to fall back to: a write that would land in
the personal store (no `org`, and no scope→org binding to an org the token
reaches) is **refused** with `key_personal_denied` (`LK002`) rather than
written. Operations that only ever act on the owner's own rows — and so have no
org-scoped form to narrow to — are refused outright for an `orgs_only` token
(MCP `-32003`, REST `403`): `memory.purge`, `memory.purge_expired`,
`memory.quota`, `memory.protect`, `policy.list/create/update/delete`,
`groom.preview/run`, and their REST twins, plus the owner analytics
`GET /memories/read-activity`, `/read-ranking`, `/utility`, `/usage` and
`/usage/runs`, and all org and membership management (`org.*`, `member.*`,
`/orgs/**`) — those list every org the owner belongs to, member handles and
invitee emails, whatever the token's `org_ids`. The writes addressed by id (`PATCH /memories/:id`,
`DELETE /memories/:id`, `POST /memories/:id/restore`) match only the caller's
own rows, which are personal, so for an `orgs_only` token they match nothing and
return `404`; archive / delete / restore by scope+key (`memory.archive`,
`memory.delete`, `memory.restore`, `DELETE /memories?scope&key`,
`POST /memories/restore`) only ever match rows in the token's orgs, so none of
them touches a personal row.

An `org_access` value a reader does not recognise **fails closed to no access**
(no personal rows, no orgs), so a new tenancy can never widen a key on a server
that predates it.

### What a scoped token sees

| Situation | Behaviour |
Expand Down
14 changes: 13 additions & 1 deletion docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Write-only tokens (`lk_wo_*`) store `permissions: ['write']` in the existing `te
**API token scoping** (`supabase/migrations/00068_api_token_scoping.sql`) adds two axes to a `lk_*` key on top of the read/write one the prefix already encodes: an allowlist of scope patterns (`api_tokens.scopes`) and a tenancy (`org_access` ∈ `all` | `personal` | `selected`, with `org_ids`). **00068 is the data model plus the two predicates only — 00068 alone enforces nothing.** It is no longer the whole story: `00069_api_token_scoping_enforcement.sql` makes the predicates binding in the transports (`mcp/auth.ts`, `_shared/api/auth.ts`, `mcp-handler.ts`, `applyTenantScope` / `applyRestTenantScope`) and inside the SQL functions the transports cannot stand in front of (see the enforcement decision below), the dashboard's `TokenManager.tsx` sets the columns, and `00070_audit_log_api_key_scope_change.sql` audits every change. The decisions below describe the model; the enforcement decision describes the behaviour that is live. Five decisions, none of them to be relitigated:

1. **EMPTY MEANS UNRESTRICTED.** `scopes = {}` is "every scope the owner can see", not "none". Empty-means-deny reads safer but bricks every existing key on deploy AND the key the dashboard auto-generates on first login (`onboarding-steps.tsx`), which has no UI to pick scopes from. Restriction is opt-in and additive; the column defaults reproduce today's behaviour exactly.
2. **TENANCY IS A TRI-STATE, NOT A LIST.** A bare `org_ids uuid[]` cannot say "personal only" — an empty list would have to mean both "unrestricted" and "no orgs", the two most different answers in the space. Hence the enum, with the list meaningful (and permitted) only under `selected`. A personal row is reachable under every tenancy: `personal` narrows which ORGS are reachable, it never revokes the owner's own memories.
2. **TENANCY IS A TRI-STATE, NOT A LIST.** A bare `org_ids uuid[]` cannot say "personal only" — an empty list would have to mean both "unrestricted" and "no orgs", the two most different answers in the space. Hence the enum, with the list meaningful (and permitted) only under `selected`. A personal row is reachable under every tenancy: `personal` narrows which ORGS are reachable, it never revokes the owner's own memories. (Sole exception since 00116: [`orgs_only`](#orgs_only-the-one-tenancy-that-excludes-personal-memories).)
3. **PATTERNS REUSE THE EXISTING WILDCARD GRAMMAR.** `repo::mthines/*` is already what users type (`expandScopeForSearch`), so the allowlist takes the same shape rather than inventing a second matcher — down to the same `[a-z0-9._:/-]` charset, because an allowlisted pattern lands in the same kind of PostgREST predicate as a searched one, and the same `\_` LIKE escape, so `repo::my_org/*` stays owner-exact, and the same WILDCARD POSITION: a trailing `*` counts only directly after a `/` or a `::`. Interior wildcards (`repo::*/lorekit`) are not supported, and neither is a trailing star off a segment boundary (`repo::mthines/lore*`) — under an "any trailing star" rule that pattern would allowlist `repo::mthines/lorekit-private` while being refused as a search filter, i.e. two grammars wearing one syntax. A restricted key fails CLOSED on a scopeless operation (`memory.purge_expired`, the account-wide reads); an unrestricted one is unaffected.
4. **THE KEY WINS OVER A SCOPE→ORG BINDING.** [Binding](#scopeorg-binding) auto-routes a write under a bound scope into that org. A key restricted to `personal` writing under a bound scope must NOT land in an org it was never granted — the binding is a convenience, the key restriction is an authorization boundary, and a boundary a convenience can widen is not one. The write falls back to personal, exactly as a non-member's write already does.
5. **WRITES GO THROUGH AN RPC, NOT AN UPDATE POLICY.** 00002 deliberately gave `api_tokens` no UPDATE policy. Adding one now would put "may this actor point this key at this org?" in a `WITH CHECK` expression that cannot consult `org_members` without re-deriving the tenancy predicate a second time. `lorekit_api_token_set_scoping` is the Phase-3 shape instead: SECURITY DEFINER, actor from `auth.uid()`, org ids validated against the actor's own membership so the row can never record access that does not exist.
Expand Down Expand Up @@ -504,6 +504,18 @@ from a list is a different task from an agent recalling a convention, and the sh
only place that list already exists. The refs are taken in injection order so the SessionStart set
leads — it is both the largest and the one in context for the whole turn.

## `orgs_only`: the one tenancy that excludes personal memories

**`org_access = 'orgs_only'` (migration `00116_api_token_orgs_only_tenancy.sql`) is the single, deliberate exception to "a personal row is reachable under every tenancy"** (decision 2 of [API token scoping](#api-token-scoping-scopes--orgs)). A token reaches the orgs in `org_ids` (intersected with current membership, exactly like `selected`) and none of its owner's personal rows. The motivating case is a token that leaves the owner's hands — a public read-only token bundled in a tool so its users can read one org's curated memories — where `selected` would also hand out every personal memory the owner keeps in the allowed scopes. A bot account with no personal memories is the workaround this replaces; it relies on nobody ever writing there.

- **One predicate change carries the reads.** `lorekit_api_token_org_allowed(p_org_access, p_org_ids, null)` now returns `false` for `orgs_only`, and every tenancy-aware RPC already calls it per row, so list, search, read, scopes, tags, facets, pivot, activity, relevant, clusters and list_archived needed no edits and no signature change. PostgREST paths match the SQL through `ownRowsFragment` / `applyTenantScope` / `applyRestTenantScope`, which add no personal branch for `orgs_only` — and the "no visible orgs" fallback matches **nothing** (`id.is.null`) rather than personal rows.
- **Writes refuse, never fall back.** `memory_write` raises `LK002 key_personal_denied` when the routed destination is personal and the key may not reach personal rows. Falling back to personal (what a `personal` key does with a bound scope) would write exactly what the token must not touch.
- **Removal and restore by (scope, key) need their own clause.** `memory_delete` (00071) and `restore_memory` (00072) match `user_id = actor OR member org` and only consult the predicate for the scope-managed disjunct, so 00116 re-creates both with an extra `orgs_only`-only clause; every other tenancy runs the 00071/00072 WHERE unchanged.
- **Owner-only operations are refused, not narrowed.** `memory.purge`, `memory.purge_expired`, `memory.quota`, `memory.protect`, `policy.*`, `groom.*` and the owner analytics (`memory.read-activity`, `memory.read-ranking`, `memory.utility`, `memory.usage`, `memory.usage-runs`, whose RPCs take no tenancy) act on the owner's own rows with no org-scoped form; org and membership management (`org.*`, `member.*`) ignores `org_ids` and exposes every org, member handle and invitee email of the owner, so it is refused too — so `OWNER_ROW_TOOLS` / `isRefusedForOrgsOnlyKey` (`account-wide-tools.ts`, mirrored) refuses them on MCP (`-32003`) and REST (`403`). The by-id REST writes (`PATCH`/`DELETE /memories/:id`, restore by id) already match only the caller's own rows, and `applyKeyScopeFilter` narrows those to the token's orgs, so for `orgs_only` they match nothing (`404`).
- **Unknown values fail closed.** `normalizeKeyRestriction` used to map an unknown `org_access` to `personal`, which still exposes personal rows — an older reader would have widened an `orgs_only` key. It now maps an unknown value to `orgs_only` with no orgs: no personal rows, no org rows. The SQL predicate already returned `false` for an unknown value on org rows and now does for personal rows too.

Do not add a second personal-excluding mode or a per-request "exclude personal" flag: the tenancy is a property of the key, so the owner decides it once, at mint time, in the dashboard ("Selected orgs only (no personal memories)").

## `memory.read` batching is conditional, verbatim-scoped, and inflates a signal it doesn't count

`memory.read` gained an optional `refs: string[]` of `scope::key` references (MCP, `POST
Expand Down
4 changes: 2 additions & 2 deletions docs/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ you omit them the server infers them from a `loop::<host>-lessons` tag
`signal`/`reviewer`), so a tagged write records them without extra arguments. On
an update the last KNOWN value wins, exactly like `origin_*`.

**Scope→org binding.** If you omit `org` but the scope is **bound to an org** (an admin set that up — see [org-sharing.md](./org-sharing.md#scope--org-binding-auto-routing)), the write auto-routes to that org **when you're a write-capable member**. A binding can also be a **wildcard prefix** (`repo::owner/*`), and when several bindings match, resolution is **most-specific-wins** (an exact scope beats any wildcard; a longer wildcard prefix beats a shorter one). If you're *not* a member of the winning org, it's saved to your personal lore instead (never rejected) and the response carries a `notice` explaining that. An explicit `org` always overrides binding resolution entirely.
**Scope→org binding.** If you omit `org` but the scope is **bound to an org** (an admin set that up — see [org-sharing.md](./org-sharing.md#scope--org-binding-auto-routing)), the write auto-routes to that org **when you're a write-capable member**. A binding can also be a **wildcard prefix** (`repo::owner/*`), and when several bindings match, resolution is **most-specific-wins** (an exact scope beats any wildcard; a longer wildcard prefix beats a shorter one). If you're *not* a member of the winning org, it's saved to your personal lore instead (never rejected) and the response carries a `notice` explaining that — unless your token is limited to selected orgs (`orgs_only`), which may not write personal memories: that write is refused with an in-band `isError` result (`key_personal_denied`), so pass `org`. An explicit `org` always overrides binding resolution entirely.

**Returns:** `{ "id": "<uuid>", "created_at": "<iso>" }` — plus an optional `"expires_at": "<iso>"` when any `ttl_*` field was supplied, and an optional `"notice": "<string>"` when a write fell back to personal because the scope is bound to an org you can't write to.

Expand Down Expand Up @@ -828,7 +828,7 @@ no `error` member. The `lorekit` CLI's own client checks `result.isError`
| `-32603` | Server fault — a DB outage or an unexpected exception. Not something the model can self-correct from, and safe for a client to retry. A BYOD storage misconfiguration (`LOREKIT_STORAGE_URL` set without `LOREKIT_STORAGE_SERVICE_KEY`) also returns `-32603`, with a message naming the missing variable — that one does not clear on retry; fix the function's secrets |
| `-32700` | Parse error — malformed JSON body |
| `-32601` | Unknown method or tool name |
| `-32003` | Forbidden (authenticated, but not permitted) — a read-only token attempted a write (or vice versa), an account-wide tool on a scoped key, or a scope outside the key's allowlist. The last one covers `memory.*` and, as of this change, `policy.*`/`groom.*` too: `policy.create`/`groom.*`'s target-or-resolved scope, and `policy.update`/`policy.delete`'s STORED scope. |
| `-32003` | Forbidden (authenticated, but not permitted) — a read-only token attempted a write (or vice versa), an account-wide tool on a scoped key, or a scope outside the key's allowlist. The last one covers `memory.*` and, as of this change, `policy.*`/`groom.*` too: `policy.create`/`groom.*`'s target-or-resolved scope, and `policy.update`/`policy.delete`'s STORED scope. It also covers a token limited to selected orgs (`orgs_only`) calling a tool that only acts on the owner's own rows (`memory.purge`, `memory.purge_expired`, `memory.quota`, `memory.protect`, `policy.*`, `groom.*`) or manages the owner's orgs (`org.*`, `member.*`). |

These are the spec's "errors in *finding* the tool […] or any other exceptional
conditions". **Auth-family errors always travel in-band** — HTTP `200` with a
Expand Down
4 changes: 3 additions & 1 deletion docs/org-sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ is routed to the org automatically:
- A **non-member** (or a viewer) writing under a bound scope → the memory is
saved to **their personal lore** (never rejected), and `memory.write` returns
a `notice` explaining it's saved personal because they aren't a write-member,
and to ask an admin to add them. Never silent, never a hard failure.
and to ask an admin to add them. Never silent, never a hard failure — except
from a token limited to selected orgs (`orgs_only`), which may not write
personal memories: that write is refused (`key_personal_denied`) instead.
- An **explicit `org`** parameter always takes precedence over the binding.

Binding is server-side truth (an `org_scope_bindings` row), not the advisory
Expand Down
54 changes: 54 additions & 0 deletions packages/mcp-core/src/auth/account-wide-tools.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import {
ACCOUNT_WIDE_TOOLS,
OWNER_ROW_TOOLS,
isRefusedForOrgsOnlyKey,
orgsOnlyRefusalMessage,
} from './account-wide-tools.js';

describe('isRefusedForOrgsOnlyKey (00116)', () => {
it('refuses every owner-row tool to an orgs_only key', () => {
for (const tool of OWNER_ROW_TOOLS) expect(isRefusedForOrgsOnlyKey(tool, 'orgs_only')).toBe(true);
});

it('covers the account-wide sweeps and the retention family', () => {
for (const tool of [...ACCOUNT_WIDE_TOOLS, 'memory.quota', 'memory.protect', 'policy.list',
'policy.create', 'policy.update', 'policy.delete', 'groom.preview', 'groom.run']) {
expect(OWNER_ROW_TOOLS.has(tool)).toBe(true);
}
});

it('covers the owner analytics whose RPCs take no tenancy', () => {
for (const tool of ['memory.read-activity', 'memory.read-ranking', 'memory.utility', 'memory.usage',
'memory.usage-runs']) {
expect(isRefusedForOrgsOnlyKey(tool, 'orgs_only')).toBe(true);
}
});

it('refuses the account-level org surface, which ignores the key\'s org list', () => {
for (const tool of ['org.list', 'org.get', 'org.create', 'org.rename', 'org.delete', 'member.list',
'member.invite_list', 'member.invite', 'member.revoke', 'member.remove', 'member.role_change']) {
expect(isRefusedForOrgsOnlyKey(tool, 'orgs_only')).toBe(true);
expect(isRefusedForOrgsOnlyKey(tool, 'selected')).toBe(false);
}
expect(orgsOnlyRefusalMessage('org.list')).toMatch(/organisations.*orgs_only/);
});

it('leaves tenancy-narrowed tools alone', () => {
for (const tool of ['memory.read', 'memory.list', 'memory.search', 'memory.write', 'memory.scopes',
'memory.list_archived', 'memory.archive', 'memory.restore', 'memory.delete']) {
expect(isRefusedForOrgsOnlyKey(tool, 'orgs_only')).toBe(false);
}
});

it('changes nothing for the other tenancies or no key', () => {
for (const access of ['all', 'personal', 'selected', undefined]) {
expect(isRefusedForOrgsOnlyKey('memory.purge', access)).toBe(false);
expect(isRefusedForOrgsOnlyKey('groom.run', access)).toBe(false);
}
});

it('names the tool and the tenancy in its refusal', () => {
expect(orgsOnlyRefusalMessage('memory.quota')).toMatch(/memory\.quota.*orgs_only/);
});
});
Loading
Loading