Skip to content

Commit a58fe66

Browse files
fix(rest): a door whose environment's security service cannot be resolved refuses rather than proceeds (#31404)
Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:REST 层所有读取环境 security 服务的地方都走同一个解析器,按服务注册表自带的「从未注册」标记分两种情况:没注册 ⇒ 视为该部署没有权限体系,各门照旧(`/security` 各门照旧答 501);注册了但解析不出来 ⇒ 答 503 SERVICE_UNAVAILABLE,什么都不提供。 - 为什么改:加固规则——一个门所在环境的 security 服务解析不出来时,这个门拒绝,而不是继续(ADR-0106 D6 第三档、ADR-0049:解析失败永远不能读成放行)。 - 风险与回滚:只在「security 已注册但构造失败,或在 security 这一步取不到环境内核」的故障态下生效:导出、导入模板、对象 schema 读取、`/security` 各门答 503;记录页照常返回,只有 explain 与 fieldAccess 两个分片各自带 503。没装 plugin-security、或单内核没有 security 服务的部署行为不变(已钉测试)。回滚 = revert 本 PR。 - 先看:① `packages/rest/src/rest-server.ts` 的 `resolveSecurityService`(核心分类,catch 分支 4 行);② `packages/rest/src/security-routes.ts`(两个族改走同一解析器,各自 catch 增加 503 分支);③ `packages/rest/src/record-page-routes.ts` 的 `answerExplain`(解析挪进每个操作自己的 try)。 - 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 src/rest-environment-scoped-providers.test.ts src/rest-export-permission-gate.test.ts src/security-explain-envelope.test.ts src/security-suggested-bindings-envelope.test.ts src/rest-server-suggested-bindings-status.test.ts`,预期 5 个文件、225 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes **Rule: a door whose environment's security service cannot be resolved refuses rather than proceeds.** `RestServer.resolveSecurityService` keeps the two answers the `security` slot can give apart, by the service registry's own "never registered" brand (`isServiceNotRegisteredError`), never by message text: - **Not registered** answers `undefined`: no provider is wired, the environment's kernel registers no `security` service, or the branded rejection reaches the resolver. The deployment has no permission posture (ADR-0106 D6 tier 1), and every caller keeps its documented degradation for that case, the `/security` doors' `501 NOT_IMPLEMENTED` included. - **Registered but unresolvable** (a registered factory that throws, or the environment's kernel failing to be acquired at the security step) raises the existing `AuthzStoreUnavailableError('security', cause)`, answered as `503 SERVICE_UNAVAILABLE` (ADR-0106 D6 tier 3; ADR-0049). No error code, envelope key or public export is added. The doors listed in the table below ask this one resolver. The access-explanation family and the suggested-binding family in `security-routes.ts` hand their reads to it through their route hosts instead of carrying their own; each family's `catch` answers the outage as the data door's own `503 SERVICE_UNAVAILABLE` (`mapDataError`, message withheld as every 5xx there is) through the family's one emitter. The record page resolves the explain part's security service once, inside each operation's own `try` and after the caller check (the order `/security/explain` asks in), so the part carries the refusal and the page still answers. This is the same classification the anonymous form doors' tenancy read already makes. The environment-scoped provider in `rest-api-plugin.ts` is unchanged. Measured on the hosted multi-environment shape (the first six rows and the explain door and suggested bindings), in each security family's envelope test (the delegable scope, confirm, dismiss and reopen), and by a one-off probe on `b7ef72106` for the effective-access door (`503` / `501` / healthy `200`), which no test drives: | Door | Security service registered but unresolvable | Not registered | |:--|:--|:--| | `GET …/data/:object/export` | `503`, no row read, nothing written | serves, as before | | `GET …/data/:object/export?template=true`, its gate | `503`, no template | serves, as before | | the same template, its column projection | `503`, no template | projection `none`, as before | | `GET …/meta/object/:name` (object-schema mask, on by default) | `503`, no schema | unmasked, as before | | `GET …/data/:object/:id/field-access` | `503` | `501`, as before | | `POST …/data/:object/:id/page`, `explain` and `fieldAccess` parts | each part carries the `503`; the page answers `200` with the record | each part `501`, as before | | `GET`/`POST …/security/explain`, `…/security/my-delegable-scope`, `…/security/users/:id/effective-access` | `503` (nested envelope) | `501`, as before | | `…/security/suggested-bindings`, its confirm, dismiss and reopen | `503` (nested envelope) | `501`, as before | Docblocks of the resolver, the export gate, the import-template gate, the object masker, both security families and the record page's explain part state the rule. ## Tests `packages/rest/src/rest-environment-scoped-providers.test.ts`, two top-level describes on the existing hosted rig (real `ObjectKernel`s behind one REST plugin), each case on both the scoped and the hostname mount: - *a door whose environment's security service cannot be resolved refuses rather than proceeds*: for a security slot registered as a throwing factory, and for an acquisition rejecting at the security step, the export, the import template's gate and the object-schema mask each refuse with `503 SERVICE_UNAVAILABLE` and serve nothing; the template's column projection refuses when its own security step cannot be resolved after the gate answered; the record page still answers, its explain and field-access parts each carrying the `503`; the access explanation and the suggested bindings refuse with `503`. Controls in the same describe: a healthy service (export `403 EXPORT_NOT_PERMITTED`, template `403 PERMISSION_DENIED`, projection `writable`, schema masked, explain part and both `/security` doors answered by the environment's service); an environment with no security service (every door serves, projection `none`, schema unmasked, the explain part and both `/security` doors `501`); an environment no acquisition reaches (still refused before any door serves); and the resolver still answering `undefined` where nothing is registered. - *a single kernel with no security service keeps serving every door, as before*. `packages/rest/src/rest-export-permission-gate.test.ts`: a wired provider that rejects answers `503 SERVICE_UNAVAILABLE` with nothing read; a provider rejecting with the registry's own "never registered" rejection (from a real kernel, asserted branded) is allowed, exactly as no security service at all. `packages/rest/src/security-explain-envelope.test.ts` and `packages/rest/src/security-suggested-bindings-envelope.test.ts`, one describe each, in the family's nested envelope: a security provider that rejects answers `503 SERVICE_UNAVAILABLE` on every route; a caller context whose authorization store cannot be read answers the same `503`, and the service is never asked; the registry's "never registered" rejection keeps the `501`. ## Acceptance notes - The two security families' outage arm answers any `AuthzStoreUnavailableError` reaching their `catch`, so a caller context whose authorization store cannot be read answers these doors `503 SERVICE_UNAVAILABLE`. Same rule, same family emitters; pinned in both envelope test files. - The data-action call sites inherit the classification through their route host with no edit (the table above). ## Verification Final head `b7ef72106` (`origin/main` merged in at `f90cb94fb` and `b5a46e2aa`; the rest dependency closure rebuilt after the first). Each reading names the head it was taken on. <details> <summary>Ablations of the server's resolver (round 1, on 40711ac)</summary> Through `scripts/ablation-replace.mjs`: anchor hit as declared, blob changed, restore proven each time (blob equals HEAD's, `git diff HEAD` empty). The subject resolves through relative `src` imports, so no build sits between mutation and run. - The classification reverted on disk: 15 red, 129 green of 144 (the 14 refusal legs and the unit case for a rejecting provider); every control green. - The "never registered" branch removed: 1 red, 143 green of 144 (the unit case for the registry's rejection). A first attempt was refused by the tool before any run (its replacement was a substring of its anchor); it is not counted. </details> <details> <summary>Ablations of round 2</summary> - On `a77a0c67a`, the explain part's resolution put back before the loop, outside the per-operation try: 4 red, 143 green of 147, exactly the record-page legs for the two unresolvable environments on both mounts. Restored. - On `a77a0c67a`, both security families' resolvers made to swallow the rejection: 4 red, 143 green of 147, exactly the access-explanation and suggested-binding legs for the two unresolvable environments on both mounts. Restored. - On `cbc8eeaf8`, both families' outage arms disabled: 4 red, 58 green of 62, exactly the four outage pins in the two envelope files. Restored. A first attempt is void and not counted: its replacement inverted the explain helper's guard instead of disabling it, so four unrelated `500`-arm cases went red too. - `b7ef72106` after these adds only a type annotation to the two families' resolvers (nothing emitted differently). </details> <details> <summary>Suites and typecheck</summary> - `pnpm --filter @objectstack/rest test` on `b7ef72106`: 394 files passed, 7538 tests passed, 332 skipped; lock verdict `command-exit 0`. - The five files of the two-minute check on `b7ef72106`: 5 files, 225 tests passed. - `pnpm --filter @objectstack/rest typecheck` (tsc plus the test-layer typecheck) on `b7ef72106`: `command-exit 0`; `tsc -p tsconfig.test.json --listFilesOnly` lists all four edited test files, and the debt ledger holds 0 files. - `test:repo` (the package's `repo` vitest project) is not run locally: this diff edits none of its files; CI's Test Core runs it. </details> <details> <summary>Derived gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `b7ef72106`: 71 commands (49 pnpm, 22 node), each run with its exit code captured before any pipe: 70 exit 0; `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET (exit 3) at its place in the run, before a later gate had built the remaining packages, and exit 0 when re-run after the run. - `--ran` reconciliation on the same head: 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN, a derived zero (every line carries its exit code; the re-run's for that one gate). - Every family CI runs is read from this PR's own check-runs, not re-run here. </details> Written for the security#1 seat (security#1 · sunhaolin · session_017xRBcT1w3zpekijraHJE4f) by its os-dev executor. <!-- signed: security#1 · sunhaolin · session_017xRBcT1w3zpekijraHJE4f --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 18ed2db commit a58fe66

8 files changed

Lines changed: 713 additions & 66 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): a door whose environment's security service cannot be resolved refuses rather than proceeds
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The REST server's security resolver keeps two answers apart, by the service registry's own "never registered" brand:
11+
12+
- **Not registered** — no `plugin-security`, or a single kernel without a security service — is a deployment with no permission posture (ADR-0106 D6 tier 1). Every door answers it exactly as before, the `/security` doors' `501 NOT_IMPLEMENTED` included.
13+
- **Registered but unresolvable** — a registered security service whose factory throws, or an environment kernel that cannot be acquired at the security step — is an unhealthy security service (ADR-0106 D6 tier 3). The request answers `503 SERVICE_UNAVAILABLE`, the same answer an unreadable authorization input gives everywhere else, and nothing is served.
14+
15+
These doors ask this resolver:
16+
17+
- the data export; the import template, at its gate and at its column projection; every object-schema read under `/meta` while object-schema masking is on (the default); and the record field-access door — each answers the `503`;
18+
- the record page, whose access-explanation and field-access parts each carry the `503` as their own part refusal, while the page still answers with the record and every other part;
19+
- the `/security` doors — the access explanation, the caller's delegable scope, a user's effective access, and the suggested audience bindings with their confirm, dismiss and reopen — each answers the `503` in its family's nested envelope. A caller context whose authorization store cannot be read answers the same `503` at these doors.
20+
21+
On a multi-environment host, an operator who sees this `503` for one environment restores that environment's security service; the other environments are unaffected.

