diff --git a/CLAUDE.md b/CLAUDE.md index 29b9b7301..0b02482bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/docs/api-tokens.md b/docs/api-tokens.md index 60e21eae7..70618b8bd 100644 --- a/docs/api-tokens.md +++ b/docs/api-tokens.md @@ -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 @@ -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. @@ -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 | diff --git a/docs/decisions.md b/docs/decisions.md index 2f55a3102..fcd267a1e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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. @@ -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 diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 9c65d711d..02974ecfb 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -148,7 +148,7 @@ you omit them the server infers them from a `loop::-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": "", "created_at": "" }` — plus an optional `"expires_at": ""` when any `ttl_*` field was supplied, and an optional `"notice": ""` when a write fell back to personal because the scope is bound to an org you can't write to. @@ -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 diff --git a/docs/org-sharing.md b/docs/org-sharing.md index abb4ee6e7..b4b1ea776 100644 --- a/docs/org-sharing.md +++ b/docs/org-sharing.md @@ -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 diff --git a/packages/mcp-core/src/auth/account-wide-tools.spec.ts b/packages/mcp-core/src/auth/account-wide-tools.spec.ts new file mode 100644 index 000000000..d99bff8c5 --- /dev/null +++ b/packages/mcp-core/src/auth/account-wide-tools.spec.ts @@ -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/); + }); +}); diff --git a/packages/mcp-core/src/auth/account-wide-tools.ts b/packages/mcp-core/src/auth/account-wide-tools.ts index 3c816a234..8c12177ad 100644 --- a/packages/mcp-core/src/auth/account-wide-tools.ts +++ b/packages/mcp-core/src/auth/account-wide-tools.ts @@ -56,3 +56,63 @@ export function accountWideRefusalMessage(toolName: string): string { return `"${toolName}" operates across your whole account, so it is not available to a ` + 'token restricted to specific scopes. Use an unscoped token for maintenance sweeps.'; } + + +/** + * Operations that act on the owner's own rows without consulting the key's + * tenancy, and so are refused outright to an `orgs_only` key (00116). + * + * `orgs_only` promises the owner's PERSONAL rows are out of reach. These + * operations choose their rows by `user_id` inside the RPC (or report on the + * owner's whole account), so there is no tenant predicate to narrow — the only + * honest answer is to refuse the CALL, exactly as `ACCOUNT_WIDE_TOOLS` is + * refused to a scoped key. Every other tool narrows instead: reads through + * `applyTenantScope` / `lorekit_api_token_org_allowed`, writes through + * `memory_write`'s personal-row refusal and `applyKeyScopeFilter`. + */ +export const OWNER_ROW_TOOLS: ReadonlySet = new Set([ + ...ACCOUNT_WIDE_TOOLS, + 'memory.quota', + 'memory.protect', + // Owner analytics: their RPCs take no tenancy and roll up the owner's own + // reads and calls, personal keys included (00080, 00079, 00083, 00085, 00106). + 'memory.read-activity', + 'memory.read-ranking', + 'memory.utility', + 'memory.usage', + 'memory.usage-runs', + 'policy.list', + 'policy.create', + 'policy.update', + 'policy.delete', + 'groom.preview', + 'groom.run', +]); + +/** + * Is this tool refused outright for a key with this tenancy? + * + * Only `orgs_only` is affected; every other tenancy (and no key at all) keeps + * exactly the behaviour it had. + */ +export function isRefusedForOrgsOnlyKey(toolName: string, orgAccess: string | undefined): boolean { + return orgAccess === 'orgs_only' && (OWNER_ROW_TOOLS.has(toolName) || isAccountOrgTool(toolName)); +} + +/** + * Org and membership management (`org.*`, `member.*`, MCP and REST). None of + * it reads the key's `org_ids`: `org.list` and `GET /orgs` return every org the + * owner belongs to, and the member/invite lists expose handles and invitee + * emails. An `orgs_only` key is the one meant to be handed out, so it gets none + * of the owner's account-level org surface. + */ +function isAccountOrgTool(toolName: string): boolean { + return toolName.startsWith('org.') || toolName.startsWith('member.'); +} + +/** The refusal sentence for `isRefusedForOrgsOnlyKey`, shared by both surfaces. */ +export function orgsOnlyRefusalMessage(toolName: string): string { + const what = isAccountOrgTool(toolName) ? 'manages your account\'s organisations' : 'acts on your own memories'; + return `"${toolName}" ${what}, so it is not available to a token ` + + 'limited to selected orgs (orgs_only). Use a token that may reach your personal memories.'; +} diff --git a/packages/mcp-core/src/auth/org-permissions.spec.ts b/packages/mcp-core/src/auth/org-permissions.spec.ts index 0c864f671..0a8aa21b7 100644 --- a/packages/mcp-core/src/auth/org-permissions.spec.ts +++ b/packages/mcp-core/src/auth/org-permissions.spec.ts @@ -17,6 +17,15 @@ describe('translateOrgPermissionError', () => { expect(orgError.message.toLowerCase()).toMatch(/permission|role/); }); + it('tells an orgs_only key to pass an org rather than ask for a role (00116)', () => { + const dbError = { code: ORG_PERMISSION_SQLSTATE, message: 'key_personal_denied: this token may not write personal memories (scope=global)' }; + const result = translateOrgPermissionError(dbError) as OrgPermissionError; + expect(result).toBeInstanceOf(OrgPermissionError); + expect(result.message).toMatch(/orgs_only/); + expect(result.message).toMatch(/Pass `org`/); + expect(result.message).not.toMatch(/change your role/); + }); + it('passes unrelated errors through unchanged', () => { const dbError = { code: 'LK001', message: 'memory_cap_exceeded: limit=1000' }; const result = translateOrgPermissionError(dbError); diff --git a/packages/mcp-core/src/auth/org-permissions.ts b/packages/mcp-core/src/auth/org-permissions.ts index 7ae7fbfe0..f17700fc1 100644 --- a/packages/mcp-core/src/auth/org-permissions.ts +++ b/packages/mcp-core/src/auth/org-permissions.ts @@ -17,6 +17,9 @@ /** Custom SQLSTATE raised by memory_write / memory_delete on an org-permission denial. */ export const ORG_PERMISSION_SQLSTATE = 'LK002'; +/** Message prefix `memory_write` raises (LK002) for a personal write from an `orgs_only` key (00116). */ +export const KEY_PERSONAL_DENIED_PREFIX = 'key_personal_denied'; + /** Message prefix every memory/org RPC raises when the caller names a non-existent org slug. */ const UNKNOWN_ORG_PREFIX = 'unknown_org: '; @@ -58,6 +61,15 @@ export function translateOrgPermissionError(err: unknown): unknown { const code = (err as { code?: string } | null | undefined)?.code; const rawMessage = (err as { message?: string } | null | undefined)?.message; + // 00116: an `orgs_only` key writing to the personal store. No role change + // fixes it; naming one of the key's orgs does. + if (code === ORG_PERMISSION_SQLSTATE && rawMessage?.startsWith(KEY_PERSONAL_DENIED_PREFIX)) { + return new OrgPermissionError( + 'This token is limited to selected orgs (orgs_only) and cannot write personal memories. ' + + 'Pass `org` with one of the token\'s orgs, or use a token that may reach your personal memories.', + ); + } + if (code === ORG_PERMISSION_SQLSTATE) { const message = rawMessage ?? 'org_permission_denied: you do not have the required role in this org'; return new OrgPermissionError( diff --git a/packages/mcp-core/src/auth/tenant-scope-usage.spec.ts b/packages/mcp-core/src/auth/tenant-scope-usage.spec.ts index a26e29196..248c75593 100644 --- a/packages/mcp-core/src/auth/tenant-scope-usage.spec.ts +++ b/packages/mcp-core/src/auth/tenant-scope-usage.spec.ts @@ -189,8 +189,10 @@ describe('tenancy exclusion guard (REST helper)', () => { // The `.eq()` branch is reached under `personal`, and under `selected` // whose intersection with the caller's memberships is empty. Without this // second filter the owner's own org rows come back on both. + // Under `orgs_only` (00116) the same branch must match NOTHING instead: + // its owner's personal rows are out of reach too. expect(restTenantSource()).toMatch( - /restrictsTenancy\(key\)\)\s*out\s*=\s*out\.or\('org_id\.is\.null'\)/, + /restrictsTenancy\(key\)\)\s*\{\s*out\s*=\s*out\.or\(reachesPersonal\(key\)\s*\?\s*'org_id\.is\.null'\s*:\s*MATCH_NOTHING\)/, ); }); @@ -612,3 +614,44 @@ describe('key-tenancy usage guard (quota org count)', () => { expect(src).toContain(".is('archived_at', null)"); }); }); + +describe('orgs_only (00116) is wired into every edge choke point', () => { + const edge = (rel: string) => readFileSync(path.resolve(here, '../../../../supabase/functions', rel), 'utf8'); + + it('the REST owner-row write family (applyKeyScopeFilter) narrows an orgs_only key to its orgs', () => { + const fn = edge('_shared/api/tenant.ts').match(/export function applyKeyScopeFilter[\s\S]*?\n}\n/)?.[0] ?? ''; + expect(fn).toMatch(/!reachesPersonal\(key\)/); + expect(fn).toMatch(/org_id\.in\./); + expect(fn).toContain('MATCH_NOTHING'); + }); + + it('every owner-row REST handler still goes through applyKeyScopeFilter', () => { + for (const handler of ['memories/handlers/update.ts', 'memories/handlers/remove.ts', 'memories/handlers/restore.ts']) { + expect(edge(handler), handler).toMatch(/applyKeyScopeFilter\(q, auth\)/); + } + }); + + it('neither write surface records citations for a key that cannot reach personal rows', () => { + // lorekit_record_memory_citations (00107) only ever credits the owner's + // personal lessons and takes no tenancy, so an orgs_only key must skip it. + expect(edge('_shared/telemetry/citations.ts')).toMatch(/input\.reachesPersonal === false\) return 0/); + expect(edge('mcp/tools.ts')).toMatch(/reachesPersonal: reachesPersonal\(keyScoping\)/); + expect(edge('memories/handlers/create.ts')).toMatch(/reachesPersonal: reachesPersonal\(keyRestriction\(auth\)\)/); + }); + + it('the REST router refuses owner-row tools before any handler runs', () => { + const src = edge('_shared/api/router.ts'); + const gate = src.indexOf('isRefusedForOrgsOnlyKey('); + const handler = src.indexOf('route.handler('); + expect(gate).toBeGreaterThan(-1); + expect(gate).toBeLessThan(handler); + }); + + it('the MCP dispatcher refuses owner-row tools before the memory/retention split', () => { + const src = edge('mcp/mcp-handler.ts'); + const gate = src.indexOf('isRefusedForOrgsOnlyKey('); + const split = src.indexOf('if (!isOrgTool && !isRetentionTool)'); + expect(gate).toBeGreaterThan(-1); + expect(gate).toBeLessThan(split); + }); +}); diff --git a/packages/mcp-core/src/auth/tenant-scope.spec.ts b/packages/mcp-core/src/auth/tenant-scope.spec.ts index 641cf0205..b161f003b 100644 --- a/packages/mcp-core/src/auth/tenant-scope.spec.ts +++ b/packages/mcp-core/src/auth/tenant-scope.spec.ts @@ -6,8 +6,10 @@ import { applyTenantScope, effectiveOrgIds, keyScopeFilter, + MATCH_NOTHING, normalizeKeyRestriction, ownRowsFragment, + reachesPersonal, restrictsTenancy, } from './tenant-scope.js'; @@ -70,12 +72,22 @@ describe('normalizeKeyRestriction', () => { expect(normalizeKeyRestriction({ org_access: null }).orgAccess).toBe('all'); }); - it('fails CLOSED on an org_access it does not recognise', () => { - // Present-but-unrecognised is corruption (the CHECK dropped, or a BYOD - // install predating it). "I do not understand this restriction" must not - // resolve to "there is no restriction", so it lands on the NARROWEST value. - expect(normalizeKeyRestriction({ org_access: 'everything' }).orgAccess).toBe('personal'); - expect(normalizeKeyRestriction({ org_access: 42 }).orgAccess).toBe('personal'); + it('fails closed on an org_access it does not recognise: no personal and no org row', () => { + // Present-but-unrecognised is corruption (the CHECK dropped, a BYOD install + // predating it) or a value newer than this code. "I do not understand this + // restriction" must not resolve to "there is no restriction" — nor to + // `personal`, which still reaches the owner's personal rows (00116). + for (const raw of ['everything', 42, 'ORGS_ONLY']) { + const key = normalizeKeyRestriction({ org_access: raw, org_ids: ['org-a'] }); + expect(key).toEqual({ scopes: [], orgAccess: 'orgs_only', orgIds: [] }); + expect(reachesPersonal(key)).toBe(false); + expect(effectiveOrgIds(['org-a'], key)).toEqual([]); + } + }); + + it('reads orgs_only', () => { + expect(normalizeKeyRestriction({ org_access: 'orgs_only', org_ids: ['org-a'] })) + .toEqual({ scopes: [], orgAccess: 'orgs_only', orgIds: ['org-a'] }); }); it('degrades an unreadable scopes column to unrestricted, not to deny-all', () => { @@ -272,6 +284,45 @@ describe('applyTenantScope with a key restriction', () => { }); }); +describe('orgs_only (00116): selected orgs, no personal rows', () => { + const key = { scopes: [], orgAccess: 'orgs_only' as const, orgIds: ['org-a'] }; + + it('reaches no personal row; every other tenancy and no key still do', () => { + expect(reachesPersonal(key)).toBe(false); + expect(reachesPersonal()).toBe(true); + for (const orgAccess of ['all', 'personal', 'selected'] as const) { + expect(reachesPersonal({ scopes: [], orgAccess, orgIds: [] })).toBe(true); + } + }); + + it('intersects its orgs with membership, like selected', () => { + expect(effectiveOrgIds(['org-a', 'org-b'], key)).toEqual(['org-a']); + expect(effectiveOrgIds(['org-b'], key)).toEqual([]); + }); + + it('its ownership disjunct matches nothing', () => { + expect(ownRowsFragment('user-1', key)).toBe(MATCH_NOTHING); + }); + + it('applyTenantScope reads only the selected org rows', () => { + const query = fakeQuery(); + applyTenantScope(query, 'user-1', ['org-a', 'org-b'], key); + expect(query.or).toHaveBeenCalledWith(`${MATCH_NOTHING},org_id.in.("org-a")`); + expect(query.or).not.toHaveBeenCalledWith(expect.stringContaining('user_id.eq.user-1')); + }); + + it('applyTenantScope with no reachable org matches NOTHING, not personal rows', () => { + const query = fakeQuery(); + applyTenantScope(query, 'user-1', ['org-b'], key); + expect(query.or).toHaveBeenCalledWith(MATCH_NOTHING); + expect(query.or).not.toHaveBeenCalledWith('org_id.is.null'); + }); + + it('restrictsTenancy is true', () => { + expect(restrictsTenancy(key)).toBe(true); + }); +}); + describe('ownRowsFragment', () => { it('is the bare ownership test with no key, and under "all"', () => { expect(ownRowsFragment('user-1')).toBe('user_id.eq.user-1'); diff --git a/packages/mcp-core/src/auth/tenant-scope.ts b/packages/mcp-core/src/auth/tenant-scope.ts index 2e06de12f..0f946f64d 100644 --- a/packages/mcp-core/src/auth/tenant-scope.ts +++ b/packages/mcp-core/src/auth/tenant-scope.ts @@ -27,8 +27,8 @@ export interface KeyRestriction { /** Allowlist of scope patterns. EMPTY = unrestricted. */ scopes: string[]; - orgAccess: 'all' | 'personal' | 'selected'; - /** Meaningful only under `selected`. */ + orgAccess: 'all' | 'personal' | 'selected' | 'orgs_only'; + /** Meaningful only under `selected` and `orgs_only`. */ orgIds: string[]; } @@ -36,10 +36,14 @@ export interface KeyRestriction { * Build a `KeyRestriction` from the raw `api_tokens` row the transports select. * * Total, and fail-closed on the one value that matters: an `org_access` the - * code does not recognise becomes `personal`, the NARROWEST tenancy, not `all`. - * A row can only carry an unknown value if `api_tokens_org_access_valid` was - * dropped or a BYOD install predates it, and in that state "I do not understand - * this restriction" must not resolve to "there is no restriction". + * code does not recognise becomes `orgs_only` with NO org ids — a key that + * reaches nothing at all, neither personal rows nor any org. It used to become + * `personal`, which still exposed the owner's personal rows: wrong for a value + * that may mean "no personal rows" (`orgs_only`, read by an older deploy). A + * row can only carry an unknown value if `api_tokens_org_access_valid` was + * dropped, a BYOD install predates it, or a newer migration added a value this + * code predates, and in that state "I do not understand this restriction" must + * not resolve to "there is no restriction". * * The scope list degrades the other way — a non-array becomes empty, i.e. * unrestricted — because that is the DEFAULT for every key ever created, so @@ -59,15 +63,13 @@ export function normalizeKeyRestriction(row: { ? row.org_ids.filter((s): s is string => typeof s === 'string') : []; const raw = row.org_access; - const orgAccess: KeyRestriction['orgAccess'] = - raw === 'all' || raw === 'personal' || raw === 'selected' - ? raw - // Absent is the pre-00068 shape (the column did not exist), which IS - // unrestricted. Present-but-unrecognised is corruption, and denies. - : raw == null - ? 'all' - : 'personal'; - return { scopes, orgAccess, orgIds }; + // Absent is the pre-00068 shape (the column did not exist), which IS + // unrestricted. Present-but-unrecognised denies everything. + if (raw == null) return { scopes, orgAccess: 'all', orgIds }; + if (raw === 'all' || raw === 'personal' || raw === 'selected' || raw === 'orgs_only') { + return { scopes, orgAccess: raw, orgIds }; + } + return { scopes, orgAccess: 'orgs_only', orgIds: [] }; } /** @@ -82,9 +84,29 @@ export function normalizeKeyRestriction(row: { export function effectiveOrgIds(memberOrgIds: string[], key?: KeyRestriction): string[] { if (!key || key.orgAccess === 'all') return memberOrgIds; if (key.orgAccess === 'personal') return []; + // `selected` and `orgs_only` narrow orgs the same way; they differ only in + // whether the owner's personal rows are reachable (`reachesPersonal`). return memberOrgIds.filter((id) => key.orgIds.includes(id)); } +/** + * May this key reach its owner's PERSONAL rows (`org_id IS NULL`)? + * + * True for every tenancy except `orgs_only` — the one mode that exists to + * publish org lore through a token without exposing the owner's own memories + * (00116). A JWT or service caller has no key and is unaffected. + */ +export function reachesPersonal(key?: KeyRestriction): boolean { + return !key || key.orgAccess !== 'orgs_only'; +} + +/** + * A PostgREST filter no row satisfies. `id` is the primary key and never null, + * so this is the honest "match nothing" for a key that may reach no row on the + * branch it guards — rather than omitting a filter, which would match all. + */ +export const MATCH_NOTHING = 'id.is.null'; + /** * True when the calling key narrows WHICH ORGS are reachable. * @@ -108,6 +130,9 @@ export function restrictsTenancy(key?: KeyRestriction): boolean { * ownership test with `org_id.is.null` so "mine" reads as "mine, and personal". */ export function ownRowsFragment(userId: string, key?: KeyRestriction): string { + // `orgs_only`: the owner's personal rows are out of reach, and their own + // org-owned rows are reached through the `org_id.in.(…)` disjunct instead. + if (!reachesPersonal(key)) return MATCH_NOTHING; return restrictsTenancy(key) ? `and(user_id.eq.${userId},org_id.is.null)` : `user_id.eq.${userId}`; @@ -183,7 +208,10 @@ export function applyTenantScope { // (retention/groom, in-handler — a `KeyScopeDeniedError` thrown by // `policy.*`/`groom.*`'s scope gate, caught here and mapped the same way). // - // Was 4, then 5. The v1→v2 bump added the retention denial: `tools.ts`'s + // Was 4, then 5, then 6. The 00116 bump added the orgs_only owner-row + // refusal (`isRefusedForOrgsOnlyKey`): an `orgs_only` key calling a tool + // that only acts on its owner's personal rows is denied outright. + // The v1→v2 bump added the retention denial: `tools.ts`'s // `assertScopeAllowed` throws `KeyScopeDeniedError` for a fenced-scope // policy/groom call, and this catch block maps it to JSONRPC_FORBIDDEN // rather than the in-band `isError` shape every other client error gets — @@ -69,7 +72,7 @@ describe('mcp-handler auth status guard', () => { // Pinned deliberately: a new denial must be a conscious edit here, because // the failure mode this guard exists for is a denial added as -32001, which // hangs the client instead of surfacing. - expect(forbidden.length).toBe(5); + expect(forbidden.length).toBe(6); expect(block).not.toMatch(/jsonrpcError\(\s*id,\s*-32001/); }); diff --git a/packages/schemas/src/domain/api-key.spec.ts b/packages/schemas/src/domain/api-key.spec.ts index f89645f71..6e092367c 100644 --- a/packages/schemas/src/domain/api-key.spec.ts +++ b/packages/schemas/src/domain/api-key.spec.ts @@ -151,6 +151,26 @@ describe('orgAllowedByKey', () => { // The SQL twin has the same `else false`, and migrations.test.sql §81 AC-3 // asserts it — this is the TypeScript half of that pair. expect(orgAllowedByKey('nonsense' as ApiKeyOrgAccess, [ORG_A], ORG_A)).toBe(false); + // ...and reaches no personal row either (00116): an unknown value fails closed. + expect(orgAllowedByKey('nonsense' as ApiKeyOrgAccess, [], null)).toBe(false); + }); + + it('orgs_only reaches its selected orgs and NO personal row (00116)', () => { + expect(orgAllowedByKey('orgs_only', [ORG_A], null)).toBe(false); + expect(orgAllowedByKey('orgs_only', [ORG_A], ORG_A)).toBe(true); + expect(orgAllowedByKey('orgs_only', [ORG_A], ORG_B)).toBe(false); + }); +}); + +describe('ApiKeyScopingSchema orgs_only (00116)', () => { + const ORG = '11111111-1111-4111-8111-111111111111'; + + it('accepts orgs_only with at least one org id', () => { + expect(ApiKeyScopingSchema.parse({ orgAccess: 'orgs_only', orgIds: [ORG] }).orgAccess).toBe('orgs_only'); + }); + + it('refuses orgs_only with no org id', () => { + expect(ApiKeyScopingSchema.safeParse({ orgAccess: 'orgs_only', orgIds: [] }).success).toBe(false); }); }); diff --git a/packages/schemas/src/domain/api-key.ts b/packages/schemas/src/domain/api-key.ts index 94295a6c4..50957b714 100644 --- a/packages/schemas/src/domain/api-key.ts +++ b/packages/schemas/src/domain/api-key.ts @@ -8,8 +8,10 @@ import { z } from 'zod'; * read/write one the token prefix already encodes: * * • `scopes` — an allowlist of scope patterns. EMPTY MEANS UNRESTRICTED. - * • `orgAccess` — the tenancy: `all` | `personal` | `selected`, with - * `orgIds` meaningful only under `selected`. + * • `orgAccess` — the tenancy: `all` | `personal` | `selected` | + * `orgs_only`, with `orgIds` meaningful only under + * `selected` and `orgs_only`. `orgs_only` (00116) is + * `selected` without the owner's personal memories. * * Kept in `@lorekit/schemas` rather than next to the dashboard's server actions * because the same shape is read by the edge auth path and, once the management @@ -63,12 +65,12 @@ export const ApiKeyScopesSchema = z .max(API_KEY_MAX_SCOPES, `at most ${API_KEY_MAX_SCOPES} scope patterns per key`) .default([]); -export const API_KEY_ORG_ACCESS = ['all', 'personal', 'selected'] as const; +export const API_KEY_ORG_ACCESS = ['all', 'personal', 'selected', 'orgs_only'] as const; /** * Which tenancy a key reaches. * - * A tri-state rather than "an empty `orgIds` means everything": empty would + * An explicit tenancy rather than "an empty `orgIds` means everything": empty would * have to mean both "unrestricted" and "no orgs at all", and those are the two * most different answers available. */ @@ -76,6 +78,11 @@ export const ApiKeyOrgAccessSchema = z.enum(API_KEY_ORG_ACCESS); export type ApiKeyOrgAccess = z.infer; +/** The tenancies that name their orgs in `orgIds` (and must name at least one). */ +export function orgAccessNamesOrgs(orgAccess: ApiKeyOrgAccess): boolean { + return orgAccess === 'selected' || orgAccess === 'orgs_only'; +} + /** * A key's full scoping, with the cross-field rule the DB also enforces * (`api_tokens_org_ids_match_access`). @@ -95,14 +102,14 @@ export const ApiKeyScopingSchema = z .default([]), }) .superRefine((val, ctx) => { - if (val.orgAccess === 'selected' && val.orgIds.length === 0) { + if (orgAccessNamesOrgs(val.orgAccess) && val.orgIds.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['orgIds'], - message: 'orgAccess "selected" requires at least one org id', + message: `orgAccess "${val.orgAccess}" requires at least one org id`, }); } - if (val.orgAccess !== 'selected' && val.orgIds.length > 0) { + if (!orgAccessNamesOrgs(val.orgAccess) && val.orgIds.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['orgIds'], @@ -154,18 +161,20 @@ export function scopeAllowedByKey(patterns: readonly string[], scope: string | n /** * Does a key with this tenancy reach a row owned by `orgId` (`null` = personal)? * - * The TypeScript twin of `lorekit_api_token_org_allowed` (00068). A personal row - * is reachable under every tenancy: `personal` narrows which ORGS are reachable, - * it never revokes the owner's own memories. + * The TypeScript twin of `lorekit_api_token_org_allowed` (00068, 00116). A + * personal row is reachable under `all`, `personal` and `selected` — those + * narrow which ORGS are reachable, never the owner's own memories — and NOT + * under `orgs_only`, which exists to publish org lore without them. An + * unrecognised value reaches nothing. */ export function orgAllowedByKey( orgAccess: ApiKeyOrgAccess, orgIds: readonly string[], orgId: string | null, ): boolean { - if (orgId === null) return true; + if (orgId === null) return orgAccess === 'all' || orgAccess === 'personal' || orgAccess === 'selected'; if (orgAccess === 'all') return true; - if (orgAccess === 'selected') return orgIds.includes(orgId); + if (orgAccess === 'selected' || orgAccess === 'orgs_only') return orgIds.includes(orgId); // `personal`, and anything else. Written as an explicit fall-through rather // than `orgAccess === 'personal' ? false : orgIds.includes(orgId)` so an // unrecognised value denies instead of being treated as `selected` — the SQL diff --git a/packages/schemas/src/llms/template.md b/packages/schemas/src/llms/template.md index de35d81d1..286b62323 100644 --- a/packages/schemas/src/llms/template.md +++ b/packages/schemas/src/llms/template.md @@ -185,7 +185,7 @@ global # least specific - Every memory is personal by default. Add `org` to `memory.write` to write org-owned lore. - All org members with read access see org-owned lore in their reads. - Roles: `viewer` (read only), `member` (read + write/archive/restore), `admin` (+ hard-delete + invite management), `owner` (+ rename/delete org). -- Scope→org binding: an admin can bind a scope to the org — subsequent writes under that scope auto-route to the org for write-capable members. Non-members fall back to personal (never rejected) with a `notice`. +- Scope→org binding: an admin can bind a scope to the org — subsequent writes under that scope auto-route to the org for write-capable members. Non-members fall back to personal (never rejected) with a `notice` — except from a token limited to selected orgs (`orgs_only`), which may not write personal memories: that write is refused, so pass `org`. - Manage orgs and members at lorekit.io → Settings → Organization. ## Filtering lore (dashboard Explorer + REST) diff --git a/packages/schemas/src/shared/tool-catalog.ts b/packages/schemas/src/shared/tool-catalog.ts index c3ebd0015..091b95682 100644 --- a/packages/schemas/src/shared/tool-catalog.ts +++ b/packages/schemas/src/shared/tool-catalog.ts @@ -361,7 +361,7 @@ export const MCP_TOOLS = [ notes: [ '**Provenance (`origin_*`):** `scope` says where a lesson *applies*; the four `origin_*` fields say where it was *recorded from*. Each is independently optional and the last KNOWN value wins — on an update, a field you omit keeps whatever a previous write recorded rather than being erased. A malformed value is rejected, never silently dropped.', '**Citations (`cited`):** the one signal read counts cannot produce. A lesson injected at session start is already in your context and is applied without ever being fetched, so LoreKit can measure whether lore was *looked up* but not whether it *worked* — `cited` is where you say so. Each entry is a `scope::key` you were shown; they raise the cited lesson\u2019s `cited_count` and are grouped by the run\u2019s correlation id. Recorded once per run, so a retried write does not inflate anything, and every failure mode is silent: an unknown reference, a self-citation, and anything past 32 entries are dropped and the write still succeeds.', - '**Scope→org binding:** An org admin can bind a scope (e.g. a repo) — or a WILDCARD prefix such as `repo::owner/*` or `branch::owner/repo::*`, matching every scope under it — to an org. A write under a bound scope with no explicit `org` auto-routes to that org for write-capable members, resolved MOST-SPECIFIC-WINS when several bindings match (an exact scope beats any wildcard; a longer wildcard prefix beats a shorter one). A non-member\u2019s write falls back to personal (never rejected) with a `notice`.', + '**Scope→org binding:** An org admin can bind a scope (e.g. a repo) — or a WILDCARD prefix such as `repo::owner/*` or `branch::owner/repo::*`, matching every scope under it — to an org. A write under a bound scope with no explicit `org` auto-routes to that org for write-capable members, resolved MOST-SPECIFIC-WINS when several bindings match (an exact scope beats any wildcard; a longer wildcard prefix beats a shorter one). A non-member\u2019s write falls back to personal (never rejected) with a `notice` \u2014 except from a token limited to selected orgs (`orgs_only`), which may not write personal memories: that write is refused, so pass `org`.', ], }, { diff --git a/packages/web/public/llms.txt b/packages/web/public/llms.txt index 965c8b653..058244de4 100644 --- a/packages/web/public/llms.txt +++ b/packages/web/public/llms.txt @@ -247,7 +247,7 @@ Returns: `{ "id": "", "created_at": "" }` — plus optional `"expires **Citations (`cited`):** the one signal read counts cannot produce. A lesson injected at session start is already in your context and is applied without ever being fetched, so LoreKit can measure whether lore was *looked up* but not whether it *worked* — `cited` is where you say so. Each entry is a `scope::key` you were shown; they raise the cited lesson’s `cited_count` and are grouped by the run’s correlation id. Recorded once per run, so a retried write does not inflate anything, and every failure mode is silent: an unknown reference, a self-citation, and anything past 32 entries are dropped and the write still succeeds. -**Scope→org binding:** An org admin can bind a scope (e.g. a repo) — or a WILDCARD prefix such as `repo::owner/*` or `branch::owner/repo::*`, matching every scope under it — to an org. A write under a bound scope with no explicit `org` auto-routes to that org for write-capable members, resolved MOST-SPECIFIC-WINS when several bindings match (an exact scope beats any wildcard; a longer wildcard prefix beats a shorter one). A non-member’s write falls back to personal (never rejected) with a `notice`. +**Scope→org binding:** An org admin can bind a scope (e.g. a repo) — or a WILDCARD prefix such as `repo::owner/*` or `branch::owner/repo::*`, matching every scope under it — to an org. A write under a bound scope with no explicit `org` auto-routes to that org for write-capable members, resolved MOST-SPECIFIC-WINS when several bindings match (an exact scope beats any wildcard; a longer wildcard prefix beats a shorter one). A non-member’s write falls back to personal (never rejected) with a `notice` — except from a token limited to selected orgs (`orgs_only`), which may not write personal memories: that write is refused, so pass `org`. --- @@ -728,7 +728,7 @@ Returns: `{ "protected": boolean }` - Every memory is personal by default. Add `org` to `memory.write` to write org-owned lore. - All org members with read access see org-owned lore in their reads. - Roles: `viewer` (read only), `member` (read + write/archive/restore), `admin` (+ hard-delete + invite management), `owner` (+ rename/delete org). -- Scope→org binding: an admin can bind a scope to the org — subsequent writes under that scope auto-route to the org for write-capable members. Non-members fall back to personal (never rejected) with a `notice`. +- Scope→org binding: an admin can bind a scope to the org — subsequent writes under that scope auto-route to the org for write-capable members. Non-members fall back to personal (never rejected) with a `notice` — except from a token limited to selected orgs (`orgs_only`), which may not write personal memories: that write is refused, so pass `org`. - Manage orgs and members at lorekit.io → Settings → Organization. ## Filtering lore (dashboard Explorer + REST) diff --git a/packages/web/src/components/dashboard/OnboardingStepContent.tsx b/packages/web/src/components/dashboard/OnboardingStepContent.tsx index 8d09eb7ef..aba1e45ef 100644 --- a/packages/web/src/components/dashboard/OnboardingStepContent.tsx +++ b/packages/web/src/components/dashboard/OnboardingStepContent.tsx @@ -7,6 +7,7 @@ import { TokenManager } from './TokenManager'; import { ClientConfigTabs, type McpClientConfig } from './ClientConfigTabs'; import type { ApiToken } from '@/lib/tokens'; import type { TokenPermission } from '@/lib/tokens'; +import type { OrgAccess } from '@/lib/token-scoping'; // ── MCP client definitions ──────────────────────────────────────────────────── @@ -267,7 +268,7 @@ interface OnboardingStepContentProps { token_prefix: string; permissions: TokenPermission[]; scopes: string[]; - org_access: 'all' | 'personal' | 'selected'; + org_access: OrgAccess; org_ids: string[]; last_used_at: string | null; created_at: string; diff --git a/packages/web/src/components/dashboard/TokenManager.tsx b/packages/web/src/components/dashboard/TokenManager.tsx index 9b3c06e13..7580165e9 100644 --- a/packages/web/src/components/dashboard/TokenManager.tsx +++ b/packages/web/src/components/dashboard/TokenManager.tsx @@ -8,7 +8,7 @@ import { } from 'lucide-react'; import { generateToken, revokeToken, setTokenScoping, type ApiToken, type TokenPermission } from '@/lib/tokens'; import { PERMISSION_TIERS, tierFor, type PermissionTierValue } from '@/lib/token-permission'; -import { type TokenScoping } from '@/lib/token-scoping'; +import { namesOrgs, type TokenScoping } from '@/lib/token-scoping'; import { ScopingBadges, ScopingFields } from '@/components/dashboard/TokenScoping'; import { DisclosurePanel, useDisclosure } from '@/components/ui/DisclosurePanel'; import { Button, IconButton } from '@/components/ui/Button'; @@ -157,7 +157,7 @@ function GenerateForm({ // Caught here rather than left to the server round-trip: the database rejects // it (`api_tokens_org_ids_match_access`) and so does the RPC, but a form that // lets you press the button and then tells you no is a worse form. - const incompleteOrgs = scoping.org_access === 'selected' && scoping.org_ids.length === 0; + const incompleteOrgs = namesOrgs(scoping.org_access) && scoping.org_ids.length === 0; function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -278,7 +278,7 @@ function TokenRow({ const [saveError, setSaveError] = useState(''); const [saving, startSaving] = useTransition(); - const draftIncomplete = draft.org_access === 'selected' && draft.org_ids.length === 0; + const draftIncomplete = namesOrgs(draft.org_access) && draft.org_ids.length === 0; const { panelId: editorPanelId, triggerProps: editorTriggerProps } = useDisclosure(editing); // Move focus INTO the panel when it opens. The generate form already does diff --git a/packages/web/src/components/dashboard/TokenScoping.tsx b/packages/web/src/components/dashboard/TokenScoping.tsx index cf09b1fc5..36dd920a9 100644 --- a/packages/web/src/components/dashboard/TokenScoping.tsx +++ b/packages/web/src/components/dashboard/TokenScoping.tsx @@ -23,6 +23,7 @@ import { creatableScopePattern, describeScoping, isScoped, + namesOrgs, orgBadgeLabel, scopeBadgeLabel, scopePatternOptions, @@ -198,13 +199,13 @@ export function ScopingFields({ key={value} type="button" // The selected tier is signalled by COLOUR alone, which a - // screen reader cannot see — all three would otherwise be + // screen reader cannot see — every tier would otherwise be // announced identically. `aria-pressed` is the right role // here rather than a radiogroup: these stay buttons, so the // arrow-key roving focus a radiogroup promises is not // implemented and claiming it would be worse than not. aria-pressed={scoping.org_access === value} - // Choosing a tenancy other than `selected` DROPS any orgs + // Choosing a tenancy that names no orgs DROPS any orgs // already picked. The database rejects the pair outright // (`api_tokens_org_ids_match_access`), and carrying a hidden // list that the next save would reject is worse than losing @@ -213,7 +214,7 @@ export function ScopingFields({ onChange({ ...scoping, org_access: value as OrgAccess, - org_ids: value === 'selected' ? scoping.org_ids : [], + org_ids: namesOrgs(value) ? scoping.org_ids : [], }) } className={[ @@ -231,7 +232,7 @@ export function ScopingFields({ ))} - {scoping.org_access === 'selected' && ( + {namesOrgs(scoping.org_access) && ( { expect(orgBadgeLabel(scoping({ org_access: 'selected', org_ids: [ORG_A] }))).toBe('1 org'); expect(orgBadgeLabel(scoping({ org_access: 'selected', org_ids: [ORG_A, ORG_B] }))).toBe('2 orgs'); }); + + it('marks an orgs_only key as orgs-only, so it never reads like a selected one', () => { + expect(orgBadgeLabel(scoping({ org_access: 'orgs_only', org_ids: [ORG_A] }))).toBe('1 org only'); + expect(orgBadgeLabel(scoping({ org_access: 'orgs_only', org_ids: [ORG_A, ORG_B] }))).toBe('2 orgs only'); + }); +}); + +describe('namesOrgs', () => { + it('is true for the two tenancies that carry an org list', () => { + expect(namesOrgs('selected')).toBe(true); + expect(namesOrgs('orgs_only')).toBe(true); + expect(namesOrgs('all')).toBe(false); + expect(namesOrgs('personal')).toBe(false); + }); }); describe('describeScoping', () => { + it('says an orgs_only key reaches no personal memories', () => { + expect(describeScoping(scoping({ org_access: 'orgs_only', org_ids: [ORG_A] }), { [ORG_A]: 'Acme' })).toBe( + 'Any scope · orgs: Acme · no personal memories.', + ); + }); + it('states the unrestricted case plainly', () => { expect(describeScoping(UNSCOPED)).toBe('Unrestricted — every scope and org you can reach.'); }); @@ -175,7 +196,12 @@ describe('ORG_ACCESS_TIERS', () => { it('covers every OrgAccess value exactly once', () => { // The form renders from this list, so a value missing here is a tenancy the // user cannot choose and a badge with no card behind it. - expect(ORG_ACCESS_TIERS.map((t) => t.value)).toEqual(['all', 'personal', 'selected']); + expect(ORG_ACCESS_TIERS.map((t) => t.value)).toEqual(['all', 'personal', 'selected', 'orgs_only']); + }); + + it('labels orgs_only so the user can see it excludes personal memories', () => { + const tier = ORG_ACCESS_TIERS.find((t) => t.value === 'orgs_only'); + expect(tier?.label).toBe('Selected orgs only (no personal memories)'); }); }); diff --git a/packages/web/src/lib/token-scoping.ts b/packages/web/src/lib/token-scoping.ts index 8905360a6..ccddc37f1 100644 --- a/packages/web/src/lib/token-scoping.ts +++ b/packages/web/src/lib/token-scoping.ts @@ -2,7 +2,8 @@ * The dashboard's model of API-key scoping — the pure half. * * A key can be narrowed along two axes (migration 00068): an allowlist of scope - * patterns, and a tenancy (`all` / `personal` / `selected` orgs). This module + * patterns, and a tenancy (`all` / `personal` / `selected` orgs, and `orgs_only` from + * migration 00116). This module * owns the decisions the UI needs and the DB does not care about: what the * picker offers, what the row badge says, and how the whole thing reads as a * sentence. @@ -17,14 +18,14 @@ */ import { ApiKeyScopePatternSchema } from '@lorekit/schemas/api-key'; -export type OrgAccess = 'all' | 'personal' | 'selected'; +export type OrgAccess = 'all' | 'personal' | 'selected' | 'orgs_only'; /** A key's scoping as the dashboard reads it back off `api_tokens`. */ export interface TokenScoping { /** Allowlist of scope patterns. EMPTY = unrestricted. */ scopes: string[]; org_access: OrgAccess; - /** Meaningful only under `selected`. */ + /** Meaningful only under `selected` and `orgs_only`. */ org_ids: string[]; } @@ -38,7 +39,7 @@ export interface OrgAccessTier { } /** - * Single source of truth for the three tenancy choices — drives both the + * Single source of truth for the tenancy choices — drives both the * "New token" cards and the row badge, so adding one is a single edit here * rather than parallel arrays. Same shape and same reasoning as * `PERMISSION_TIERS`. @@ -59,8 +60,18 @@ export const ORG_ACCESS_TIERS: readonly OrgAccessTier[] = [ label: 'Specific orgs', desc: 'Your own memories plus the orgs you pick', }, + { + value: 'orgs_only', + label: 'Selected orgs only (no personal memories)', + desc: 'Only the orgs you pick — none of your own memories. For tokens you share or publish', + }, ] as const; +/** Does this tenancy carry an org list? `selected` and `orgs_only` both need ≥ 1 org. */ +export function namesOrgs(orgAccess: OrgAccess): boolean { + return orgAccess === 'selected' || orgAccess === 'orgs_only'; +} + /** Is this key restricted at all? Drives whether a badge renders. */ export function isScoped(scoping: TokenScoping): boolean { return scoping.scopes.length > 0 || scoping.org_access !== 'all'; @@ -89,7 +100,8 @@ export function orgBadgeLabel(scoping: TokenScoping): string | null { if (scoping.org_access === 'all') return null; if (scoping.org_access === 'personal') return 'personal only'; const n = scoping.org_ids.length; - return n === 1 ? '1 org' : `${n} orgs`; + const orgs = n === 1 ? '1 org' : `${n} orgs`; + return scoping.org_access === 'orgs_only' ? `${orgs} only` : orgs; } /** @@ -117,13 +129,14 @@ export function describeScoping( if (scoping.org_access === 'personal') { parts.push('personal memories only'); - } else if (scoping.org_access === 'selected') { + } else if (namesOrgs(scoping.org_access)) { const named = scoping.org_ids.map((id) => orgNameById[id]).filter((n): n is string => !!n); parts.push( named.length === scoping.org_ids.length && named.length > 0 ? `orgs: ${named.join(', ')}` : `${scoping.org_ids.length} org${scoping.org_ids.length === 1 ? '' : 's'}`, ); + if (scoping.org_access === 'orgs_only') parts.push('no personal memories'); } return `${parts.join(' · ')}.`; diff --git a/supabase/byod/bootstrap.sql b/supabase/byod/bootstrap.sql index a6c413e65..ef97383ec 100644 --- a/supabase/byod/bootstrap.sql +++ b/supabase/byod/bootstrap.sql @@ -2,7 +2,7 @@ -- -- ⚠ GENERATED FILE — DO NOT EDIT BY HAND. -- Generated by `node supabase/byod/generate.mjs` from the --- 110 migrations in supabase/migrations/ (newest: 00115_explorer_recent_read_sort.sql). +-- 111 migrations in supabase/migrations/ (newest: 00116_api_token_orgs_only_tenancy.sql). -- CI fails when this file and the migrations disagree; edit a migration (or add -- one) and regenerate instead. -- @@ -23679,6 +23679,803 @@ insert into supabase_migrations.schema_migrations (version, name) values ('00115', 'explorer_recent_read_sort') on conflict (version) do nothing; +-- ───────────────────────────────────────────────────────────────────────────── +-- 00116_api_token_orgs_only_tenancy.sql +-- ───────────────────────────────────────────────────────────────────────────── + +-- ═════════════════════════════════════════════════════════════════════════ +-- 00116 — API-token tenancy `orgs_only`: selected orgs, and NO personal rows. +-- +-- THE GAP. Every tenancy (00068) could reach the token owner's personal rows: +-- `lorekit_api_token_org_allowed` answered `true` for `org_id IS NULL` before it +-- even looked at the tenancy, on the reasoning that a key never revokes its own +-- owner's memories. That is right for a key the owner keeps — and wrong for a +-- key the owner PUBLISHES. A project that ships a read-only token so its users +-- can read the project's org-owned lore (reaper-mcp's built-in mix knowledge) +-- also hands every user the owner's personal memories in the allowed scopes, +-- including archived ones. +-- +-- THE MODE. `orgs_only` narrows orgs exactly like `selected` (org_ids, ≥ 1, +-- intersected with live membership) and additionally closes the personal +-- branch: reads see no personal row, and a write that would land on a personal +-- row is refused (LK002 key_personal_denied) instead of falling back to +-- personal. The other three tenancies are unchanged. +-- +-- WHERE IT IS ENFORCED. +-- • `lorekit_api_token_org_allowed` — the one predicate every tenancy-aware +-- RPC already calls per row (list, scopes, tags, facets, pivot, activity, +-- relevant, …). No signature changes anywhere: every RPC already threads +-- `p_key_org_access` through. +-- • `memory_write` — its personal destination never consulted the predicate, +-- so it gains an explicit check (the only body change here). +-- • `memory_delete` / `restore_memory` (00071 / 00072) match their own-row and +-- member-org disjuncts WITHOUT the predicate — it only guards the +-- scope-managed one — so each removal/restore WHERE gains it for an +-- `orgs_only` key. Every other tenancy runs the 00071/00072 WHERE unchanged. +-- • `lorekit_api_token_set_scoping` + the table CHECKs accept the new value. +-- The edge applies the same rule to its PostgREST reads (`tenant-scope.ts`), +-- and refuses `orgs_only` the operations that pick rows by owner rather than +-- by tenant (purge, quota, protect, policies, groom, and the owner analytics — +-- read-activity, read-ranking, utility, usage — `account-wide-tools.ts`). +-- +-- UNKNOWN VALUES FAIL CLOSED. The personal branch now answers `true` only for +-- the three tenancies known to allow it, so a value this function does not +-- recognise (a BYOD install, a future migration) reaches nothing. The edge's +-- `normalizeKeyRestriction` maps an unknown value to `orgs_only` with no orgs — +-- the same "nothing" — so an older reader can never widen a newer value. +-- +-- ROLLOUT. Apply this migration and deploy the edge together. Until both are +-- live no key can carry `orgs_only`: the CHECK below is what admits the value, +-- and the dashboard/API only offer it once the edge understands it. +-- ═════════════════════════════════════════════════════════════════════════ + +-- 1. The table CHECKs admit the new value. +alter table api_tokens + drop constraint if exists api_tokens_org_access_valid; +alter table api_tokens + add constraint api_tokens_org_access_valid + check (org_access in ('all', 'personal', 'selected', 'orgs_only')); + +-- `orgs_only`, like `selected`, names its orgs: an `orgs_only` key with no orgs +-- would reach nothing at all, and any other tenancy carrying org ids would be a +-- lie the next reader believes. +alter table api_tokens + drop constraint if exists api_tokens_org_ids_match_access; +alter table api_tokens + add constraint api_tokens_org_ids_match_access + check ((org_access in ('selected', 'orgs_only')) = (cardinality(org_ids) > 0)); + +-- 2. The per-row predicate. +create or replace function lorekit_api_token_org_allowed( + p_org_access text, + p_org_ids uuid[], + p_org_id uuid +) +returns boolean +language sql +immutable +as $$ + select case + -- A personal row (org_id IS NULL) is reachable under the three tenancies + -- that keep the owner's own memories. NOT under `orgs_only` (00116), and not + -- under a value this function does not recognise — that fails closed. A + -- NULL tenancy is the pre-00068 "no key restriction" and stays reachable. + when p_org_id is null + then coalesce(p_org_access, 'all') in ('all', 'personal', 'selected') + when p_org_access = 'all' then true + when p_org_access = 'personal' then false + -- `coalesce(…, false)` is load-bearing: a NULL ELEMENT in p_org_ids makes + -- `= any(…)` evaluate to NULL, and an authorization predicate must answer + -- true or false (see 00068). + when p_org_access in ('selected', 'orgs_only') + then coalesce(p_org_id = any(coalesce(p_org_ids, '{}'::uuid[])), false) + else false + end; +$$; + +comment on function lorekit_api_token_org_allowed(text, uuid[], uuid) is + 'May a key with this tenancy reach a row owned by p_org_id (NULL = personal)? ' + 'orgs_only (00116) reaches selected orgs and no personal row; an unknown ' + 'tenancy reaches nothing. Authoritative over org_scope_bindings auto-routing.'; + +revoke execute on function lorekit_api_token_org_allowed(text, uuid[], uuid) from public, anon; +grant execute on function lorekit_api_token_org_allowed(text, uuid[], uuid) to authenticated, service_role; + +-- 3. `lorekit_api_token_set_scoping` — the dashboard's write path accepts the +-- new value. Body copied from 00068; only the two tenancy re-statements change. + +create or replace function lorekit_api_token_set_scoping( + p_token_id uuid, + p_scopes text[], + p_org_access text, + p_org_ids uuid[] +) +returns table ( + id uuid, + scopes text[], + org_access text, + org_ids uuid[] +) +language plpgsql +volatile +security definer +set search_path = public +as $$ +declare + v_actor uuid := auth.uid(); + v_scopes text[]; + v_org_ids uuid[]; + v_owner uuid; + v_stray uuid; +begin + if v_actor is null then + raise exception 'LK001: authentication required' using errcode = '28000'; + end if; + + -- These used to be `coalesce(p_scopes, '{}')` / `coalesce(p_org_ids, '{}')` in + -- the declare block, which reopened the hole giving the arguments no DEFAULT + -- was meant to close. No DEFAULT stops an OMITTED argument; the coalesce let + -- an EXPLICIT null through and turned it into '{}' — and for `scopes`, '{}' + -- means UNRESTRICTED (decision 1). So `set_scoping(id, null, 'all', '{}')` + -- silently widened the key to every scope, which is exactly the fail-open + -- partial update decision 5 refuses. + -- + -- Refused rather than treated as "leave unchanged": that would make this a + -- partial-update API that has to read and merge the current row, and decision + -- 5 chose "state the full intended state on every call". Same reasoning, and + -- now the same treatment, as the NULL `p_org_access` guard below. + if p_scopes is null then + raise exception 'LK004: scopes must not be null; pass an empty array to un-scope the key' + using errcode = '22023'; + end if; + + if p_org_ids is null then + raise exception 'LK004: org_ids must not be null; pass an empty array for a key with no orgs' + using errcode = '22023'; + end if; + + v_scopes := p_scopes; + v_org_ids := p_org_ids; + + -- Ownership first, and by SELECT rather than by letting the UPDATE match zero + -- rows: "you do not own this key" and "no such key" must be the same answer to + -- the caller (no existence oracle), but they must be a real error rather than + -- a silent no-op that the dashboard would render as success. + select user_id into v_owner from api_tokens where api_tokens.id = p_token_id; + if v_owner is null or v_owner <> v_actor then + raise exception 'LK003: api token not found' using errcode = 'P0002'; + end if; + + -- Re-state the table's CHECKs here so a bad argument comes back as a legible + -- LK004 the dashboard can render, not as a raw constraint-violation string + -- naming an internal constraint. The CHECKs stay the authority — this is the + -- error message, not the gate. + if cardinality(v_scopes) > 50 then + raise exception 'LK004: at most 50 scope patterns per key' using errcode = '22023'; + end if; + + if not lorekit_api_token_scopes_valid(v_scopes) then + raise exception 'LK004: a scope pattern may contain only [a-z0-9._:/-], with an optional trailing "*" directly after a "/" or a "::"' + using errcode = '22023'; + end if; + + if cardinality(v_org_ids) > 50 then + raise exception 'LK004: at most 50 orgs per key' using errcode = '22023'; + end if; + + -- `is null or` is load-bearing, not defensive noise. `null not in (…)` is + -- NULL, `if NULL` takes the false branch, and the `<>` guard below is NULL for + -- the same reason — so a NULL tenancy would fall through BOTH re-statements + -- and reach the UPDATE, where the column's NOT NULL rejects it as a raw 23502 + -- naming an internal constraint. That is the exact unreadable failure these + -- re-statements exist to prevent. All three scoping arguments are now read + -- raw and each carries its own explicit NULL refusal — `p_scopes` and + -- `p_org_ids` above, this one here. + -- + -- Refusing rather than coalescing to 'all' is deliberate: 'all' is the WIDEST + -- tenancy, so defaulting a missing one would let an under-specified call widen + -- the key — the same fail-open shape decision 5 refuses by giving the + -- arguments no DEFAULT in the first place. That is also why the two arguments + -- above are no longer coalesced: their default, '{}', means UNRESTRICTED. + if p_org_access is null or p_org_access not in ('all', 'personal', 'selected', 'orgs_only') then + raise exception 'LK004: org_access must be all, personal, selected or orgs_only' + using errcode = '22023'; + end if; + + if (p_org_access in ('selected', 'orgs_only')) <> (cardinality(v_org_ids) > 0) then + raise exception 'LK004: org_ids must be non-empty for org_access=selected or orgs_only and empty otherwise' + using errcode = '22023'; + end if; + + -- A key cannot be pointed at an org its OWNER cannot reach. Without this the + -- column would be a wish rather than a restriction: the request-time check + -- intersects with the owner's real membership anyway, so an unreachable org id + -- would sit in the row looking like access that was granted. Rejecting it here + -- keeps the row honest and the failure legible. + -- A NULL ELEMENT has to go first, because the membership guard below cannot + -- see it: a NULL org id IS selected into v_stray (`m.org_id = null` is NULL, + -- so `not exists` is true), but then `v_stray is not null` is false and the + -- raise never fires. Two layers of three-valued logic cancelling out into + -- "looks like a member". The CHECK is the authority; this is the legible + -- LK004, same division of labour as the other re-statements above. + if array_position(v_org_ids, null) is not null then + raise exception 'LK004: org_ids must not contain a null element' + using errcode = '22023'; + end if; + + select t.org_id into v_stray + from unnest(v_org_ids) as t(org_id) + where not exists ( + select 1 from lorekit_member_org_ids(v_actor) as m(org_id) + where m.org_id = t.org_id + ) + limit 1; + if v_stray is not null then + raise exception 'LK002: not a member of org %', v_stray using errcode = '42501'; + end if; + + -- Qualified in RETURNING, bare on the left of SET — the shape `memory_write` + -- has used since 00007, which is what keeps the OUT parameter names from + -- colliding with the identically-named columns. + return query + update api_tokens + set scopes = v_scopes, + org_access = p_org_access, + org_ids = v_org_ids + where api_tokens.id = p_token_id + and api_tokens.user_id = v_actor + returning api_tokens.id, api_tokens.scopes, api_tokens.org_access, api_tokens.org_ids; +end; +$$; + +-- 4. `memory_write` — refuses a personal write from a key that may not reach +-- personal rows. Body copied from 00111; the only change is the marked block +-- after the org-routing resolution. Signature and RETURNS TABLE unchanged. + +create or replace function memory_write( + p_user_id uuid, + p_scope text, + p_key text, + p_value text, + p_tags text[] default '{}', + p_source_agent text default null, + p_trigger text default null, + p_created_at timestamptz default null, + p_org_slug text default null, + p_ttl_seconds integer default null, + p_clear_ttl boolean default false, + p_origin_repo text default null, + p_origin_branch text default null, + p_origin_commit text default null, + p_origin_pr integer default null, + p_kind text default null, + p_host text default null, + p_key_scopes text[] default '{}', + p_key_org_access text default 'all', + p_key_org_ids uuid[] default '{}' +) +returns table ( + id uuid, + created_at timestamptz, + inserted boolean, + org_routed boolean, + binding_org_slug text, + expires_at timestamptz, + scope text, + key text, + value text, + tags text[], + source_agent text, + trigger text, + updated_at timestamptz, + archived_at timestamptz, + origin_repo text, + origin_branch text, + origin_commit text, + origin_pr integer, + kind text, + host text, + seen_count integer +) +language plpgsql +security definer +set search_path = public +as $$ +-- See 00075's comment for why this directive is required (the OUT columns +-- `scope`/`key` collide with the bare column names in the `on conflict` +-- arbiter lists below). +#variable_conflict use_column +declare + v_org_id uuid; + v_binding_org uuid; + v_binding_slug text; + v_expires_at timestamptz; + v_ttl_action text := 'keep'; +begin + if not lorekit_api_token_scope_allowed(p_key_scopes, p_scope) then + raise exception using errcode = 'LK002', + message = format('key_scope_denied: scope=%s', p_scope); + end if; + + if p_clear_ttl then + v_ttl_action := 'clear'; + elsif p_ttl_seconds is not null then + if p_ttl_seconds < 1 or p_ttl_seconds > 31536000 then + raise exception using errcode = 'P0001', + message = format('ttl_seconds must be between 1 and 31536000, got %s', p_ttl_seconds); + end if; + v_expires_at := now() + (p_ttl_seconds * interval '1 second'); + v_ttl_action := 'set'; + end if; + + if p_org_slug is not null then + select o.id into v_org_id from orgs o where o.slug = p_org_slug and o.deleted_at is null; + if v_org_id is null then + raise exception using errcode = 'P0001', message = format('unknown_org: %s', p_org_slug); + end if; + if not lorekit_org_can(p_user_id, v_org_id, 'write') then + raise exception using errcode = 'LK002', message = format('org_permission_denied: org=%s', p_org_slug); + end if; + if not lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, v_org_id) then + raise exception using errcode = 'LK002', + message = format('key_org_denied: org=%s', p_org_slug); + end if; + else + -- Wildcard-aware, most-specific-wins resolution. A binding matches + -- p_scope either by exact string equality, or — when it ends in '*' — as + -- an owner-wildcard prefix (the same LIKE-escaping `lorekit_api_token_ + -- scope_allowed` (00068) uses, since `%` and `\` cannot occur in a + -- pattern that already passed the grammar gate above). Among matches: + -- exact (is-wildcard = false) always sorts before any wildcard; among + -- wildcards, the LONGER literal prefix (length desc) sorts first as the + -- more specific match; the trailing `scope asc` is a determinism + -- safeguard for a tie that provably cannot occur (see the header + -- comment), not a reachable precedence branch. + select b.org_id, o.slug into v_binding_org, v_binding_slug + from org_scope_bindings b + join orgs o on o.id = b.org_id + where o.deleted_at is null + and ( + b.scope = p_scope + or ( + right(b.scope, 1) = '*' + and p_scope like replace(left(b.scope, -1), '_', '\_') || '%' + ) + ) + order by (right(b.scope, 1) = '*') asc, length(b.scope) desc, b.scope asc + limit 1; + + if v_binding_org is not null + and p_user_id is not null + and lorekit_org_can(p_user_id, v_binding_org, 'write') + and lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, v_binding_org) then + v_org_id := v_binding_org; + end if; + end if; + + -- 00116: a PERSONAL write is gated by the key's tenancy too. Every branch + -- above either picked an org (and checked it) or left v_org_id NULL, which + -- means "write a personal row" — the one destination no tenancy used to gate, + -- because every tenancy could reach personal rows. `orgs_only` cannot, so + -- refuse instead of silently falling back to personal: a token that was + -- scoped to publish org lore must never grow its owner a personal row. + if v_org_id is null + and not lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, null) then + raise exception using errcode = 'LK002', + message = format('key_personal_denied: this token may not write personal memories (scope=%s)', p_scope); + end if; + + if v_org_id is not null then + return query + insert into memories ( + user_id, org_id, scope, key, value, tags, source_agent, trigger, + created_at, updated_at, created_by, updated_by, expires_at, + origin_repo, origin_branch, origin_commit, origin_pr, kind, host, seen_count + ) + values ( + null, v_org_id, p_scope, p_key, p_value, p_tags, p_source_agent, p_trigger, + coalesce(p_created_at, now()), coalesce(p_created_at, now()), + p_user_id, p_user_id, v_expires_at, + p_origin_repo, p_origin_branch, p_origin_commit, p_origin_pr, p_kind, p_host, 1 + ) + on conflict (org_id, scope, key) where org_id is not null and archived_at is null + do update set + value = excluded.value, + tags = excluded.tags, + source_agent = excluded.source_agent, + trigger = excluded.trigger, + updated_at = now(), + updated_by = p_user_id, + seen_count = memories.seen_count + 1, + origin_repo = coalesce(excluded.origin_repo, memories.origin_repo), + origin_branch = coalesce(excluded.origin_branch, memories.origin_branch), + origin_commit = coalesce(excluded.origin_commit, memories.origin_commit), + origin_pr = coalesce(excluded.origin_pr, memories.origin_pr), + kind = coalesce(excluded.kind, memories.kind), + host = coalesce(excluded.host, memories.host), + expires_at = case v_ttl_action + when 'clear' then null + when 'set' then v_expires_at + else memories.expires_at + end + returning + memories.id, memories.created_at, (xmax::text = '0') as inserted, + true as org_routed, v_binding_slug as binding_org_slug, memories.expires_at, + memories.scope, memories.key, memories.value, memories.tags, + memories.source_agent, memories.trigger, memories.updated_at, memories.archived_at, + memories.origin_repo, memories.origin_branch, memories.origin_commit, memories.origin_pr, + memories.kind, memories.host, memories.seen_count; + + elsif p_user_id is null then + return query + insert into memories ( + user_id, scope, key, value, tags, source_agent, trigger, + created_at, updated_at, created_by, updated_by, expires_at, + origin_repo, origin_branch, origin_commit, origin_pr, kind, host, seen_count + ) + values ( + null, p_scope, p_key, p_value, p_tags, p_source_agent, p_trigger, + coalesce(p_created_at, now()), coalesce(p_created_at, now()), + null, null, v_expires_at, + p_origin_repo, p_origin_branch, p_origin_commit, p_origin_pr, p_kind, p_host, 1 + ) + on conflict (scope, key) where org_id is null and user_id is null and archived_at is null + do update set + value = excluded.value, + tags = excluded.tags, + source_agent = excluded.source_agent, + trigger = excluded.trigger, + updated_at = now(), + seen_count = memories.seen_count + 1, + origin_repo = coalesce(excluded.origin_repo, memories.origin_repo), + origin_branch = coalesce(excluded.origin_branch, memories.origin_branch), + origin_commit = coalesce(excluded.origin_commit, memories.origin_commit), + origin_pr = coalesce(excluded.origin_pr, memories.origin_pr), + kind = coalesce(excluded.kind, memories.kind), + host = coalesce(excluded.host, memories.host), + expires_at = case v_ttl_action + when 'clear' then null + when 'set' then v_expires_at + else memories.expires_at + end + returning + memories.id, memories.created_at, (xmax::text = '0') as inserted, + false as org_routed, v_binding_slug as binding_org_slug, memories.expires_at, + memories.scope, memories.key, memories.value, memories.tags, + memories.source_agent, memories.trigger, memories.updated_at, memories.archived_at, + memories.origin_repo, memories.origin_branch, memories.origin_commit, memories.origin_pr, + memories.kind, memories.host, memories.seen_count; + + else + return query + insert into memories ( + user_id, scope, key, value, tags, source_agent, trigger, + created_at, updated_at, created_by, updated_by, expires_at, + origin_repo, origin_branch, origin_commit, origin_pr, kind, host, seen_count + ) + values ( + p_user_id, p_scope, p_key, p_value, p_tags, p_source_agent, p_trigger, + coalesce(p_created_at, now()), coalesce(p_created_at, now()), + p_user_id, p_user_id, v_expires_at, + p_origin_repo, p_origin_branch, p_origin_commit, p_origin_pr, p_kind, p_host, 1 + ) + on conflict (user_id, scope, key) where org_id is null and user_id is not null and archived_at is null + do update set + value = excluded.value, + tags = excluded.tags, + source_agent = excluded.source_agent, + trigger = excluded.trigger, + updated_at = now(), + updated_by = p_user_id, + seen_count = memories.seen_count + 1, + origin_repo = coalesce(excluded.origin_repo, memories.origin_repo), + origin_branch = coalesce(excluded.origin_branch, memories.origin_branch), + origin_commit = coalesce(excluded.origin_commit, memories.origin_commit), + origin_pr = coalesce(excluded.origin_pr, memories.origin_pr), + kind = coalesce(excluded.kind, memories.kind), + host = coalesce(excluded.host, memories.host), + expires_at = case v_ttl_action + when 'clear' then null + when 'set' then v_expires_at + else memories.expires_at + end + returning + memories.id, memories.created_at, (xmax::text = '0') as inserted, + false as org_routed, v_binding_slug as binding_org_slug, memories.expires_at, + memories.scope, memories.key, memories.value, memories.tags, + memories.source_agent, memories.trigger, memories.updated_at, memories.archived_at, + memories.origin_repo, memories.origin_branch, memories.origin_commit, memories.origin_pr, + memories.kind, memories.host, memories.seen_count; + end if; +end; +$$; + +grant execute on function memory_write( + uuid, text, text, text, text[], text, text, timestamptz, text, + integer, boolean, text, text, text, integer, text, text, text[], text, uuid[] +) to anon, authenticated, service_role; + +-- 6. Archive / hard-delete and restore by (scope, key): 00071's `memory_delete` +-- and 00072's `restore_memory`, verbatim except the marked `orgs_only` clause. +create or replace function memory_delete( + p_user_id uuid, + p_org_slug text default null, + p_scope text default null, + p_key text default null, + p_force boolean default false, + p_key_scopes text[] default '{}', + p_key_org_access text default 'all', + p_key_org_ids uuid[] default '{}' +) +returns table (deleted boolean, archived boolean, existed boolean) +language plpgsql +security definer +set search_path = public +as $$ +declare + v_org_id uuid; + v_count integer; + v_existed boolean; + v_actor uuid := case + when auth.role() = 'service_role' then coalesce(p_user_id, auth.uid()) + else auth.uid() + end; + -- A scoped key on the trusted service-role connection manages the whole + -- allowed scope, not just its owner's rows. See the header for why BOTH + -- conjuncts are load-bearing (service_role gate closes the p_key_scopes IDOR; + -- the length check keeps an unscoped key own-rows-only). + v_scope_managed boolean := ( + auth.role() = 'service_role' + and array_length(p_key_scopes, 1) is not null + ); + -- The SERVICE tier — the bare `SUPABASE_SERVICE_ROLE_KEY` (CI, the smoke + -- suite, an operator script). It authenticates as no user and no key, so both + -- surfaces resolve `p_user_id => null` for it (REST `auth.userId` is unset for + -- `type:'service'`; MCP's `toolUserId` returns null for anything that is not + -- an api_key). `auth.uid()` is null on that connection too, so v_actor is NULL + -- and every `m.user_id = v_actor` disjunct below is NULL — before this the + -- personal branch matched ZERO rows for the tier that is meant to reach all of + -- them, and the edge reported the row it could see but not touch as a 403. + -- + -- Own-rows-only is the API-KEY rule (00046), and null actor is precisely "no + -- key, no user": the tier that holds the service-role secret can write the + -- table directly, so pinning it here protected nothing. This restores what + -- the raw-query paths have always done — `if (userId) …eq('user_id', userId)`, + -- i.e. no owner filter when there is no user (mcp-core/src/tools/delete.ts). + -- + -- It cannot widen anyone else: an api_key ALWAYS carries `p_user_id` (the + -- `api_tokens.user_id` it was matched by), and a non-service-role caller fails + -- the role conjunct, so `auth.uid()` keeps pinning it — the 00046 IDOR guard + -- is untouched on both. + v_service_tier boolean := ( + auth.role() = 'service_role' + and p_user_id is null + ); +begin + -- The scope allowlist, before either branch. LK002 maps to a 403 on both + -- surfaces, so no second mapping is needed here. + if not lorekit_api_token_scope_allowed(p_key_scopes, p_scope) then + raise exception using errcode = 'LK002', + message = format('key_scope_denied: scope=%s', p_scope); + end if; + + if p_org_slug is not null then + select o.id into v_org_id from orgs o where o.slug = p_org_slug; + if v_org_id is null then + raise exception using + errcode = 'P0001', + message = format('unknown_org: %s', p_org_slug); + end if; + + -- The tenancy half, before the capability gates so the denial names the + -- key, not its owner's role. + if not lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, v_org_id) then + raise exception using errcode = 'LK002', + message = format('key_org_denied: org=%s', p_org_slug); + end if; + + v_existed := exists( + select 1 from memories m + where m.org_id = v_org_id and m.scope = p_scope and m.key = p_key + ); + + if p_force then + if not lorekit_org_can(v_actor, v_org_id, 'hard_delete') then + raise exception using + errcode = 'LK002', + message = format('org_permission_denied: org=%s capability=hard_delete', p_org_slug); + end if; + + delete from memories + where org_id = v_org_id and scope = p_scope and key = p_key; + get diagnostics v_count = row_count; + + return query select (v_count > 0), false, v_existed; + else + if not lorekit_org_can(v_actor, v_org_id, 'archive') then + raise exception using + errcode = 'LK002', + message = format('org_permission_denied: org=%s capability=archive', p_org_slug); + end if; + + update memories + set archived_at = now() + where org_id = v_org_id and scope = p_scope and key = p_key and archived_at is null; + get diagnostics v_count = row_count; + + return query select false, (v_count > 0), v_existed; + end if; + else + -- Personal / scope-authorized branch. + -- + -- `existed` is the caller's VISIBILITY of (scope,key), deliberately BROADER + -- than the removal WHERE below so that `existed=true, removed=false` is a + -- meaningful "present but not removed by this call" (forbidden / already + -- archived) rather than collapsing into not_found. A service-role caller + -- (the edge, which can already read the whole scope via memory.list) sees + -- any row; a non-service-role caller sees only its own / member-org rows, + -- so it can never enumerate another user's memories. + v_existed := case + when auth.role() = 'service_role' then exists( + select 1 from memories m where m.scope = p_scope and m.key = p_key + ) + else exists( + select 1 from memories m + where m.scope = p_scope and m.key = p_key + and ( m.user_id = v_actor + or m.org_id in (select lorekit_member_org_ids(v_actor)) ) + ) + end; + + if p_force then + delete from memories m + where m.scope = p_scope and m.key = p_key + and ( v_service_tier + or m.user_id = v_actor + or m.org_id in (select lorekit_member_org_ids(v_actor)) + or ( v_scope_managed + and lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, m.org_id) ) ) + -- 00116: an orgs_only key never reaches a personal row (or an org it did not select). + and ( coalesce(p_key_org_access, 'all') <> 'orgs_only' + or lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, m.org_id) ); + get diagnostics v_count = row_count; + + return query select (v_count > 0), false, (v_existed or v_count > 0); + else + update memories m + set archived_at = now() + where m.scope = p_scope and m.key = p_key and m.archived_at is null + and ( v_service_tier + or m.user_id = v_actor + or m.org_id in (select lorekit_member_org_ids(v_actor)) + or ( v_scope_managed + and lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, m.org_id) ) ) + -- 00116: an orgs_only key never reaches a personal row (or an org it did not select). + and ( coalesce(p_key_org_access, 'all') <> 'orgs_only' + or lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, m.org_id) ); + get diagnostics v_count = row_count; + + return query select false, (v_count > 0), (v_existed or v_count > 0); + end if; + end if; +end; +$$; + +-- 00046/00068's revoke is carried over verbatim against the (unchanged) +-- signature: 00020 granted `anon` EXPLICITLY, and `revoke … from public` does +-- not remove a per-role grant, so anon must be named again or the re-created +-- function silently comes back reachable. +revoke execute on function memory_delete(uuid, text, text, text, boolean, text[], text, uuid[]) + from public, anon; +grant execute on function memory_delete(uuid, text, text, text, boolean, text[], text, uuid[]) + to authenticated, service_role; + +comment on function memory_delete(uuid, text, text, text, boolean, text[], text, uuid[]) is + 'Archives (p_force=false) or hard-deletes (p_force=true) a memory and reports + whether a row for (scope,key) the caller may see existed. A key with a + non-empty scope allowlist reaching this function on the service-role + connection manages every writer''s row within the scopes/orgs it is scoped + to; an unscoped key, and any non-service-role caller, stay pinned to their + own (and member-org) rows, preserving the 00046 actor guard. The service tier + (service-role connection, no p_user_id — no user and no key) is unpinned: it + has no actor to be pinned to. Org removals remain gated on the archive / + hard_delete capability.'; + +create or replace function restore_memory( + p_user_id uuid, + p_scope text default null, + p_key text default null, + p_key_scopes text[] default '{}', + p_key_org_access text default 'all', + p_key_org_ids uuid[] default '{}' +) +returns table (restored boolean, existed boolean) +language plpgsql +security definer +set search_path = public +as $$ +declare + v_count integer; + v_existed boolean; + v_actor uuid := case + when auth.role() = 'service_role' then coalesce(p_user_id, auth.uid()) + else auth.uid() + end; + -- Same gate as memory_delete (00071): a scoped key on the trusted + -- service-role connection manages the whole allowlist; an unscoped key and + -- any non-service-role caller stay own-rows-only (auth.uid() pins them), + -- preserving the 00046 actor guard against a request-supplied p_key_scopes. + v_scope_managed boolean := ( + auth.role() = 'service_role' + and array_length(p_key_scopes, 1) is not null + ); + -- The SERVICE tier, exactly as in 00071's `memory_delete` — see that header + -- for the full argument. Short form: the bare service-role key authenticates + -- as no user and no key, so `p_user_id` is null AND `auth.uid()` is null, + -- leaving v_actor NULL and every ownership disjunct below NULL. Restore has to + -- answer this the same way removal does, or a row the service tier can archive + -- is a row it cannot un-archive. + v_service_tier boolean := ( + auth.role() = 'service_role' + and p_user_id is null + ); +begin + if not lorekit_api_token_scope_allowed(p_key_scopes, p_scope) then + raise exception using errcode = 'LK002', + message = format('key_scope_denied: scope=%s', p_scope); + end if; + + -- Visibility of an ARCHIVED (restorable) row, broader than the restore WHERE + -- for a service-role caller so `existed=true, restored=false` is a meaningful + -- forbidden signal; a non-service-role caller never sees another user's row. + v_existed := case + when auth.role() = 'service_role' then exists( + select 1 from memories m + where m.scope = p_scope and m.key = p_key and m.archived_at is not null + ) + else exists( + select 1 from memories m + where m.scope = p_scope and m.key = p_key and m.archived_at is not null + and ( m.user_id = v_actor + or m.org_id in (select lorekit_member_org_ids(v_actor)) ) + ) + end; + + update memories m + set archived_at = null + where m.scope = p_scope and m.key = p_key and m.archived_at is not null + and ( v_service_tier + or m.user_id = v_actor + or m.org_id in (select lorekit_member_org_ids(v_actor)) + or ( v_scope_managed + and lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, m.org_id) ) ) + -- 00116: an orgs_only key never reaches a personal row (or an org it did not select). + and ( coalesce(p_key_org_access, 'all') <> 'orgs_only' + or lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, m.org_id) ); + get diagnostics v_count = row_count; + + return query select (v_count > 0), (v_existed or v_count > 0); +end; +$$; + +revoke execute on function restore_memory(uuid, text, text, text[], text, uuid[]) + from public, anon; +grant execute on function restore_memory(uuid, text, text, text[], text, uuid[]) + to authenticated, service_role; + +comment on function restore_memory(uuid, text, text, text[], text, uuid[]) is + 'Un-archives a memory and reports whether a restorable (archived) row the + caller may see existed. A key with a non-empty scope allowlist reaching this + on the service-role connection restores any writer''s row within its + scopes/orgs; an unscoped key and any non-service-role caller stay pinned to + their own (and member-org) rows — the 00046/00071 actor guard, applied to + restore. The service tier (service-role connection, no p_user_id — no user and + no key) is unpinned: it has no actor to be pinned to.'; +; +insert into supabase_migrations.schema_migrations (version, name) +values ('00116', 'api_token_orgs_only_tenancy') +on conflict (version) do nothing; + -- ───────────────────────────────────────────────────────────────────────────── -- storage-overlay.sql — BYOD only: drop foreign keys into auth.users -- ───────────────────────────────────────────────────────────────────────────── diff --git a/supabase/functions/_shared/api/errors.ts b/supabase/functions/_shared/api/errors.ts index 440cd32cf..8f9ec8876 100644 --- a/supabase/functions/_shared/api/errors.ts +++ b/supabase/functions/_shared/api/errors.ts @@ -46,6 +46,13 @@ export function translateDbError(err: unknown): RestError | null { const code = e?.code ?? e?.error?.code; const message = e?.message ?? e?.error?.message; if (code === 'LK001') return new RestError('Memory limit exceeded. Archive unused memories or upgrade your plan.', 429, 'memory_cap'); + if (code === 'LK002' && typeof message === 'string' && message.startsWith('key_personal_denied')) { + return new RestError( + 'This token is limited to selected orgs (orgs_only) and cannot write personal memories. Pass `org` with one of the token\'s orgs.', + 403, + 'key_personal_denied', + ); + } if (code === 'LK002') return new RestError('Insufficient permissions for this org action.', 403, 'org_permission_denied'); if (code === 'PGRST116') return new RestError('Not found', 404, 'not_found'); if (code === '23505') return new RestError('A record with this identifier already exists.', 409, 'conflict'); diff --git a/supabase/functions/_shared/api/router.ts b/supabase/functions/_shared/api/router.ts index 1e743d712..eebe267f1 100644 --- a/supabase/functions/_shared/api/router.ts +++ b/supabase/functions/_shared/api/router.ts @@ -1,5 +1,6 @@ import type { AuthContext, ResolvedAuth, DbClient } from './auth.ts'; -import { hasPermission, isJwtAuth, analyticsUserId, usageAuthType } from './auth.ts'; +import { hasPermission, isJwtAuth, analyticsUserId, usageAuthType, keyRestriction } from './auth.ts'; +import { isRefusedForOrgsOnlyKey, orgsOnlyRefusalMessage } from '../auth/account-wide-tools.ts'; import { forbidden, notFound, methodNotAllowed } from './respond.ts'; import { translateDbError } from './errors.ts'; import { recordUsageEvent, getUserPlanName } from '../telemetry/usage.ts'; @@ -224,6 +225,14 @@ export function createRouter(routes: Route[], functionName: string) { if (route.requires === 'jwt' && !isJwtAuth(resolved.auth)) return forbidden('This endpoint requires a Supabase JWT (not an API token)', cors); if (route.requires === 'read' && !hasPermission(resolved.auth, 'read')) return forbidden('Read permission required', cors); if (route.requires === 'write' && !hasPermission(resolved.auth, 'write')) return forbidden('Write permission required', cors); + // An `orgs_only` key may not reach an operation that picks its rows by + // owner (00116) — refused here, once, for the whole REST surface, keyed + // on the same tool name the MCP dispatcher refuses. + const gateTool = restToolName({ fn: functionName, method, path: route.path, force: url.searchParams.get('force') === 'true' }); + if (isRefusedForOrgsOnlyKey(gateTool, keyRestriction(resolved.auth)?.orgAccess)) { + span.setAttributes({ 'authz.result': 'denied', 'authz.reason': 'key_orgs_only_owner_rows' }); + return forbidden(orgsOnlyRefusalMessage(gateTool), cors); + } const hs = span.child(`lorekit.${functionName}.${method.toLowerCase()}${route.path}`, { 'http.route': route.path }); diff --git a/supabase/functions/_shared/api/tenant.ts b/supabase/functions/_shared/api/tenant.ts index 986d45d40..70725de62 100644 --- a/supabase/functions/_shared/api/tenant.ts +++ b/supabase/functions/_shared/api/tenant.ts @@ -21,7 +21,9 @@ import { scopeAllowedByKey } from '../schemas/api-key.ts'; import { effectiveOrgIds, keyScopeFilter, + MATCH_NOTHING, ownRowsFragment, + reachesPersonal, restrictsTenancy, type KeyRestriction, } from '../auth/tenant-scope.ts'; @@ -78,7 +80,9 @@ export function applyRestTenantScope>( let out = visibleOrgIds.length === 0 ? (q.eq('user_id', userId) as Q) : (q.or(`${ownRowsFragment(userId, key)},org_id.in.(${visibleOrgIds.map((id) => `"${id}"`).join(',')})`) as Q); - if (visibleOrgIds.length === 0 && restrictsTenancy(key)) out = out.or('org_id.is.null') as Q; + if (visibleOrgIds.length === 0 && restrictsTenancy(key)) { + out = out.or(reachesPersonal(key) ? 'org_id.is.null' : MATCH_NOTHING) as Q; + } const scopeFilter = keyScopeFilter(key); // A second `.or()` — PostgREST ANDs top-level filters, so this reads as // "(mine or my orgs') AND (in the key's allowlist)". @@ -99,8 +103,18 @@ export function applyRestTenantScope>( * A no-op for a JWT/service caller and for an unrestricted key. */ export function applyKeyScopeFilter>(q: Q, auth: AuthContext): Q { - const filter = keyScopeFilter(keyRestriction(auth)); - return filter ? (q.or(filter) as Q) : q; + const key = keyRestriction(auth); + const filter = keyScopeFilter(key); + let out = filter ? (q.or(filter) as Q) : q; + // An `orgs_only` key must not touch its owner's personal rows on this + // owner-row write family either: narrow to the key's own orgs (no ids → no + // row). The other tenancies keep exactly the filter they always had. + if (key && !reachesPersonal(key)) { + out = out.or( + key.orgIds.length > 0 ? `org_id.in.(${key.orgIds.map((id) => `"${id}"`).join(',')})` : MATCH_NOTHING, + ) as Q; + } + return out; } /** diff --git a/supabase/functions/_shared/auth/account-wide-tools.ts b/supabase/functions/_shared/auth/account-wide-tools.ts index 1abd634db..29f86d067 100644 --- a/supabase/functions/_shared/auth/account-wide-tools.ts +++ b/supabase/functions/_shared/auth/account-wide-tools.ts @@ -56,3 +56,63 @@ export function accountWideRefusalMessage(toolName: string): string { return `"${toolName}" operates across your whole account, so it is not available to a ` + 'token restricted to specific scopes. Use an unscoped token for maintenance sweeps.'; } + + +/** + * Operations that act on the owner's own rows without consulting the key's + * tenancy, and so are refused outright to an `orgs_only` key (00116). + * + * `orgs_only` promises the owner's PERSONAL rows are out of reach. These + * operations choose their rows by `user_id` inside the RPC (or report on the + * owner's whole account), so there is no tenant predicate to narrow — the only + * honest answer is to refuse the CALL, exactly as `ACCOUNT_WIDE_TOOLS` is + * refused to a scoped key. Every other tool narrows instead: reads through + * `applyTenantScope` / `lorekit_api_token_org_allowed`, writes through + * `memory_write`'s personal-row refusal and `applyKeyScopeFilter`. + */ +export const OWNER_ROW_TOOLS: ReadonlySet = new Set([ + ...ACCOUNT_WIDE_TOOLS, + 'memory.quota', + 'memory.protect', + // Owner analytics: their RPCs take no tenancy and roll up the owner's own + // reads and calls, personal keys included (00080, 00079, 00083, 00085, 00106). + 'memory.read-activity', + 'memory.read-ranking', + 'memory.utility', + 'memory.usage', + 'memory.usage-runs', + 'policy.list', + 'policy.create', + 'policy.update', + 'policy.delete', + 'groom.preview', + 'groom.run', +]); + +/** + * Is this tool refused outright for a key with this tenancy? + * + * Only `orgs_only` is affected; every other tenancy (and no key at all) keeps + * exactly the behaviour it had. + */ +export function isRefusedForOrgsOnlyKey(toolName: string, orgAccess: string | undefined): boolean { + return orgAccess === 'orgs_only' && (OWNER_ROW_TOOLS.has(toolName) || isAccountOrgTool(toolName)); +} + +/** + * Org and membership management (`org.*`, `member.*`, MCP and REST). None of + * it reads the key's `org_ids`: `org.list` and `GET /orgs` return every org the + * owner belongs to, and the member/invite lists expose handles and invitee + * emails. An `orgs_only` key is the one meant to be handed out, so it gets none + * of the owner's account-level org surface. + */ +function isAccountOrgTool(toolName: string): boolean { + return toolName.startsWith('org.') || toolName.startsWith('member.'); +} + +/** The refusal sentence for `isRefusedForOrgsOnlyKey`, shared by both surfaces. */ +export function orgsOnlyRefusalMessage(toolName: string): string { + const what = isAccountOrgTool(toolName) ? 'manages your account\'s organisations' : 'acts on your own memories'; + return `"${toolName}" ${what}, so it is not available to a token ` + + 'limited to selected orgs (orgs_only). Use a token that may reach your personal memories.'; +} diff --git a/supabase/functions/_shared/auth/tenant-scope.ts b/supabase/functions/_shared/auth/tenant-scope.ts index fdfc48a5d..f762b2f87 100644 --- a/supabase/functions/_shared/auth/tenant-scope.ts +++ b/supabase/functions/_shared/auth/tenant-scope.ts @@ -30,8 +30,8 @@ export interface KeyRestriction { /** Allowlist of scope patterns. EMPTY = unrestricted. */ scopes: string[]; - orgAccess: 'all' | 'personal' | 'selected'; - /** Meaningful only under `selected`. */ + orgAccess: 'all' | 'personal' | 'selected' | 'orgs_only'; + /** Meaningful only under `selected` and `orgs_only`. */ orgIds: string[]; } @@ -39,10 +39,14 @@ export interface KeyRestriction { * Build a `KeyRestriction` from the raw `api_tokens` row the transports select. * * Total, and fail-closed on the one value that matters: an `org_access` the - * code does not recognise becomes `personal`, the NARROWEST tenancy, not `all`. - * A row can only carry an unknown value if `api_tokens_org_access_valid` was - * dropped or a BYOD install predates it, and in that state "I do not understand - * this restriction" must not resolve to "there is no restriction". + * code does not recognise becomes `orgs_only` with NO org ids — a key that + * reaches nothing at all, neither personal rows nor any org. It used to become + * `personal`, which still exposed the owner's personal rows: wrong for a value + * that may mean "no personal rows" (`orgs_only`, read by an older deploy). A + * row can only carry an unknown value if `api_tokens_org_access_valid` was + * dropped, a BYOD install predates it, or a newer migration added a value this + * code predates, and in that state "I do not understand this restriction" must + * not resolve to "there is no restriction". * * The scope list degrades the other way — a non-array becomes empty, i.e. * unrestricted — because that is the DEFAULT for every key ever created, so @@ -62,15 +66,13 @@ export function normalizeKeyRestriction(row: { ? row.org_ids.filter((s): s is string => typeof s === 'string') : []; const raw = row.org_access; - const orgAccess: KeyRestriction['orgAccess'] = - raw === 'all' || raw === 'personal' || raw === 'selected' - ? raw - // Absent is the pre-00068 shape (the column did not exist), which IS - // unrestricted. Present-but-unrecognised is corruption, and denies. - : raw == null - ? 'all' - : 'personal'; - return { scopes, orgAccess, orgIds }; + // Absent is the pre-00068 shape (the column did not exist), which IS + // unrestricted. Present-but-unrecognised denies everything. + if (raw == null) return { scopes, orgAccess: 'all', orgIds }; + if (raw === 'all' || raw === 'personal' || raw === 'selected' || raw === 'orgs_only') { + return { scopes, orgAccess: raw, orgIds }; + } + return { scopes, orgAccess: 'orgs_only', orgIds: [] }; } /** @@ -85,9 +87,29 @@ export function normalizeKeyRestriction(row: { export function effectiveOrgIds(memberOrgIds: string[], key?: KeyRestriction): string[] { if (!key || key.orgAccess === 'all') return memberOrgIds; if (key.orgAccess === 'personal') return []; + // `selected` and `orgs_only` narrow orgs the same way; they differ only in + // whether the owner's personal rows are reachable (`reachesPersonal`). return memberOrgIds.filter((id) => key.orgIds.includes(id)); } +/** + * May this key reach its owner's PERSONAL rows (`org_id IS NULL`)? + * + * True for every tenancy except `orgs_only` — the one mode that exists to + * publish org lore through a token without exposing the owner's own memories + * (00116). A JWT or service caller has no key and is unaffected. + */ +export function reachesPersonal(key?: KeyRestriction): boolean { + return !key || key.orgAccess !== 'orgs_only'; +} + +/** + * A PostgREST filter no row satisfies. `id` is the primary key and never null, + * so this is the honest "match nothing" for a key that may reach no row on the + * branch it guards — rather than omitting a filter, which would match all. + */ +export const MATCH_NOTHING = 'id.is.null'; + /** * True when the calling key narrows WHICH ORGS are reachable. * @@ -115,6 +137,9 @@ export function restrictsTenancy(key?: KeyRestriction): boolean { * question differently. */ export function ownRowsFragment(userId: string, key?: KeyRestriction): string { + // `orgs_only`: the owner's personal rows are out of reach, and their own + // org-owned rows are reached through the `org_id.in.(…)` disjunct instead. + if (!reachesPersonal(key)) return MATCH_NOTHING; return restrictsTenancy(key) ? `and(user_id.eq.${userId},org_id.is.null)` : `user_id.eq.${userId}`; @@ -190,7 +215,10 @@ export function applyTenantScope; +/** The tenancies that name their orgs in `orgIds` (and must name at least one). */ +export function orgAccessNamesOrgs(orgAccess: ApiKeyOrgAccess): boolean { + return orgAccess === 'selected' || orgAccess === 'orgs_only'; +} + /** * A key's full scoping, with the cross-field rule the DB also enforces * (`api_tokens_org_ids_match_access`). @@ -100,14 +107,14 @@ export const ApiKeyScopingSchema = z .default([]), }) .superRefine((val, ctx) => { - if (val.orgAccess === 'selected' && val.orgIds.length === 0) { + if (orgAccessNamesOrgs(val.orgAccess) && val.orgIds.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['orgIds'], - message: 'orgAccess "selected" requires at least one org id', + message: `orgAccess "${val.orgAccess}" requires at least one org id`, }); } - if (val.orgAccess !== 'selected' && val.orgIds.length > 0) { + if (!orgAccessNamesOrgs(val.orgAccess) && val.orgIds.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['orgIds'], @@ -159,18 +166,20 @@ export function scopeAllowedByKey(patterns: readonly string[], scope: string | n /** * Does a key with this tenancy reach a row owned by `orgId` (`null` = personal)? * - * The TypeScript twin of `lorekit_api_token_org_allowed` (00068). A personal row - * is reachable under every tenancy: `personal` narrows which ORGS are reachable, - * it never revokes the owner's own memories. + * The TypeScript twin of `lorekit_api_token_org_allowed` (00068, 00116). A + * personal row is reachable under `all`, `personal` and `selected` — those + * narrow which ORGS are reachable, never the owner's own memories — and NOT + * under `orgs_only`, which exists to publish org lore without them. An + * unrecognised value reaches nothing. */ export function orgAllowedByKey( orgAccess: ApiKeyOrgAccess, orgIds: readonly string[], orgId: string | null, ): boolean { - if (orgId === null) return true; + if (orgId === null) return orgAccess === 'all' || orgAccess === 'personal' || orgAccess === 'selected'; if (orgAccess === 'all') return true; - if (orgAccess === 'selected') return orgIds.includes(orgId); + if (orgAccess === 'selected' || orgAccess === 'orgs_only') return orgIds.includes(orgId); // `personal`, and anything else. Written as an explicit fall-through rather // than `orgAccess === 'personal' ? false : orgIds.includes(orgId)` so an // unrecognised value denies instead of being treated as `selected` — the SQL diff --git a/supabase/functions/_shared/schemas/tool-catalog.ts b/supabase/functions/_shared/schemas/tool-catalog.ts index d4f08e3c9..0ba84a16a 100644 --- a/supabase/functions/_shared/schemas/tool-catalog.ts +++ b/supabase/functions/_shared/schemas/tool-catalog.ts @@ -366,7 +366,7 @@ export const MCP_TOOLS = [ notes: [ '**Provenance (`origin_*`):** `scope` says where a lesson *applies*; the four `origin_*` fields say where it was *recorded from*. Each is independently optional and the last KNOWN value wins — on an update, a field you omit keeps whatever a previous write recorded rather than being erased. A malformed value is rejected, never silently dropped.', '**Citations (`cited`):** the one signal read counts cannot produce. A lesson injected at session start is already in your context and is applied without ever being fetched, so LoreKit can measure whether lore was *looked up* but not whether it *worked* — `cited` is where you say so. Each entry is a `scope::key` you were shown; they raise the cited lesson\u2019s `cited_count` and are grouped by the run\u2019s correlation id. Recorded once per run, so a retried write does not inflate anything, and every failure mode is silent: an unknown reference, a self-citation, and anything past 32 entries are dropped and the write still succeeds.', - '**Scope→org binding:** An org admin can bind a scope (e.g. a repo) — or a WILDCARD prefix such as `repo::owner/*` or `branch::owner/repo::*`, matching every scope under it — to an org. A write under a bound scope with no explicit `org` auto-routes to that org for write-capable members, resolved MOST-SPECIFIC-WINS when several bindings match (an exact scope beats any wildcard; a longer wildcard prefix beats a shorter one). A non-member\u2019s write falls back to personal (never rejected) with a `notice`.', + '**Scope→org binding:** An org admin can bind a scope (e.g. a repo) — or a WILDCARD prefix such as `repo::owner/*` or `branch::owner/repo::*`, matching every scope under it — to an org. A write under a bound scope with no explicit `org` auto-routes to that org for write-capable members, resolved MOST-SPECIFIC-WINS when several bindings match (an exact scope beats any wildcard; a longer wildcard prefix beats a shorter one). A non-member\u2019s write falls back to personal (never rejected) with a `notice` \u2014 except from a token limited to selected orgs (`orgs_only`), which may not write personal memories: that write is refused, so pass `org`.', ], }, { diff --git a/supabase/functions/_shared/telemetry/citations.ts b/supabase/functions/_shared/telemetry/citations.ts index 17202bb97..01e3a6653 100644 --- a/supabase/functions/_shared/telemetry/citations.ts +++ b/supabase/functions/_shared/telemetry/citations.ts @@ -36,6 +36,12 @@ export interface CitationInput { cited: unknown; /** The run this write belongs to, from `X-LoreKit-Correlation-Id`. */ correlationId: string | null; + /** + * False for a key that may not reach its owner's personal rows (`orgs_only`, + * 00116). The RPC only ever credits the owner's personal lessons, so such a + * key records nothing. Omitted means true. + */ + reachesPersonal?: boolean; } /** @@ -55,7 +61,7 @@ export async function recordCitations( const refs = parseMemoryRefs(input.cited); // Nothing to record is the common case — most writes cite nothing — so this // returns before touching the database at all. - if (refs.length === 0 || !input.userId) return 0; + if (refs.length === 0 || !input.userId || input.reachesPersonal === false) return 0; try { // Through the traced client, like every other RPC on a request path: the diff --git a/supabase/functions/mcp/mcp-handler.ts b/supabase/functions/mcp/mcp-handler.ts index 62ce6067b..12841cf56 100644 --- a/supabase/functions/mcp/mcp-handler.ts +++ b/supabase/functions/mcp/mcp-handler.ts @@ -25,7 +25,12 @@ import { type Span } from '../_shared/telemetry/otel.ts'; import { redactUrls } from '../_shared/telemetry/span-semconv.ts'; import { LimitError, recordUsageEvent, getUserPlanName } from './limits.ts'; import { toolRequires } from './permissions.ts'; -import { isRefusedForScopedKey, accountWideRefusalMessage } from '../_shared/auth/account-wide-tools.ts'; +import { + isRefusedForScopedKey, + accountWideRefusalMessage, + isRefusedForOrgsOnlyKey, + orgsOnlyRefusalMessage, +} from '../_shared/auth/account-wide-tools.ts'; import { wireTools } from '../_shared/schemas/tool-catalog.ts'; import { countRecords, parseCorrelationId, parseUsageClient, usageToolKind } from '../_shared/telemetry/usage-stats.ts'; import { parseSessionKind } from '../_shared/telemetry/session-kind.ts'; @@ -209,6 +214,15 @@ export async function handleMcp(req: Request, auth: AuthContext, span: Span, ada // calls, thrown as `KeyScopeDeniedError` and caught below). This keeps one // gating code path per surface — inline pre-dispatch for memory, in-handler // for retention — rather than splitting memory tools across both. + // An `orgs_only` key may not reach an operation that picks its rows by + // owner rather than by tenant (00116) — memory AND retention tools, so this + // sits before the memory-only block below: there is nothing to narrow. + if (isRefusedForOrgsOnlyKey(toolName, keyRestriction(auth)?.orgAccess)) { + span + .clientError('PermissionDenied: owner-row tool on an orgs_only token') + .setAttributes({ 'authz.result': 'denied', 'authz.reason': 'key_orgs_only_owner_rows' }); + return jsonrpcError(id, JSONRPC_FORBIDDEN, orgsOnlyRefusalMessage(toolName)); + } if (!isOrgTool && !isRetentionTool) { // Scope allowlist (migration 00068). Same class of denial as the two // above — authenticated, insufficient scope — so the same diff --git a/supabase/functions/mcp/org-permissions.ts b/supabase/functions/mcp/org-permissions.ts index 7ae7fbfe0..f17700fc1 100644 --- a/supabase/functions/mcp/org-permissions.ts +++ b/supabase/functions/mcp/org-permissions.ts @@ -17,6 +17,9 @@ /** Custom SQLSTATE raised by memory_write / memory_delete on an org-permission denial. */ export const ORG_PERMISSION_SQLSTATE = 'LK002'; +/** Message prefix `memory_write` raises (LK002) for a personal write from an `orgs_only` key (00116). */ +export const KEY_PERSONAL_DENIED_PREFIX = 'key_personal_denied'; + /** Message prefix every memory/org RPC raises when the caller names a non-existent org slug. */ const UNKNOWN_ORG_PREFIX = 'unknown_org: '; @@ -58,6 +61,15 @@ export function translateOrgPermissionError(err: unknown): unknown { const code = (err as { code?: string } | null | undefined)?.code; const rawMessage = (err as { message?: string } | null | undefined)?.message; + // 00116: an `orgs_only` key writing to the personal store. No role change + // fixes it; naming one of the key's orgs does. + if (code === ORG_PERMISSION_SQLSTATE && rawMessage?.startsWith(KEY_PERSONAL_DENIED_PREFIX)) { + return new OrgPermissionError( + 'This token is limited to selected orgs (orgs_only) and cannot write personal memories. ' + + 'Pass `org` with one of the token\'s orgs, or use a token that may reach your personal memories.', + ); + } + if (code === ORG_PERMISSION_SQLSTATE) { const message = rawMessage ?? 'org_permission_denied: you do not have the required role in this org'; return new OrgPermissionError( diff --git a/supabase/functions/mcp/tools.ts b/supabase/functions/mcp/tools.ts index c0471233a..d59fb9ef6 100644 --- a/supabase/functions/mcp/tools.ts +++ b/supabase/functions/mcp/tools.ts @@ -33,7 +33,7 @@ import { countActiveOrgMemories } from '../_shared/limits/org-memory-count.ts'; import { parseTtl } from './ttl.ts'; import { parseOrigin } from '../_shared/provenance/origin.ts'; import { recordAuditDeferred } from '../_shared/audit/audit.ts'; -import { applyTenantScope, type KeyRestriction } from '../_shared/auth/tenant-scope.ts'; +import { applyTenantScope, reachesPersonal, type KeyRestriction } from '../_shared/auth/tenant-scope.ts'; import { decodeCursor, buildPage } from './cursor.ts'; import { pgArrayLiteral, resolveKindHost, toTagList } from '../_shared/schemas/tags.ts'; import { rankLessons, selectDiverse } from '../_shared/ranking/lesson-rank.ts'; @@ -268,6 +268,7 @@ export async function toolWrite( citingMemoryId: row.id, cited, correlationId: correlationId ?? null, + reachesPersonal: reachesPersonal(keyScoping), }); // `inserted` is an internal audit-classification signal (D4), not part of diff --git a/supabase/functions/memories/handlers/create.ts b/supabase/functions/memories/handlers/create.ts index c7a3d8ba5..6bd1eb4c6 100644 --- a/supabase/functions/memories/handlers/create.ts +++ b/supabase/functions/memories/handlers/create.ts @@ -2,6 +2,7 @@ import type { AuthContext } from '../../_shared/api/auth.ts'; import { actorUserId, auditUserId, keyRestriction } from '../../_shared/api/auth.ts'; import { created, tooManyRequests, badRequest, dryRun, forbidden } from '../../_shared/api/respond.ts'; import { firstDeniedScope } from '../../_shared/api/tenant.ts'; +import { reachesPersonal } from '../../_shared/auth/tenant-scope.ts'; import { DRY_RUN_HEADER, isDryRunHeader } from '../../_shared/limits/dry-run.ts'; import { validateBody } from '../../_shared/api/validate.ts'; import { createTracedClient } from '../../_shared/telemetry/otel.ts'; @@ -210,6 +211,7 @@ export async function handleCreate( citingMemoryId: row.id, cited: body.cited, correlationId: parseCorrelationId(req.headers.get(CORRELATION_HEADER)), + reachesPersonal: reachesPersonal(keyRestriction(auth)), }); // Audit AFTER the write succeeded. Same action/resource/target/metadata diff --git a/supabase/migrations/00116_api_token_orgs_only_tenancy.sql b/supabase/migrations/00116_api_token_orgs_only_tenancy.sql new file mode 100644 index 000000000..9660436fd --- /dev/null +++ b/supabase/migrations/00116_api_token_orgs_only_tenancy.sql @@ -0,0 +1,788 @@ +-- ═════════════════════════════════════════════════════════════════════════ +-- 00116 — API-token tenancy `orgs_only`: selected orgs, and NO personal rows. +-- +-- THE GAP. Every tenancy (00068) could reach the token owner's personal rows: +-- `lorekit_api_token_org_allowed` answered `true` for `org_id IS NULL` before it +-- even looked at the tenancy, on the reasoning that a key never revokes its own +-- owner's memories. That is right for a key the owner keeps — and wrong for a +-- key the owner PUBLISHES. A project that ships a read-only token so its users +-- can read the project's org-owned lore (reaper-mcp's built-in mix knowledge) +-- also hands every user the owner's personal memories in the allowed scopes, +-- including archived ones. +-- +-- THE MODE. `orgs_only` narrows orgs exactly like `selected` (org_ids, ≥ 1, +-- intersected with live membership) and additionally closes the personal +-- branch: reads see no personal row, and a write that would land on a personal +-- row is refused (LK002 key_personal_denied) instead of falling back to +-- personal. The other three tenancies are unchanged. +-- +-- WHERE IT IS ENFORCED. +-- • `lorekit_api_token_org_allowed` — the one predicate every tenancy-aware +-- RPC already calls per row (list, scopes, tags, facets, pivot, activity, +-- relevant, …). No signature changes anywhere: every RPC already threads +-- `p_key_org_access` through. +-- • `memory_write` — its personal destination never consulted the predicate, +-- so it gains an explicit check (the only body change here). +-- • `memory_delete` / `restore_memory` (00071 / 00072) match their own-row and +-- member-org disjuncts WITHOUT the predicate — it only guards the +-- scope-managed one — so each removal/restore WHERE gains it for an +-- `orgs_only` key. Every other tenancy runs the 00071/00072 WHERE unchanged. +-- • `lorekit_api_token_set_scoping` + the table CHECKs accept the new value. +-- The edge applies the same rule to its PostgREST reads (`tenant-scope.ts`), +-- and refuses `orgs_only` the operations that pick rows by owner rather than +-- by tenant (purge, quota, protect, policies, groom, and the owner analytics — +-- read-activity, read-ranking, utility, usage — `account-wide-tools.ts`). +-- +-- UNKNOWN VALUES FAIL CLOSED. The personal branch now answers `true` only for +-- the three tenancies known to allow it, so a value this function does not +-- recognise (a BYOD install, a future migration) reaches nothing. The edge's +-- `normalizeKeyRestriction` maps an unknown value to `orgs_only` with no orgs — +-- the same "nothing" — so an older reader can never widen a newer value. +-- +-- ROLLOUT. Apply this migration and deploy the edge together. Until both are +-- live no key can carry `orgs_only`: the CHECK below is what admits the value, +-- and the dashboard/API only offer it once the edge understands it. +-- ═════════════════════════════════════════════════════════════════════════ + +-- 1. The table CHECKs admit the new value. +alter table api_tokens + drop constraint if exists api_tokens_org_access_valid; +alter table api_tokens + add constraint api_tokens_org_access_valid + check (org_access in ('all', 'personal', 'selected', 'orgs_only')); + +-- `orgs_only`, like `selected`, names its orgs: an `orgs_only` key with no orgs +-- would reach nothing at all, and any other tenancy carrying org ids would be a +-- lie the next reader believes. +alter table api_tokens + drop constraint if exists api_tokens_org_ids_match_access; +alter table api_tokens + add constraint api_tokens_org_ids_match_access + check ((org_access in ('selected', 'orgs_only')) = (cardinality(org_ids) > 0)); + +-- 2. The per-row predicate. +create or replace function lorekit_api_token_org_allowed( + p_org_access text, + p_org_ids uuid[], + p_org_id uuid +) +returns boolean +language sql +immutable +as $$ + select case + -- A personal row (org_id IS NULL) is reachable under the three tenancies + -- that keep the owner's own memories. NOT under `orgs_only` (00116), and not + -- under a value this function does not recognise — that fails closed. A + -- NULL tenancy is the pre-00068 "no key restriction" and stays reachable. + when p_org_id is null + then coalesce(p_org_access, 'all') in ('all', 'personal', 'selected') + when p_org_access = 'all' then true + when p_org_access = 'personal' then false + -- `coalesce(…, false)` is load-bearing: a NULL ELEMENT in p_org_ids makes + -- `= any(…)` evaluate to NULL, and an authorization predicate must answer + -- true or false (see 00068). + when p_org_access in ('selected', 'orgs_only') + then coalesce(p_org_id = any(coalesce(p_org_ids, '{}'::uuid[])), false) + else false + end; +$$; + +comment on function lorekit_api_token_org_allowed(text, uuid[], uuid) is + 'May a key with this tenancy reach a row owned by p_org_id (NULL = personal)? ' + 'orgs_only (00116) reaches selected orgs and no personal row; an unknown ' + 'tenancy reaches nothing. Authoritative over org_scope_bindings auto-routing.'; + +revoke execute on function lorekit_api_token_org_allowed(text, uuid[], uuid) from public, anon; +grant execute on function lorekit_api_token_org_allowed(text, uuid[], uuid) to authenticated, service_role; + +-- 3. `lorekit_api_token_set_scoping` — the dashboard's write path accepts the +-- new value. Body copied from 00068; only the two tenancy re-statements change. + +create or replace function lorekit_api_token_set_scoping( + p_token_id uuid, + p_scopes text[], + p_org_access text, + p_org_ids uuid[] +) +returns table ( + id uuid, + scopes text[], + org_access text, + org_ids uuid[] +) +language plpgsql +volatile +security definer +set search_path = public +as $$ +declare + v_actor uuid := auth.uid(); + v_scopes text[]; + v_org_ids uuid[]; + v_owner uuid; + v_stray uuid; +begin + if v_actor is null then + raise exception 'LK001: authentication required' using errcode = '28000'; + end if; + + -- These used to be `coalesce(p_scopes, '{}')` / `coalesce(p_org_ids, '{}')` in + -- the declare block, which reopened the hole giving the arguments no DEFAULT + -- was meant to close. No DEFAULT stops an OMITTED argument; the coalesce let + -- an EXPLICIT null through and turned it into '{}' — and for `scopes`, '{}' + -- means UNRESTRICTED (decision 1). So `set_scoping(id, null, 'all', '{}')` + -- silently widened the key to every scope, which is exactly the fail-open + -- partial update decision 5 refuses. + -- + -- Refused rather than treated as "leave unchanged": that would make this a + -- partial-update API that has to read and merge the current row, and decision + -- 5 chose "state the full intended state on every call". Same reasoning, and + -- now the same treatment, as the NULL `p_org_access` guard below. + if p_scopes is null then + raise exception 'LK004: scopes must not be null; pass an empty array to un-scope the key' + using errcode = '22023'; + end if; + + if p_org_ids is null then + raise exception 'LK004: org_ids must not be null; pass an empty array for a key with no orgs' + using errcode = '22023'; + end if; + + v_scopes := p_scopes; + v_org_ids := p_org_ids; + + -- Ownership first, and by SELECT rather than by letting the UPDATE match zero + -- rows: "you do not own this key" and "no such key" must be the same answer to + -- the caller (no existence oracle), but they must be a real error rather than + -- a silent no-op that the dashboard would render as success. + select user_id into v_owner from api_tokens where api_tokens.id = p_token_id; + if v_owner is null or v_owner <> v_actor then + raise exception 'LK003: api token not found' using errcode = 'P0002'; + end if; + + -- Re-state the table's CHECKs here so a bad argument comes back as a legible + -- LK004 the dashboard can render, not as a raw constraint-violation string + -- naming an internal constraint. The CHECKs stay the authority — this is the + -- error message, not the gate. + if cardinality(v_scopes) > 50 then + raise exception 'LK004: at most 50 scope patterns per key' using errcode = '22023'; + end if; + + if not lorekit_api_token_scopes_valid(v_scopes) then + raise exception 'LK004: a scope pattern may contain only [a-z0-9._:/-], with an optional trailing "*" directly after a "/" or a "::"' + using errcode = '22023'; + end if; + + if cardinality(v_org_ids) > 50 then + raise exception 'LK004: at most 50 orgs per key' using errcode = '22023'; + end if; + + -- `is null or` is load-bearing, not defensive noise. `null not in (…)` is + -- NULL, `if NULL` takes the false branch, and the `<>` guard below is NULL for + -- the same reason — so a NULL tenancy would fall through BOTH re-statements + -- and reach the UPDATE, where the column's NOT NULL rejects it as a raw 23502 + -- naming an internal constraint. That is the exact unreadable failure these + -- re-statements exist to prevent. All three scoping arguments are now read + -- raw and each carries its own explicit NULL refusal — `p_scopes` and + -- `p_org_ids` above, this one here. + -- + -- Refusing rather than coalescing to 'all' is deliberate: 'all' is the WIDEST + -- tenancy, so defaulting a missing one would let an under-specified call widen + -- the key — the same fail-open shape decision 5 refuses by giving the + -- arguments no DEFAULT in the first place. That is also why the two arguments + -- above are no longer coalesced: their default, '{}', means UNRESTRICTED. + if p_org_access is null or p_org_access not in ('all', 'personal', 'selected', 'orgs_only') then + raise exception 'LK004: org_access must be all, personal, selected or orgs_only' + using errcode = '22023'; + end if; + + if (p_org_access in ('selected', 'orgs_only')) <> (cardinality(v_org_ids) > 0) then + raise exception 'LK004: org_ids must be non-empty for org_access=selected or orgs_only and empty otherwise' + using errcode = '22023'; + end if; + + -- A key cannot be pointed at an org its OWNER cannot reach. Without this the + -- column would be a wish rather than a restriction: the request-time check + -- intersects with the owner's real membership anyway, so an unreachable org id + -- would sit in the row looking like access that was granted. Rejecting it here + -- keeps the row honest and the failure legible. + -- A NULL ELEMENT has to go first, because the membership guard below cannot + -- see it: a NULL org id IS selected into v_stray (`m.org_id = null` is NULL, + -- so `not exists` is true), but then `v_stray is not null` is false and the + -- raise never fires. Two layers of three-valued logic cancelling out into + -- "looks like a member". The CHECK is the authority; this is the legible + -- LK004, same division of labour as the other re-statements above. + if array_position(v_org_ids, null) is not null then + raise exception 'LK004: org_ids must not contain a null element' + using errcode = '22023'; + end if; + + select t.org_id into v_stray + from unnest(v_org_ids) as t(org_id) + where not exists ( + select 1 from lorekit_member_org_ids(v_actor) as m(org_id) + where m.org_id = t.org_id + ) + limit 1; + if v_stray is not null then + raise exception 'LK002: not a member of org %', v_stray using errcode = '42501'; + end if; + + -- Qualified in RETURNING, bare on the left of SET — the shape `memory_write` + -- has used since 00007, which is what keeps the OUT parameter names from + -- colliding with the identically-named columns. + return query + update api_tokens + set scopes = v_scopes, + org_access = p_org_access, + org_ids = v_org_ids + where api_tokens.id = p_token_id + and api_tokens.user_id = v_actor + returning api_tokens.id, api_tokens.scopes, api_tokens.org_access, api_tokens.org_ids; +end; +$$; + +-- 4. `memory_write` — refuses a personal write from a key that may not reach +-- personal rows. Body copied from 00111; the only change is the marked block +-- after the org-routing resolution. Signature and RETURNS TABLE unchanged. + +create or replace function memory_write( + p_user_id uuid, + p_scope text, + p_key text, + p_value text, + p_tags text[] default '{}', + p_source_agent text default null, + p_trigger text default null, + p_created_at timestamptz default null, + p_org_slug text default null, + p_ttl_seconds integer default null, + p_clear_ttl boolean default false, + p_origin_repo text default null, + p_origin_branch text default null, + p_origin_commit text default null, + p_origin_pr integer default null, + p_kind text default null, + p_host text default null, + p_key_scopes text[] default '{}', + p_key_org_access text default 'all', + p_key_org_ids uuid[] default '{}' +) +returns table ( + id uuid, + created_at timestamptz, + inserted boolean, + org_routed boolean, + binding_org_slug text, + expires_at timestamptz, + scope text, + key text, + value text, + tags text[], + source_agent text, + trigger text, + updated_at timestamptz, + archived_at timestamptz, + origin_repo text, + origin_branch text, + origin_commit text, + origin_pr integer, + kind text, + host text, + seen_count integer +) +language plpgsql +security definer +set search_path = public +as $$ +-- See 00075's comment for why this directive is required (the OUT columns +-- `scope`/`key` collide with the bare column names in the `on conflict` +-- arbiter lists below). +#variable_conflict use_column +declare + v_org_id uuid; + v_binding_org uuid; + v_binding_slug text; + v_expires_at timestamptz; + v_ttl_action text := 'keep'; +begin + if not lorekit_api_token_scope_allowed(p_key_scopes, p_scope) then + raise exception using errcode = 'LK002', + message = format('key_scope_denied: scope=%s', p_scope); + end if; + + if p_clear_ttl then + v_ttl_action := 'clear'; + elsif p_ttl_seconds is not null then + if p_ttl_seconds < 1 or p_ttl_seconds > 31536000 then + raise exception using errcode = 'P0001', + message = format('ttl_seconds must be between 1 and 31536000, got %s', p_ttl_seconds); + end if; + v_expires_at := now() + (p_ttl_seconds * interval '1 second'); + v_ttl_action := 'set'; + end if; + + if p_org_slug is not null then + select o.id into v_org_id from orgs o where o.slug = p_org_slug and o.deleted_at is null; + if v_org_id is null then + raise exception using errcode = 'P0001', message = format('unknown_org: %s', p_org_slug); + end if; + if not lorekit_org_can(p_user_id, v_org_id, 'write') then + raise exception using errcode = 'LK002', message = format('org_permission_denied: org=%s', p_org_slug); + end if; + if not lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, v_org_id) then + raise exception using errcode = 'LK002', + message = format('key_org_denied: org=%s', p_org_slug); + end if; + else + -- Wildcard-aware, most-specific-wins resolution. A binding matches + -- p_scope either by exact string equality, or — when it ends in '*' — as + -- an owner-wildcard prefix (the same LIKE-escaping `lorekit_api_token_ + -- scope_allowed` (00068) uses, since `%` and `\` cannot occur in a + -- pattern that already passed the grammar gate above). Among matches: + -- exact (is-wildcard = false) always sorts before any wildcard; among + -- wildcards, the LONGER literal prefix (length desc) sorts first as the + -- more specific match; the trailing `scope asc` is a determinism + -- safeguard for a tie that provably cannot occur (see the header + -- comment), not a reachable precedence branch. + select b.org_id, o.slug into v_binding_org, v_binding_slug + from org_scope_bindings b + join orgs o on o.id = b.org_id + where o.deleted_at is null + and ( + b.scope = p_scope + or ( + right(b.scope, 1) = '*' + and p_scope like replace(left(b.scope, -1), '_', '\_') || '%' + ) + ) + order by (right(b.scope, 1) = '*') asc, length(b.scope) desc, b.scope asc + limit 1; + + if v_binding_org is not null + and p_user_id is not null + and lorekit_org_can(p_user_id, v_binding_org, 'write') + and lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, v_binding_org) then + v_org_id := v_binding_org; + end if; + end if; + + -- 00116: a PERSONAL write is gated by the key's tenancy too. Every branch + -- above either picked an org (and checked it) or left v_org_id NULL, which + -- means "write a personal row" — the one destination no tenancy used to gate, + -- because every tenancy could reach personal rows. `orgs_only` cannot, so + -- refuse instead of silently falling back to personal: a token that was + -- scoped to publish org lore must never grow its owner a personal row. + if v_org_id is null + and not lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, null) then + raise exception using errcode = 'LK002', + message = format('key_personal_denied: this token may not write personal memories (scope=%s)', p_scope); + end if; + + if v_org_id is not null then + return query + insert into memories ( + user_id, org_id, scope, key, value, tags, source_agent, trigger, + created_at, updated_at, created_by, updated_by, expires_at, + origin_repo, origin_branch, origin_commit, origin_pr, kind, host, seen_count + ) + values ( + null, v_org_id, p_scope, p_key, p_value, p_tags, p_source_agent, p_trigger, + coalesce(p_created_at, now()), coalesce(p_created_at, now()), + p_user_id, p_user_id, v_expires_at, + p_origin_repo, p_origin_branch, p_origin_commit, p_origin_pr, p_kind, p_host, 1 + ) + on conflict (org_id, scope, key) where org_id is not null and archived_at is null + do update set + value = excluded.value, + tags = excluded.tags, + source_agent = excluded.source_agent, + trigger = excluded.trigger, + updated_at = now(), + updated_by = p_user_id, + seen_count = memories.seen_count + 1, + origin_repo = coalesce(excluded.origin_repo, memories.origin_repo), + origin_branch = coalesce(excluded.origin_branch, memories.origin_branch), + origin_commit = coalesce(excluded.origin_commit, memories.origin_commit), + origin_pr = coalesce(excluded.origin_pr, memories.origin_pr), + kind = coalesce(excluded.kind, memories.kind), + host = coalesce(excluded.host, memories.host), + expires_at = case v_ttl_action + when 'clear' then null + when 'set' then v_expires_at + else memories.expires_at + end + returning + memories.id, memories.created_at, (xmax::text = '0') as inserted, + true as org_routed, v_binding_slug as binding_org_slug, memories.expires_at, + memories.scope, memories.key, memories.value, memories.tags, + memories.source_agent, memories.trigger, memories.updated_at, memories.archived_at, + memories.origin_repo, memories.origin_branch, memories.origin_commit, memories.origin_pr, + memories.kind, memories.host, memories.seen_count; + + elsif p_user_id is null then + return query + insert into memories ( + user_id, scope, key, value, tags, source_agent, trigger, + created_at, updated_at, created_by, updated_by, expires_at, + origin_repo, origin_branch, origin_commit, origin_pr, kind, host, seen_count + ) + values ( + null, p_scope, p_key, p_value, p_tags, p_source_agent, p_trigger, + coalesce(p_created_at, now()), coalesce(p_created_at, now()), + null, null, v_expires_at, + p_origin_repo, p_origin_branch, p_origin_commit, p_origin_pr, p_kind, p_host, 1 + ) + on conflict (scope, key) where org_id is null and user_id is null and archived_at is null + do update set + value = excluded.value, + tags = excluded.tags, + source_agent = excluded.source_agent, + trigger = excluded.trigger, + updated_at = now(), + seen_count = memories.seen_count + 1, + origin_repo = coalesce(excluded.origin_repo, memories.origin_repo), + origin_branch = coalesce(excluded.origin_branch, memories.origin_branch), + origin_commit = coalesce(excluded.origin_commit, memories.origin_commit), + origin_pr = coalesce(excluded.origin_pr, memories.origin_pr), + kind = coalesce(excluded.kind, memories.kind), + host = coalesce(excluded.host, memories.host), + expires_at = case v_ttl_action + when 'clear' then null + when 'set' then v_expires_at + else memories.expires_at + end + returning + memories.id, memories.created_at, (xmax::text = '0') as inserted, + false as org_routed, v_binding_slug as binding_org_slug, memories.expires_at, + memories.scope, memories.key, memories.value, memories.tags, + memories.source_agent, memories.trigger, memories.updated_at, memories.archived_at, + memories.origin_repo, memories.origin_branch, memories.origin_commit, memories.origin_pr, + memories.kind, memories.host, memories.seen_count; + + else + return query + insert into memories ( + user_id, scope, key, value, tags, source_agent, trigger, + created_at, updated_at, created_by, updated_by, expires_at, + origin_repo, origin_branch, origin_commit, origin_pr, kind, host, seen_count + ) + values ( + p_user_id, p_scope, p_key, p_value, p_tags, p_source_agent, p_trigger, + coalesce(p_created_at, now()), coalesce(p_created_at, now()), + p_user_id, p_user_id, v_expires_at, + p_origin_repo, p_origin_branch, p_origin_commit, p_origin_pr, p_kind, p_host, 1 + ) + on conflict (user_id, scope, key) where org_id is null and user_id is not null and archived_at is null + do update set + value = excluded.value, + tags = excluded.tags, + source_agent = excluded.source_agent, + trigger = excluded.trigger, + updated_at = now(), + updated_by = p_user_id, + seen_count = memories.seen_count + 1, + origin_repo = coalesce(excluded.origin_repo, memories.origin_repo), + origin_branch = coalesce(excluded.origin_branch, memories.origin_branch), + origin_commit = coalesce(excluded.origin_commit, memories.origin_commit), + origin_pr = coalesce(excluded.origin_pr, memories.origin_pr), + kind = coalesce(excluded.kind, memories.kind), + host = coalesce(excluded.host, memories.host), + expires_at = case v_ttl_action + when 'clear' then null + when 'set' then v_expires_at + else memories.expires_at + end + returning + memories.id, memories.created_at, (xmax::text = '0') as inserted, + false as org_routed, v_binding_slug as binding_org_slug, memories.expires_at, + memories.scope, memories.key, memories.value, memories.tags, + memories.source_agent, memories.trigger, memories.updated_at, memories.archived_at, + memories.origin_repo, memories.origin_branch, memories.origin_commit, memories.origin_pr, + memories.kind, memories.host, memories.seen_count; + end if; +end; +$$; + +grant execute on function memory_write( + uuid, text, text, text, text[], text, text, timestamptz, text, + integer, boolean, text, text, text, integer, text, text, text[], text, uuid[] +) to anon, authenticated, service_role; + +-- 6. Archive / hard-delete and restore by (scope, key): 00071's `memory_delete` +-- and 00072's `restore_memory`, verbatim except the marked `orgs_only` clause. +create or replace function memory_delete( + p_user_id uuid, + p_org_slug text default null, + p_scope text default null, + p_key text default null, + p_force boolean default false, + p_key_scopes text[] default '{}', + p_key_org_access text default 'all', + p_key_org_ids uuid[] default '{}' +) +returns table (deleted boolean, archived boolean, existed boolean) +language plpgsql +security definer +set search_path = public +as $$ +declare + v_org_id uuid; + v_count integer; + v_existed boolean; + v_actor uuid := case + when auth.role() = 'service_role' then coalesce(p_user_id, auth.uid()) + else auth.uid() + end; + -- A scoped key on the trusted service-role connection manages the whole + -- allowed scope, not just its owner's rows. See the header for why BOTH + -- conjuncts are load-bearing (service_role gate closes the p_key_scopes IDOR; + -- the length check keeps an unscoped key own-rows-only). + v_scope_managed boolean := ( + auth.role() = 'service_role' + and array_length(p_key_scopes, 1) is not null + ); + -- The SERVICE tier — the bare `SUPABASE_SERVICE_ROLE_KEY` (CI, the smoke + -- suite, an operator script). It authenticates as no user and no key, so both + -- surfaces resolve `p_user_id => null` for it (REST `auth.userId` is unset for + -- `type:'service'`; MCP's `toolUserId` returns null for anything that is not + -- an api_key). `auth.uid()` is null on that connection too, so v_actor is NULL + -- and every `m.user_id = v_actor` disjunct below is NULL — before this the + -- personal branch matched ZERO rows for the tier that is meant to reach all of + -- them, and the edge reported the row it could see but not touch as a 403. + -- + -- Own-rows-only is the API-KEY rule (00046), and null actor is precisely "no + -- key, no user": the tier that holds the service-role secret can write the + -- table directly, so pinning it here protected nothing. This restores what + -- the raw-query paths have always done — `if (userId) …eq('user_id', userId)`, + -- i.e. no owner filter when there is no user (mcp-core/src/tools/delete.ts). + -- + -- It cannot widen anyone else: an api_key ALWAYS carries `p_user_id` (the + -- `api_tokens.user_id` it was matched by), and a non-service-role caller fails + -- the role conjunct, so `auth.uid()` keeps pinning it — the 00046 IDOR guard + -- is untouched on both. + v_service_tier boolean := ( + auth.role() = 'service_role' + and p_user_id is null + ); +begin + -- The scope allowlist, before either branch. LK002 maps to a 403 on both + -- surfaces, so no second mapping is needed here. + if not lorekit_api_token_scope_allowed(p_key_scopes, p_scope) then + raise exception using errcode = 'LK002', + message = format('key_scope_denied: scope=%s', p_scope); + end if; + + if p_org_slug is not null then + select o.id into v_org_id from orgs o where o.slug = p_org_slug; + if v_org_id is null then + raise exception using + errcode = 'P0001', + message = format('unknown_org: %s', p_org_slug); + end if; + + -- The tenancy half, before the capability gates so the denial names the + -- key, not its owner's role. + if not lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, v_org_id) then + raise exception using errcode = 'LK002', + message = format('key_org_denied: org=%s', p_org_slug); + end if; + + v_existed := exists( + select 1 from memories m + where m.org_id = v_org_id and m.scope = p_scope and m.key = p_key + ); + + if p_force then + if not lorekit_org_can(v_actor, v_org_id, 'hard_delete') then + raise exception using + errcode = 'LK002', + message = format('org_permission_denied: org=%s capability=hard_delete', p_org_slug); + end if; + + delete from memories + where org_id = v_org_id and scope = p_scope and key = p_key; + get diagnostics v_count = row_count; + + return query select (v_count > 0), false, v_existed; + else + if not lorekit_org_can(v_actor, v_org_id, 'archive') then + raise exception using + errcode = 'LK002', + message = format('org_permission_denied: org=%s capability=archive', p_org_slug); + end if; + + update memories + set archived_at = now() + where org_id = v_org_id and scope = p_scope and key = p_key and archived_at is null; + get diagnostics v_count = row_count; + + return query select false, (v_count > 0), v_existed; + end if; + else + -- Personal / scope-authorized branch. + -- + -- `existed` is the caller's VISIBILITY of (scope,key), deliberately BROADER + -- than the removal WHERE below so that `existed=true, removed=false` is a + -- meaningful "present but not removed by this call" (forbidden / already + -- archived) rather than collapsing into not_found. A service-role caller + -- (the edge, which can already read the whole scope via memory.list) sees + -- any row; a non-service-role caller sees only its own / member-org rows, + -- so it can never enumerate another user's memories. + v_existed := case + when auth.role() = 'service_role' then exists( + select 1 from memories m where m.scope = p_scope and m.key = p_key + ) + else exists( + select 1 from memories m + where m.scope = p_scope and m.key = p_key + and ( m.user_id = v_actor + or m.org_id in (select lorekit_member_org_ids(v_actor)) ) + ) + end; + + if p_force then + delete from memories m + where m.scope = p_scope and m.key = p_key + and ( v_service_tier + or m.user_id = v_actor + or m.org_id in (select lorekit_member_org_ids(v_actor)) + or ( v_scope_managed + and lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, m.org_id) ) ) + -- 00116: an orgs_only key never reaches a personal row (or an org it did not select). + and ( coalesce(p_key_org_access, 'all') <> 'orgs_only' + or lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, m.org_id) ); + get diagnostics v_count = row_count; + + return query select (v_count > 0), false, (v_existed or v_count > 0); + else + update memories m + set archived_at = now() + where m.scope = p_scope and m.key = p_key and m.archived_at is null + and ( v_service_tier + or m.user_id = v_actor + or m.org_id in (select lorekit_member_org_ids(v_actor)) + or ( v_scope_managed + and lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, m.org_id) ) ) + -- 00116: an orgs_only key never reaches a personal row (or an org it did not select). + and ( coalesce(p_key_org_access, 'all') <> 'orgs_only' + or lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, m.org_id) ); + get diagnostics v_count = row_count; + + return query select false, (v_count > 0), (v_existed or v_count > 0); + end if; + end if; +end; +$$; + +-- 00046/00068's revoke is carried over verbatim against the (unchanged) +-- signature: 00020 granted `anon` EXPLICITLY, and `revoke … from public` does +-- not remove a per-role grant, so anon must be named again or the re-created +-- function silently comes back reachable. +revoke execute on function memory_delete(uuid, text, text, text, boolean, text[], text, uuid[]) + from public, anon; +grant execute on function memory_delete(uuid, text, text, text, boolean, text[], text, uuid[]) + to authenticated, service_role; + +comment on function memory_delete(uuid, text, text, text, boolean, text[], text, uuid[]) is + 'Archives (p_force=false) or hard-deletes (p_force=true) a memory and reports + whether a row for (scope,key) the caller may see existed. A key with a + non-empty scope allowlist reaching this function on the service-role + connection manages every writer''s row within the scopes/orgs it is scoped + to; an unscoped key, and any non-service-role caller, stay pinned to their + own (and member-org) rows, preserving the 00046 actor guard. The service tier + (service-role connection, no p_user_id — no user and no key) is unpinned: it + has no actor to be pinned to. Org removals remain gated on the archive / + hard_delete capability.'; + +create or replace function restore_memory( + p_user_id uuid, + p_scope text default null, + p_key text default null, + p_key_scopes text[] default '{}', + p_key_org_access text default 'all', + p_key_org_ids uuid[] default '{}' +) +returns table (restored boolean, existed boolean) +language plpgsql +security definer +set search_path = public +as $$ +declare + v_count integer; + v_existed boolean; + v_actor uuid := case + when auth.role() = 'service_role' then coalesce(p_user_id, auth.uid()) + else auth.uid() + end; + -- Same gate as memory_delete (00071): a scoped key on the trusted + -- service-role connection manages the whole allowlist; an unscoped key and + -- any non-service-role caller stay own-rows-only (auth.uid() pins them), + -- preserving the 00046 actor guard against a request-supplied p_key_scopes. + v_scope_managed boolean := ( + auth.role() = 'service_role' + and array_length(p_key_scopes, 1) is not null + ); + -- The SERVICE tier, exactly as in 00071's `memory_delete` — see that header + -- for the full argument. Short form: the bare service-role key authenticates + -- as no user and no key, so `p_user_id` is null AND `auth.uid()` is null, + -- leaving v_actor NULL and every ownership disjunct below NULL. Restore has to + -- answer this the same way removal does, or a row the service tier can archive + -- is a row it cannot un-archive. + v_service_tier boolean := ( + auth.role() = 'service_role' + and p_user_id is null + ); +begin + if not lorekit_api_token_scope_allowed(p_key_scopes, p_scope) then + raise exception using errcode = 'LK002', + message = format('key_scope_denied: scope=%s', p_scope); + end if; + + -- Visibility of an ARCHIVED (restorable) row, broader than the restore WHERE + -- for a service-role caller so `existed=true, restored=false` is a meaningful + -- forbidden signal; a non-service-role caller never sees another user's row. + v_existed := case + when auth.role() = 'service_role' then exists( + select 1 from memories m + where m.scope = p_scope and m.key = p_key and m.archived_at is not null + ) + else exists( + select 1 from memories m + where m.scope = p_scope and m.key = p_key and m.archived_at is not null + and ( m.user_id = v_actor + or m.org_id in (select lorekit_member_org_ids(v_actor)) ) + ) + end; + + update memories m + set archived_at = null + where m.scope = p_scope and m.key = p_key and m.archived_at is not null + and ( v_service_tier + or m.user_id = v_actor + or m.org_id in (select lorekit_member_org_ids(v_actor)) + or ( v_scope_managed + and lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, m.org_id) ) ) + -- 00116: an orgs_only key never reaches a personal row (or an org it did not select). + and ( coalesce(p_key_org_access, 'all') <> 'orgs_only' + or lorekit_api_token_org_allowed(p_key_org_access, p_key_org_ids, m.org_id) ); + get diagnostics v_count = row_count; + + return query select (v_count > 0), (v_existed or v_count > 0); +end; +$$; + +revoke execute on function restore_memory(uuid, text, text, text[], text, uuid[]) + from public, anon; +grant execute on function restore_memory(uuid, text, text, text[], text, uuid[]) + to authenticated, service_role; + +comment on function restore_memory(uuid, text, text, text[], text, uuid[]) is + 'Un-archives a memory and reports whether a restorable (archived) row the + caller may see existed. A key with a non-empty scope allowlist reaching this + on the service-role connection restores any writer''s row within its + scopes/orgs; an unscoped key and any non-service-role caller stay pinned to + their own (and member-org) rows — the 00046/00071 actor guard, applied to + restore. The service tier (service-role connection, no p_user_id — no user and + no key) is unpinned: it has no actor to be pinned to.'; diff --git a/supabase/tests/migrations.test.sql b/supabase/tests/migrations.test.sql index 24ee52545..8caf405ff 100644 --- a/supabase/tests/migrations.test.sql +++ b/supabase/tests/migrations.test.sql @@ -11247,6 +11247,172 @@ begin end; $$; +-- ── 116. API-token tenancy `orgs_only` — selected orgs, no personal rows (00116) ─ +-- +-- AC-1: lorekit_api_token_org_allowed — orgs_only reaches a selected org, not +-- an unselected one, and NOT a personal row; all/personal/selected/NULL +-- still reach personal rows; an unknown tenancy reaches nothing. +-- AC-2: the CHECKs admit orgs_only with org ids and refuse it without. +-- AC-3: memory_write refuses a personal write from an orgs_only key +-- (LK002 key_personal_denied) instead of falling back to personal, and +-- writes nothing; an org write through the same key still lands; a +-- `selected` key's personal write is unchanged. +-- AC-4: lorekit_memory_list hides the owner's personal rows from orgs_only and +-- still shows them to selected. +-- AC-5: lorekit_api_token_set_scoping accepts orgs_only with org ids, refuses +-- it without. +-- AC-6: memory_delete / restore_memory by (scope, key) never archive, delete or +-- restore a personal row for orgs_only, still act on its selected org, +-- and are unchanged for a selected key. +do $$ +declare + v_owner uuid := '00000000-0000-0000-0000-0000000116a1'; + v_other uuid := '00000000-0000-0000-0000-0000000116a2'; + v_org uuid; + v_org_b uuid; + v_token uuid; + v_failed boolean; + v_msg text; + v_n bigint; + v_access text; +begin + insert into auth.users (instance_id, id, aud, role, email, created_at, updated_at) + values + ('00000000-0000-0000-0000-000000000000', v_owner, 'authenticated', 'authenticated', + 'lk-mig-116-owner@test.local', now(), now()), + ('00000000-0000-0000-0000-000000000000', v_other, 'authenticated', 'authenticated', + 'lk-mig-116-other@test.local', now(), now()); + insert into orgs (slug, name, created_by) values ('orgs-only-116-a', 'A', v_owner) returning id into v_org; + insert into orgs (slug, name, created_by) values ('orgs-only-116-b', 'B', v_other) returning id into v_org_b; + insert into org_members (org_id, user_id, role) values (v_org, v_owner, 'owner'); + insert into org_members (org_id, user_id, role) values (v_org_b, v_other, 'owner'); + + -- ── AC-1: the predicate ──────────────────────────────────────────────── + assert not lorekit_api_token_org_allowed('orgs_only', array[v_org], null), + '116 AC-1a: orgs_only must NOT reach a personal row'; + assert lorekit_api_token_org_allowed('orgs_only', array[v_org], v_org), + '116 AC-1b: orgs_only must reach a selected org'; + assert not lorekit_api_token_org_allowed('orgs_only', array[v_org], v_org_b), + '116 AC-1c: orgs_only must not reach an unselected org'; + assert lorekit_api_token_org_allowed('all', '{}'::uuid[], null) + and lorekit_api_token_org_allowed('personal', '{}'::uuid[], null) + and lorekit_api_token_org_allowed('selected', array[v_org], null) + and lorekit_api_token_org_allowed(null, '{}'::uuid[], null), + '116 AC-1d: all/personal/selected/NULL must still reach personal rows'; + assert lorekit_api_token_org_allowed('selected', array[v_org], v_org) + and not lorekit_api_token_org_allowed('selected', array[v_org], v_org_b) + and not lorekit_api_token_org_allowed('personal', '{}'::uuid[], v_org) + and lorekit_api_token_org_allowed('all', '{}'::uuid[], v_org), + '116 AC-1e: the org half of the other tenancies is unchanged'; + assert not lorekit_api_token_org_allowed('bogus', '{}'::uuid[], null) + and not lorekit_api_token_org_allowed('bogus', array[v_org], v_org), + '116 AC-1f: an unknown tenancy must reach nothing (fail closed)'; + + -- ── AC-2: the CHECKs ─────────────────────────────────────────────────── + insert into api_tokens (user_id, name, token_prefix, token_hash) + values (v_owner, '116 fixture', 'lk_ro_aaaa...', 'hash-116-orgs-only-0001') + returning id into v_token; + update api_tokens set org_access = 'orgs_only', org_ids = array[v_org] where id = v_token; + select org_access into v_access from api_tokens where id = v_token; + assert v_access = 'orgs_only', '116 AC-2a: orgs_only with an org id must be accepted'; + v_failed := false; + begin + update api_tokens set org_access = 'orgs_only', org_ids = '{}'::uuid[] where id = v_token; + exception when check_violation then v_failed := true; + end; + assert v_failed, '116 AC-2b: orgs_only without org ids must be refused'; + + -- ── AC-3: memory_write ───────────────────────────────────────────────── + v_failed := false; + begin + perform memory_write(p_user_id => v_owner, p_scope => 'global', p_key => '116-personal', + p_value => 'v', p_key_org_access => 'orgs_only', p_key_org_ids => array[v_org]); + exception when sqlstate 'LK002' then + get stacked diagnostics v_msg = message_text; + v_failed := v_msg like 'key_personal_denied%'; + end; + assert v_failed, '116 AC-3a: a personal write from orgs_only must raise LK002 key_personal_denied'; + assert not exists (select 1 from memories where key = '116-personal'), + '116 AC-3b: the refused personal write must write nothing'; + + perform memory_write(p_user_id => v_owner, p_scope => 'global', p_key => '116-org', + p_value => 'v', p_org_slug => 'orgs-only-116-a', + p_key_org_access => 'orgs_only', p_key_org_ids => array[v_org]); + assert exists (select 1 from memories where key = '116-org' and org_id = v_org), + '116 AC-3c: an org write from orgs_only must still land'; + + perform memory_write(p_user_id => v_owner, p_scope => 'global', p_key => '116-selected-personal', + p_value => 'v', p_key_org_access => 'selected', p_key_org_ids => array[v_org]); + assert exists (select 1 from memories where key = '116-selected-personal' and user_id = v_owner and org_id is null), + '116 AC-3d: a selected key must still write personal rows'; + + -- ── AC-4: lorekit_memory_list ────────────────────────────────────────── + set local role service_role; + perform set_config('request.jwt.claims', + format('{"sub":"%s","role":"service_role"}', v_owner), true); + select count(*) into v_n from lorekit_memory_list( + p_user_id => v_owner, p_scope => 'global', p_limit => 100, + p_key_org_access => 'orgs_only', p_key_org_ids => array[v_org]) + where key like '116-%'; + assert v_n = 1, format('116 AC-4a: orgs_only must list only the org row, got %s', v_n); + select count(*) into v_n from lorekit_memory_list( + p_user_id => v_owner, p_scope => 'global', p_limit => 100, + p_key_org_access => 'selected', p_key_org_ids => array[v_org]) + where key like '116-%'; + assert v_n = 2, format('116 AC-4b: selected must list the org row and the personal row, got %s', v_n); + + -- ── AC-6: memory_delete / restore_memory by (scope, key) ─────────────── + -- Scoped like the published-token case (a non-empty allowlist), and unscoped. + perform memory_delete(p_user_id => v_owner, p_scope => 'global', p_key => '116-selected-personal', + p_key_scopes => array['global'], p_key_org_access => 'orgs_only', p_key_org_ids => array[v_org]); + perform memory_delete(p_user_id => v_owner, p_scope => 'global', p_key => '116-selected-personal', p_force => true, + p_key_org_access => 'orgs_only', p_key_org_ids => array[v_org]); + assert exists (select 1 from memories where key = '116-selected-personal' and archived_at is null), + '116 AC-6a: orgs_only must not archive or hard-delete a personal row'; + + perform memory_delete(p_user_id => v_owner, p_scope => 'global', p_key => '116-org', + p_key_scopes => array['global'], p_key_org_access => 'orgs_only', p_key_org_ids => array[v_org]); + assert exists (select 1 from memories where key = '116-org' and archived_at is not null), + '116 AC-6b: orgs_only must still archive a row in its selected org'; + perform restore_memory(p_user_id => v_owner, p_scope => 'global', p_key => '116-org', + p_key_scopes => array['global'], p_key_org_access => 'orgs_only', p_key_org_ids => array[v_org]); + assert exists (select 1 from memories where key = '116-org' and archived_at is null), + '116 AC-6c: orgs_only must still restore a row in its selected org'; + + update memories set archived_at = now() where key = '116-selected-personal'; + perform restore_memory(p_user_id => v_owner, p_scope => 'global', p_key => '116-selected-personal', + p_key_scopes => array['global'], p_key_org_access => 'orgs_only', p_key_org_ids => array[v_org]); + assert exists (select 1 from memories where key = '116-selected-personal' and archived_at is not null), + '116 AC-6d: orgs_only must not restore a personal row'; + perform restore_memory(p_user_id => v_owner, p_scope => 'global', p_key => '116-selected-personal', + p_key_scopes => array['global'], p_key_org_access => 'selected', p_key_org_ids => array[v_org]); + assert exists (select 1 from memories where key = '116-selected-personal' and archived_at is null), + '116 AC-6e: a selected key must still restore its personal row'; + perform memory_delete(p_user_id => v_owner, p_scope => 'global', p_key => '116-selected-personal', + p_key_org_access => 'selected', p_key_org_ids => array[v_org]); + assert exists (select 1 from memories where key = '116-selected-personal' and archived_at is not null), + '116 AC-6f: a selected key must still archive its personal row'; + reset role; + + -- ── AC-5: set_scoping ────────────────────────────────────────────────── + set local role authenticated; + perform set_config('request.jwt.claims', + format('{"sub":"%s","role":"authenticated"}', v_owner), true); + perform lorekit_api_token_set_scoping(v_token, array['project::reaper-mcp'], 'orgs_only', array[v_org]); + v_failed := false; + begin + perform lorekit_api_token_set_scoping(v_token, '{}'::text[], 'orgs_only', '{}'::uuid[]); + exception when invalid_parameter_value then v_failed := true; + end; + assert v_failed, '116 AC-5b: set_scoping must refuse orgs_only without org ids (LK004)'; + reset role; + select org_access into v_access from api_tokens where id = v_token; + assert v_access = 'orgs_only', '116 AC-5a: set_scoping must accept orgs_only with org ids'; + + perform set_config('request.jwt.claims', '', true); +end; +$$; + rollback; \echo 'migrations.test.sql: all assertions passed'