@ultimat3/policy
Advanced tools
| // The one derived question a SURFACE asks of a policy tree: can an anonymous caller be allowed at | ||
| // all. Apart from `policy.ts` so each file stays one responsibility — that one CONSTRUCTS trees, | ||
| // this one projects a single boolean out of a built one — and in this package rather than in the | ||
| // surfaces, because the answer is a property of the combinators and a copy per surface drifts from | ||
| // them the first time one changes. | ||
| import type { Policy, PolicyKind } from './policy'; | ||
| /** | ||
| * Whether an ANONYMOUS caller can be allowed by this policy — what a surface derives its | ||
| * "does this route need a session" flag from. `policy.kind === 'allow'` is the read this | ||
| * replaces, and it looked at the ROOT combinator only: `or(allow(), can('x:y'))` answered | ||
| * "needs a session", so `@ultimat3/http`'s auth stage 401'd a caller the policy itself ALLOWS, | ||
| * while the MCP tool and the job surface let that same caller through the same object. One | ||
| * policy, a different answer per surface, which is the thing this package exists to prevent. | ||
| * | ||
| * **EXACT for `actor === null`, not a heuristic.** With no actor `can()` short-circuits on the | ||
| * actor check before its predicate ever runs, and `allow()`/`deny()` ignore their arguments | ||
| * entirely — so no predicate is consulted and the tree alone decides. It lives in THIS package | ||
| * for that reason: the answer is a property of the combinators `policy.ts` declares, and a copy in | ||
| * a surface package would drift from them the first time one changes. | ||
| * | ||
| * `true` never means "unguarded". It says only that the 401 is not the stage's to raise; the | ||
| * surface still evaluates the policy through `enforce()`. | ||
| */ | ||
| export const admitsAnonymous = <I = unknown, R = unknown>(policy: Policy<I, R>): boolean => | ||
| anonymousOutcome(policy) === 'allowed'; | ||
| /** | ||
| * Three-valued, because `not()` needs the distinction: it PROPAGATES `X_UNAUTHENTICATED` rather | ||
| * than inverting it, so `not(can('order:internal'))` denies an anonymous caller while | ||
| * `not(deny(…))` allows one. | ||
| */ | ||
| type AnonymousOutcome = 'allowed' | 'denied' | 'unauthenticated'; | ||
| /** The two fields the walk reads, so every `Policy<I, R>` satisfies it whatever its arguments. */ | ||
| interface PolicyTree { | ||
| readonly kind: PolicyKind; | ||
| readonly children: readonly PolicyTree[]; | ||
| } | ||
| /** | ||
| * Exhaustive over `PolicyKind` by construction: a seventh kind is a missing-key TYPE error here, | ||
| * never a silent "needs a session" on every route guarded by it. The import of `PolicyKind` is | ||
| * what makes that hold across the split — a local union would be the copy this file exists not to | ||
| * be. | ||
| */ | ||
| const OUTCOME_BY_KIND = Object.freeze<Record<PolicyKind, (policy: PolicyTree) => AnonymousOutcome>>( | ||
| { | ||
| allow: () => 'allowed', | ||
| deny: () => 'denied', | ||
| // `can()` denies with X_UNAUTHENTICATED before its predicate, for every permission. | ||
| permission: () => 'unauthenticated', | ||
| // First non-allowance wins, left to right — `and`'s own short-circuit. | ||
| and: (policy) => { | ||
| for (const child of policy.children) { | ||
| const outcome = anonymousOutcome(child); | ||
| if (outcome !== 'allowed') return outcome; | ||
| } | ||
| return 'allowed'; | ||
| }, | ||
| // First allowance wins; if none allow, `or` reports the LAST denial. | ||
| or: (policy) => { | ||
| let last: AnonymousOutcome = 'denied'; | ||
| for (const child of policy.children) { | ||
| last = anonymousOutcome(child); | ||
| if (last === 'allowed') return 'allowed'; | ||
| } | ||
| return last; | ||
| }, | ||
| not: (policy) => { | ||
| const inner = policy.children[0]; | ||
| // A `not` with no child cannot come from `not()`; refusing anonymous is the safe reading. | ||
| if (inner === undefined) return 'unauthenticated'; | ||
| const outcome = anonymousOutcome(inner); | ||
| if (outcome === 'allowed') return 'denied'; | ||
| return outcome === 'unauthenticated' ? 'unauthenticated' : 'allowed'; | ||
| }, | ||
| }, | ||
| ); | ||
| function anonymousOutcome(policy: PolicyTree): AnonymousOutcome { | ||
| // `Object.hasOwn`, never the read alone: `kind` is a field of a plain object, so a foreign | ||
| // `Policy` can carry any string — and `OUTCOME_BY_KIND['valueOf']` is a function off the | ||
| // prototype chain that THROWS when called with no receiver, which would kill a route | ||
| // projection at mount. An unrecognised kind requires authentication. | ||
| const decide = Object.hasOwn(OUTCOME_BY_KIND, policy.kind) | ||
| ? OUTCOME_BY_KIND[policy.kind] | ||
| : undefined; | ||
| return decide === undefined ? 'unauthenticated' : decide(policy); | ||
| } |
+11
-1
@@ -35,2 +35,11 @@ # @ultimat3/policy | ||
| adapter to `surfaces.ts` — nothing else. | ||
| - **A derived question about a policy TREE is answered in this PACKAGE, once.** `policyPermissions` | ||
| (in `policy.ts`) and `admitsAnonymous` (in `policy-anonymous.ts`) both walk the combinators | ||
| `policy.ts` declares, so an answer computed in a surface package would drift from them the first | ||
| time one changes — and could not be shared: | ||
| `@ultimat3/action` and `@ultimat3/query` are the same tier and may not import each other, so a | ||
| copy in either is a second answer for the other. Both shipped that copy briefly and it was | ||
| hoisted here. `admitsAnonymous` in particular is EXACT for `actor === null` rather than a | ||
| heuristic, because `can()` short-circuits on the actor check before its predicate and | ||
| `allow()`/`deny()` ignore their arguments — no predicate is consulted, so the tree alone decides. | ||
| - **One predicate shape.** A row-level rule reads `args.row`. Never pass a row through | ||
@@ -115,3 +124,4 @@ `input`, and never add a per-surface args type. | ||
| |---|---| | ||
| | `policy.ts` | `can`/`allow`/`deny`/`and`/`or`/`not` + decision recording | | ||
| | `policy.ts` | `can`/`allow`/`deny`/`and`/`or`/`not` + decision recording, and `policyPermissions` | | ||
| | `policy-anonymous.ts` | `admitsAnonymous` — the one question a SURFACE asks of a built tree, apart from the file that builds them | | ||
| | `evaluate.ts` | the single entry point; builds the trace, emits the one decision event | | ||
@@ -118,0 +128,0 @@ | `decisions.ts` | the `DecisionSink` seam — no-op default, one call site, never PII | |
+2
-2
| { | ||
| "name": "@ultimat3/policy", | ||
| "version": "7.0.0", | ||
| "version": "8.0.0", | ||
| "description": "The one authz rule, evaluated identically in every surface", | ||
@@ -34,4 +34,4 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "7.0.0" | ||
| "@ultimat3/core": "8.0.0" | ||
| } | ||
| } |
+20
-0
@@ -87,2 +87,22 @@ # @ultimat3/policy 🔐 | ||
| `admitsAnonymous(policy)` is the other derived question `As of 2026-08`, and it is a **walk, not a | ||
| root read**: | ||
| whether an anonymous caller can be allowed at all. `policy.kind === 'allow'` is the read it | ||
| replaces, and it answered "needs a session" for `or(allow(), can('x:y'))` — so an HTTP route 401'd | ||
| a caller the policy itself allows, while the same policy over MCP or a job let that caller in. | ||
| ```ts | ||
| import { admitsAnonymous, allow, and, can, not, or } from '@ultimat3/policy'; | ||
| admitsAnonymous(or(allow('public'), can('post:publish'))); // true | ||
| admitsAnonymous(and(allow('public'), can('post:publish'))); // false | ||
| admitsAnonymous(not(can('order:internal'))); // false — X_UNAUTHENTICATED propagates | ||
| ``` | ||
| It is **exact for an anonymous caller, not a heuristic**: with `actor === null`, `can()` | ||
| short-circuits on the actor check before its predicate runs and `allow()`/`deny()` ignore their | ||
| arguments, so no predicate is ever consulted and the tree alone decides. `true` never means | ||
| "unguarded" — it says only that a 401 before the handler is wrong; the surface still calls | ||
| `enforce()`. `@ultimat3/action` and `@ultimat3/query` derive `RouteMeta.auth` from it. | ||
| ## Four surfaces, four adapters, one rule | ||
@@ -89,0 +109,0 @@ |
+36
-5
@@ -6,3 +6,3 @@ // The policy layer's stable error codes. `X_POLICY_MISSING` is enforced by the TYPE system, | ||
| // a policy resolved by name — and `policyMissing()` is how such a site says it. | ||
| import { registerErrorCodes, UltimateError } from '@ultimat3/core'; | ||
| import { nearestName, registerErrorCodes, UltimateError } from '@ultimat3/core'; | ||
@@ -46,2 +46,13 @@ export const POLICY_ERROR_CODES = [ | ||
| /** | ||
| * A label `x policy explain` can resolve: one declared permission, `<resource>:<verb>`. | ||
| * | ||
| * Every other `Policy` renders its label as a DESCRIPTION — `and(post:publish, org:administer)`, | ||
| * `not(post:publish)`, `allow`, `deny(read-only mode)` — and `knownPolicySubjects()` holds | ||
| * permissions, action names and route paths, none of which those match. Interpolating one produced | ||
| * `x policy explain and(post:publish, org:administer)`, reproduced in `examples/dummy` as | ||
| * `X_DECLARATION_UNKNOWN`: a fix line whose only effect is a second error. | ||
| */ | ||
| const BARE_PERMISSION = /^[a-z0-9_-]+:[a-z0-9_-]+$/; | ||
| /** `reason` comes from a decision and is always safe to log: no row data, no PII. */ | ||
@@ -52,3 +63,7 @@ export const forbidden = (label: string, reason: string): PolicyError => | ||
| cause: `${label} denied: ${reason}`, | ||
| fix: `x policy explain ${label} --json # shows which clause decided and why`, | ||
| // `x policy list --json` is what `X_DECLARATION_UNKNOWN`'s own fix falls back to, so a reader | ||
| // who follows either one lands in the same place. | ||
| fix: BARE_PERMISSION.test(label) | ||
| ? `x policy explain ${label} --json # shows which clause decided and why` | ||
| : `x policy list --json # then: x policy explain <permission> --json for the clause that decided`, | ||
| }); | ||
@@ -74,7 +89,23 @@ | ||
| export const permissionUnknown = (permission: string, known: readonly string[]): PolicyError => | ||
| new PolicyError({ | ||
| /** | ||
| * The nearest declared permission comes FIRST, and the declare-it path second. | ||
| * | ||
| * The other order is what shipped: `add 'billing:wirte' to definePermissions([...])` reads as an | ||
| * instruction to declare the typo, and a permission nothing grants and nothing enforces is a | ||
| * silent hole — `assertPermission` then passes, every `can('billing:wirte')` denies, and the | ||
| * failure moves from this throw to a page that renders empty. Only the generated `policy.test.ts` | ||
| * caught it. A typo is by far the likelier of the two readings, so it leads. | ||
| */ | ||
| export const permissionUnknown = (permission: string, known: readonly string[]): PolicyError => { | ||
| const nearest = nearestName(permission, known); | ||
| return new PolicyError({ | ||
| code: 'X_PERMISSION_UNKNOWN', | ||
| // The COUNT, never the set: an app with 200 permissions would bury the fix line under names | ||
| // nobody asked for, and `x policy list --json` is one command away. | ||
| cause: `"${permission}" is not in the permission set (${known.length} known)`, | ||
| fix: `add '${permission}' to definePermissions([...]) — or fix the typo`, | ||
| fix: | ||
| nearest === undefined | ||
| ? `add '${permission}' to definePermissions([...]) if it is genuinely new — otherwise x policy list --json shows the ${known.length} already declared` | ||
| : `use '${nearest}', the nearest declared permission — or add '${permission}' to definePermissions([...]) if it is genuinely new`, | ||
| }); | ||
| }; |
+2
-11
@@ -57,13 +57,4 @@ // The public surface of @ultimat3/policy. Explicit, never `export *`. | ||
| } from './policy'; | ||
| export { | ||
| ALLOWED, | ||
| allow, | ||
| and, | ||
| can, | ||
| denied, | ||
| deny, | ||
| not, | ||
| or, | ||
| policyPermissions, | ||
| } from './policy'; | ||
| export { ALLOWED, allow, and, can, denied, deny, not, or, policyPermissions } from './policy'; | ||
| export { admitsAnonymous } from './policy-anonymous'; | ||
| export type { Actor, RoleDef, RoleMap } from './roles'; | ||
@@ -70,0 +61,0 @@ export { |
78377
12.39%16
6.67%1300
8.88%184
12.2%+ Added
- Removed
Updated