diff --git a/.changeset/25561-analytics-read-gate-fault-503.md b/.changeset/25561-analytics-read-gate-fault-503.md new file mode 100644 index 00000000000..18d02b1faa2 --- /dev/null +++ b/.changeset/25561-analytics-read-gate-fault-503.md @@ -0,0 +1,21 @@ +--- +'@objectstack/service-analytics': patch +'@objectstack/metadata-protocol': patch +--- + +fix(service-analytics)!: an analytics read gate that could not decide answers `503 SERVICE_UNAVAILABLE`, not the `403 PERMISSION_DENIED` of a real denial + +Three of the analytics door's refusals are operator-side faults: the object-level read admission provider threw, the field-level read permissions could not be resolved, or the API exposure declaration could not be read. Each refused the query, which is right, but each answered with the same body as a real denial, `403 PERMISSION_DENIED`. A caller could not tell "you may not read this" from "the server could not decide", except by parsing the English message. All three now answer `503 SERVICE_UNAVAILABLE`. That is an existing ADR-0112 code, and it is the spelling `@objectstack/core` already uses when the permission store is unreachable. The refusal names the object and nothing else; it names no gate, grant or existence fact. The cause stays in the server log, as before. Fail-closed is unchanged: nothing that was refused is now admitted. Real denials keep their answers byte for byte: `403 PERMISSION_DENIED` when the caller holds no read grant or reads a hidden field, and `404 OBJECT_API_DISABLED` / `405 OBJECT_API_METHOD_NOT_ALLOWED` for an unexposed object. `POST /api/v1/analytics/dataset/query` relays the declared fault as `503 SERVICE_UNAVAILABLE`, with the message withheld as for every declared server fault. It does not answer `500 ANALYTICS_QUERY_FAILED`. The cube query door relays the status the same way. + +The publish build probe (`@objectstack/metadata-protocol`) reads the code too. A widget query that fails with `SERVICE_UNAVAILABLE` now gets a `widget_query_failed` fix that names the server-side fault: the dataset is not the cause, and the server could not decide read access to the named object. Before, it fell to "Fix the dataset definition…", which is wrong for an outage. The finding's `code`, `severity` and `message` are unchanged. + +Clause-②: no +Metadata-protocol: none + + + +**BREAKING** (the wire answer of three fault refusals changes; no request that was served is refused and none that was refused is served), shipped as `patch` because the metadata protocol does not move. + +- **Before:** when the analytics read-admission provider threw, or the field-level read permissions or the API exposure declaration could not be resolved, the query was refused `403 PERMISSION_DENIED`. That was the same code and status as a caller who genuinely may not read the object. +- **Now:** the same refusals answer `503 SERVICE_UNAVAILABLE` (`status: 503`, `object` naming the object). On `POST /api/v1/analytics/dataset/query` the body is `{"code":"SERVICE_UNAVAILABLE","error":"Internal server error"}`. Real denials still answer `403 PERMISSION_DENIED` (and `404` / `405` for exposure), unchanged. +- **Fix:** a client that treated every analytics `403` as "no access" should treat `503 SERVICE_UNAVAILABLE` as a server-side fault: retry once the permission or exposure resolver is healthy, and check the server log for the cause. Do not show it as a missing grant. diff --git a/packages/metadata-protocol/src/build-probes-widget-refusal-fix.test.ts b/packages/metadata-protocol/src/build-probes-widget-refusal-fix.test.ts index f8eb20e8004..206b513636e 100644 --- a/packages/metadata-protocol/src/build-probes-widget-refusal-fix.test.ts +++ b/packages/metadata-protocol/src/build-probes-widget-refusal-fix.test.ts @@ -7,9 +7,11 @@ * The widget plane runs `queryDataset` as the publishing caller, so read * admission can refuse a dataset that is correct. That refusal is told apart * by the thrown error's ADR-0112 `code` (`PERMISSION_DENIED`), and its `fix` - * names the caller's read access — never a dataset edit. Every other throw + * names the caller's read access — never a dataset edit. A read gate that + * could not decide at all answers `SERVICE_UNAVAILABLE`, and its `fix` names + * the server-side fault — neither a grant nor a dataset edit. Every other throw * (a selection the dataset does not declare, an unjudgeable member) keeps the - * dataset fix. `code`, `severity` and `message` are the same on both arms. + * dataset fix. `code`, `severity` and `message` are the same on every arm. * * The errors below are the analytics service's envelopes field for field * (`read-admission.ts`, `field-read-admission.ts`, `dataset-refusal.ts`); the @@ -84,6 +86,31 @@ describe('widget_query_failed — the fix follows the failure', () => { expect(issue.fix).toContain('read access to object "task_item" and its field(s) salary'); }); + it('a read gate that could not decide (SERVICE_UNAVAILABLE) names neither the dataset nor an access grant', async () => { + // The analytics service's fault refusal (`readAdmissionUnresolvedError`): + // a permission or exposure resolver failed, so no verdict was reached. + const issue = await probeThrowing(envelope( + '[Analytics] Read access to "project" could not be determined, so the query was not run. ' + + 'This is a server-side fault, not a permission denial.', + { code: 'SERVICE_UNAVAILABLE', status: 503, object: 'project' }, + )); + expect(issue.fix).toContain('Dataset "ds" is not the cause'); + expect(issue.fix).toContain('could not decide read access to object "project"'); + expect(issue.fix).toContain('server log'); + expect(issue.fix).not.toContain(DATASET_FIX); + // Not the denial's remedy: nobody is told to grant anything. + expect(issue.fix).not.toContain('grant it'); + }); + + it('a SERVICE_UNAVAILABLE that names no object falls back to the dataset\'s object', async () => { + const issue = await probeThrowing(envelope( + 'unavailable', + { code: 'SERVICE_UNAVAILABLE', status: 503 }, + )); + expect(issue.fix).toContain('could not decide read access to object "task_item"'); + expect(issue.fix).not.toContain(DATASET_FIX); + }); + it('a selection the dataset does not declare keeps the dataset fix', async () => { const issue = await probeThrowing(envelope( 'Dataset "ds" does not declare measure "nope".', diff --git a/packages/metadata-protocol/src/build-probes.ts b/packages/metadata-protocol/src/build-probes.ts index b4e89c9068d..dbe91790b60 100644 --- a/packages/metadata-protocol/src/build-probes.ts +++ b/packages/metadata-protocol/src/build-probes.ts @@ -138,6 +138,27 @@ function readAdmissionRefusal(e: unknown): { object?: string; fields: string[] } }; } +/** + * `SERVICE_UNAVAILABLE`, pinned against the STANDARD catalog — the code the + * analytics service throws (ADR-0112 envelope, 503) when a read gate could not + * reach a verdict: a permission or API-exposure resolver failed. + */ +const SERVICE_UNAVAILABLE: StandardErrorCode = 'SERVICE_UNAVAILABLE'; + +/** + * The fault refusal a widget query can be answered with, judged by the thrown + * error's ADR-0112 `code` — never by its message text. + * + * Neither the dataset nor the caller's access is the cause: the server could + * not decide, so the remedy is the operator's. `object` names the object the + * server could not decide read access to, when the error carries one. + */ +function serviceUnavailableRefusal(e: unknown): { object?: string } | undefined { + const err = e && typeof e === 'object' ? (e as Rec) : undefined; + if (!err || err.code !== SERVICE_UNAVAILABLE) return undefined; + return { object: typeof err.object === 'string' && err.object ? err.object : undefined }; +} + /** Result rows of a queryDataset call, tolerant of {rows}/{data}/array shapes. */ function resultRows(result: unknown): unknown[] { if (Array.isArray(result)) return result; @@ -419,15 +440,24 @@ export async function runBuildProbes(opts: RunBuildProbesOptions): Promise { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.end = vi.fn(() => res); + return res; +} + +/** Build a RestServer over an analytics provider (positional arg #15) — a resolver of the service. */ +function buildRoute(analyticsProvider: () => Promise) { + const rest = new RestServer( + mockServer() as any, mockProtocol() as any, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + analyticsProvider as any, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + return rest.getRoutes().find((r) => r.method === 'POST' && r.path.endsWith('/analytics/dataset/query'))!; +} + +// The route's fault line is asserted below, so the level this file reads is the loud one. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + +/** The service's log sink, recording every line it is handed — where the cause must land. */ +function recordingLogger(): { logger: Logger; lines: string[] } { + const lines: string[] = []; + const record = (message: string, err?: unknown) => { + lines.push(`${message} ${err instanceof Error ? err.message : ''}`); + }; + return { lines, logger: { debug() {}, info() {}, warn: record, error: record } as Logger }; +} + +type Gates = Pick; + +/** A real `AnalyticsService` on the ObjectQL aggregate path, with the gates under test. */ +function analytics(gates: Gates, executed: string[], logger: Logger): AnalyticsService { + return new AnalyticsService({ + logger, + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (object: string) => { + executed.push(object); + return [{ stage: 'won', revenue: 100 }]; + }, + isRegisteredObject: () => true, + ...gates, + }); +} + +const OBJECT = 'crm_opportunity'; +const body = { + dataset: { + name: 'pipeline', + label: 'Pipeline', + object: OBJECT, + dimensions: [{ name: 'stage', field: 'stage', type: 'string' }], + measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }], + }, + selection: { dimensions: ['stage'], measures: ['revenue'] }, +}; + +async function post(gates: Gates) { + const executed: string[] = []; + const { logger, lines } = recordingLogger(); + const service = analytics(gates, executed, logger); + const route = buildRoute(async () => service); + const res = mockRes(); + const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let routeLog: string; + try { + await route.handler({ method: 'POST', params: {}, headers: {}, body } as any, res); + // Read the calls BEFORE restoring: `mockRestore` clears them. + routeLog = logSpy.mock.calls.map((args) => args.map(String).join(' ')).join('\n'); + } finally { + logSpy.mockRestore(); + } + return { status: res.statusCode as number, body: res.body, executed, serviceLog: lines.join('\n'), routeLog }; +} + +const exploded = () => { + throw new Error('permission resolver exploded'); +}; + +const ROWS: Array<{ gate: string; fault: Gates; verdict: Gates; verdictCode: string; verdictStatus: number }> = [ + { + gate: 'the object-level read admission', + fault: { admitObjectRead: exploded }, + verdict: { admitObjectRead: () => false }, + verdictCode: 'PERMISSION_DENIED', + verdictStatus: 403, + }, + { + gate: 'the field-level read permissions', + fault: { getReadableFields: exploded }, + verdict: { getReadableFields: () => ['stage'] }, + verdictCode: 'PERMISSION_DENIED', + verdictStatus: 403, + }, + { + gate: 'the API exposure declaration', + fault: { getObjectDeclaration: exploded }, + verdict: { getObjectDeclaration: () => ({ enable: { apiEnabled: false } }) }, + verdictCode: 'OBJECT_API_DISABLED', + verdictStatus: 404, + }, +]; + +describe('[#25561] a read gate that could not decide answers 503 SERVICE_UNAVAILABLE on the dataset door', () => { + it('the control: with every gate admitting, the route serves the query', async () => { + const res = await post({ + admitObjectRead: () => true, + getReadableFields: () => ['stage', 'amount'], + getObjectDeclaration: () => ({}), + }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.executed).toEqual([OBJECT]); + }); + + describe.each(ROWS)('$gate', ({ fault, verdict, verdictCode, verdictStatus }) => { + it('a resolver that THROWS answers 503 SERVICE_UNAVAILABLE — not the denial, not ANALYTICS_QUERY_FAILED — and nothing runs', async () => { + const res = await post(fault); + expect(res.status).toBe(503); + expect(res.body.code).toBe('SERVICE_UNAVAILABLE'); + expect(res.body.code).not.toBe('PERMISSION_DENIED'); + expect(res.body.code).not.toBe('ANALYTICS_QUERY_FAILED'); + // A declared server fault's prose is the operator's: withheld here, kept + // in the server log. Neither the cause nor a layer reaches the caller. + expect(res.body.error).toBe(INTERNAL_ERROR_MESSAGE); + expect(JSON.stringify(res.body)).not.toContain('exploded'); + expect(res.executed).toEqual([]); + // …and the operator keeps it: the cause at the site, the fault at the route. + expect(res.serviceLog).toContain('exploded'); + expect(res.routeLog).toContain('Analytics dataset query error'); + }); + + it(`its verdict twin — the same gate answering "no" — still answers ${verdictStatus} ${verdictCode}`, async () => { + const res = await post(verdict); + expect(res.status).toBe(verdictStatus); + expect(res.body.code).toBe(verdictCode); + expect(res.executed).toEqual([]); + }); + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts b/packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts index 5cb45adceb9..b9d635538f2 100644 --- a/packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts +++ b/packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts @@ -398,7 +398,10 @@ describe.each(STRATEGIES)('the analytics door honours the generic-exit declarati }); describe('the exposure gate fails closed', () => { - it('a declaration lookup that throws refuses the query PERMISSION_DENIED / 403, and nothing runs', async () => { + // [#25561] A lookup that THROWS reached no verdict, so the refusal does not + // claim one: `503 SERVICE_UNAVAILABLE`, the door's one fault answer — never + // the data door's exposure codes and never the denial's `403`. + it('a declaration lookup that throws refuses the query SERVICE_UNAVAILABLE / 503, and nothing runs', async () => { const executed: string[] = []; const service = new AnalyticsService({ queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), @@ -414,7 +417,7 @@ describe('the exposure gate fails closed', () => { () => service.query({ cube: OPEN, measures: ['count'] }), () => service.generateSql({ cube: OPEN, measures: ['count'] }), ]) { - expect(pick(await rejection(call))).toEqual({ code: 'PERMISSION_DENIED', status: 403, object: OPEN }); + expect(pick(await rejection(call))).toEqual({ code: 'SERVICE_UNAVAILABLE', status: 503, object: OPEN }); } expect(executed).toEqual([]); }); @@ -438,7 +441,7 @@ describe('the exposure gate fails closed', () => { } as never); const service = registered.analytics as AnalyticsService; expect(pick(await rejection(() => service.query({ cube: OPEN, measures: ['count'] })))).toEqual({ - code: 'PERMISSION_DENIED', status: 403, object: OPEN, + code: 'SERVICE_UNAVAILABLE', status: 503, object: OPEN, }); expect(executed).toEqual([]); }); diff --git a/packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts b/packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts index 67c87f44014..7c84dd8ebcd 100644 --- a/packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts +++ b/packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts @@ -165,9 +165,10 @@ describe('[#20935] analytics — a field the caller is served masked is readable getQueryableFields: () => { throw new Error('reader unavailable'); }, logger, }); + // [#25561] No verdict was reached: the fault's 503, not a masked field's 403. await expect( service.query({ cube: LEDGER, measures: ['count'], dimensions: ['title'] } as never, CALLER), - ).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403, object: LEDGER }); + ).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503, object: LEDGER }); expect(executed).toEqual([]); expect(error.mock.calls.map((c) => String(c[0])).join('\n')).toMatch(/field-level read admission could not be resolved .*\(fail-closed\)/); }); diff --git a/packages/services/service-analytics/src/__tests__/field-read-admission-gate.test.ts b/packages/services/service-analytics/src/__tests__/field-read-admission-gate.test.ts index f519fd507f6..314c26902fe 100644 --- a/packages/services/service-analytics/src/__tests__/field-read-admission-gate.test.ts +++ b/packages/services/service-analytics/src/__tests__/field-read-admission-gate.test.ts @@ -246,6 +246,9 @@ describe('[#20917] analytics — the field-level read gate at the door', () => { expect(executed.length).toBeGreaterThan(0); }); + // [#25561] A reader that THROWS reached no verdict, so the refusal does not + // claim one: 503 SERVICE_UNAVAILABLE, naming the object and not the cause. + // A hidden field — the verdict — keeps 403 PERMISSION_DENIED (above). it('fails closed: a reader that throws refuses the query, and says so in the error log', async () => { const error = vi.fn(); const logger = { debug() {}, info() {}, warn() {}, error, child() { return logger; } }; @@ -254,9 +257,11 @@ describe('[#20917] analytics — the field-level read gate at the door', () => { getReadableFields: () => { throw new Error('reader unavailable'); }, logger, }); - await expect( - service.query({ cube: LEDGER, measures: ['count'], dimensions: ['title'] } as never, CALLER), - ).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403, object: LEDGER }); + const refusal = await service + .query({ cube: LEDGER, measures: ['count'], dimensions: ['title'] } as never, CALLER) + .then(() => undefined, (e: unknown) => e as Error); + expect(refusal).toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503, object: LEDGER }); + expect(refusal?.message).not.toContain('reader unavailable'); expect(executed).toEqual([]); expect(error.mock.calls.map((c) => String(c[0])).join('\n')).toMatch(/field-level read admission could not be resolved .*\(fail-closed\)/); }); @@ -438,7 +443,8 @@ describe('[#20917] analytics plugin — the field-level half of the "security" b it('refuses, and says why, when the registered security service exposes no getReadableFields', async () => { const { service, reads, error } = await bootPlugin(() => ({ ...objectAndRowsOpen })); - await expect(service.query(groupedReadable as never, CALLER)).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + // [#25561] The reader could not answer, so the refusal is the fault's 503. + await expect(service.query(groupedReadable as never, CALLER)).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503, object: LEDGER }); expect(reads).toEqual([]); expect(error.mock.calls.map((c) => String(c[0])).join('\n')).toMatch(/getReadableFields\(\)/); }); diff --git a/packages/services/service-analytics/src/__tests__/read-admission-gate.test.ts b/packages/services/service-analytics/src/__tests__/read-admission-gate.test.ts index 36835c73ed7..78d95236060 100644 --- a/packages/services/service-analytics/src/__tests__/read-admission-gate.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-admission-gate.test.ts @@ -150,24 +150,38 @@ describe('analytics — object-level read admission at the door', () => { ).rejects.toThrow(/employer_member/); }); - it('fails CLOSED when the admission provider throws', async () => { - const seen = emptySeen(); - const svc = makeService( - { - capabilities, - admitObjectRead: () => { - throw new Error('permission-set resolution exploded'); + // [#25561] A provider that THROWS reached no verdict. It still refuses — + // fail-closed, nothing runs — but it does not answer as a denial: the + // denial above and this fault were byte-identical on the wire, so a caller + // could not tell "you may not read this" from "the server could not decide". + it.each(DOORS)( + 'fails CLOSED when the admission provider throws, answering SERVICE_UNAVAILABLE / 503 — $label', + async ({ run }) => { + const seen = emptySeen(); + const svc = makeService( + { + capabilities, + admitObjectRead: () => { + throw new Error('permission-set resolution exploded'); + }, }, - }, - seen, - ); - - await expect( - svc.queryDataset(memberCount, { measures: ['cnt'] }, CALLER), - ).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); - expect(seen.sql).toEqual([]); - expect(seen.aggregate).toEqual([]); - }); + seen, + ); + + const refusal = await run(svc).then( + () => undefined, + (e: unknown) => e as Error & { code?: string; status?: number; object?: string }, + ); + expect(refusal).toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503, object: 'employer_member' }); + expect(refusal?.code).not.toBe('PERMISSION_DENIED'); + // The no-oracle rule holds for the fault too: the object, and not the + // cause, which stays in the server log. + expect(refusal?.message).toContain('employer_member'); + expect(refusal?.message).not.toContain('exploded'); + expect(seen.sql).toEqual([]); + expect(seen.aggregate).toEqual([]); + }, + ); it('refuses when the caller may read the BASE object but not a JOINED one', async () => { const seen = emptySeen(); diff --git a/packages/services/service-analytics/src/__tests__/unexposed-definition-registration.test.ts b/packages/services/service-analytics/src/__tests__/unexposed-definition-registration.test.ts index d35fb9aa32a..adcb9215482 100644 --- a/packages/services/service-analytics/src/__tests__/unexposed-definition-registration.test.ts +++ b/packages/services/service-analytics/src/__tests__/unexposed-definition-registration.test.ts @@ -252,8 +252,9 @@ describe('cannot answer, do not block — the registration doors\' own tiering', logger: recordingLogger().logger, }); expect(await listed(service)).toEqual(['open_cube']); + // [#25561] Fail-closed, and answered as the fault it is: 503, not a 403. expect(pick(await rejection(() => service.query({ cube: 'open_cube', measures: ['rows'] })))).toEqual({ - code: 'PERMISSION_DENIED', status: 403, object: OPEN, + code: 'SERVICE_UNAVAILABLE', status: 503, object: OPEN, }); }); diff --git a/packages/services/service-analytics/src/api-exposure-door.ts b/packages/services/service-analytics/src/api-exposure-door.ts index fcac6f0e9d1..ba36fded559 100644 --- a/packages/services/service-analytics/src/api-exposure-door.ts +++ b/packages/services/service-analytics/src/api-exposure-door.ts @@ -64,8 +64,11 @@ * ## Fail direction * * The provider is access-NARROWING, so it fails CLOSED: a lookup that throws - * refuses the query (`403 PERMISSION_DENIED`, logged at `error`), and the - * plugin's bridge throws when no data engine can answer. An object the + * refuses the query, logged at `error`, and the plugin's bridge throws when no + * data engine can answer. No declaration was read, so the refusal claims no + * verdict: `503 SERVICE_UNAVAILABLE`, the door's one fault answer + * (`readAdmissionUnresolvedError`, `read-admission.ts`), never the `403` a + * real denial answers. An object the * provider answers no declaration for (`undefined`) declares nothing, and the * spec decision answers "served" for it — the same default every door reads. * A host that constructs `AnalyticsService` with no provider gets no gate and @@ -103,6 +106,7 @@ import { import type { RegisteredErrorCode, StandardErrorCode } from '@objectstack/spec/api'; import { collectInternalWriteResponseFields } from '@objectstack/core'; import type { NamedField, NamedRead } from './field-read-admission.js'; +import { readAdmissionUnresolvedError } from './read-admission.js'; /** * The slice of an object's registered definition the door reads: its `enable` @@ -153,9 +157,6 @@ const OBJECT_API_METHOD_NOT_ALLOWED_CODE: RegisteredErrorCode = 'OBJECT_API_METH /** The member refusal every sibling refusal at this door answers. */ const INVALID_FIELD: StandardErrorCode = 'INVALID_FIELD'; -/** The fail-closed refusal, the read admission's code. */ -const PERMISSION_DENIED: StandardErrorCode = 'PERMISSION_DENIED'; - type DoorRefusal = Error & { code?: string; status?: number; @@ -227,18 +228,6 @@ function unexposedDefinitionError( return err; } -/** `403 PERMISSION_DENIED` — the declaration could not be read, so nothing runs. */ -function exposureUnresolvedError(object: string): Error { - const err = new Error( - `[Analytics] Access denied: the API exposure declaration for "${object}" could not be resolved, ` + - 'so the query was not run.', - ) as DoorRefusal; - err.code = PERMISSION_DENIED; - err.status = 403; - err.object = object; - return err; -} - /** * `400 INVALID_FIELD` for a member that reads an `internal: true` field. Names * the object and the field — the field the member resolved to — and no value. @@ -273,7 +262,7 @@ function declarationOf( 'denying the query (fail-closed)'; if (logger?.error) logger.error(report, cause); else logger?.warn(`${report}: ${cause.message}`); - throw exposureUnresolvedError(object); + throw readAdmissionUnresolvedError(object); } } diff --git a/packages/services/service-analytics/src/field-read-admission.ts b/packages/services/service-analytics/src/field-read-admission.ts index 0cf6be5fa3f..d5487153555 100644 --- a/packages/services/service-analytics/src/field-read-admission.ts +++ b/packages/services/service-analytics/src/field-read-admission.ts @@ -76,7 +76,10 @@ * * - A provider THROWS → the query is refused (fail-closed) and the failure is * logged at `error`. That holds for the queryable provider exactly as for - * the readable one. + * the readable one. No verdict was reached, so the refusal does not claim + * one: `503 SERVICE_UNAVAILABLE`, the door's one fault answer + * (`readAdmissionUnresolvedError`, `read-admission.ts`), never the `403` a + * hidden field answers. * - The provider answers `undefined` for an object → the reader has no field * answer for it (its contract's "no answer"), and no field of that object is * judged: an object the security service cannot resolve is one the engine @@ -88,6 +91,7 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { StandardErrorCode } from '@objectstack/spec/api'; +import { readAdmissionUnresolvedError } from './read-admission.js'; /** * `PERMISSION_DENIED`, pinned against the STANDARD catalog — the code and the @@ -223,21 +227,6 @@ export function fieldReadUnjudgeableError(object: string, member: string): Error return err; } -/** - * The fail-closed refusal: the reader could not answer for `object`. Names the - * object and nothing else — the cause is the operator's, logged at the site. - */ -function fieldReadUnresolvedError(object: string): Error { - const err = new Error( - `[Analytics] Access denied: the field-level read permissions for "${object}" could not be resolved, ` + - 'so the query was not run.', - ) as FieldRefusal; - err.code = PERMISSION_DENIED; - err.status = 403; - err.object = object; - return err; -} - /** * Refuse the query unless the caller may read — and query on — every field it * names. @@ -289,7 +278,7 @@ export async function assertNamedFieldsReadable( `denying query (fail-closed): ${cause.message}`; if (logger?.error) logger.error(report, cause); else logger?.warn(report); - throw fieldReadUnresolvedError(object); + throw readAdmissionUnresolvedError(object); } if (readable === undefined && queryableFields === undefined) continue; diff --git a/packages/services/service-analytics/src/read-admission.ts b/packages/services/service-analytics/src/read-admission.ts index 40f890515e9..323b9767fd2 100644 --- a/packages/services/service-analytics/src/read-admission.ts +++ b/packages/services/service-analytics/src/read-admission.ts @@ -51,7 +51,11 @@ * The provider is access-NARROWING, so it fails CLOSED, and it is worth saying * which states are which: * - * - a wired provider that THROWS, or answers `false`, denies (this module); + * - a wired provider that answers `false` denies, `403 PERMISSION_DENIED` + * (this module); + * - a wired provider that THROWS refuses too, but it reached no verdict, so + * it does not answer as one: `503 SERVICE_UNAVAILABLE` + * ({@link readAdmissionUnresolvedError}), the cause in the server log; * - a `security` service that is wired but cannot be used — resolving it * throws, or it exposes neither `canReadObject` nor `explain` — denies at * the bridge, because `/data`'s middleware does not fall open in those @@ -100,7 +104,12 @@ const PERMISSION_DENIED: StandardErrorCode = 'PERMISSION_DENIED'; * layer refused, which permission set the caller holds, or whether the object * exists with different grants — an admission refusal that explains itself is * an oracle over exactly the metadata the refusal exists to withhold. The - * server-side log at the producing site carries the detail. + * server-side log at the producing site carries the detail. The same rule + * binds the FAULT refusal below ({@link readAdmissionUnresolvedError}): it + * names no layer, no grant and no existence fact either. + * + * This is the answer for a VERDICT only — the gate asked and was told "no". + * A gate that could not ask answers {@link readAdmissionUnresolvedError}. */ export function readAdmissionDeniedError(objectName: string): Error { const err = new Error( @@ -112,12 +121,62 @@ export function readAdmissionDeniedError(objectName: string): Error { return err; } +/** + * `SERVICE_UNAVAILABLE`, pinned against the STANDARD catalog — an existing + * ADR-0112 member that `HttpStatusErrorCodeMap` maps to 503. + */ +const SERVICE_UNAVAILABLE: StandardErrorCode = 'SERVICE_UNAVAILABLE'; + +/** + * The FAULT refusal, in the ADR-0112 envelope — `SERVICE_UNAVAILABLE` / 503 — + * for a read gate at this door that could not reach a verdict: the object-level + * admission provider threw (this module), the field-level read permissions + * could not be resolved (`field-read-admission.ts`), or the API exposure + * declaration could not be read (`api-exposure-door.ts`). + * + * ## Why not the denial's 403 + * + * Fail-closed is unchanged: nothing is admitted on this path. What changes is + * what the refusal CLAIMS. Answered as {@link readAdmissionDeniedError}, an + * outage was byte-identical to a real denial — the server asserted "you may + * not read this" when it never decided — so a caller, a retry policy or a + * build probe could not tell an access remedy from an operator one. That is + * the class `@objectstack/core`'s `authz-store-unavailable.ts` ends for the + * authz context, under the maintainer ruling quoted there; this answer takes + * its spelling, `SERVICE_UNAVAILABLE` / 503, so no code is added to the + * vocabulary and no envelope gains a key. + * + * ## One answer for the three gates + * + * ⛔ The message names the OBJECT and nothing else, the no-oracle rule of + * {@link readAdmissionDeniedError}: not WHICH gate could not answer, not why, + * not whether the object exists. It says only that the server could not + * decide. The cause stays in the server log at the producing site. + * + * `/analytics/dataset/query` relays a declared 5xx's status and code (the + * declared-fault arm), withholding the message as it does for every declared + * server fault; the cube query door relays the status the same way. So this + * reaches the wire as `503 SERVICE_UNAVAILABLE`, never as + * `500 ANALYTICS_QUERY_FAILED`. + */ +export function readAdmissionUnresolvedError(objectName: string): Error { + const err = new Error( + `[Analytics] Read access to "${objectName}" could not be determined, so the query was not run. ` + + 'This is a server-side fault, not a permission denial.', + ) as Error & { code?: string; status?: number; object?: string }; + err.code = SERVICE_UNAVAILABLE; + err.status = 503; + err.object = objectName; + return err; +} + /** * The object-level read admission provider the service asks. * * MAY be async — the production bridge resolves the verdict from the * `security` service, which can hit the database. Returns `true` to admit and - * `false` to refuse; a throw is a refusal (fail-closed). + * `false` to refuse; a throw is a refusal too (fail-closed), answered as a + * fault rather than a denial ({@link readAdmissionUnresolvedError}). */ export type ObjectReadAdmissionProvider = ( objectName: string, @@ -178,7 +237,9 @@ export async function assertObjectsReadable( // either way. This is the guarantee the required `warn` above buys. if (logger?.error) logger.error(report, cause); else logger?.warn(`${report}: ${cause.message}`); - throw readAdmissionDeniedError(objectName); + // No verdict was reached, so the refusal does not claim one: 503, not + // the 403 a real denial answers below. + throw readAdmissionUnresolvedError(objectName); } if (!admitted) { logger?.warn(