fix: enforce registered authorization permissions
Two related fail-closed gaps let a well-formed but never-declared permission id slip through: - POST /api/v1/grants created explicit grants for any well-formed PermissionId without checking the injected permission registry, allowing dormant grants for invented identifiers that could become effective the moment those identifiers were registered later. - createAuthorizationEvaluator validated the permission id only inside the restricted-baseline branch, so a malformed permission combined with the public or authenticated baseline could still return allowed. Both now fail closed: the grants route checks the target permission against the trusted registry before calling store.createGrant (sanitized 403, no store or audit write), and the evaluator validates permission shape before any baseline is allowed to return an allowed decision, without adding a registry dependency to the evaluator itself. The live integration lifecycle test switched from an unregistered calendar.events.read to the registered core.authorization.grants.manage permission, since module permission registration does not exist yet.
This commit is contained in:
@@ -46,6 +46,18 @@ or malformed permission id is treated identically to a denied decision, never a
|
||||
would work if declared" response — deny-by-default holds even for permissions nobody has declared
|
||||
yet.
|
||||
|
||||
Registration is enforced everywhere a permission id can originate from outside Core's own trusted
|
||||
route wiring, not only for the caller's own required permission. `requirePermission`
|
||||
(`apps/api/src/authorization-middleware.ts`) checks the registry for the permission a *route*
|
||||
requires; `POST /api/v1/grants` (`apps/api/src/grants-routes.ts`) separately checks the registry
|
||||
for the *target* permission a caller is requesting be granted to someone else, since that value
|
||||
comes from the request body rather than route wiring. Without the second check, a caller with
|
||||
`core.authorization.grants.manage` could create a stored grant for a permission id that is merely
|
||||
well-formed, not registered — a dormant grant that would silently become effective the moment
|
||||
that identifier was registered later, with no further action or review at registration time. Both
|
||||
checks reject with the identical sanitized `403 {"code":"AUTHORIZATION_DENIED"}` an ordinary denial
|
||||
would return, and neither ever calls into the store on rejection.
|
||||
|
||||
### Permissions declare one trusted baseline: public, authenticated, or restricted
|
||||
|
||||
Every registered permission declares exactly one `PermissionBaseline` at registration time. The
|
||||
@@ -60,14 +72,23 @@ restricted check.
|
||||
|
||||
### Unknown permissions and client-supplied baselines fail closed
|
||||
|
||||
Two independent fail-closed layers exist on purpose, not one relied on twice. The registry itself
|
||||
(`PermissionRegistry.get`/`.has`) returns `undefined`/`false` for anything malformed or
|
||||
unregistered. `requirePermission` treats a registry miss as an immediate sanitized `403`, before
|
||||
principal resolution or evaluation ever runs. Separately, `createAuthorizationEvaluator` itself
|
||||
denies (without throwing) any malformed or unrecognized `permission`/`baseline`/`scope` it
|
||||
receives, so a future caller that reaches the evaluator directly — bypassing the registry, for
|
||||
example a different transport Core does not yet have — inherits the same deny-by-default guarantee
|
||||
rather than depending on the registry check having already run.
|
||||
Two independent fail-closed layers exist on purpose, not one relied on twice, and they check
|
||||
different things. The registry itself (`PermissionRegistry.get`/`.has`) returns `undefined`/`false`
|
||||
for anything malformed or unregistered — this is the only layer that knows what is *registered*.
|
||||
`requirePermission` treats a registry miss as an immediate sanitized `403`, before principal
|
||||
resolution or evaluation ever runs; `POST /api/v1/grants` performs the equivalent registry check
|
||||
for the target permission named in its body (see above). Separately, `createAuthorizationEvaluator`
|
||||
independently re-validates permission *shape* — it holds no registry and cannot recognize
|
||||
registration at all — denying (without throwing) any malformed or unrecognized
|
||||
`permission`/`baseline`/`scope` it receives. This shape check runs, and can deny, **before any
|
||||
baseline is allowed to return an allowed decision** — `public` and `authenticated` are not
|
||||
exempt, even though neither reads the permission value for any other reason — so a future caller
|
||||
that reaches the evaluator directly, bypassing the registry entirely (for example a different
|
||||
transport Core does not yet have), inherits the same fail-closed guarantee for malformed input
|
||||
rather than depending on a registry check having already run upstream. The evaluator's shape check
|
||||
and the registry's registration check are deliberately not merged into one: only the registry can
|
||||
answer "is this permission registered," and the evaluator must remain usable — and safe against
|
||||
malformed input — by a caller that has no registry to consult at all.
|
||||
|
||||
### Explicit and relationship-derived grants share persistence but have distinct source kinds
|
||||
|
||||
|
||||
Reference in New Issue
Block a user