Skip to content

feat(tokens): add orgs_only tenancy so a token can exclude its owner's personal memories - #729

Merged
mthines merged 14 commits into
mainfrom
feat/token-orgs-only-tenancy
Oct 27, 2026
Merged

mthines merged 14 commits into
mainfrom
feat/token-orgs-only-tenancy

Conversation

@mthines

@mthines mthines commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

Every API-token tenancy could reach the owner's personal memories, so a token you hand out exposes everything personal in its scopes. One example is a public lk_ro_ token bundled in a tool so its users can read one org's curated lore, which is the reaper-mcp case. selected narrows orgs but never personal rows.

This adds org_access = 'orgs_only'. It works like selected (at least one org, intersected with current membership), but the token reaches none of its owner's personal rows on any MCP or REST path.

  • Reads: lorekit_api_token_org_allowed(…, org_id = null) is now false for orgs_only. Every tenancy-aware RPC already calls it per row, so no signatures change. On the edge, tenant-scope.ts (both copies) adds no personal branch, and the "no visible orgs" fallback now matches nothing.
  • Writes: memory_write refuses a personal destination with LK002 key_personal_denied instead of falling back to personal. Delete, archive and restore by scope+key (memory_delete / restore_memory, re-created from 00071/00072) get a clause that applies only to orgs_only. By-id REST writes only ever match the caller's own (personal) rows, so for orgs_only they match nothing (404). The write's cited refs are not recorded, because citations only ever credit personal lessons.
  • Refused outright (MCP -32003, REST 403): operations that only act on the owner's own rows and have no org-scoped form. These are purge, quota, protect, policy.*, groom.*, the owner analytics (read-activity, read-ranking, utility, usage), and all org and membership management (org.*, member.*). Those ignore org_ids and would list every org, member handle and invitee email.
  • Unknown values fail closed: normalizeKeyRestriction used to map an unknown org_access to personal. It now maps it to no access, so an older reader can never widen a newer value.
  • Dashboard: offers a "Selected orgs only (no personal memories)" option.
  • Docs: docs/api-tokens.md and a docs/decisions.md entry.

all, personal and selected behave exactly as before. The SQL assertions in §116 cover both the new tenancy and the unchanged ones.

Rollout: apply migration 00116 and deploy the edge together. No key can carry orgs_only until the CHECK admits it.

Out of scope, already broken on main:

  • selected and personal keys can still archive member-org rows outside their allowlist through delete/restore by scope+key.
  • Delete/restore by key still answers 403 rather than 404 for a personal key that exists. This confirms the key exists, though it reveals no content.

Verified locally:

  • Sealed checks 10/10.
  • mcp-core 2061, schemas 322 and web lib 1621 tests pass.
  • tsc and the deno check are clean.
  • The full migrations.test.sql passes on PGlite (120 sections). Removing a guard fails its assertion.
  • Local branch review converged (iteration 1 found 2 gaps, both fixed; iteration 2 PASS).
🧪 UI verification spec — run against the preview deployment

Target: preview
Format: intent

Spec 1: Create a token limited to selected orgs, with no personal memories

Changed: the tenancy choice "Selected orgs only (no personal memories)" in the New token form's Scoping panel
Start: /settings/api-keys
Steps:

  1. Open the New token form and expand the Scoping panel.
  2. [must-follow] Choose "Selected orgs only (no personal memories)".
  3. Pick one organisation in the Organisations picker.
  4. Create the token.
    Expected:
  • The "Selected orgs only (no personal memories)" option shows as pressed (aria-pressed="true").
  • The Organisations picker appears once that option is chosen, as it does for "Specific orgs".
  • Before an org is picked, the form does not allow creating the token.
  • The new token row shows a "1 org only" badge, and its title text contains "no personal memories".
    Hints:
  • The tenancy options are buttons with aria-pressed; the picker is labelled "Organisations".

Spec 2: Out of bounds — switching away drops the picked orgs

