diff --git a/.changeset/sys-invitation-row-scope.md b/.changeset/sys-invitation-row-scope.md new file mode 100644 index 0000000000..690bab8ae5 --- /dev/null +++ b/.changeset/sys-invitation-row-scope.md @@ -0,0 +1,74 @@ +--- +"@objectstack/plugin-security": patch +--- + +fix(plugin-security): a plain member can no longer read the organization's whole invitation ledger (#8095) + +**Security — narrowing.** Any authenticated `member` of an organization could +read **every** `sys_invitation` row of that organization through the data API: +each invitee's email address, the role they were about to be granted, who +invited them, and the expiry. Measured live: `GET /api/v1/data/sys_invitation` +as a plain member returned `200` with the same rows the org **owner** sees. + +The grant was declared, not accidental. `sys_invitation` is in +`BETTER_AUTH_MANAGED_OBJECTS`, whose blanket `denyWritesOnManagedObjects()` +entry sets `allowRead: true` on every managed identity table for +`member_default` and `viewer_readonly` — reads permitted, "subject to the rest +of the RLS chain". For this object there was no rest of the chain: neither set +declared a row-level policy for `sys_invitation`, and an object with no +applicable policy compiles to a null business-RLS filter, i.e. no row scope at +all. `sys_member` is a staff directory and reads org-wide on purpose; a pending +invitation is administrative *intent* about people who are not members and never +consented to a directory listing — and *who is about to become an admin* is +enough for targeted social engineering. + +**What changed.** `member_default` and `viewer_readonly` each gain one +row-level policy, `sys_invitation_self` (`select`, +`email == current_user.email`), and `organization_admin` (with the wall-less +`organization_admin_no_bypass` variant derived from it) gains +`sys_invitation_org_admin` — the org-administration side of the same ledger, +scoped by `positions` to `org_owner` / `org_admin`. + +The second policy is not decoration. `member_default` resolves for **every** +authenticated principal (the `everyone` anchor), so the addressee scope reaches +org admins too, and on the **default** `single` posture neither mechanism that +normally keeps an admin whole is present: the wildcard +`viewAllRecords` short-circuit is withheld from a wall-less deployment +(ADR-0105 D4), and `sys_invitation_org` is stripped as a platform tenant policy +when org isolation is inactive (ADR-0105 D3). Measured on a stock boot with only +the member-side scope in place, the org **owner** read zero invitations — the +Invitations page would have gone blank for the one persona entitled to it. +`sys_invitation_org_admin` states the admission on the axis that survives both, +carrying no tenant token for the strip to key on; the organization boundary +remains Layer 0's, which AND-composes ahead of it, so its widest reach is the +admin's own organization — exactly what `sys_invitation_org` already declared. + +**The invitee still sees their own invitation**, and that half is not +incidental: the recipient-side row actions on `sys_invitation` +(`accept_invitation` / `reject_invitation`) are gated on +`record.email == ctx.user.email`, so an addressee who cannot read their row +cannot act on it. The object-level read bit is therefore deliberately left open +and the narrowing done at the row level — closing the object would have broken +acceptance while looking like the same fix. + +**Not covered by the ruling, and therefore unchanged here:** a +`delegated_admin` normalizes to neither `org_owner` nor `org_admin`, so that +role now reads only its own row through the data API even though it may issue +invitations. Filed separately rather than decided in this PR. + +**Unaffected.** Every better-auth organization endpoint +(`invite-member`, `accept-invitation`, `reject-invitation`, +`cancel-invitation`, `list-invitations`, `list-user-invitations`, +`get-invitation`) reads and writes `sys_invitation` through the identity +adapter under a system context, so the invitation lifecycle and the console's +accept page — which use those endpoints, not the data API — behave exactly as +before. Owners and admins are unchanged, in both the wall-enforcing +(`organization_admin`) and wall-less (`organization_admin_no_bypass`) +variants. Platform admins are unchanged. + +**You may notice** that a principal who is neither owner nor admin no longer +sees other people's invitations on a generic `sys_invitation` grid — including +the Setup app's Invitations page and the Organization record's Invitations tab +if a non-admin reaches them. That is the fix, not a regression. A deployment +that genuinely wants a wider invitation read should declare it on an +application permission set rather than rely on the managed-object baseline. diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts index abdb5124f6..4e67c61025 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts @@ -94,3 +94,74 @@ describe('default permission sets carry the managed denies (static baseline)', ( expect(Object.keys(admin.objects)).toEqual(['*']); }); }); + +/** + * [#8095] The `sys_invitation` row scope — a DELETION TRIPWIRE, not the proof. + * + * The proof that the narrowing works lives over HTTP, in + * `packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts`: an + * assertion whose expectation and reality both come from this module cannot + * fail, so nothing here is evidence that a member is actually narrowed. What + * this block does buy is the one thing the HTTP fixture cannot — it names the + * predicate and the sets it must appear in, so removing a carve-out (or adding + * a managed object to `BETTER_AUTH_MANAGED_OBJECTS` and assuming the blanket + * read is safe for it) fails HERE, in the file being edited, instead of in a + * suite the author may not run. + * + * The predicate is written out rather than imported for the same reason. + */ +describe('sys_invitation is row-scoped to its addressee (#8095)', () => { + const SELF_PREDICATE = 'email == current_user.email'; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const policiesFor = (setName: string, object: string): any[] => + (setByName(setName)?.rowLevelSecurity ?? []).filter((p: any) => p.object === object); + + it.each(['member_default', 'viewer_readonly'])( + '%s scopes sys_invitation to the addressee — the blanket managed read is NOT the whole story', + (setName) => { + // The object-level read bit stays open: it is what makes the invitee's OWN + // row reachable, and closing it would break the accept flow rather than + // narrow it. The narrowing is the row predicate. + expect(setByName(setName).objects.sys_invitation.allowRead).toBe(true); + + const scoped = policiesFor(setName, 'sys_invitation'); + expect(scoped.map((p) => p.name)).toEqual(['sys_invitation_self']); + expect(scoped[0].using).toBe(SELF_PREDICATE); + // Read the operation off THIS policy. The same-named carve-outs across + // these sets do not all agree (`sys_api_key_self` is spelled three times + // at two different operations), so a sibling's value is not evidence. + expect(scoped[0].operation).toBe('select'); + expect(scoped[0].enabled).not.toBe(false); + }, + ); + + it('organization_admin keeps the ORG-wide ledger — the ruling narrowed members, not admins', () => { + // The other half of the ruling, and the half that is easy to get wrong: + // `member_default` resolves for admins too, so the member-side scope reaches + // them, and the two mechanisms that look like they already protect an admin + // are both absent on the DEFAULT `single` posture — the `viewAllRecords` + // short-circuit is withheld from `organization_admin_no_bypass` (ADR-0105 + // D4), and `sys_invitation_org` is stripped as a platform tenant policy when + // org isolation is inactive (ADR-0105 D3). Only `sys_invitation_org_admin` + // survives both, which is why it must be present on BOTH variants. + for (const setName of ['organization_admin', 'organization_admin_no_bypass']) { + const scoped = policiesFor(setName, 'sys_invitation'); + expect(scoped.map((p) => p.name).sort(), `${setName} sys_invitation policies`).toEqual([ + 'sys_invitation_org', + 'sys_invitation_org_admin', + ]); + + const admission = scoped.find((p) => p.name === 'sys_invitation_org_admin'); + // No tenant token — that is what keeps the strip from taking it. + expect(admission.using).not.toContain('current_user.organization_id'); + expect(admission.using).toBe('id != null'); + // Domained to the org-administration identities, so it can only WIDEN. + // A principal it does not match keeps the addressee scope and fails + // closed; putting the domain on the narrowing instead would make "no + // matching position" mean "no policy" — i.e. no row filter at all. + expect(admission.positions.sort()).toEqual(['org_admin', 'org_owner']); + expect(admission.operation).toBe('select'); + } + }); +}); diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index d296c973e7..19b04e5b5e 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -1,7 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; -import { ORGANIZATION_ADMIN, ORGANIZATION_ADMIN_NO_BYPASS } from '@objectstack/spec'; +import { + ORGANIZATION_ADMIN, + ORGANIZATION_ADMIN_NO_BYPASS, + BUILTIN_IDENTITY_ORG_ADMIN, + BUILTIN_IDENTITY_ORG_OWNER, +} from '@objectstack/spec'; import { MCP_AGENT_PERMISSION_SET_READ, MCP_AGENT_PERMISSION_SET_WRITE, @@ -23,6 +28,18 @@ import { * permission sets keep their `*` wildcard so they can rescue data * directly when needed. * + * ⚠️ "Subject to the rest of the RLS chain" is the load-bearing half, and it is + * a BLANKET grant — read this list as 22 objects whose object-level read bit is + * open, each narrowed (or not) by whatever `rowLevelSecurity` its holder set + * declares for it. An object named here with NO `_self` / `_org` policy in + * `member_default` is org-wide readable by every authenticated member. That is + * intended for the staff-directory shapes (`sys_member`, `sys_user` via + * `sys_user_org_members`) and was NOT intended for `sys_invitation` + * (maintainer ruling 2026-08-12) — see `sys_invitation_self` below. Do not + * "fix" a future instance of this class by editing the blanket: dropping + * `allowRead` here retires the read on all 22 at once, and it would take the + * invitee's own row with it. The per-object row scope is the narrow instrument. + * * This is the COMPILE-TIME BASELINE. At `kernel:ready` it is unioned with the * live registry by `applyManagedWriteDenies` (see `managed-object-write-denies.ts`), * which injects a deny entry for every registered `managedBy: 'better-auth'` @@ -302,6 +319,55 @@ const baseDefaultPermissionSets: PermissionSet[] = [ operation: 'select', using: 'organization_id == current_user.organization_id', }, + // [#8095] The org-admin half of the invitation narrowing, and the reason + // it needs a SECOND policy rather than leaning on the one above. + // + // `member_default` now row-scopes `sys_invitation` to the addressee, and + // that set resolves for EVERY authenticated principal (the `everyone` + // anchor) — org owners and admins included. Policies OR-combine, so an + // admin is only unnarrowed while some policy of theirs still admits the + // rest of the ledger. Two mechanisms were supposed to do that, and in the + // DEFAULT posture neither does: + // - the wildcard `viewAllRecords` short-circuit skips Layer 1 whole — + // but ADR-0105 D4 withholds those bits from a wall-less deployment, + // which is where `organization_admin_no_bypass` comes from; + // - `sys_invitation_org` above is a PLATFORM TENANT POLICY, and + // `collectRLSPolicies` strips those when org isolation is inactive + // (ADR-0105 D3) — correctly, since `current_user.organization_id` + // means nothing without a wall. + // Measured on a stock `single`-posture boot: with only the member-side + // scope in place, the org OWNER read ZERO invitations. The Invitations + // page would have gone blank for the one persona entitled to it, on the + // default posture, as a side effect of a member-side fix. + // + // So the admission is stated in its own right, on the axis that survives + // both mechanisms: `positions` (ADR-0090 P2's applicability domain — the + // same lever `owner_only_writes` uses to keep a members-only restriction + // off admins), with a predicate carrying no tenant token for the strip to + // key on. `id != null` is every row of this object, said plainly: the + // organization boundary is NOT this policy's job — Layer 0 is the tenant + // wall (ADR-0095 D1) and AND-composes ahead of it, so the widest this can + // ever reach is the admin's own organization, which is exactly what + // `sys_invitation_org` already declares. It is the same grant, spelled so + // a wall-less deployment keeps it. + // + // Why a `positions` DOMAIN rather than dropping the member-side scope's + // reach: the domain here only ever WIDENS, so a principal it does not + // match keeps the addressee scope and fails closed. Putting the domain on + // the narrowing instead would invert that — anyone outside the listed + // positions (an org-less session, a role that normalizes to neither name) + // would match no policy at all, and no policy means NO row filter, which + // is precisely the wide read #8095 is about. + // + // `delegated_admin` is deliberately absent: the ruling narrows the ledger + // to owner/admin, and that role normalizes to neither name. + { + name: 'sys_invitation_org_admin', + object: 'sys_invitation', + operation: 'select', + using: 'id != null', + positions: [BUILTIN_IDENTITY_ORG_OWNER, BUILTIN_IDENTITY_ORG_ADMIN], + }, { name: 'sys_team_org', object: 'sys_team', @@ -597,6 +663,63 @@ const baseDefaultPermissionSets: PermissionSet[] = [ operation: 'select', using: 'user_id == current_user.id', }, + // [#8095] The invitation ledger is ADMINISTRATIVE INTENT, not a staff + // directory — row-scoped to the addressee. + // + // `sys_invitation` is in BETTER_AUTH_MANAGED_OBJECTS, so the blanket above + // grants read on it; this set declared NO policy for the object, and an + // object with no applicable policy compiles to a null Layer 1 — i.e. no + // row filter at all (`RLSCompiler.compileFilter` returns null on an empty + // applicable set). Measured live: a plain `member` of an org got + // `200, total: 2` on `GET /api/v1/data/sys_invitation` — byte-identical to + // what the org OWNER sees, including other people's email addresses, the + // role each is about to be granted, the inviter and the expiry. Pending + // invitees are not members and never consented to a directory listing, and + // "who is about to become an admin" is the wrong thing to broadcast. + // + // Maintainer ruling (2026-08-12): narrow the read to owner/admin, PLUS a + // row-scope carve-out so an invitee still sees THEIR OWN invitation. Both + // halves are load-bearing and this policy is both of them at once: + // - NARROWING — for a rank-and-file member this is now the only + // applicable policy, so the readable set is exactly `{their own row}`; + // - CARVE-OUT — the accept flow's surfaces are record-scoped + // (`sys_invitation`'s `accept_invitation` / `reject_invitation` row + // actions declare `visible: record.email == ctx.user.email`), so an + // invitee who cannot READ their row cannot act on it. Narrowing + // without this predicate would break acceptance while looking like a + // permissions fix. + // ⚠️ This set resolves for EVERY authenticated principal (the `everyone` + // anchor), so the predicate binds org owners and admins too — and the + // arrival of a first policy on an object that had none is a NARROWING for + // whoever it reaches, not a no-op. Keeping the admin whole therefore takes + // an explicit admission on their side: `sys_invitation_org_admin` in + // `organization_admin` (which the `_no_bypass` variant inherits). Read its + // comment before touching either one — the two mechanisms that look like + // they already cover the admin (the `viewAllRecords` short-circuit and + // `sys_invitation_org`) are BOTH absent on the default `single` posture, + // and with only this policy in place the org owner measurably read zero + // invitations. + // + // `email` (not `user_id`): an invitation predates the account it invites, + // so the addressee is identified by address. `current_user.email` is the + // auth-enforced, unique-by-construction identity `RLSUserContext` exposes + // for exactly this (the display `name` is deliberately not exposed). When + // it cannot be resolved the policy compiles to nothing and the single + // applicable policy yields `RLS_DENY_FILTER` — zero rows, fail-closed. + // + // `select` (not `all`), matching `sys_inbox_message_self` above: every + // write on this table is denied at the object layer by the managed-object + // block, refused again by the ADR-0092 D2 identity write guard, and + // answered 405 by `apiMethods: ['get', 'list']` before either. The #7665 + // derive-from-select rule additionally lends this predicate to the write + // classes should a write bit ever be granted, so `all` would buy nothing + // and would overstate what the policy is for. + { + name: 'sys_invitation_self', + object: 'sys_invitation', + operation: 'select', + using: 'email == current_user.email', + }, ], }), PermissionSetSchema.parse({ @@ -698,6 +821,24 @@ const baseDefaultPermissionSets: PermissionSet[] = [ operation: 'select', using: 'user_id == current_user.id', }, + // [#8095] Same row scope as `member_default`'s `sys_invitation_self`, and + // it must be repeated here rather than inherited: this set is resolved + // INSTEAD of the member baseline for a read-only principal, and its `'*'` + // wildcard read is if anything wider — carrying no `viewAllRecords`, it + // does NOT take the Layer 1 short-circuit, so without a policy of its own + // a viewer would read the whole org's invitation ledger that a member can + // no longer see. `select` here matches this set's own convention (every + // carve-out above is `select`; `viewer_readonly` grants no write bit at + // all) and happens to agree with `member_default`'s — which is NOT a rule + // to generalise from: the `sys_api_key_self` carve-out is spelled three + // times across these sets at two different `operation` values. Read the + // `operation` on each policy; never infer it from a same-named sibling. + { + name: 'sys_invitation_self', + object: 'sys_invitation', + operation: 'select', + using: 'email == current_user.email', + }, ], }), diff --git a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts index dc6f6726bf..fc046f455c 100644 --- a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts +++ b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts @@ -106,6 +106,13 @@ describe('default permission sets', () => { // `_self` shape for the same reason: no `organization_id`, so Layer 0 is // inert and these policies are the row scoping for their read grants. 'sys_inbox_message_self', + // [#8095] The invitation ledger's row scope. Keyed on `email`, not + // `user_id`, because an invitation predates the account it invites — the + // addressee is identified by address. Its object DOES carry + // `organization_id`, so unlike the two above this is not "Layer 0 is + // inert here": Layer 0 was engaged and correctly org-scoped, and the org + // is precisely the audience the row must be hidden from. + 'sys_invitation_self', 'sys_notification_receipt_self', 'sys_oauth_access_token_self', 'sys_oauth_application_self', diff --git a/packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts b/packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts new file mode 100644 index 0000000000..d04a5b309f --- /dev/null +++ b/packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts @@ -0,0 +1,271 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8095] `sys_invitation` is row-scoped to its addressee — proven over the real + * data API, with the card's own probe. + * + * The defect, measured live by the filer and reproduced here: `sys_invitation` + * is in `BETTER_AUTH_MANAGED_OBJECTS`, so `denyWritesOnManagedObjects()` grants + * `allowRead: true` on it to `member_default` / `viewer_readonly`, and NEITHER + * set declared a row-level policy for the object. An object with no applicable + * policy compiles to a NULL Layer 1 — no row filter at all — so a plain `member` + * read the organization's entire invitation ledger: every invitee's email + * address, the role each was about to be granted, who invited them, and when it + * expires. Byte-identical to what the org owner sees. `sys_member` is a staff + * directory and reads that way on purpose; a pending invitation is + * administrative INTENT about people who are not members and never consented to + * a directory listing, and "who is about to become an admin" is the wrong thing + * to broadcast to everyone who can log in. + * + * Maintainer ruling (2026-08-12): narrow the read to owner/admin, PLUS a + * row-scope carve-out so an invitee still sees their own invitation. This file + * pins BOTH halves, because each one alone is satisfied by a broken fix: + * + * 1. NARROWING — a plain member reads none of the ledger; + * 2. CARVE-OUT — the addressee still reads THEIR OWN row. Narrowing without + * this breaks acceptance (`sys_invitation`'s `accept_invitation` / + * `reject_invitation` row actions are gated on + * `record.email == ctx.user.email`, so an invitee who cannot read the row + * cannot act on it) — and that breakage sails past assertion 1 looking + * exactly like "permissions fixed"; + * 3. and the OWNER still reads the full ledger, because a change that broke + * the object for everyone would pass 1 and 2 together. + * + * Why over HTTP and not against the permission-set constant: an assertion whose + * expectation and reality both come from `default-permission-sets.ts` cannot + * fail. The narrowing is only real if it survives the whole resolved chain — + * permission-set resolution, the `everyone` anchor, `auto-org-admin-grant`, the + * Layer 0 / Layer 1 composition and the REST layer. So every assertion below + * reads a real `GET /api/v1/data/sys_invitation` response body. + * + * Harness note: `bootStack` disables the default-org bootstrap, so this file + * mints the organization itself and sets membership roles through the system + * context — the only writer better-auth-managed tables accept (ADR-0092) and + * exactly what the single-org bootstrap would do. The two invitation rows are + * created through the REAL `invite-member` endpoint, so they carry whatever + * better-auth actually writes rather than a hand-built approximation. Sessions + * are stamped with the active organization the way a real org switch does + * (`session.active_organization_id` is the one field `resolveAuthzContext` + * reads into `tenantId`), because the card's probe was taken "with the active + * organization set". + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const SYSTEM_CTX = { isSystem: true }; + +/** The org owner — deliberately NOT the seeded platform admin. */ +const OWNER_EMAIL = 'owner.8095@acme-test.example'; +/** A plain member with no invitation addressed to them (the card's attacker). */ +const MEMBER_EMAIL = 'member.8095@acme-test.example'; +/** A member who DOES have an invitation addressed to them (the card's `lisi@`). */ +const INVITEE_EMAIL = 'invitee.8095@acme-test.example'; +/** Invited as `admin`, never signed up (the card's `wangwu@` — the row that leaked). */ +const OUTSIDER_EMAIL = 'outsider.8095@acme-test.example'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function findRows(ql: any, object: string, where: any, limit = 50): Promise { + const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : (rows?.records ?? []); +} + +/** The membership row the sign-up reconciler writes lands asynchronously. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function waitForMembership(ql: any, userId: string): Promise { + for (let i = 0; i < 40; i++) { + const rows = await findRows(ql, 'sys_member', { user_id: userId }, 5); + if (rows.length > 0) return rows[0]; + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`no sys_member row appeared for ${userId}`); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function userIdOf(ql: any, email: string): Promise { + const [user] = await findRows(ql, 'sys_user', { email }, 1); + if (!user) throw new Error(`no sys_user row for ${email}`); + return String(user.id); +} + +/** + * Stamp the active organization onto every session the user holds — the one + * wire field a real org switch sets, and what `resolveAuthzContext` reads into + * `ExecutionContext.tenantId`. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function setActiveOrg(ql: any, userId: string, orgId: string): Promise { + for (const s of await findRows(ql, 'sys_session', { user_id: userId }, 20)) { + await ql.update( + 'sys_session', + { id: s.id, active_organization_id: orgId }, + { context: SYSTEM_CTX }, + ); + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function rowsOf(body: any): any[] { + return Array.isArray(body) ? body : (body?.records ?? []); +} + +/** Read `/data/sys_invitation` as a persona and return `{ status, rows, raw }`. */ +async function readLedger(stack: VerifyStack, token: string) { + const res = await stack.apiAs(token, 'GET', '/data/sys_invitation'); + const raw = await res.text(); + let body: unknown = null; + try { + body = JSON.parse(raw); + } catch { + /* non-JSON body — `raw` is still asserted on */ + } + return { status: res.status, rows: rowsOf(body), raw }; +} + +describe('#8095: a member cannot read the org invitation ledger — only their own row', () => { + let stack: VerifyStack; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + let orgId: string; + let ownerToken: string; + let memberToken: string; + let inviteeToken: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack, {}); + const adminToken = await stack.signIn(); // the seeded dev admin (platform admin) + ql = await stack.kernel.getServiceAsync('objectql'); + + const org = await ql.insert( + 'sys_organization', + { name: 'Acme Test Org', slug: 'acme-8095' }, + { context: SYSTEM_CTX }, + ); + orgId = String(org.id); + + // The seeded dev admin predates the org row; give it the owner membership + // the single-org bootstrap would have, so `invite-member` has an authorized + // caller for the fixture rows. + const adminUserId = await userIdOf(ql, 'admin@objectos.ai'); + const adminMembers = await findRows(ql, 'sys_member', { user_id: adminUserId }, 5); + if (adminMembers.length > 0) { + await ql.update( + 'sys_member', + { id: adminMembers[0].id, organization_id: orgId, role: 'owner' }, + { context: SYSTEM_CTX }, + ); + } else { + await ql.insert( + 'sys_member', + { user_id: adminUserId, organization_id: orgId, role: 'owner' }, + { context: SYSTEM_CTX }, + ); + } + await setActiveOrg(ql, adminUserId, orgId); + + // ── The three personas ──────────────────────────────────────────────── + // The org owner. A SEPARATE principal from the seeded platform admin on + // purpose: `admin_full_access` would satisfy the owner assertion through + // the platform-admin path and prove nothing about an ORG owner, which is + // the persona the card compared against. + ownerToken = await stack.signUp(OWNER_EMAIL, 'Owner!Pass123', 'Owner 8095'); + const ownerUserId = await userIdOf(ql, OWNER_EMAIL); + const ownerMember = await waitForMembership(ql, ownerUserId); + await ql.update('sys_member', { id: ownerMember.id, role: 'owner' }, { context: SYSTEM_CTX }); + + // The two invitations, written by the real better-auth endpoint. + for (const [email, role] of [ + [INVITEE_EMAIL, 'member'], + [OUTSIDER_EMAIL, 'admin'], + ] as const) { + const res = await stack.apiAs(adminToken, 'POST', '/auth/organization/invite-member', { + email, + role, + organizationId: orgId, + }); + expect(res.status, `invite ${email} as ${role}: ${await res.clone().text()}`).toBe(200); + } + + // The addressee of one of them, who then joins as an ordinary member. + inviteeToken = await stack.signUp(INVITEE_EMAIL, 'Invitee!Pass123', 'Invitee 8095'); + const inviteeUserId = await userIdOf(ql, INVITEE_EMAIL); + await waitForMembership(ql, inviteeUserId); + + // The plain member — no invitation carries their address. + memberToken = await stack.signUp(MEMBER_EMAIL, 'Member!Pass123', 'Member 8095'); + const memberUserId = await userIdOf(ql, MEMBER_EMAIL); + await waitForMembership(ql, memberUserId); + + for (const uid of [ownerUserId, inviteeUserId, memberUserId]) { + await setActiveOrg(ql, uid, orgId); + } + + // Fixture sanity: both rows really exist in the ledger. Without this, every + // "member sees nothing" assertion below would pass on an EMPTY table. + const ledger = await findRows(ql, 'sys_invitation', { organization_id: orgId }, 50); + expect(ledger.map((r) => r.email).sort()).toEqual([INVITEE_EMAIL, OUTSIDER_EMAIL].sort()); + }, 240_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('the org OWNER still reads the full ledger — both rows, with role and inviter', async () => { + // Asserted FIRST and deliberately: it is the control that keeps the two + // narrowing assertions honest. A change that simply broke `sys_invitation` + // for everyone satisfies "a member sees nothing" perfectly. + const { status, rows } = await readLedger(stack, ownerToken); + expect(status).toBe(200); + expect(rows.map((r) => r.email).sort()).toEqual([INVITEE_EMAIL, OUTSIDER_EMAIL].sort()); + + const outsider = rows.find((r) => r.email === OUTSIDER_EMAIL); + // The administrative fields the card names — an owner is entitled to them. + expect(outsider.role).toBe('admin'); + expect(outsider.inviter_id).toBeTruthy(); + }, 60_000); + + it('a plain MEMBER reads NONE of it — the exposure the card measured is gone', async () => { + const { status, rows, raw } = await readLedger(stack, memberToken); + + // 200, not 403: this is a ROW narrowing. A 403 here would mean the object + // was closed outright, which also closes the invitee's own row — the + // failure mode assertion 3 exists to catch. + expect(status).toBe(200); + expect(rows).toHaveLength(0); + + // The card's exact finding, asserted on the wire rather than on a row + // count: the pending admin invitation's address must not appear ANYWHERE in + // the response — not in a record, not in a facet, not in an echoed filter. + expect(raw).not.toContain(OUTSIDER_EMAIL); + expect(raw).not.toContain(INVITEE_EMAIL); + }, 60_000); + + it('the INVITEE still reads their own row — and only their own', async () => { + // The accept flow's requirement. If this goes red the narrowing has broken + // acceptance, which is the one way to "fix" #8095 and ship a worse bug. + const { status, rows, raw } = await readLedger(stack, inviteeToken); + + expect(status).toBe(200); + expect(rows).toHaveLength(1); + expect(rows[0].email).toBe(INVITEE_EMAIL); + // Their own row is whole — the row action they need to act on it is gated + // on fields that must actually be present. + expect(rows[0].status).toBeTruthy(); + // …and the carve-out is a row scope, not a blanket re-open. + expect(raw).not.toContain(OUTSIDER_EMAIL); + }, 60_000); + + it("the narrowing holds against a by-id fetch of another person's invitation", async () => { + // A row filter that only engages on the list path is not a row filter. The + // by-id route composes the same Layer 1 predicate; if it did not, the list + // assertion above would be a facade an attacker walks around with one id — + // and ids are handed out by the invitation email link. + const [outsiderRow] = await findRows(ql, 'sys_invitation', { email: OUTSIDER_EMAIL }, 1); + expect(outsiderRow?.id, 'fixture: the outsider row exists to be fetched').toBeTruthy(); + + const res = await stack.apiAs(memberToken, 'GET', `/data/sys_invitation/${outsiderRow.id}`); + expect([403, 404]).toContain(res.status); + expect(await res.text()).not.toContain(OUTSIDER_EMAIL); + }, 60_000); +});