‎packages/rest/src/record-page-routes.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,13 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath:
427427

428428
const answerExplain = async () => {
429429
const out: Record<string, unknown> = {};
430-
const security = await this.resolveSecurityService(environmentId, req);
430+
// Resolved once, inside each operation's own `try`: a
431+
// security service that cannot be resolved is each
432+
// operation's refusal (the `503` `GET /security/explain`
433+
// answers), as the fieldAccess part's is its own, and the
434+
// page still answers. Asked after the caller check, in the
435+
// order that door asks.
436+
let resolvingSecurity: ReturnType<RecordPageRouteHost['resolveSecurityService']> | undefined;
431437
for (const operation of new Set(body.explain ?? [])) {
432438
try {
433439
// `POST /security/explain` answers only an
@@ -436,6 +442,7 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath:
436442
out[operation] = { error: { code: 'UNAUTHORIZED', status: 401, message: 'The access-explanation endpoint requires an authenticated caller.' } };
437443
continue;
438444
}
445+
const security = await (resolvingSecurity ??= this.resolveSecurityService(environmentId, req));
439446
if (typeof security?.explain !== 'function') {
440447
out[operation] = { error: { code: 'NOT_IMPLEMENTED', status: 501, message: 'Access explanation is not available on this deployment.' } };
441448
continue;

‎packages/rest/src/rest-environment-scoped-providers.test.ts‎

Lines changed: 441 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/rest/src/rest-export-permission-gate.test.ts‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
*/
1717

1818
import { describe, it, expect, vi } from 'vitest';
19+
import { ObjectKernel, isServiceNotRegisteredError } from '@objectstack/core';
1920
import { RestServer } from './rest-server';
21+
// Pays `@objectstack/objectql`'s first load at collection, never inside a clocked case
22+
// (the route modules import it lazily).
23+
import '@objectstack/objectql';
2024

2125
const TASK = {
2226
name: 'task',
@@ -53,13 +57,14 @@ function makeRes() {
5357

5458
/**
5559
* Boot the export route over a stub protocol, with `security` resolved from
56-
* `securityServiceProvider` (the 18th positional ctor arg).
60+
* `securityServiceProvider` (the 18th positional ctor arg) — or, when
61+
* `provider` is given, from that provider itself.
5762
*
5863
* `enable` is left undefined on the object so the OBJECT-level gate
5964
* (`apiAccessDenialFromEnable`) is fully open — every denial these tests observe
6065
* is therefore the USER-level axis, never the `apiMethods` whitelist.
6166
*/
62-
function boot(security: any) {
67+
function boot(security: any, provider?: () => Promise<unknown>) {
6368
const findData = vi.fn().mockResolvedValue({ object: 'task', records: ROWS });
6469
const protocol: any = {
6570
getMetaItems: vi.fn().mockResolvedValue({ items: [TASK] }),
@@ -84,7 +89,7 @@ function boot(security: any) {
8489
undefined, // analyticsServiceProvider
8590
undefined, // settingsServiceProvider
8691
undefined, // serviceExistsProvider
87-
security === undefined ? undefined : async () => security,
92+
provider ?? (security === undefined ? undefined : async () => security),
8893
);
8994
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
9095
rest.registerRoutes();
@@ -167,6 +172,34 @@ describe('export route — user-level export axis (#3544)', () => {
167172
expect(findData).not.toHaveBeenCalled();
168173
});
169174

175+
it('a wired security provider that REJECTS → 503 SERVICE_UNAVAILABLE, and NOTHING is read', async () => {
176+
// A door whose security service cannot be resolved refuses rather than
177+
// proceeds: a service that is registered but unresolvable is not "no
178+
// security service" (ADR-0106 D6 tier 3; ADR-0049).
179+
const { route, findData } = boot(undefined, async () => {
180+
throw new Error('the security service failed to construct');
181+
});
182+
const out = await run(route);
183+
184+
expect(out.getStatus()).toBe(503);
185+
expect(out.getJson()).toMatchObject({ code: 'SERVICE_UNAVAILABLE' });
186+
expect(findData).not.toHaveBeenCalled();
187+
expect(out.chunks.join('')).toBe('');
188+
});
189+
190+
it('a provider rejecting with the registry\'s "never registered" brand → allowed, as with no security service at all', async () => {
191+
// The registry's own rejection for a slot nothing ever registered, from a
192+
// real kernel: the one rejection that means "no permission posture here".
193+
const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as any);
194+
const provider = () => kernel.getServiceAsync('security');
195+
await expect(provider()).rejects.toSatisfy(isServiceNotRegisteredError);
196+
const { route, findData } = boot(undefined, provider);
197+
const out = await run(route);
198+
199+
expect(out.getStatus()).toBe(200);
200+
expect(findData).toHaveBeenCalled();
201+
});
202+
170203
it('the object-level 405 still precedes the user-level 403', async () => {
171204
// Two gates, two questions: `apiMethods` decides whether the OBJECT exposes
172205
// export (405), this axis decides whether the CALLER may use it (403). An

‎packages/rest/src/rest-server.ts‎

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2834,7 +2834,9 @@ export class RestServer {
28342834
* every other permission gate here and `/me/permissions`' documented
28352835
* fail-open. Service PRESENT but unable to answer → fail CLOSED: it resolves
28362836
* permission sets to decide, and a resolution failure must never read as a
2837-
* grant (ADR-0049).
2837+
* grant (ADR-0049). A service that is registered but cannot be RESOLVED is
2838+
* not "no security service": {@link resolveSecurityService} refuses the
2839+
* request first (`503`), before this gate is asked anything.
28382840
*/
28392841
private async enforceExportPermission(
28402842
req: any,
@@ -2891,7 +2893,8 @@ export class RestServer {
28912893
*
28922894
* Fail stance, as {@link enforceExportPermission}'s: no security service,
28932895
* or one without `explain`, → allow (no permission sets exist to deny
2894-
* with); `explain` throwing → deny, never read as a grant. One direction is
2896+
* with); `explain` throwing → deny, never read as a grant; a service that
2897+
* cannot be resolved → the resolver's `503`. One direction is
28952898
* stricter than a write: `explain` denies a caller whose permission sets
28962899
* resolve EMPTY, where the middleware skips its CRUD gate — reachable only
28972900
* on a deployment that configures no baseline set at all, and in the closed
@@ -4435,7 +4438,11 @@ export class RestServer {
44354438
* The returned function REJECTS with {@link ObjectSchemaMaskEvaluationError}
44364439
* on D6 tier 3 — the security service threw. Call sites answer 5xx via
44374440
* {@link sendFieldVisibilityFault}; they must never fall back to the
4438-
* unmasked body.
4441+
* unmasked body. This resolution itself rejects with
4442+
* {@link AuthzStoreUnavailableError} — D6 tier 3 as well — when the caller's
4443+
* context or the environment's security service cannot be resolved; call
4444+
* sites rethrow it to the route's `503`. Only a security service that is
4445+
* NOT REGISTERED reaches tier 1.
44394446
*/
44404447
private async resolveObjectMasker(
44414448
environmentId: string | undefined,
@@ -5940,16 +5947,30 @@ export class RestServer {
59405947
/**
59415948
* [#3547] Resolve the environment's `security` service — the ENVIRONMENT's
59425949
* kernel service (its evaluator / FieldMasker are bound to that kernel's
5943-
* data engine). Mirrors the resolver in registerSecurityExplainEndpoints.
5944-
* Returns `undefined` when no security service is reachable (no
5945-
* plugin-security / single-kernel without a provider), so callers degrade
5946-
* gracefully.
5950+
* data engine).
59475951
*
5948-
* [#30192] The provider resolves the environment's kernel itself now
5952+
* [#30192] The provider resolves the environment's kernel itself
59495953
* (`rest-api-plugin`'s environment-scoped seam), so this asks it for the
59505954
* REQUEST's environment, the shared resolver's answer, and nothing else.
5951-
* The host's occupant is no longer a fallback: an environment whose kernel
5952-
* carries no security service, or cannot be acquired, answers `undefined`.
5955+
* The host's occupant is never a fallback.
5956+
*
5957+
* A door whose environment's security service cannot be resolved refuses
5958+
* rather than proceeds. The two answers are told apart by the registry's
5959+
* own "never registered" brand, never by message text:
5960+
*
5961+
* - NOT REGISTERED → `undefined`: no provider is wired, the environment's
5962+
* kernel registers no `security` service (the provider absorbs the
5963+
* branded rejection), or that branded rejection reaches this resolver.
5964+
* The deployment has no permission posture at all (no plugin-security,
5965+
* or a single kernel without one), and each caller's documented
5966+
* degradation for that case stands (ADR-0106 D6 tier 1).
5967+
* - REGISTERED BUT UNRESOLVABLE → {@link AuthzStoreUnavailableError}, the
5968+
* door's `503 SERVICE_UNAVAILABLE`: every other rejection — a registered
5969+
* factory that threw, or the environment's kernel failing to be
5970+
* acquired at this step. An unhealthy security service must not open a
5971+
* door (ADR-0106 D6 tier 3), and a resolution failure never reads as a
5972+
* grant (ADR-0049). The same classification the tenancy read makes
5973+
* (`resolveAnonymousFormTenancy`).
59535974
*
59545975
* Typed as a PARTIAL {@link ISecurityService}: an implementation may omit a
59555976
* method it cannot honour, so every call site must keep feature-detecting
@@ -5963,8 +5984,10 @@ export class RestServer {
59635984
try {
59645985
const envId = await this.resolveRequestEnvironmentId(environmentId, req);
59655986
return await this.securityServiceProvider(envId);
5987+
} catch (err) {
5988+
if (isServiceNotRegisteredError(err)) return undefined;
5989+
throw new AuthzStoreUnavailableError('security', err);
59665990
}
5967-
catch { return undefined; }
59685991
}
59695992

59705993
/**
@@ -6264,13 +6287,12 @@ export class RestServer {
62646287
private registerSecurityExplainEndpoints(basePath: string): void {
62656288
// [#23050 slice 6] The body moved to `security-routes.ts`. This literal
62666289
// hands it every member it reads — the list IS its dependency on this
6267-
// class (see `SecurityExplainRouteHost`, and that module's header for why
6268-
// `securityServiceProvider` is a getter).
6269-
const readSecurityServiceProvider = () => this.securityServiceProvider;
6290+
// class (see `SecurityExplainRouteHost`). The `security` slot is read through
6291+
// {@link resolveSecurityService}, the server's one resolver, so this
6292+
// family classifies a rejection exactly as every other door does.
62706293
const host: SecurityExplainRouteHost = {
62716294
routeManager: this.routeManager,
6272-
get securityServiceProvider() { return readSecurityServiceProvider(); },
6273-
resolveRequestEnvironmentId: (...a) => this.resolveRequestEnvironmentId(...a),
6295+
resolveSecurityService: (...a) => this.resolveSecurityService(...a),
62746296
resolveExecCtx: (...a) => this.resolveExecCtx(...a),
62756297
enforceAuth: (...a) => this.enforceAuth(...a),
62766298
};
@@ -6402,13 +6424,12 @@ export class RestServer {
64026424
private registerSecurityEndpoints(basePath: string): void {
64036425
// [#23050 slice 6] The body moved to `security-routes.ts`. This literal
64046426
// hands it every member it reads — the list IS its dependency on this
6405-
// class (see `SecurityRouteHost`, and that module's header for why
6406-
// `securityServiceProvider` is a getter).
6407-
const readSecurityServiceProvider = () => this.securityServiceProvider;
6427+
// class (see `SecurityRouteHost`). The `security` slot is read through
6428+
// {@link resolveSecurityService}, the server's one resolver, so this
6429+
// family classifies a rejection exactly as every other door does.
64086430
const host: SecurityRouteHost = {
64096431
routeManager: this.routeManager,
6410-
get securityServiceProvider() { return readSecurityServiceProvider(); },
6411-
resolveRequestEnvironmentId: (...a) => this.resolveRequestEnvironmentId(...a),
6432+
resolveSecurityService: (...a) => this.resolveSecurityService(...a),
64126433
resolveExecCtx: (...a) => this.resolveExecCtx(...a),
64136434
enforceAuth: (...a) => this.enforceAuth(...a),
64146435
};

‎packages/rest/src/security-explain-envelope.test.ts‎

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,13 @@
5757
*/
5858

5959
import { describe, it, expect, vi } from 'vitest';
60+
import { AuthzStoreUnavailableError, ObjectKernel, isServiceNotRegisteredError } from '@objectstack/core';
6061
// `.js` on purpose — NodeNext resolution requires the extension, and this
6162
// package's TEST_DEBT ceiling has no margin for another TS2835 (#7248).
6263
import { RestServer } from './rest-server.js';
64+
// Pays `@objectstack/objectql`'s first load at collection, never inside a clocked case
65+
// (the route modules import it lazily).
66+
import '@objectstack/objectql';
6367

6468
const EXPLAIN = '/api/v1/security/explain';
6569
const DELEGABLE = '/api/v1/security/my-delegable-scope';
@@ -113,15 +117,22 @@ type Answer = { status: number; body: any };
113117
* MISSING the method is the other half (the route's own
114118
* duck-type check), which is how both routes reach 501 while
115119
* still being driven normally.
120+
* @param wiring overrides for the two reads every arm begins with: the
121+
* security provider itself, and the caller's context.
116122
*/
117-
function boot(service?: any, context: any = CALLER) {
123+
function boot(
124+
service?: any,
125+
context: any = CALLER,
126+
wiring: { provider?: () => Promise<unknown>; execCtx?: () => Promise<unknown> } = {},
127+
) {
118128
const rest = new RestServer(
119129
mockServer() as any,
120130
{ getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: {} }) } as any,
121131
{ api: { requireAuth: false } } as any,
122132
);
123-
(rest as any).resolveExecCtx = async () => context;
124-
if (service !== undefined) (rest as any).securityServiceProvider = async () => service;
133+
(rest as any).resolveExecCtx = wiring.execCtx ?? (async () => context);
134+
if (wiring.provider) (rest as any).securityServiceProvider = wiring.provider;
135+
else if (service !== undefined) (rest as any).securityServiceProvider = async () => service;
125136
rest.registerRoutes();
126137

127138
const route = (method: string, path: string) => {
@@ -496,3 +507,34 @@ describe('[#20603] /security/explain — a classified service refusal keeps its
496507
expectNestedEnvelope(await boot(throwingExplain(unknown)).explainPost(), 404, 'OBJECT_NOT_FOUND');
497508
});
498509
});
510+
511+
describe('an authorization input that cannot be read answers the explain family\'s 503, never its 500 or 501', () => {
512+
const healthy = { explain: vi.fn().mockResolvedValue(DECISION), describeDelegableScope: vi.fn().mockResolvedValue({}) };
513+
514+
it('a security service that cannot be resolved → 503 SERVICE_UNAVAILABLE, and nothing is asked of it', async () => {
515+
const doors = boot(undefined, CALLER, {
516+
provider: async () => { throw new Error('the security service failed to construct'); },
517+
});
518+
expectNestedEnvelope(await doors.explainPost(), 503, 'SERVICE_UNAVAILABLE');
519+
expectNestedEnvelope(await doors.explainGet(), 503, 'SERVICE_UNAVAILABLE');
520+
expectNestedEnvelope(await doors.delegable(), 503, 'SERVICE_UNAVAILABLE');
521+
});
522+
523+
it('a caller context whose authorization store cannot be read → 503 SERVICE_UNAVAILABLE', async () => {
524+
const doors = boot(healthy, CALLER, {
525+
execCtx: async () => { throw new AuthzStoreUnavailableError('sys_user_permission_set', new Error('store down')); },
526+
});
527+
expectNestedEnvelope(await doors.explainPost(), 503, 'SERVICE_UNAVAILABLE');
528+
expectNestedEnvelope(await doors.delegable(), 503, 'SERVICE_UNAVAILABLE');
529+
expect(healthy.explain).not.toHaveBeenCalled();
530+
});
531+
532+
it('the registry\'s "never registered" rejection keeps the 501 — no security service on this deployment', async () => {
533+
const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as any);
534+
const provider = () => kernel.getServiceAsync('security');
535+
await expect(provider()).rejects.toSatisfy(isServiceNotRegisteredError);
536+
const doors = boot(undefined, CALLER, { provider });
537+
expectNestedEnvelope(await doors.explainPost(), 501, 'NOT_IMPLEMENTED');
538+
expectNestedEnvelope(await doors.delegable(), 501, 'NOT_IMPLEMENTED');
539+
});
540+
});

0 commit comments

Comments
 (0)