Changed: the same Scoping panel
Start: /settings/api-keys
Steps:

  1. Open the New token form, expand Scoping, choose "Selected orgs only (no personal memories)" and pick one org.
  2. [must-follow] Choose "Personal only", then choose "Selected orgs only (no personal memories)" again.
    Expected:
  • After choosing "Personal only" the Organisations picker is hidden.
  • After choosing "Selected orgs only" again, the picker shows no org selected ("Pick at least one"), and the form does not allow creating the token until one is picked.

…_write

Migration 00116 lets org_access be 'orgs_only': lorekit_api_token_org_allowed
returns false for a personal row under it, so every tenancy-aware RPC hides the
owner's personal memories from such a key without a signature change.
memory_write refuses a personal destination (LK002 key_personal_denied) instead
of falling back to personal. set_scoping requires >= 1 org, like 'selected'.
tenant-scope (both copies) adds no personal branch for orgs_only and matches
nothing when no org is visible; unknown org_access now fails closed to no
access instead of 'personal'. Owner-row tools (purge, quota, protect, policy.*,
groom.*) are refused for orgs_only on MCP (-32003) and REST (403), and by-id
REST writes are narrowed to the key's orgs. Schemas accept orgs_only.
…ore by key

memory_delete (00071) and restore_memory (00072) matched own-row and member-org
rows without the tenancy predicate, so an orgs_only key could archive, delete
or restore its owner's personal memories by (scope, key). 00116 re-creates both
with an orgs_only-only clause; other tenancies keep the 00071/00072 WHERE.
read-activity, read-ranking, utility, usage and usage-runs roll up the owner's
own rows with no tenancy parameter, so they listed personal keys to an
orgs_only token. They join OWNER_ROW_TOOLS and are refused (-32003 / 403).
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

The dashboard preview for this PR — redeployed on each push that changes the web app.

Project Deployment Actions Updated
lorekit Ready Ready Preview Oct 27, 2026 11:13am UTC

Preview always points at this PR's latest commit; Deployment is the immutable build of cad45e3. · Comment /web-preview to force a redeploy. · Workflow logs

@mthines

mthines commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner Author

🟡 1 non-blocking finding

orgs_only closes the personal branch on every memory read, write, delete and restore path, and the delta fixes all three earlier findings. The org.* surface still ignores the key's tenancy.

Warnings: Body omits the orgs_only citation skip, and says by-id writes are narrowed; they match nothing (404)

What changes on merge. 1: orgs_only token still reaches every owner org
Finding Where
🟡 1 orgs_only token still reaches every owner org
Fix: For orgs_only, narrow org.* and /orgs reads to key.orgIds, or add those tools to OWNER_ROW_TOOLS.
supabase/functions/_shared/auth/account-wide-tools.ts:73

Fix all with Agent0

Review details
Gate Details
Description vs. code ⚠️ Body omits the orgs_only citation skip, and says by-id writes are narrowed; they match nothing (404).
Prior review feedback ✅ Earlier review comments are resolved.
Documentation ✅ api-tokens.md, decisions.md, mcp-tools.md, org-sharing.md, remote.mdx and the llms template all cover orgs_only.
Self-review signals ✅ No debug logs, leftover TODOs, or unreviewed stubs.
Code review ⚠️ 1 non-blocking finding — see inline comments.
Run Details
Mode full · 36 lines in delta · tier deep · depth checkout
Progress open review threads 2 → 3 → 1 across the last 3 reviews
Checked 30 of 40 changed files read · 6 possible issues → 1 confirmed → 1 posted
Reach 41 changed exports · 65 dependent files · 4 checked · 61 not checked · 4 changed files imported by 7 files, not traced
OrgAccess — 4 consumer files · 0 checked
translateDbError — 15 consumer files · 0 checked
applyRestTenantScope — 9 consumer files · 4 checked
KeyRestriction — 6 consumer files · 2 checked
scopeAllowedByKey — 5 consumer files · 0 checked
+36 more
supabase/functions/_shared/schemas/tool-catalog.ts — imported by 3 files
packages/web/src/components/dashboard/OnboardingStepContent.tsx — imported by 2 files
packages/web/src/components/dashboard/TokenManager.tsx — imported by 1 file
supabase/functions/memories/handlers/create.ts — imported by 1 file
Found produced 6 → posted inline 1 · cleared 1 · carried forward 0 · deferred 0 · below-bar 0
Dropped 3 below-bar
Severity 🟡 1 medium
Standards ran — 6 governing docs, 157 normative bullets; 0 findings (the earlier remote.mdx / llms gap is fixed)
Measurability ran — api paths; both new refusals set authz.reason on the span, key_personal_denied is a client error; 0 missing
Integrations not activated (no manifest or lockfile in the diff)
CI still pending: Harness tests + dry-run plans, Preflight — environment is clean · 1 passed
Memories 3 indexed · 3 used (2 knowledge · 1 hotspot)

