Skip to content

[finding] a permission-store read failure resolves as an AUTHENTICATED caller holding ZERO capabilities — the package door answers 403 FORBIDDEN, byte-identical to a genuine capability denial #13279

Description

@os-trump

Found while measuring #13255 (reachability + consequence of a swallowed execution-context resolution at the package-management door). ⛔ No severity is asserted here; this records what was driven and what came back, for triage.

The seam

tryFind in packages/core/src/security/resolve-authz-context.ts wraps every permission-store read:

  try {
    let rows = await ql.find(object, { where, limit, context });
    ...
  } catch {
    return [];
  }

resolveUserAuthzGrants issues all of its reads through it (sys_user_permission_set, sys_permission_set, sys_member, sys_user_position, sys_position, ...), and resolveAuthzContext's own docblock states the contract this creates: "Always resolves — never throws."

⇒ when the permission store is unreachable, the resolution does not fail. It SUCCEEDS, with an authenticated principal whose capability set is empty.

What was measured

Driven through the real RestServer (constructor seams only, no private replaced) and the real registerPackageRoutes, wired exactly as rest-api-plugin.ts wires it. Full harness and controls: packages/rest/src/package-door-execctx-fault-reachability.test.ts.

wiring resolved context GET /api/v1/packages
healthy store granting manage_metadata + studio.access userId: u_admin, those two capabilities 200
the same store, every read throws userId: u_admin, systemPermissions: [] 403 FORBIDDEN
the same store, reachable and genuinely empty userId: u_admin, systemPermissions: [] 403 FORBIDDEN

The last two responses are compared with JSON.stringify and are equal. Positive control on the same comparison: the healthy 200 and the refusal are not equal, so the comparison discriminates.

The refusal message the caller and the operator both see is

Reading packages requires the studio.access or setup.access capability.

An authenticated administrator is told they lack a capability, during an outage of the store that holds the capability.

Why this is not #13255's seam

#13255 is about resolveExecCtx's .catch(() = undefined ) and the computeExecCtx swallow behind it, whose degrade is CONTEXT LOST — the whole context becomes undefined and the anonymous floor answers 401. This one is GRANTS LOST: identity survives, only the aggregation is empty, and the refusal comes from a different clause with a different status. It travels through neither the .catch nor computeExecCtx's catch. A repair aimed at either of those leaves this shape exactly as it is.

Not ruled here

Whether tryFind should distinguish "no rows" from "the read failed" is a behaviour change on the shared authorization resolver — every transport that authorizes through resolveAuthzContext inherits it, not just REST. Recorded for triage, deliberately unruled.

Refs

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions