diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 6e76c661614..73d2fdfa78f 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -4228,7 +4228,7 @@ "title": "Write guards tightened on v18: a walled update cannot null out organization_id, a detail write naming an unreadable header is reference_not_found, a validation rule reading an unreadable header answers like a missing one, and a controlled_by_parent child's attachments / comments / explain follow the master's edit grant", "since": "v18", "status": "active", - "revision": 6, + "revision": 7, "priority": "P1", "surface": "api", "personas": [ @@ -4286,7 +4286,7 @@ ], "automated": { "kind": "dogfood", - "ref": "packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts + packages/objectql/src/engine-unreadable-master-detail-header.test.ts + packages/objectql/src/engine-predicate-related-master-detail-header.test.ts + packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts + packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts + packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts. Layers (RUNNER rule 6): tenant-wall-organization-removal.test.ts and the two engine master-detail-header tests are in-process — a real engine and SecurityPlugin over SQL drivers, the answer read off the thrown statusCode or through resolveThrownHttpError — so acceptance[1], acceptance[2] and negative[0] (api oracle) score partial from them: no pin here drives those doors over HTTP. The 18.0 sweep's PASS for them at revision 4 (#24840) predates the layer rule; it is not a precedent for scoring them pass from these pins" + "ref": "packages/qa/dogfood/test/parent-and-organization-write-guards.dogfood.test.ts + packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts + packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts + packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts + packages/objectql/src/engine-unreadable-master-detail-header.test.ts + packages/objectql/src/engine-predicate-related-master-detail-header.test.ts + packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts. Per clause, at the api oracle's layer (in-process HTTP): acceptance[1] — parent-and-organization-write-guards' walled describe boots the showcase with multiTenant 'posture-only' (a real isolated posture: the tenancy service reports the Layer 0 wall active and not degraded, asserted); an organization owner who is not the platform administrator PATCHes their own showcase_private_note with organization_id null and is refused 403 PERMISSION_DENIED, the row keeping its organization (the same caller's PATCH that leaves the column alone is admitted, the control), and inserts sending organization_id null and '' are stamped with the caller's active organization. acceptance[2] and negative[0] — the same file: a member holding a scratch task-writer set whose row policy selects no showcase_project POSTs a showcase_task naming a seeded project it cannot read (asserted) and one naming a project id that exists nowhere; both answer 400 carrying reference_not_found, the two bodies equal field by field once the named id is normalised, and nothing lands (the admin's identical POST lands, the control). On the controlled_by_parent detail (showcase_invoice_line: a stock contributor naming another contributor's invoice, and a missing one) the ADR-0055 §3 master-edit gate answers before the reference check: both are the one 403 PERMISSION_DENIED body, so negative[0] holds there too; that code is the gate's by design (engine-unreadable-master-detail-header.test.ts's controls keep a controlled_by_parent detail unchanged). acceptance[3] — cbp-parent-attachment-comment-gates and cbp-explain-master-write. The plugin-security and objectql files are engine pins beneath (a real engine and SecurityPlugin over SQL drivers). acceptance[0] is the pin run itself" }, "source": [ "packages/plugins/plugin-security/src/security-plugin.ts#SecurityPlugin", @@ -4330,6 +4330,12 @@ "date": "2026-10-26", "change": "One checklist-accuracy finding of Tier 1 record #25815 (a1/a2/n0), re-read on main b4199c96: the walled organization-removal refusal and the unreadable-header parity are pinned only in-process (engine + SecurityPlugin over SQL drivers, thrown statusCode / resolveThrownHttpError), with no HTTP pin for the api-oracle legs; #24840 scored them PASS at revision 4 before the layer rule. automated.ref names the layer and the precedent. No clause or status changed", "ref": "#25835" + }, + { + "revision": 7, + "date": "2026-10-27", + "change": "Access-security pin upgrade, slice 4 (#26571; Tier 1 record #26468 scored the item PARTIAL). Pinned over the REST data door: acceptance[1] on a posture-only walled showcase boot (the organization-emptying PATCH refused 403 PERMISSION_DENIED, empty-org inserts stamped); acceptance[2] and negative[0] as the unreadable/nonexistent master comparison, field by field, on a plain master_detail detail, and the same parity on the controlled_by_parent line, where the master-edit gate's 403 answers first (new parent-and-organization-write-guards). No clause or status changed", + "ref": "#26571" } ] }, @@ -4338,7 +4344,7 @@ "title": "Seven deployment tables (sys_job, sys_job_run, sys_job_queue, sys_flow_dispatch, sys_migration, sys_migration_journal, sys_presence) carry no organization column on v18 and are readable only with manage_platform_settings — an org admin who is not platform admin is refused even on single", "since": "v18", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "api", "personas": [ @@ -4381,8 +4387,8 @@ "wrong-persona" ], "automated": { - "kind": "unit", - "ref": "packages/platform-objects/src/deployment-level-no-tenant-column.test.ts (sys_job, sys_job_queue, sys_job_run, sys_migration, sys_migration_journal) + packages/services/service-automation/src/sys-flow-dispatch.object.test.ts (sys_flow_dispatch) + packages/services/service-realtime/src/objects/sys-presence.object.test.ts (sys_presence) — object-declaration pins (no tenant column, requiredPermissions manage_platform_settings); they score acceptance[0] only. No pin drives either door over HTTP: acceptance[1] (org admin 403, platform admin 200) and acceptance[2] (400 INVALID_FIELD on an organization_id filter) stay live-run clauses" + "kind": "dogfood", + "ref": "packages/qa/dogfood/test/deployment-tables-platform-only.dogfood.test.ts + packages/platform-objects/src/deployment-level-no-tenant-column.test.ts (sys_job, sys_job_queue, sys_job_run, sys_migration, sys_migration_journal) + packages/services/service-automation/src/sys-flow-dispatch.object.test.ts (sys_flow_dispatch) + packages/services/service-realtime/src/objects/sys-presence.object.test.ts (sys_presence). Per clause, at the api oracle's layer (in-process HTTP): acceptance[1] — deployment-tables-platform-only signs up a member of the Default Organization and promotes its sys_member role to admin (guarded: it resolves an organization_admin permission set, holds no manage_platform_settings and is not the platform administrator), then reads GET /api/v1/data/TABLE for each of the seven tables: the org admin is refused 403 PERMISSION_DENIED on every one, and the platform administrator is served 200. acceptance[2] — the platform administrator's GET with the URL-encoded filter [[\"organization_id\",\"=\",\"x\"]] answers 400 INVALID_FIELD on each of the seven. The three object-declaration pins (no tenant column, requiredPermissions manage_platform_settings) are beneath it. acceptance[0] is the pin run itself" }, "source": [ "packages/platform-objects/src/audit/sys-job.object.ts", @@ -4407,6 +4413,12 @@ "date": "2026-10-26", "change": "Two checklist-accuracy findings of Tier 1 record #25815, re-read on main b4199c96. a0: automated.ref named packages/plugins/plugin-security/src/authz-identity-objects-boot-refusal.test.ts, which pins SecurityPlugin's boot refusal when sys_user / sys_member / sys_organization are not registered — nothing about the seven deployment tables or manage_platform_settings — so it left the ref. a1/a2: the ref now says no pin drives either door over HTTP and both clauses stay live-run. No clause or status changed", "ref": "#25835" + }, + { + "revision": 4, + "date": "2026-10-27", + "change": "Access-security pin upgrade, slice 4 (#26571; Tier 1 record #26468 scored the item PARTIAL: no pin drove either door over HTTP). Pinned over the REST data door: acceptance[1]'s org admin 403 PERMISSION_DENIED and platform admin 200 on all seven tables, and acceptance[2]'s 400 INVALID_FIELD on an organization_id filter (new deployment-tables-platform-only). automated.kind unit -> dogfood, since the ref now leads with the dogfood pin. No clause or status changed", + "ref": "#26571" } ] }, @@ -4415,7 +4427,7 @@ "title": "Share links with two gates combined: signed_in + password answers an anonymous caller 401 SIGN_IN_REQUIRED whatever password is sent, email + password answers a uniform 404 INVALID_OR_EXPIRED, and /messages opens exactly where /resolve opens", "since": "v18", "status": "active", - "revision": 4, + "revision": 5, "priority": "P2", "surface": "api", "personas": [ @@ -4464,8 +4476,8 @@ "a password-correct anonymous caller reading link A is an authorization FAIL — carve-out applies" ], "automated": { - "kind": "conformance", - "ref": "packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts + packages/plugins/plugin-sharing/src/share-link-envelope.conformance.test.ts + packages/runtime/src/domains/share-links-messages-audience.test.ts (acceptance[3]: `/messages` opens for a caller exactly when `/resolve` opens the same link, on both mounts) + packages/runtime/src/domains/share-links-refusal-order.test.ts (acceptance[1] and acceptance[2]: the anonymous no-password-oracle row and the email-audience identical-refusal rows). Layers (RUNNER rule 6): the two runtime files are in-process — the domain's route entries kept by a minimal IHttpServer stand-in over a real engine, no Hono and no HTTP — so they score acceptance[0] only; acceptance[1] to acceptance[3] (api oracle) score partial from them, and their live legs are driven over HTTP" + "kind": "dogfood", + "ref": "packages/qa/dogfood/test/share-link-combined-gates.dogfood.test.ts + packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts + packages/plugins/plugin-sharing/src/share-link-envelope.conformance.test.ts + packages/runtime/src/domains/share-links-messages-audience.test.ts + packages/runtime/src/domains/share-links-refusal-order.test.ts. Per clause, at the api oracle's layer (in-process HTTP): the dogfood file mints two links through POST /api/v1/share-links on the seeded published client brief and drives the public token routes anonymously. acceptance[1] and negative[0] — link A (audience signed_in + password) resolved with no password, a wrong one and the right one in the UTF-8 header form answers 401 SIGN_IN_REQUIRED, the three bodies byte-identical. acceptance[2] — link B (audience email + password): no email and no password, the right password with no email, an off-list email with the right password, and the allowlisted email with a wrong password and with none all answer 404 INVALID_OR_EXPIRED, byte-identical with an unknown token's body; the allowlisted email with the right password resolves 200 (the control). acceptance[3] — the same eight calls paired with /messages: 404 NOT_FOUND on every call /resolve refused, and 400 UNSUPPORTED (a brief is no ai_conversations record) on the one call /resolve passed. The plugin-sharing and runtime files are beneath (the route entries over a real engine, no HTTP). acceptance[0] is the pin run itself" }, "source": [ "packages/plugins/plugin-sharing/src/share-link-routes.ts#registerShareLinkRoutes", @@ -4496,6 +4508,12 @@ "date": "2026-10-26", "change": "Two checklist-accuracy findings of Tier 1 record #25815, re-read on main b4199c96. a1/a2: packages/runtime/src/domains/share-links-refusal-order.test.ts asserts the anonymous no-password-oracle and email-audience identical-refusal rows but was not in the ref; it joins it. a3: share-links-messages-audience.test.ts drives in-process route entries (no Hono, no HTTP), so it scores acceptance[0], not the api oracle of acceptance[3]. automated.ref names the layer of both. No clause or status changed", "ref": "#25835" + }, + { + "revision": 5, + "date": "2026-10-27", + "change": "Access-security pin upgrade, slice 4 (#26571; Tier 1 record #26468 scored the item PARTIAL: the runtime pins drive the route entries with no HTTP). Pinned over HTTP on anonymous calls: acceptance[1] and negative[0]'s byte-identical 401 SIGN_IN_REQUIRED across the password variants, acceptance[2]'s byte-identical 404 INVALID_OR_EXPIRED across the non-entitled variants and the unknown token with the allowlisted 200 control, and acceptance[3]'s resolve/messages pairing (new share-link-combined-gates). automated.kind conformance -> dogfood, since the ref now leads with the dogfood pin. No clause or status changed", + "ref": "#26571" } ] }, diff --git a/packages/qa/dogfood/test/deployment-tables-platform-only.dogfood.test.ts b/packages/qa/dogfood/test/deployment-tables-platform-only.dogfood.test.ts new file mode 100644 index 00000000000..dca26c411d5 --- /dev/null +++ b/packages/qa/dogfood/test/deployment-tables-platform-only.dogfood.test.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#26571] `access-security.deployment-tables-platform-only`, acceptance[1] +// and acceptance[2], over the REST data door on the stock showcase. +// +// Seven tables are deployment-level: they carry no tenant column and declare +// `requiredPermissions: ['manage_platform_settings']` (the object-declaration +// pins in platform-objects, service-automation and service-realtime). The item +// scores the doors at the `api` oracle, so this file reads them as the two +// personas the item names: +// +// - an organization administrator (the Default Organization's membership +// role promoted to `admin`, which provisions the organization-admin set) +// who is not the platform administrator: 403 PERMISSION_DENIED on every +// table; +// - the platform administrator: 200 on every table; +// - acceptance[2]: an `organization_id` filter is 400 INVALID_FIELD, since +// the column does not exist on these tables. +// +// Isolated boot: the file promotes a membership. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const SYS = { context: { isSystem: true } } as const; +const ORG_ADMIN = 'deployment-tables-org-admin@verify.test'; +const PASSWORD = 'Deploy-Tables-26571'; +const TABLES = [ + 'sys_job', 'sys_job_queue', 'sys_job_run', 'sys_migration', 'sys_migration_journal', 'sys_flow_dispatch', 'sys_presence', +] as const; + +const codeOf = (body: any) => + (body?.error && typeof body.error === 'object' ? body.error.code : undefined) ?? body?.code ?? null; + +describe('deployment-level tables are the platform administrator\'s alone', () => { + let stack: VerifyStack; + let admin: string; + let orgAdmin: string; + + beforeAll(async () => { + stack = await bootShowcase(); + admin = await stack.signIn(); + const ql: any = await stack.kernel.getServiceAsync('objectql'); + orgAdmin = await stack.signUp(ORG_ADMIN, PASSWORD); + const userId = (await ql.findOne('sys_user', { where: { email: ORG_ADMIN }, ...SYS }))?.id; + expect(userId, 'the org admin is provisioned').toBeTruthy(); + // Promote the way an organization does: the membership role. + const [membership] = await ql.find('sys_member', { where: { user_id: userId }, ...SYS }); + expect(membership?.organization_id, 'a member of the Default Organization').toBeTruthy(); + await ql.update('sys_member', { id: membership.id, role: 'admin' }, SYS); + // A fresh session reads the promoted role. + orgAdmin = await stack.signIn(ORG_ADMIN, PASSWORD); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('guard the guard: the org admin holds the organization-admin standing and is not the platform administrator', async () => { + const session = (await (await stack.apiAs(orgAdmin, 'GET', '/auth/get-session')).json()) as any; + expect(session.user?.isPlatformAdmin, 'not the platform administrator').not.toBe(true); + expect(session.user?.positions ?? []).not.toContain('platform_admin'); + const p = (await (await stack.apiAs(orgAdmin, 'GET', '/auth/me/permissions')).json()) as any; + const resolved = p?.data ?? p; + expect((resolved.permissionSets ?? []).map(String).join(','), 'the organization-admin set is held').toMatch(/organization_admin/); + expect(resolved.systemPermissions ?? [], 'no platform-settings capability').not.toContain('manage_platform_settings'); + }); + + it.each(TABLES)('acceptance[1]: %s — the org admin is refused 403 PERMISSION_DENIED; the platform admin reads 200', async (table) => { + const refused = await stack.apiAs(orgAdmin, 'GET', `/data/${table}`); + const body = await refused.json(); + expect([refused.status, codeOf(body)], JSON.stringify(body)).toEqual([403, 'PERMISSION_DENIED']); + const served = await stack.apiAs(admin, 'GET', `/data/${table}`); + expect(served.status, await served.clone().text()).toBe(200); + }); + + it.each(TABLES)('acceptance[2]: %s — an organization_id filter is 400 INVALID_FIELD (the column does not exist)', async (table) => { + const filter = encodeURIComponent(JSON.stringify([['organization_id', '=', 'x']])); + const res = await stack.apiAs(admin, 'GET', `/data/${table}?filter=${filter}`); + const body = await res.json(); + expect([res.status, codeOf(body)], JSON.stringify(body)).toEqual([400, 'INVALID_FIELD']); + }); +}); diff --git a/packages/qa/dogfood/test/parent-and-organization-write-guards.dogfood.test.ts b/packages/qa/dogfood/test/parent-and-organization-write-guards.dogfood.test.ts new file mode 100644 index 00000000000..a6b2f7ddb8b --- /dev/null +++ b/packages/qa/dogfood/test/parent-and-organization-write-guards.dogfood.test.ts @@ -0,0 +1,232 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#26571] `access-security.parent-and-organization-write-guards`, +// acceptance[1], acceptance[2] and negative[0], over HTTP on the showcase. +// +// `tenant-wall-organization-removal.test.ts` and the two engine master-detail +// header tests drive a real engine and SecurityPlugin in-process and read the +// answer off the thrown error; the item scores these clauses at the `api` +// oracle, so this file drives the REST data door: +// +// - acceptance[2] / negative[0] (stock single posture), on a plain +// master_detail detail: a member holding a scratch task-writer set, whose +// row policy selects no `showcase_project`, POSTs a `showcase_task` naming +// a seeded project it cannot read and one naming a project id that exists +// nowhere. Both answer 400 with the reference_not_found code, and the two +// bodies are equal field by field once the id each names is normalised — +// no existence oracle. The admin, who reads the project, lands the same +// task (the control); +// - negative[0] on the controlled_by_parent invoice line: a showcase +// contributor names another contributor's invoice (owner RLS +// `owner == current_user.email` hides it) and one that exists nowhere. +// The ADR-0055 §3 master-edit gate answers before the reference check, so +// both get its one 403 PERMISSION_DENIED body, identical once the id is +// normalised. The invoice's own owner (C1) lands the same line (the +// control); +// - acceptance[1] (walled: `multiTenant: 'posture-only'`, a real `isolated` +// posture with the Layer 0 wall active): an organization owner who is not +// the platform administrator PATCHes their own record with +// `organization_id: null` and is refused 403 PERMISSION_DENIED, the row +// keeping its organization; an insert that sends an empty organization is +// stamped with the caller's active one. +// +// acceptance[3] is pinned in `cbp-parent-attachment-comment-gates` and +// `cbp-explain-master-write`. Isolated boots: the file writes records and +// creates an organization. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; + +const SYS = { isSystem: true } as const; +const LINES = '/data/showcase_invoice_line'; +const C1 = 'header-parity-c1@verify.test'; +const C2 = 'header-parity-c2@verify.test'; +const MISSING_INVOICE = 'inv_missing_26571'; +const TASKS = '/data/showcase_task'; +const MISSING_PROJECT = 'prj_missing_26571'; +const TASK_WRITER = 'qa_task_writer_26571'; +const WRITER = 'header-parity-writer@verify.test'; + +const codeOf = (body: any) => + (body?.error && typeof body.error === 'object' ? body.error.code : undefined) ?? body?.code ?? null; +const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id ?? b?.recordId; + +/** The body with every occurrence of `id` replaced by a placeholder, so two refusals naming different ids compare. */ +const normalised = (text: string, id: string) => JSON.parse(text.split(id).join('')); + +/** Every `code` anywhere in the body (envelope or per-field). */ +const codesIn = (body: unknown): string[] => { + const out: string[] = []; + const walk = (v: unknown) => { + if (Array.isArray(v)) v.forEach(walk); + else if (v && typeof v === 'object') { + for (const [k, x] of Object.entries(v)) { + if (k === 'code' && typeof x === 'string') out.push(x); + walk(x); + } + } + }; + walk(body); + return out; +}; + +describe('a detail write naming an unreadable header answers what a nonexistent header does', () => { + let stack: VerifyStack; + let ql: any; + let admin: string; + let writer: string; + let projectId: string; + const tok: Record = {}; + let invoiceId: string; + let product: string; + + const grant = async (email: string, set: string) => { + const token = await stack.signUp(email); + const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; + expect(userId, `${email} provisioned`).toBeTruthy(); + await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: set }, { context: SYS }); + return token; + }; + const answer = async (token: string, path: string, body: Record) => { + const res = await stack.apiAs(token, 'POST', path, body); + return { status: res.status, text: await res.text() }; + }; + + beforeAll(async () => { + stack = await bootShowcase(); + admin = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + + // A scratch set that writes tasks and selects no project: every member reads + // projects through the stock baseline, so the set's row policy narrows it. + const saved = await stack.apiAs(admin, 'PUT', `/meta/permission/${TASK_WRITER}`, { + name: TASK_WRITER, label: 'QA task writer', systemPermissions: [], + objects: { showcase_task: { allowRead: true, allowCreate: true, allowEdit: false, allowDelete: false } }, + rowLevelSecurity: [{ + name: 'qa_no_project_rows', label: 'No project rows', object: 'showcase_project', operation: 'select', + using: "name == 'qa-no-such-project-26571'", enabled: true, + }], + }); + expect(saved.status, await saved.clone().text()).toBe(200); + writer = await grant(WRITER, TASK_WRITER); + projectId = String((await ql.find('showcase_project', { limit: 1, context: SYS }))?.[0]?.id ?? ''); + expect(projectId, 'the showcase seed provides a project').toBeTruthy(); + + // The controlled_by_parent pair: two stock contributors, C1 owning an invoice. + const ps = await catalogPermissionSet(ql, 'showcase_contributor'); + expect(ps, 'the stock contributor set').toBeTruthy(); + for (const email of [C1, C2]) tok[email] = await grant(email, ps.name); + // An invoice may not be issued against a churned account (#18682). + const account = (await ql.find('showcase_account', { where: { status: { $ne: 'churned' } }, limit: 1, context: SYS }))?.[0]?.id; + product = (await ql.find('showcase_product', { limit: 1, context: SYS }))?.[0]?.id; + expect(account && product, 'the showcase seed provides an account and a product').toBeTruthy(); + const inv = await stack.apiAs(tok[C1], 'POST', '/data/showcase_invoice', { name: 'INV-HEADER-PARITY', account, status: 'draft', owner: C1 }); + expect(inv.status, await inv.clone().text()).toBeLessThan(300); + invoiceId = String(idOf(await inv.json())); + + for (const [object, id] of [['showcase_project', MISSING_PROJECT], ['showcase_invoice', MISSING_INVOICE]]) { + expect(await ql.findOne(object, { where: { id }, context: SYS }), `${id} exists nowhere`).toBeFalsy(); + } + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('guard the guard: each writer cannot read the header it names; its owner or the admin can', async () => { + expect((await stack.apiAs(writer, 'GET', `/data/showcase_project/${projectId}`)).status, 'the task writer cannot read the project').toBeGreaterThanOrEqual(400); + expect((await stack.apiAs(admin, 'GET', `/data/showcase_project/${projectId}`)).status).toBe(200); + expect((await stack.apiAs(tok[C2], 'GET', `/data/showcase_invoice/${invoiceId}`)).status, 'C2 cannot read C1\'s invoice').toBeGreaterThanOrEqual(400); + expect((await stack.apiAs(tok[C1], 'GET', `/data/showcase_invoice/${invoiceId}`)).status).toBe(200); + }); + + it('acceptance[2] / negative[0]: an unreadable and a nonexistent master answer the same 400 reference_not_found, field by field', async () => { + const task = (project: string) => ({ title: 'header parity', status: 'todo', project }); + const unreadable = await answer(writer, TASKS, task(projectId)); + const missing = await answer(writer, TASKS, task(MISSING_PROJECT)); + expect(unreadable.status, unreadable.text).toBe(400); + expect(missing.status, missing.text).toBe(400); + expect(codesIn(JSON.parse(missing.text)), missing.text).toContain('reference_not_found'); + expect(normalised(unreadable.text, projectId), 'no existence oracle').toEqual(normalised(missing.text, MISSING_PROJECT)); + expect(await ql.find('showcase_task', { where: { title: 'header parity' }, context: SYS }), 'nothing landed').toEqual([]); + // Control: the admin, who reads the project, lands the same task. + const control = await answer(admin, TASKS, task(projectId)); + expect(control.status, control.text).toBeLessThan(300); + }); + + it('negative[0] on a controlled_by_parent detail: an unreadable and a nonexistent invoice answer one refusal, byte for byte', async () => { + // ADR-0055 §3 gates a controlled_by_parent detail's insert on edit access to + // its master before the reference check runs, so the answer here is that + // gate's 403, not 400 — measured identical for both, so no existence oracle. + const line = (invoice: string) => ({ invoice, product, quantity: 1, description: 'header parity' }); + const unreadable = await answer(tok[C2], LINES, line(invoiceId)); + const missing = await answer(tok[C2], LINES, line(MISSING_INVOICE)); + expect([unreadable.status, codeOf(JSON.parse(unreadable.text))], unreadable.text).toEqual([403, 'PERMISSION_DENIED']); + expect(normalised(unreadable.text, invoiceId), 'no existence oracle').toEqual(normalised(missing.text, MISSING_INVOICE)); + expect(await ql.find('showcase_invoice_line', { where: { invoice: invoiceId }, context: SYS }), 'nothing landed').toEqual([]); + // Control: the invoice's owner posts the same line and it lands. + const control = await answer(tok[C1], LINES, line(invoiceId)); + expect(control.status, control.text).toBeLessThan(300); + }); +}); + +describe('walled: an update that empties organization_id is refused; an empty-org insert is stamped', () => { + const OWNER = 'org-removal-owner@verify.test'; + const NOTES = '/data/showcase_private_note'; + let stack: VerifyStack; + let ql: any; + let owner: string; + let orgId: string; + let noteId: string; + + const orgOf = async (id: string) => + (await ql.findOne('showcase_private_note', { where: { id }, context: SYS }))?.organization_id ?? null; + + beforeAll(async () => { + stack = await bootShowcase({ multiTenant: 'posture-only' }); + ql = await stack.kernel.getServiceAsync('objectql'); + owner = await stack.signUp(OWNER); + const created = await stack.apiAs(owner, 'POST', '/auth/organization/create', { name: 'Org Removal', slug: 'org-removal-26571' }); + expect(created.status, await created.clone().text()).toBe(200); + orgId = String(((await created.json()) as { id: string }).id); + const active = await stack.apiAs(owner, 'POST', '/auth/organization/set-active', { organizationId: orgId }); + expect(active.status, await active.clone().text()).toBe(200); + const note = await stack.apiAs(owner, 'POST', NOTES, { title: 'org removal' }); + expect(note.status, await note.clone().text()).toBeLessThan(300); + noteId = String(idOf(await note.json())); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('guard the guard: the Layer 0 wall is active and the caller is no platform administrator', async () => { + const tenancy = await stack.kernel.getServiceAsync<{ posture: string; isolationActive: boolean; degraded: boolean }>('tenancy'); + expect([tenancy.posture, tenancy.isolationActive, tenancy.degraded]).toEqual(['isolated', true, false]); + const session = (await (await stack.apiAs(owner, 'GET', '/auth/get-session')).json()) as any; + expect(session.user?.isPlatformAdmin).not.toBe(true); + expect(session.user?.positions ?? []).not.toContain('platform_admin'); + expect(await orgOf(noteId), 'the note lives in the owner\'s organization').toBe(orgId); + }); + + it('acceptance[1]: PATCH { organization_id: null } answers 403 PERMISSION_DENIED and the row keeps its organization', async () => { + const res = await stack.apiAs(owner, 'PATCH', `${NOTES}/${noteId}`, { organization_id: null }); + const body = (await res.json()) as any; + expect([res.status, body?.error?.code ?? body?.code], JSON.stringify(body)).toEqual([403, 'PERMISSION_DENIED']); + expect(await orgOf(noteId)).toBe(orgId); + // Control: the same caller's update that leaves the column alone is admitted. + const ok = await stack.apiAs(owner, 'PATCH', `${NOTES}/${noteId}`, { body: 'edited' }); + expect(ok.status, await ok.clone().text()).toBeLessThan(300); + expect(await orgOf(noteId)).toBe(orgId); + }); + + it('acceptance[1]: an insert that sends an empty organization is stamped with the caller\'s active one', async () => { + for (const empty of [null, '']) { + const res = await stack.apiAs(owner, 'POST', NOTES, { title: `empty org ${JSON.stringify(empty)}`, organization_id: empty }); + expect(res.status, await res.clone().text()).toBeLessThan(300); + expect(await orgOf(String(idOf(await res.json())))).toBe(orgId); + } + }); +}); diff --git a/packages/qa/dogfood/test/share-link-combined-gates.dogfood.test.ts b/packages/qa/dogfood/test/share-link-combined-gates.dogfood.test.ts new file mode 100644 index 00000000000..e40664bd9d2 --- /dev/null +++ b/packages/qa/dogfood/test/share-link-combined-gates.dogfood.test.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#26571] `access-security.share-link-combined-gates`, acceptance[1] to +// acceptance[3] and negative[0], over HTTP on the stock showcase. +// +// `share-links-refusal-order.test.ts` and `share-links-messages-audience.test.ts` +// drive both mounts' route entries over a real engine through a minimal +// IHttpServer stand-in — no Hono, no HTTP. This file mints two links through +// the real mint door on a seeded showcase record and drives the public token +// routes anonymously: +// +// - link A, audience `signed_in` + a password: `/resolve` answers 401 +// SIGN_IN_REQUIRED for no password, a wrong one and the right one (sent +// in the UTF-8 header form), byte-identical — the password is never +// evaluated first, so a password-correct anonymous caller reads nothing +// (negative[0]); +// - link B, audience `email` + a password: every non-entitled variant +// answers the same 404 INVALID_OR_EXPIRED body, which is also what an +// unknown token answers; an allowlisted email with the right password is +// the one that resolves (the control); +// - `/messages` on the same call: refused (404 NOT_FOUND) exactly where +// `/resolve` refused, open where it passed — 400 UNSUPPORTED, since a +// showcase brief is no `ai_conversations` record. +// +// Isolated boot: the file mints links. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const SYS = { isSystem: true } as const; +const OBJECT = 'showcase_client_brief'; +const PUBLISHED_BRIEF = 'Northwind — Website Relaunch brief'; +const PW_A = 'pässwort-A-26571'; +const PW_B = 'pässwort-B-26571'; +const ALLOWED = 'allowed.reader@example.com'; +const OFF_LIST = 'stranger@example.com'; + +interface Answer { status: number; code: unknown; text: string } + +const codeOf = (body: any) => + (body?.error && typeof body.error === 'object' ? body.error.code : undefined) ?? body?.code ?? null; + +/** The password the way the console's landing page sends it: UTF-8, percent-encoded, declared. */ +const passwordHeaders = (password?: string): Record => + password === undefined ? {} : { 'x-share-password': encodeURIComponent(password), 'x-share-password-encoding': 'utf-8' }; + +describe('share-link combined gates, anonymously over HTTP', () => { + let stack: VerifyStack; + let tokenA: string; + let tokenB: string; + + const anon = async (path: string, password?: string): Promise => { + const res = await stack.raw(`/api/v1${path}`, { method: 'GET', headers: passwordHeaders(password) }); + const text = await res.text(); + let body: any = null; + try { body = JSON.parse(text); } catch { /* not JSON */ } + return { status: res.status, code: codeOf(body), text }; + }; + const both = async (token: string, query: string, password?: string) => ({ + resolve: await anon(`/share-links/${token}/resolve${query}`, password), + messages: await anon(`/share-links/${token}/messages${query}`, password), + }); + + beforeAll(async () => { + stack = await bootShowcase(); + const admin = await stack.signIn(); + const ql: any = await stack.kernel.getServiceAsync('objectql'); + const briefId = String((await ql.findOne(OBJECT, { where: { title: PUBLISHED_BRIEF }, context: SYS }))?.id ?? ''); + expect(briefId, 'the seeded published brief').toBeTruthy(); + + const mint = async (body: Record) => { + const res = await stack.apiAs(admin, 'POST', '/share-links', { object: OBJECT, recordId: briefId, ...body }); + const text = await res.text(); + expect(res.status, `mint: ${text}`).toBe(201); + return String(JSON.parse(text)?.data?.token ?? ''); + }; + tokenA = await mint({ audience: 'signed_in', password: PW_A, label: 'combined-gates A' }); + tokenB = await mint({ audience: 'email', emailAllowlist: [ALLOWED], password: PW_B, label: 'combined-gates B' }); + expect(tokenA && tokenB, 'both tokens minted').toBeTruthy(); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('acceptance[1] / negative[0]: link A answers 401 SIGN_IN_REQUIRED for no, a wrong and the right password — byte-identical', async () => { + const answers = [await anon(`/share-links/${tokenA}/resolve`), await anon(`/share-links/${tokenA}/resolve`, 'wrong-password'), await anon(`/share-links/${tokenA}/resolve`, PW_A)]; + for (const a of answers) expect([a.status, a.code], a.text).toEqual([401, 'SIGN_IN_REQUIRED']); + expect(new Set(answers.map((a) => a.text)).size, 'no anonymous password oracle').toBe(1); + }); + + it('acceptance[2]: link B answers one 404 INVALID_OR_EXPIRED body to every non-entitled variant — the unknown token\'s body', async () => { + const variants = [ + await anon(`/share-links/${tokenB}/resolve`), + await anon(`/share-links/${tokenB}/resolve`, PW_B), + await anon(`/share-links/${tokenB}/resolve?email=${encodeURIComponent(OFF_LIST)}`, PW_B), + await anon(`/share-links/${tokenB}/resolve?email=${encodeURIComponent(ALLOWED)}`, 'wrong-password'), + await anon(`/share-links/${tokenB}/resolve?email=${encodeURIComponent(ALLOWED)}`), + ]; + for (const v of variants) expect([v.status, v.code], v.text).toEqual([404, 'INVALID_OR_EXPIRED']); + const unknown = await anon('/share-links/not-a-real-token-26571/resolve'); + expect(new Set([...variants, unknown].map((v) => v.text)).size, 'one body, the unknown token\'s too').toBe(1); + }); + + it('control: an allowlisted email with the right password resolves link B', async () => { + const ok = await anon(`/share-links/${tokenB}/resolve?email=${encodeURIComponent(ALLOWED)}`, PW_B); + expect(ok.status, ok.text).toBe(200); + }); + + it('acceptance[3]: /messages is refused exactly where /resolve refuses, and open exactly where it passes', async () => { + const calls = [ + await both(tokenA, ''), + await both(tokenA, '', 'wrong-password'), + await both(tokenA, '', PW_A), + await both(tokenB, ''), + await both(tokenB, '', PW_B), + await both(tokenB, `?email=${encodeURIComponent(OFF_LIST)}`, PW_B), + await both(tokenB, `?email=${encodeURIComponent(ALLOWED)}`, 'wrong-password'), + await both(tokenB, `?email=${encodeURIComponent(ALLOWED)}`, PW_B), + ]; + for (const { resolve, messages } of calls) { + const resolveOpen = resolve.status === 200; + if (resolveOpen) { + expect([messages.status, messages.code], `open: ${messages.text}`).toEqual([400, 'UNSUPPORTED']); + } else { + expect([401, 404, 410], resolve.text).toContain(resolve.status); + expect([messages.status, messages.code], `refused: ${messages.text}`).toEqual([404, 'NOT_FOUND']); + } + } + expect(calls.filter((c) => c.resolve.status === 200), 'the open pairing is exercised').toHaveLength(1); + }); +});