Memories used

  • knowledge knowledge::applyKeyScopeFilter@supabase/functions/_shared/api/tenant.ts — finder pointer (re-verified: by-id writes still match nothing)
  • knowledge knowledge::isRefusedForOrgsOnlyKey@supabase/functions/_shared/auth/account-wide-tools.ts — finder pointer (re-verified: org.* not in the set)
  • hotspot hotspot::supabase/migrations/00116_api_token_orgs_only_tenancy.sql — hotspot checklist (security)

Nothing to report — 0 files skipped.

pr-reviewer · commit cad45e3 · full review · how these findings are produced · updated Oct 27, 2026 11:19am UTC

Comment thread supabase/functions/_shared/api/tenant.ts
Comment thread docs/decisions.md Outdated
Regenerated by the Update visual baselines workflow for PR #729.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Update visual baselines: committed 12 updated baseline(s) as 14be13ffe12daa1360bb438ad62b9f193d3abaae.

ℹ️ This commit was pushed with GITHUB_TOKEN, so web-test will not re-run automatically — push a follow-up commit if you need CI to re-verify.

…is refused

key_personal_denied reused LK002, so MCP said "ask an org admin to change
your role" and REST said "insufficient permissions for this org action" —
neither fixes it. Both now name the orgs_only limit and point at `org`.
memory.write's description, mcp-tools.md and org-sharing.md now say an
orgs_only key's personal write is refused rather than saved personal; the
-32003 row lists the orgs_only refusals; by-id writes are documented as
matching nothing (404) for orgs_only; decisions.md names memory_write.
…fresh

The whole-suite refresh also rewrote ten unrelated components' screenshots
(rendering drift on main). Restore them so this PR changes only the panel
it actually changed.
Comment thread packages/web/src/lib/token-scoping.ts
Comment thread supabase/migrations/00116_api_token_orgs_only_tenancy.sql
Comment thread docs/mcp-tools.md Outdated
lorekit_record_memory_citations (00107) only credits the owner's personal
lessons and takes no tenancy, so an org write from an orgs_only key still
raised cited_count on personal rows. Both write surfaces now skip it for a key
that cannot reach personal rows.
remote.mdx listed three tenancies and said personal memories stay reachable
under all of them; the llms template said bound-scope writes are never
rejected. mcp-tools.md now says key_personal_denied is an in-band isError
result, not -32003.
Comment thread supabase/functions/_shared/auth/account-wide-tools.ts
org.list / GET /orgs return every org the owner belongs to and the member and
invite lists expose handles and invitee emails, none of it filtered by the
key's org_ids. orgs_only is the tenancy meant for handed-out tokens, so org.*
and member.* are refused for it (-32003 / 403).
@mthines
mthines marked this pull request as ready for review October 27, 2026 11:43
@mthines
mthines merged commit 59f4583 into main Oct 27, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant