diff --git a/.changeset/unpublished-object-deny-message.md b/.changeset/unpublished-object-deny-message.md new file mode 100644 index 0000000000..0df05a452c --- /dev/null +++ b/.changeset/unpublished-object-deny-message.md @@ -0,0 +1,18 @@ +--- +"@objectstack/plugin-security": patch +--- + +**Message change (no behaviour change):** a data-plane read against an object that exists only as an **unpublished draft** now says so, instead of reporting an internal security step (#10401). + +The refusal itself is unchanged and stays fail-closed (#3545): same `PermissionDeniedError`, same `PERMISSION_DENIED` code, same HTTP 403, same `[Security] Access denied` prefix — which is a **matcher** the transports read as "this is a 403", not house style. Nothing here widens access, and no access decision branches on the new information. + +What changed is what the refusal *says*. One sentence — "the security posture of object 'X' could not be resolved for operation 'find'" — covered two conditions with two different remedies, and described neither: because it named a *security* step, every reader took it for a permissions problem and went looking for a sharing rule to change. Measured downstream (objectstack-ai/cloud#1481): an end-user AI turn asked "how many customers do I have?" against a draft-only object, spent seven tool calls oscillating between a metadata plane that said the object existed and this refusal, then told the user the object was "missing its sharing/visibility setting" — confident, professional, and wrong. On a free plan that one turn also exhausted the daily allowance. + +The two conditions are now separated: + +- **The object has a `sys_metadata` draft and no published row** → *"object 'X' is not published — a draft declaration exists but no published one … Publish the object to make it queryable. This is NOT a permissions problem …"*. +- **The declaration genuinely cannot be read** (never declared, or a metadata-store outage) → the pre-existing clause **verbatim**, so any surface matching `the security posture of object 'X' could not be resolved for operation 'Y'` keeps matching, followed by the remedy and the same explicit statement that permissions are not the lever. + +Both sentences, and the operator log line beside them, are derived from one module (`unresolved-posture.ts`) shared with the explain engine's `object_crud` layer detail. Enforcement and explanation stating one refusal in two drifting wordings is the defect shape this closes, so the wording is a single source rather than two literals. + +The discriminator comes from a **best-effort** `sys_metadata` probe that runs only on the path already refusing, reads under a system context (so it cannot re-enter the middleware), and fails safe in one direction only: any failure — no `sys_metadata` in the deployment, an unprovisioned store, a driver error — reports the both-conditions wording rather than a claim. A posture that resolves never probes at all. diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index 70b592f430..0cb39b4ed7 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -40,6 +40,10 @@ import type { } from '@objectstack/spec/security'; import type { PermissionEvaluator } from './permission-evaluator.js'; import { superuserBypassBitForOperation } from './permission-evaluator.js'; +import { + unresolvedPostureExplainDetail, + type UnresolvedPostureCause, +} from './unresolved-posture.js'; const SYSTEM_CTX = { isSystem: true } as const; @@ -160,6 +164,13 @@ export interface ExplainEngineDeps { fieldRequiredPermissions: Record; /** [#3545] Posture could not be read — the middleware denies (fail-closed). */ unresolved?: boolean; + /** + * [#10401] Which condition that is — an unpublished draft, or a declaration + * that genuinely cannot be read. Optional: a deps bag wired without it (or a + * deployment whose probe could not run) explains as `'unknown'`, the wording + * that covers both. Never an input to the verdict — only to the prose. + */ + unresolvedCause?: UnresolvedPostureCause; }>; /** The middleware's requiredPermissions AND-gate resolution for an operation. */ requiredCaps: (meta: any, engineOperation: string) => string[]; @@ -1118,9 +1129,10 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput ? `${operation} on '${object}' is granted by [${granting.join(', ')}]` + (delegatorSets ? ' AND by the delegator (D10 intersection).' : '.') : postureUnresolved - ? `The security posture of '${object}' could not be resolved (neither the live schema nor the ` + - `metadata service returned it) — its 'private' flag and required-capability contract are ` + - `unknown, so access fails CLOSED rather than defaulting to public/uncontracted (#3545).` + // [#10401] One wording, shared with the middleware's own throw, so + // explanation and enforcement cannot drift into telling a reader two + // different things about one refusal. See `unresolved-posture.ts`. + ? unresolvedPostureExplainDetail(object, secMeta.unresolvedCause ?? 'unknown') : delegatorMissing ? `Delegator no longer exists — D10 fails closed (access denied).` : agentCrud && !delegatorCrud diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index f52c8b1b25..0740628d3b 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -60,6 +60,11 @@ import { owdOpenWritesCoversOperation, } from './platform-ownership-policies.js'; import { hasPhantomTenantAnchor } from './federated-phantom-anchors.js'; +import { + unresolvedPostureDenialMessage, + unresolvedPostureLogLine, + type UnresolvedPostureCause, +} from './unresolved-posture.js'; import { normalizeTenancyPosture, postureEnforcesWall, @@ -224,6 +229,19 @@ interface ObjectSecurityMeta { * the permissive default withholds the exemption. */ unresolved: boolean; + /** + * [#10401] WHICH of the two conditions behind {@link ObjectSecurityMeta.unresolved} + * this is — the object exists only as an unpublished draft, or its declaration + * genuinely cannot be read. Meaningful only when `unresolved` is `true`; absent + * (and therefore `'unknown'`) on every resolved posture. + * + * ⛔ Explanation only. Nothing may branch an ACCESS DECISION on it: both causes + * deny, identically and fail-closed, and the probe that produces it is + * best-effort by design (see `probeUnpublishedDraft`). Its whole job is to stop + * the refusal naming a remedy — "change a sharing rule" — that cannot fix + * either condition. + */ + unresolvedCause?: UnresolvedPostureCause; } const EMPTY_REQUIRED_PERMISSIONS: NormalizedRequiredPermissions = Object.freeze({ @@ -1386,7 +1404,12 @@ export class SecurityPlugin implements Plugin { const secMeta = permissionSets.length > 0 ? await this.getObjectSecurityMeta(opCtx.object) - : { isPrivate: false, tenancyDisabled: false, isBetterAuthManaged: false, requiredPermissions: EMPTY_REQUIRED_PERMISSIONS, fieldRequiredPermissions: {} as Record, fieldMaskingRules: {} as Record, unresolved: false }; + // [#10401] `unresolvedCause` is spelled out rather than omitted so this + // stand-in and the real posture share one readable shape — the throw + // site below reads the key off the union. `undefined` is correct here: + // this branch declares `unresolved: false` by fiat (no permission sets + // were resolved, so no posture was read), and there is no cause. + : { isPrivate: false, tenancyDisabled: false, isBetterAuthManaged: false, requiredPermissions: EMPTY_REQUIRED_PERMISSIONS, fieldRequiredPermissions: {} as Record, fieldMaskingRules: {} as Record, unresolved: false, unresolvedCause: undefined as UnresolvedPostureCause | undefined }; // [#3545] Fail CLOSED when the object's own posture could not be resolved. // #3545 accepted the API-exposure gate's fail-open on unresolvable metadata @@ -1409,15 +1432,31 @@ export class SecurityPlugin implements Plugin { // not by the permissive default — which is why the tiered decision recorded // for the exposure gate (transient unavailability → fail open) can stay // fail-open there while the boundary itself fails closed here. + // + // [#10401] …and the refusal has to SAY that, because the sentence it used + // to carry said something else. "The security posture could not be + // resolved" describes an internal security step, so every reader — human + // and model — read it as a permissions problem and went hunting for a + // sharing rule to change; and it covered two conditions with two + // different remedies (an unpublished draft, which is *publish it*, and a + // genuinely unreadable declaration, which is not). The DENY is unchanged + // — same `PermissionDeniedError`, same `PERMISSION_DENIED`, same 403 — + // only the explanation is now honest about which condition this is and + // about permissions not being the lever. Wording for both surfaces lives + // in `unresolved-posture.ts`; see its header for the measured cost of the + // old sentence. if (secMeta.unresolved) { + const cause: UnresolvedPostureCause = secMeta.unresolvedCause ?? 'unknown'; ctx.logger.error( - `[security] object security posture unresolvable for operation '${opCtx.operation}' on ` + - `object '${opCtx.object}' (user ${opCtx.context?.userId ?? 'unknown'}) — ` + - `denying request (fail-closed, #3545)`, + unresolvedPostureLogLine( + opCtx.object, + opCtx.operation, + String(opCtx.context?.userId ?? 'unknown'), + cause, + ), ); throw new PermissionDeniedError( - `[Security] Access denied: the security posture of object '${opCtx.object}' ` + - `could not be resolved for operation '${opCtx.operation}'`, + unresolvedPostureDenialMessage(opCtx.object, opCtx.operation, cause), { operation: opCtx.operation, object: opCtx.object }, ); } @@ -5545,11 +5584,59 @@ export class SecurityPlugin implements Plugin { fieldRequiredPermissions, fieldMaskingRules, unresolved: !obj, + // [#10401] Explanation only, and only on the path that is already + // refusing. Both causes deny identically — see the field's TSDoc. + ...(obj ? {} : { unresolvedCause: await this.probeUnpublishedDraft(object) }), }; if (obj) this.objectSecurityMetaCache.set(object, meta); return meta; } + /** + * [#10401] Best-effort: is this unresolvable object simply an UNPUBLISHED + * draft? + * + * `sys_metadata` keys a pending edit as `state: 'draft'` and the published + * value as `state: 'active'`; an object that exists only as a draft therefore + * resolves from neither the live ObjectQL schema nor the metadata service (both + * serve published values), which is precisely how it lands on the #3545 + * fail-closed branch wearing the same sentence as a genuinely missing + * declaration. + * + * Four properties this probe deliberately has: + * + * - **It runs ONLY on the unresolved path**, i.e. on a request that is already + * being refused. `getObjectSecurityMeta` returns before reaching here on + * every posture that resolves, so no authorized request pays for it. On the + * refusal path it is one indexed `sys_metadata` lookup beside the + * `metadata.get` that path already performs. + * - **It uses a SYSTEM context**, so the read short-circuits this very + * middleware at its `isSystem` guard rather than re-entering the posture + * resolution it is being called from. + * - **It fails SAFE, in one direction only.** Any failure — no `sys_metadata` + * in this deployment (file-backed metadata, LiteKernel test kernels), an + * unprovisioned store, a driver error — answers `'unknown'` and the caller + * gets the wording that covers both conditions. It can never turn a resolved + * posture into a denial, and it can never turn a refusal into a grant: it is + * read after the deny decision is already made. + * - **It is not org-scoped, and the wording is written so it does not need to + * be.** A draft row belonging to another organization would still make + * "a draft declaration exists but no published one" a true statement about + * this runtime, which is all the message claims. + */ + private async probeUnpublishedDraft(object: string): Promise { + if (typeof this.ql?.findOne !== 'function') return 'unknown'; + try { + const row = await this.ql.findOne('sys_metadata', { + where: { type: 'object', name: object, state: 'draft' }, + context: { isSystem: true }, + }); + return row ? 'unpublished_draft' : 'unknown'; + } catch { + return 'unknown'; + } + } + /** * [ADR-0066 D3] Fold per-field `requiredPermissions` into a FieldPermission map. * A field whose declared capabilities are NOT all held by the caller is forced diff --git a/packages/plugins/plugin-security/src/unpublished-object-deny-message.test.ts b/packages/plugins/plugin-security/src/unpublished-object-deny-message.test.ts new file mode 100644 index 0000000000..63c2fe0fb5 --- /dev/null +++ b/packages/plugins/plugin-security/src/unpublished-object-deny-message.test.ts @@ -0,0 +1,313 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#10401] An UNPUBLISHED object is denied — correctly — but the refusal must + * say WHY, and the why must not be "permissions". + * + * The #3545 fail-closed branch (pinned next door in + * `metadata-unresolvable-posture.test.ts`, and unchanged by this file) answers + * two very different conditions with one sentence: + * + * • the object exists only as an unpublished DRAFT — the author's declaration + * is fine, the remedy is to publish it; + * • the declaration genuinely cannot be read — the remedy is to check that the + * object is declared, or to look at a metadata-store outage. + * + * Because the one sentence described an internal *security* step ("the security + * posture … could not be resolved"), readers took it for a permissions problem. + * Measured downstream (objectstack-ai/cloud#1481): an AI turn burned seven tool + * calls and a free plan's daily allowance before telling the user the object was + * "missing its sharing/visibility setting". + * + * So this file pins three things: + * + * 1. **The rejection contract does not move.** Both branches are still + * `PermissionDeniedError` / `PERMISSION_DENIED` / 403, and both still open + * with the `[Security] Access denied` prefix — which is a MATCHER the + * transports read as "this is a 403" (`errors.ts` header), not house style. + * 2. **Both branches of the discriminator**, by their message. + * 3. **Enforcement and explanation stay in sync** — the two surfaces that + * state this condition are derived from one wording module, and drifting + * apart is the incident shape this card exists to close. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { SecurityPlugin } from './security-plugin.js'; +import { PermissionEvaluator } from './permission-evaluator.js'; +import { explainAccess, type ExplainEngineDeps } from './explain-engine.js'; +import { isPermissionDeniedError } from './errors.js'; +import { + unresolvedPostureRemedy, + unresolvedPostureDenialMessage, + unresolvedPostureExplainDetail, +} from './unresolved-posture.js'; +import type { PermissionSet } from '@objectstack/spec/security'; + +/** Plain member: blanket wildcard grant, no superuser bits, no capabilities. */ +const memberSet: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, +} as any; + +/** + * Middleware harness for the unresolved path. + * + * `draftRow` decides what the `sys_metadata` probe finds: a row (the object has + * a pending draft and no published value) or nothing. `probeThrows` stands in + * for every deployment where the probe cannot run at all — no `sys_metadata` + * registered, an unprovisioned store, a driver error — which must degrade to the + * both-conditions wording rather than to a claim. + */ +const makeHarness = (opts: { + resolvable: boolean; + draftRow?: boolean; + probeThrows?: boolean; +}) => { + const fields: Record = {}; + for (const f of ['id', 'organization_id', 'owner_id', 'name']) fields[f] = { name: f }; + const baseSchema: any = { name: 'shyx_customer', fields }; + + let middleware: any; + const findOne = vi.fn(async (object: string, query: any) => { + if (object !== 'sys_metadata') return null; + if (opts.probeThrows) throw new Error('no such table: sys_metadata'); + const w = query?.where ?? {}; + if (opts.draftRow && w.type === 'object' && w.name === 'shyx_customer' && w.state === 'draft') { + return { id: 'md_1', type: 'object', name: 'shyx_customer', state: 'draft' }; + } + return null; + }); + const ql = { + registerMiddleware: (mw: any) => { + if (!middleware) middleware = mw; + }, + getSchema: () => (opts.resolvable ? baseSchema : undefined), + findOne, + }; + const metadata = { + get: async (type: string, name: string) => { + if (!opts.resolvable && type === 'object' && name === 'shyx_customer') return undefined; + return baseSchema; + }, + list: async () => [memberSet], + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: ql, + metadata, + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + return { + ctx, + logger: ctx.logger, + findOne, + run: async (opCtx: any) => { + await middleware(opCtx, async () => {}); + return opCtx; + }, + }; +}; + +const boot = async (opts: { resolvable: boolean; draftRow?: boolean; probeThrows?: boolean }) => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeHarness(opts); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + return harness; +}; + +/** Authenticated member — resolves to a non-empty permission-set list. */ +const memberRead = (): any => ({ + object: 'shyx_customer', + operation: 'find', + ast: { where: undefined }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }, +}); + +/** The thrown refusal, as an object (the harness rejects). */ +const denialOf = async (h: { run: (c: any) => Promise }): Promise => { + try { + await h.run(memberRead()); + } catch (e) { + return e; + } + throw new Error('expected the middleware to deny, but it allowed the operation'); +}; + +describe('[#10401] unpublished object — the deny stays, the explanation gets honest', () => { + describe('the rejection contract is untouched (ADR-0112 envelope)', () => { + it('an UNPUBLISHED object still denies with PERMISSION_DENIED / 403', async () => { + const err = await denialOf(await boot({ resolvable: false, draftRow: true })); + expect(err.name).toBe('PermissionDeniedError'); + expect(err.code).toBe('PERMISSION_DENIED'); + expect(err.statusCode).toBe(403); + }); + + it('a genuinely UNRESOLVABLE object still denies with PERMISSION_DENIED / 403', async () => { + const err = await denialOf(await boot({ resolvable: false, draftRow: false })); + expect(err.name).toBe('PermissionDeniedError'); + expect(err.code).toBe('PERMISSION_DENIED'); + expect(err.statusCode).toBe(403); + }); + + it('both branches keep the `[Security] Access denied` prefix the transports match on', async () => { + for (const draftRow of [true, false]) { + const err = await denialOf(await boot({ resolvable: false, draftRow })); + expect(err.message.startsWith('[Security] Access denied')).toBe(true); + // The prefix is load-bearing: it is how a 403 is recognised downstream. + expect(isPermissionDeniedError(err)).toBe(true); + } + }); + }); + + describe('the discriminator — both branches', () => { + it('a draft-only object is told it is NOT PUBLISHED, and told to publish it', async () => { + const err = await denialOf(await boot({ resolvable: false, draftRow: true })); + expect(err.message).toContain("object 'shyx_customer' is not published"); + expect(err.message).toContain('Publish the object to make it queryable'); + // The whole point: it must not read as a permissions problem any more. + expect(err.message).toContain('NOT a permissions problem'); + expect(err.message).not.toContain('could not be resolved'); + }); + + it('a genuinely unresolvable object keeps the original clause, plus the remedy', async () => { + const err = await denialOf(await boot({ resolvable: false, draftRow: false })); + // Pinned verbatim: any surface matching the pre-#10401 opening still does. + expect(err.message).toContain( + "the security posture of object 'shyx_customer' could not be resolved for operation 'find'", + ); + expect(err.message).toContain('Check that the object is declared and published'); + expect(err.message).toContain('NOT a permissions problem'); + }); + + it('the two branches really are different sentences', async () => { + const unpublished = await denialOf(await boot({ resolvable: false, draftRow: true })); + const unresolvable = await denialOf(await boot({ resolvable: false, draftRow: false })); + expect(unpublished.message).not.toBe(unresolvable.message); + }); + + it('the operator log names the cause too, so the two are separable in logs', async () => { + const h = await boot({ resolvable: false, draftRow: true }); + await denialOf(h); + expect(h.logger.error).toHaveBeenCalled(); + const line = String(h.logger.error.mock.calls.at(-1)?.[0] ?? ''); + expect(line).toContain('DRAFT declaration'); + expect(line).toContain('shyx_customer'); + }); + }); + + describe('the probe fails safe, and costs nothing on the allowed path', () => { + it('a probe that THROWS degrades to the both-conditions wording — never to a claim', async () => { + const err = await denialOf(await boot({ resolvable: false, probeThrows: true })); + expect(err.code).toBe('PERMISSION_DENIED'); + expect(err.message).toContain('could not be resolved'); + expect(err.message).not.toContain('is not published'); + }); + + it('a resolvable posture never probes sys_metadata at all', async () => { + const h = await boot({ resolvable: true, draftRow: true }); + await expect(h.run(memberRead())).resolves.toBeDefined(); + const probed = h.findOne.mock.calls.filter((c: any[]) => c[0] === 'sys_metadata'); + expect(probed).toHaveLength(0); + }); + + it('the probe reads under a SYSTEM context, so it cannot re-enter the posture resolution', async () => { + const h = await boot({ resolvable: false, draftRow: true }); + await denialOf(h); + const probed = h.findOne.mock.calls.filter((c: any[]) => c[0] === 'sys_metadata'); + expect(probed).toHaveLength(1); + expect(probed[0][1]?.context?.isSystem).toBe(true); + expect(probed[0][1]?.where).toMatchObject({ + type: 'object', + name: 'shyx_customer', + state: 'draft', + }); + }); + + it('the probe never turns a denial into a grant — an unpublished object is still refused', async () => { + const h = await boot({ resolvable: false, draftRow: true }); + await expect(h.run(memberRead())).rejects.toMatchObject({ name: 'PermissionDeniedError' }); + }); + }); + + // The incident shape this card closes is TWO FILES stating one condition and + // drifting apart. One module owns the wording; these pin that both surfaces + // read it rather than re-spelling it. + describe('enforcement and explanation cannot drift apart', () => { + const explainDeps = (unresolvedCause?: 'unpublished_draft' | 'unknown'): ExplainEngineDeps => + ({ + ql: { getSchema: () => ({ name: 'shyx_customer' }) }, + resolveSets: async () => [memberSet], + evaluator: new PermissionEvaluator(), + getObjectSecurityMeta: async () => ({ + isPrivate: false, + requiredPermissions: { all: [], read: [], create: [], update: [], delete: [] }, + fieldRequiredPermissions: {}, + unresolved: true, + ...(unresolvedCause ? { unresolvedCause } : {}), + }), + requiredCaps: (meta: any, op: string) => { + const bucket = op === 'find' ? 'read' : op === 'insert' ? 'create' : op; + return [...(meta.all ?? []), ...(meta[bucket] ?? [])]; + }, + computeRlsFilter: async () => null, + getFieldMask: () => ({}), + getPartialMaskRules: async () => ({}), + baselinePermissionSets: ['member_default'], + }) as any; + + const explainCtx = { userId: 'u1', positions: ['everyone'], permissions: [] }; + + const crudDetail = async (cause?: 'unpublished_draft' | 'unknown') => { + const d = await explainAccess(explainDeps(cause), { + object: 'shyx_customer', + operation: 'read', + context: explainCtx, + }); + expect(d.allowed).toBe(false); + const crud = d.layers.find((l) => l.layer === 'object_crud')!; + expect(crud.verdict).toBe('denies'); + return crud.detail; + }; + + it('explain names the unpublished cause and the same remedy the refusal names', async () => { + const detail = await crudDetail('unpublished_draft'); + expect(detail).toContain('is not published'); + expect(detail).toContain(unresolvedPostureRemedy('unpublished_draft')); + // …and the refusal carries that same remedy sentence, verbatim. + const err = await denialOf(await boot({ resolvable: false, draftRow: true })); + expect(err.message).toContain(unresolvedPostureRemedy('unpublished_draft')); + }); + + it('explain keeps the unresolvable prose, and shares its remedy with the refusal too', async () => { + const detail = await crudDetail('unknown'); + expect(detail).toContain('could not be resolved'); + expect(detail).toContain(unresolvedPostureRemedy('unknown')); + const err = await denialOf(await boot({ resolvable: false, draftRow: false })); + expect(err.message).toContain(unresolvedPostureRemedy('unknown')); + }); + + it('a deps bag with no cause at all explains as the both-conditions wording', async () => { + // Back-compat: an explain caller wired before #10401 must not crash or + // silently claim "unpublished" for a condition nobody probed. + const detail = await crudDetail(undefined); + expect(detail).toBe(unresolvedPostureExplainDetail('shyx_customer', 'unknown')); + expect(detail).not.toContain('is not published'); + }); + + it('the middleware throw is the wording module verbatim, not a second spelling', async () => { + const err = await denialOf(await boot({ resolvable: false, draftRow: true })); + expect(err.message).toBe( + unresolvedPostureDenialMessage('shyx_customer', 'find', 'unpublished_draft'), + ); + }); + }); +}); diff --git a/packages/plugins/plugin-security/src/unresolved-posture.ts b/packages/plugins/plugin-security/src/unresolved-posture.ts new file mode 100644 index 0000000000..4f2fb54e98 --- /dev/null +++ b/packages/plugins/plugin-security/src/unresolved-posture.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#10401] The ONE wording for "this object has no resolvable security posture", + * shared by the two surfaces that state it: the middleware's fail-closed throw + * (`security-plugin.ts`, the #3545 branch) and the explain engine's + * `object_crud` layer detail (`explain-engine.ts`). + * + * ## Why the wording is a module rather than two string literals + * + * They were two literals, and the pair is exactly the drift this file removes: + * enforcement and explanation are supposed to describe one decision, and a + * reader who is told two different things about the same refusal cannot tell + * which one is the platform's actual position. Both sentences are now derived + * from the same {@link unresolvedPostureRemedy}, so a future edit to the remedy + * cannot land on one surface only. + * + * ## What was wrong with the sentence (the reason this file exists) + * + * The deny itself is correct and stays fail-closed — #3545's stance is + * untouched here, and nothing in this file widens access. What was wrong was + * the EXPLANATION. One string, "the security posture of object 'X' could not be + * resolved", covered two conditions with two different remedies: + * + * • the object exists only as an unpublished DRAFT — the remedy is *publish + * it*, and the author's declaration is perfectly fine; + * • the declaration genuinely cannot be read — the remedy is to check that + * the object is declared at all, or to look at a metadata-store outage. + * + * …and because it described an internal *security* step, every reader — human + * and model alike — read it as a permissions problem and went looking for a + * sharing rule to change. Measured downstream (objectstack-ai/cloud#1481): an + * end-user AI turn spent seven tool calls oscillating between a metadata plane + * that said the object existed and this refusal, then told the user the object + * was "missing its sharing/visibility setting" — confident and wrong, on a free + * plan whose daily allowance that one turn exhausted. + * + * Hence both sentences below end by saying, in words, that permissions are not + * the lever. A refusal that names the wrong remedy is worse than a terse one: + * it is actively load-bearing for the next reader's diagnosis. + * + * ## Why the `[Security] Access denied` prefix survives + * + * It is a MATCHER, not house style — `isPermissionDeniedError`, `mapDataError` + * and the rest-server sanitiser all read it as "this is a 403" (see the + * `errors.ts` header). The refusal keeps `PermissionDeniedError`'s + * `PERMISSION_DENIED` / 403 contract unchanged, so nothing on the wire moves; + * only what the message SAYS about the cause and the remedy changes. + * + * The cause is carried in the message rather than in `details` for the reason + * `errors.ts` already records: `details` is not a reliable carrier across both + * transports, so each of these sentences has to stand on its own. + */ + +/** + * Which of the two conditions behind an unresolved posture this is. + * + * `'unknown'` is the honest default and the fail-safe: the draft probe that + * distinguishes the two is best-effort (see `probeUnpublishedDraft` in + * `security-plugin.ts`), so a deployment with no queryable `sys_metadata` — or + * any probe failure at all — reports `'unknown'` and gets the wording that + * covers both cases. The discriminator may never turn a *real* unpublished + * object into a claim the platform cannot support. + */ +export type UnresolvedPostureCause = 'unpublished_draft' | 'unknown'; + +/** + * The remedy half — the sentence that tells the reader what to actually do, and + * (just as load-bearing) what NOT to do. Shared by both surfaces so the advice + * cannot diverge from the diagnosis. + */ +export function unresolvedPostureRemedy(cause: UnresolvedPostureCause): string { + return cause === 'unpublished_draft' + ? 'Publish the object to make it queryable. This is NOT a permissions problem — no sharing rule, ' + + 'visibility setting or permission-set change grants access to an unpublished object.' + : 'Check that the object is declared and published on this runtime. This is NOT a permissions ' + + 'problem — no sharing rule, visibility setting or permission-set change grants access to an ' + + 'object whose declaration cannot be read.'; +} + +/** + * The end-user/API-facing refusal thrown by the middleware's #3545 branch. + * + * The `'unknown'` branch keeps the pre-#10401 opening clause verbatim — "the + * security posture of object 'X' could not be resolved for operation 'Y'" — so + * any surface pinning that substring keeps matching; what follows it is new. + */ +export function unresolvedPostureDenialMessage( + object: string, + operation: string, + cause: UnresolvedPostureCause, +): string { + const remedy = unresolvedPostureRemedy(cause); + return cause === 'unpublished_draft' + ? `[Security] Access denied: object '${object}' is not published — a draft declaration exists but ` + + `no published one, so there is no security posture to authorize '${operation}' against. ${remedy}` + : `[Security] Access denied: the security posture of object '${object}' could not be resolved for ` + + `operation '${operation}' — neither the live schema nor the metadata service returned a ` + + `declaration for it, so access fails closed. ${remedy}`; +} + +/** + * The explain engine's `object_crud` layer detail for the same condition. + * + * Reports on the existing layer (no new layer kind) for the reason the call + * site records: the posture is what that layer's grant is computed FROM, so + * naming the real cause there beats a misleading "no set grants it" when the + * sets were never the problem. + */ +export function unresolvedPostureExplainDetail( + object: string, + cause: UnresolvedPostureCause, +): string { + const remedy = unresolvedPostureRemedy(cause); + return cause === 'unpublished_draft' + ? `'${object}' is not published — a draft declaration exists but no published one, so its 'private' ` + + `flag and required-capability contract are unknown and access fails CLOSED rather than ` + + `defaulting to public/uncontracted (#3545). ${remedy}` + : `The security posture of '${object}' could not be resolved (neither the live schema nor the ` + + `metadata service returned it) — its 'private' flag and required-capability contract are ` + + `unknown, so access fails CLOSED rather than defaulting to public/uncontracted (#3545). ${remedy}`; +} + +/** + * The operator-facing log line for the same condition, so a persistent + * metadata-store outage and a routine "somebody queried a draft" are + * distinguishable in the logs and not only in the response body. + */ +export function unresolvedPostureLogLine( + object: string, + operation: string, + userId: string, + cause: UnresolvedPostureCause, +): string { + return cause === 'unpublished_draft' + ? `[security] object '${object}' has a DRAFT declaration and no published one — denying operation ` + + `'${operation}' (user ${userId}) with the unpublished-object refusal (fail-closed, #3545/#10401)` + : `[security] object security posture unresolvable for operation '${operation}' on ` + + `object '${object}' (user ${userId}) — denying request (fail-closed, #3545)`; +}