@ultimat3/policy 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # @ultimat3/policy ๐Ÿ”
2
+
3
+ **One authz system.** Two authz systems โ€” one for HTTP, one for "the API", one for
4
+ jobs โ€” is how every Meteor-shaped framework died: the surfaces drift, one of them is
5
+ wrong, and nobody finds out until it is a CVE. This package exists so a second one is
6
+ never necessary. Every MCP tool, live query, job and route resolves the *same* policy
7
+ object through the *same* `evaluate()`.
8
+
9
+ ```ts
10
+ export const publishPost = action({
11
+ policy: can('post:publish', ({ input, actor }) => ownsPost(actor, input.postId)),
12
+ });
13
+ ```
14
+
15
+ ## Shape
16
+
17
+ A policy is a pure `(input, actor, ctx) => PolicyDecision`.
18
+
19
+ ```ts
20
+ type PolicyDecision =
21
+ | { allowed: true }
22
+ | { allowed: false; reason: string; code: string };
23
+ ```
24
+
25
+ `reason` is always **safe to log** (it names permissions, never row data) and useful
26
+ to an agent: `actor lacks post:publish` and `post:publish predicate returned false`
27
+ are different problems with different fixes.
28
+
29
+ ## Combinators
30
+
31
+ | Builder | Behaviour |
32
+ |---|---|
33
+ | `can(p, predicate?)` | permission first, then the row-level predicate |
34
+ | `allow()` / `deny(reason)` | terminal; `allow()` is how "public" is said out loud |
35
+ | `and(...)` | first denial wins, its reason is the reason |
36
+ | `or(...)` | first allowance wins; otherwise the last denial is reported |
37
+ | `not(p)` | inverts |
38
+
39
+ ## Four surfaces, four adapters, one rule
40
+
41
+ `surfaces.ts` is the proof. Each adapter evaluates and maps a denial to that surface's
42
+ error shape; allowed returns `undefined`.
43
+
44
+ | Adapter | Denial shape |
45
+ |---|---|
46
+ | `enforceHttp` | `403` + RFC-9457 fields |
47
+ | `enforceLive` | close frame `4403` |
48
+ | `enforceJob` | `failed`, `retryable: false` โ€” the answer will not change on retry |
49
+ | `enforceMcp` | `isError: true` with readable text |
50
+
51
+ Adding a fifth surface means adding an adapter here **and nothing else**.
52
+
53
+ ## Permissions and roles
54
+
55
+ `definePermissions(['post:publish', ...])` gives a typed set; augmenting
56
+ `PermissionRegistry` (which `x g policy` generates) makes a typo a compile error, and
57
+ `can()` throws `X_PERMISSION_UNKNOWN` at declaration time either way. Roles are sugar:
58
+ `defineRoles({ owner: { grants: ['post:delete'], inherits: ['editor'] } })` expands
59
+ depth-first to a flat set, cycles included. `post:*` and `*` are supported.
60
+
61
+ ## Traces
62
+
63
+ `evaluate()` returns a depth-first trace naming the clause that decided. `/_x` renders
64
+ it, `explain()` logs one line, and `policyMatrix()` turns actors ร— policy into an
65
+ assert-ready table:
66
+
67
+ ```
68
+ owner allow
69
+ editor deny post:read predicate returned false
70
+ viewer deny actor lacks post:publish
71
+ ```
72
+
73
+ ## Errors
74
+
75
+ `X_FORBIDDEN` ยท `X_POLICY_MISSING` (an action with no policy is a **build** error, not
76
+ a public endpoint) ยท `X_PERMISSION_UNKNOWN`
77
+
78
+ ## Boundaries
79
+
80
+ Tier 2. Imports `@ultimat3/core` only. Surface error shapes are declared structurally
81
+ so this package never imports `@ultimat3/http` (sibling tier) or the tier-3/4 surfaces
82
+ that import it.
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@ultimat3/policy",
3
+ "version": "0.0.1",
4
+ "description": "The one authz rule, evaluated identically in every surface",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/developerz-ai/ultimate.git",
10
+ "directory": "packages/policy"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "provenance": true
15
+ },
16
+ "exports": {
17
+ ".": "./src/index.ts"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "engines": {
26
+ "bun": ">=1.3.0"
27
+ },
28
+ "scripts": {
29
+ "typecheck": "tsc --noEmit -p tsconfig.json",
30
+ "test": "bun test"
31
+ },
32
+ "dependencies": {
33
+ "@ultimat3/core": "^0.0.1"
34
+ }
35
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,52 @@
1
+ // The policy layer's stable error codes. `X_POLICY_MISSING` is deliberately a build
2
+ // error rather than a runtime default: an action with no policy is not "public", it
3
+ // is unfinished.
4
+ import { UltimateError } from '@ultimat3/core';
5
+
6
+ export const POLICY_ERROR_CODES = [
7
+ 'X_FORBIDDEN',
8
+ 'X_POLICY_MISSING',
9
+ 'X_PERMISSION_UNKNOWN',
10
+ ] as const;
11
+
12
+ export type PolicyErrorCode = (typeof POLICY_ERROR_CODES)[number];
13
+
14
+ export const POLICY_ERROR_TITLES: Readonly<Record<PolicyErrorCode, string>> = {
15
+ X_FORBIDDEN: 'policy denied this actor',
16
+ X_POLICY_MISSING: 'an action was declared without a policy',
17
+ X_PERMISSION_UNKNOWN: 'permission string is not in the permission set',
18
+ };
19
+
20
+ export class PolicyError extends UltimateError {
21
+ constructor(init: { code: PolicyErrorCode; cause: string; fix: string }) {
22
+ super({
23
+ code: init.code,
24
+ cause: init.cause,
25
+ fix: init.fix,
26
+ docs: `https://ultimate.dev/errors/${init.code}`,
27
+ });
28
+ this.name = 'PolicyError';
29
+ }
30
+ }
31
+
32
+ /** `reason` comes from a decision and is always safe to log: no row data, no PII. */
33
+ export const forbidden = (label: string, reason: string): PolicyError =>
34
+ new PolicyError({
35
+ code: 'X_FORBIDDEN',
36
+ cause: `${label} denied: ${reason}`,
37
+ fix: `x policy explain ${label} --json # shows which clause decided and why`,
38
+ });
39
+
40
+ export const policyMissing = (subject: string): PolicyError =>
41
+ new PolicyError({
42
+ code: 'X_POLICY_MISSING',
43
+ cause: `${subject} has no policy; an action without a policy is a build error, not a public endpoint`,
44
+ fix: `add policy: can('<resource>:<verb>') to ${subject}, or allow('public') to say so explicitly`,
45
+ });
46
+
47
+ export const permissionUnknown = (permission: string, known: readonly string[]): PolicyError =>
48
+ new PolicyError({
49
+ code: 'X_PERMISSION_UNKNOWN',
50
+ cause: `"${permission}" is not in the permission set (${known.length} known)`,
51
+ fix: `add '${permission}' to definePermissions([...]) โ€” or fix the typo`,
52
+ });
@@ -0,0 +1,74 @@
1
+ // One entry point for evaluating a policy, and the only place a decision trace is
2
+ // built. The trace is what makes an authz denial debuggable: `/_x` renders it, policy
3
+ // tests assert on it, and an agent reading a 403 can see which clause decided.
4
+ import type { Ctx } from '@ultimat3/core';
5
+ import type { Policy, PolicyDecision, TraceEntry } from './policy';
6
+ import type { Actor } from './roles';
7
+
8
+ export interface EvaluateArgs<I> {
9
+ readonly input: I;
10
+ readonly actor: Actor | null;
11
+ readonly ctx?: Ctx;
12
+ }
13
+
14
+ export interface PolicyEvaluation {
15
+ readonly allowed: boolean;
16
+ readonly decision: PolicyDecision;
17
+ /** Depth-first, in evaluation order. Empty only for a policy that never ran. */
18
+ readonly trace: readonly TraceEntry[];
19
+ /** The clause whose result the caller is looking at. */
20
+ readonly deciding: TraceEntry | null;
21
+ readonly label: string;
22
+ }
23
+
24
+ export const evaluate = <I>(policy: Policy<I>, args: EvaluateArgs<I>): PolicyEvaluation => {
25
+ const trace: TraceEntry[] = [];
26
+ const decision = policy.run(
27
+ {
28
+ input: args.input,
29
+ actor: args.actor,
30
+ ...(args.ctx === undefined ? {} : { ctx: args.ctx }),
31
+ },
32
+ (entry) => trace.push(entry),
33
+ );
34
+ // Entries are recorded post-order (children before their combinator), so the first
35
+ // entry that agrees with the outcome is the leaf that actually decided โ€” and when a
36
+ // reason is available it is matched too, which disambiguates `or(...)`.
37
+ const agrees = (entry: TraceEntry): boolean => entry.allowed === decision.allowed;
38
+ const deciding =
39
+ trace.find(
40
+ (entry) => agrees(entry) && (decision.allowed || entry.reason === decision.reason),
41
+ ) ??
42
+ trace.find(agrees) ??
43
+ null;
44
+ return {
45
+ allowed: decision.allowed,
46
+ decision,
47
+ trace,
48
+ deciding,
49
+ label: policy.label,
50
+ };
51
+ };
52
+
53
+ export const reasonOf = (decision: PolicyDecision): string | null =>
54
+ decision.allowed ? null : decision.reason;
55
+
56
+ export const codeOf = (decision: PolicyDecision): string | null =>
57
+ decision.allowed ? null : decision.code;
58
+
59
+ /** `post:publish -> denied: actor lacks post:publish` โ€” one line, safe to log. */
60
+ export const explain = (evaluation: PolicyEvaluation): string => {
61
+ const outcome = evaluation.allowed ? 'allowed' : `denied: ${reasonOf(evaluation.decision)}`;
62
+ const by = evaluation.deciding === null ? '' : ` (by ${evaluation.deciding.label})`;
63
+ return `${evaluation.label} -> ${outcome}${by}`;
64
+ };
65
+
66
+ /** Indented tree for the dev dashboard; one line per clause. */
67
+ export const renderTrace = (evaluation: PolicyEvaluation): string =>
68
+ evaluation.trace
69
+ .map((entry) => {
70
+ const mark = entry.allowed ? 'allow' : 'deny ';
71
+ const why = entry.reason === null ? '' : ` โ€” ${entry.reason}`;
72
+ return `${' '.repeat(entry.depth)}${mark} ${entry.label}${why}`;
73
+ })
74
+ .join('\n');
package/src/index.ts ADDED
@@ -0,0 +1,67 @@
1
+ // The public surface of @ultimat3/policy. Explicit, never `export *`.
2
+
3
+ export type { PolicyErrorCode } from './errors';
4
+ export {
5
+ forbidden,
6
+ POLICY_ERROR_CODES,
7
+ POLICY_ERROR_TITLES,
8
+ PolicyError,
9
+ permissionUnknown,
10
+ policyMissing,
11
+ } from './errors';
12
+ export type { EvaluateArgs, PolicyEvaluation } from './evaluate';
13
+ export { codeOf, evaluate, explain, reasonOf, renderTrace } from './evaluate';
14
+ export type {
15
+ KnownPermission,
16
+ Permission,
17
+ PermissionRegistry,
18
+ PermissionSet,
19
+ } from './permissions';
20
+ export {
21
+ assertPermission,
22
+ clearPermissions,
23
+ definePermissions,
24
+ isKnownPermission,
25
+ knownPermissions,
26
+ resourceOf,
27
+ verbOf,
28
+ } from './permissions';
29
+ export type {
30
+ Policy,
31
+ PolicyArgs,
32
+ PolicyDecision,
33
+ PolicyKind,
34
+ PolicyPredicate,
35
+ Recorder,
36
+ TraceEntry,
37
+ } from './policy';
38
+ export { ALLOWED, allow, and, can, denied, deny, not, or } from './policy';
39
+ export type { Actor, PolicyActorFields, RoleDef, RoleMap } from './roles';
40
+ export {
41
+ actorHas,
42
+ actorPermissions,
43
+ clearRoles,
44
+ defineRoles,
45
+ expandRoles,
46
+ grantMatches,
47
+ roleDefinitions,
48
+ rolesGranting,
49
+ } from './roles';
50
+ export type {
51
+ HttpDenial,
52
+ JobDenial,
53
+ LiveDenial,
54
+ McpDenial,
55
+ Surface,
56
+ SurfaceDenial,
57
+ } from './surfaces';
58
+ export {
59
+ assertAllowed,
60
+ enforce,
61
+ enforceHttp,
62
+ enforceJob,
63
+ enforceLive,
64
+ enforceMcp,
65
+ } from './surfaces';
66
+ export type { MatrixArgs, MatrixRow, NamedActor, PolicyMatrix } from './test-kit';
67
+ export { policyMatrix, testActor } from './test-kit';
@@ -0,0 +1,77 @@
1
+ // Permissions are `resource:verb` strings. Two layers of protection: the template
2
+ // literal type rejects a malformed string at compile time, and an app that augments
3
+ // `PermissionRegistry` (which `x g policy` does) turns a typo into a type error.
4
+ import { permissionUnknown } from './errors';
5
+
6
+ export type Permission = `${string}:${string}`;
7
+
8
+ /**
9
+ * Augmented by the generated app code:
10
+ *
11
+ * ```ts
12
+ * declare module '@ultimat3/policy' {
13
+ * interface PermissionRegistry { 'post:publish': true; 'post:read': true }
14
+ * }
15
+ * ```
16
+ */
17
+ export interface PermissionRegistry {
18
+ /** Phantom member; never augment or read this key. */
19
+ readonly __ultimate?: never;
20
+ }
21
+
22
+ type Declared = Exclude<keyof PermissionRegistry, '__ultimate'>;
23
+
24
+ /** Every declared permission, or any `resource:verb` string before augmentation. */
25
+ export type KnownPermission = [Declared] extends [never]
26
+ ? Permission
27
+ : Extract<Declared, Permission>;
28
+
29
+ export interface PermissionSet<P extends Permission> {
30
+ readonly all: readonly P[];
31
+ has(value: string): value is P;
32
+ /** Narrows a string to a declared permission, or throws `X_PERMISSION_UNKNOWN`. */
33
+ assert(value: string): P;
34
+ byResource(resource: string): readonly P[];
35
+ resources(): readonly string[];
36
+ }
37
+
38
+ const declared = new Set<string>();
39
+
40
+ export const knownPermissions = (): readonly string[] => [...declared].sort();
41
+
42
+ /**
43
+ * Runtime membership check. It stays silent until an app has declared its set,
44
+ * because there is nothing to check against before then โ€” `x verify` is what fails
45
+ * a build that references a permission no `definePermissions()` call declares.
46
+ */
47
+ export const isKnownPermission = (value: string): boolean =>
48
+ declared.size === 0 || declared.has(value);
49
+
50
+ export const assertPermission = (value: string): string => {
51
+ if (!isKnownPermission(value)) throw permissionUnknown(value, knownPermissions());
52
+ return value;
53
+ };
54
+
55
+ export const resourceOf = (permission: string): string => permission.split(':')[0] ?? permission;
56
+
57
+ export const verbOf = (permission: string): string => permission.split(':')[1] ?? '';
58
+
59
+ export const definePermissions = <const P extends readonly Permission[]>(
60
+ list: P,
61
+ ): PermissionSet<P[number]> => {
62
+ for (const permission of list) declared.add(permission);
63
+ const all = [...list] as P[number][];
64
+ return {
65
+ all,
66
+ has: (value): value is P[number] => all.includes(value as P[number]),
67
+ assert: (value) => {
68
+ if (!all.includes(value as P[number])) throw permissionUnknown(value, all);
69
+ return value as P[number];
70
+ },
71
+ byResource: (resource) => all.filter((permission) => resourceOf(permission) === resource),
72
+ resources: () => [...new Set(all.map(resourceOf))].sort(),
73
+ };
74
+ };
75
+
76
+ /** Test seam; production never forgets a permission it declared. */
77
+ export const clearPermissions = (): void => declared.clear();
package/src/policy.ts ADDED
@@ -0,0 +1,175 @@
1
+ // A policy is a pure function of (input, actor, ctx). Purity is what lets the same
2
+ // object be evaluated in an HTTP request, a job, a live query and an MCP tool without
3
+ // any of them re-implementing the rule โ€” one authz system, never two.
4
+ import type { Ctx } from '@ultimat3/core';
5
+ import { assertPermission, type KnownPermission, type Permission } from './permissions';
6
+ import { type Actor, actorHas } from './roles';
7
+
8
+ export type PolicyDecision =
9
+ | { readonly allowed: true }
10
+ | { readonly allowed: false; readonly reason: string; readonly code: string };
11
+
12
+ export const ALLOWED: PolicyDecision = { allowed: true };
13
+
14
+ export const denied = (reason: string, code = 'X_FORBIDDEN'): PolicyDecision => ({
15
+ allowed: false,
16
+ reason,
17
+ code,
18
+ });
19
+
20
+ export interface PolicyArgs<I> {
21
+ readonly input: I;
22
+ readonly actor: Actor | null;
23
+ readonly ctx?: Ctx;
24
+ }
25
+
26
+ export type PolicyPredicate<I> = (args: PolicyArgs<I>) => boolean | PolicyDecision;
27
+
28
+ export type PolicyKind = 'permission' | 'allow' | 'deny' | 'and' | 'or' | 'not';
29
+
30
+ export interface TraceEntry {
31
+ readonly label: string;
32
+ readonly kind: PolicyKind;
33
+ readonly depth: number;
34
+ readonly allowed: boolean;
35
+ readonly reason: string | null;
36
+ readonly code: string | null;
37
+ }
38
+
39
+ export type Recorder = (entry: TraceEntry) => void;
40
+
41
+ export interface Policy<I = unknown> {
42
+ readonly kind: PolicyKind;
43
+ /** Stable, human-readable, safe to log: shown in traces and denial reasons. */
44
+ readonly label: string;
45
+ readonly permissions: readonly Permission[];
46
+ readonly children: readonly Policy<I>[];
47
+ run(args: PolicyArgs<I>, record?: Recorder, depth?: number): PolicyDecision;
48
+ }
49
+
50
+ const record = (
51
+ recorder: Recorder | undefined,
52
+ policy: { kind: PolicyKind; label: string },
53
+ depth: number,
54
+ decision: PolicyDecision,
55
+ ): PolicyDecision => {
56
+ recorder?.({
57
+ label: policy.label,
58
+ kind: policy.kind,
59
+ depth,
60
+ allowed: decision.allowed,
61
+ reason: decision.allowed ? null : decision.reason,
62
+ code: decision.allowed ? null : decision.code,
63
+ });
64
+ return decision;
65
+ };
66
+
67
+ const asDecision = (result: boolean | PolicyDecision, label: string): PolicyDecision => {
68
+ if (typeof result === 'boolean') {
69
+ return result ? ALLOWED : denied(`${label} predicate returned false`);
70
+ }
71
+ return result;
72
+ };
73
+
74
+ /**
75
+ * The blessed constructor. The permission is checked first and the optional
76
+ * predicate second, so a denial reason distinguishes "you may never do this" from
77
+ * "you may, but not to this row" โ€” an agent can act on the difference.
78
+ */
79
+ export const can = <I = unknown>(
80
+ permission: KnownPermission,
81
+ predicate?: PolicyPredicate<I>,
82
+ ): Policy<I> => {
83
+ assertPermission(permission);
84
+ const label = permission;
85
+ return {
86
+ kind: 'permission',
87
+ label,
88
+ permissions: [permission as Permission],
89
+ children: [],
90
+ run(args, recorder, depth = 0) {
91
+ if (args.actor === null) {
92
+ return record(recorder, this, depth, denied(`no actor for ${label}`, 'X_UNAUTHENTICATED'));
93
+ }
94
+ if (!actorHas(args.actor, permission as Permission)) {
95
+ return record(recorder, this, depth, denied(`actor lacks ${label}`));
96
+ }
97
+ if (predicate === undefined) return record(recorder, this, depth, ALLOWED);
98
+ return record(recorder, this, depth, asDecision(predicate(args), label));
99
+ },
100
+ };
101
+ };
102
+
103
+ /** Explicitly public. Saying so is required; forgetting a policy is a build error. */
104
+ export const allow = <I = unknown>(label = 'allow'): Policy<I> => ({
105
+ kind: 'allow',
106
+ label,
107
+ permissions: [],
108
+ children: [],
109
+ run(_args, recorder, depth = 0) {
110
+ return record(recorder, this, depth, ALLOWED);
111
+ },
112
+ });
113
+
114
+ export const deny = <I = unknown>(reason: string, code = 'X_FORBIDDEN'): Policy<I> => ({
115
+ kind: 'deny',
116
+ label: `deny(${reason})`,
117
+ permissions: [],
118
+ children: [],
119
+ run(_args, recorder, depth = 0) {
120
+ return record(recorder, this, depth, denied(reason, code));
121
+ },
122
+ });
123
+
124
+ const combined = <I>(
125
+ kind: PolicyKind,
126
+ label: string,
127
+ children: readonly Policy<I>[],
128
+ decide: (args: PolicyArgs<I>, recorder: Recorder | undefined, depth: number) => PolicyDecision,
129
+ ): Policy<I> => ({
130
+ kind,
131
+ label,
132
+ permissions: children.flatMap((child) => child.permissions),
133
+ children,
134
+ run(args, recorder, depth = 0) {
135
+ return record(recorder, this, depth, decide(args, recorder, depth + 1));
136
+ },
137
+ });
138
+
139
+ /** First denial wins, and its reason is the reason โ€” short-circuit, left to right. */
140
+ export const and = <I>(...policies: readonly Policy<I>[]): Policy<I> =>
141
+ combined(
142
+ 'and',
143
+ `and(${policies.map((policy) => policy.label).join(', ')})`,
144
+ policies,
145
+ (args, recorder, depth) => {
146
+ for (const policy of policies) {
147
+ const decision = policy.run(args, recorder, depth);
148
+ if (!decision.allowed) return decision;
149
+ }
150
+ return ALLOWED;
151
+ },
152
+ );
153
+
154
+ /** First allowance wins; if none allow, the LAST denial is reported. */
155
+ export const or = <I>(...policies: readonly Policy<I>[]): Policy<I> =>
156
+ combined(
157
+ 'or',
158
+ `or(${policies.map((policy) => policy.label).join(', ')})`,
159
+ policies,
160
+ (args, recorder, depth) => {
161
+ let last: PolicyDecision = denied('no clause allowed this actor');
162
+ for (const policy of policies) {
163
+ const decision = policy.run(args, recorder, depth);
164
+ if (decision.allowed) return ALLOWED;
165
+ last = decision;
166
+ }
167
+ return last;
168
+ },
169
+ );
170
+
171
+ export const not = <I>(policy: Policy<I>): Policy<I> =>
172
+ combined('not', `not(${policy.label})`, [policy], (args, recorder, depth) => {
173
+ const decision = policy.run(args, recorder, depth);
174
+ return decision.allowed ? denied(`not(${policy.label}) โ€” inner clause allowed`) : ALLOWED;
175
+ });
package/src/roles.ts ADDED
@@ -0,0 +1,92 @@
1
+ // Roles are sugar over permissions: a role grants a set, and may inherit others.
2
+ // Everything is expanded to a flat permission set before any policy runs, so the
3
+ // evaluator never has to reason about hierarchy โ€” and a cycle is caught here, once.
4
+ import type { Actor as CoreActor } from '@ultimat3/core';
5
+ import { type Permission, resourceOf } from './permissions';
6
+
7
+ /**
8
+ * The fields policy evaluation reads off an actor. `Actor` itself is core's; these
9
+ * are authz roles ("editor", "owner"), not core's runtime `Role` ("web", "worker").
10
+ */
11
+ export interface PolicyActorFields {
12
+ readonly id: string;
13
+ readonly roles?: readonly string[] | undefined;
14
+ /** Direct grants, bypassing roles. Used by service tokens. */
15
+ readonly permissions?: readonly string[] | undefined;
16
+ readonly orgId?: string | null | undefined;
17
+ }
18
+
19
+ export type Actor = CoreActor & PolicyActorFields;
20
+
21
+ export interface RoleDef {
22
+ readonly grants: readonly string[];
23
+ readonly inherits?: readonly string[];
24
+ readonly description?: string;
25
+ }
26
+
27
+ export type RoleMap = Readonly<Record<string, RoleDef>>;
28
+
29
+ let roleMap: RoleMap = {};
30
+
31
+ export const defineRoles = <const M extends RoleMap>(map: M): M => {
32
+ roleMap = map;
33
+ return map;
34
+ };
35
+
36
+ export const roleDefinitions = (): RoleMap => roleMap;
37
+
38
+ /** Test seam. */
39
+ export const clearRoles = (): void => {
40
+ roleMap = {};
41
+ };
42
+
43
+ /**
44
+ * Depth-first expansion with a visited set: `owner -> admin -> editor` collapses to
45
+ * one list, and `a -> b -> a` terminates instead of blowing the stack.
46
+ */
47
+ export const expandRoles = (
48
+ roles: readonly string[],
49
+ map: RoleMap = roleMap,
50
+ ): readonly string[] => {
51
+ const seen = new Set<string>();
52
+ const out = new Set<string>();
53
+ const walk = (name: string): void => {
54
+ if (seen.has(name)) return;
55
+ seen.add(name);
56
+ const definition = map[name];
57
+ if (definition === undefined) return;
58
+ for (const grant of definition.grants) out.add(grant);
59
+ for (const parent of definition.inherits ?? []) walk(parent);
60
+ };
61
+ for (const role of roles) walk(role);
62
+ return [...out].sort();
63
+ };
64
+
65
+ /** `post:*` matches every verb on `post`; `*` matches everything. */
66
+ export const grantMatches = (grant: string, wanted: string): boolean => {
67
+ if (grant === '*' || grant === wanted) return true;
68
+ if (grant.endsWith(':*')) return resourceOf(grant) === resourceOf(wanted);
69
+ return false;
70
+ };
71
+
72
+ export const actorPermissions = (
73
+ actor: Actor | null,
74
+ map: RoleMap = roleMap,
75
+ ): readonly string[] => {
76
+ if (actor === null) return [];
77
+ const direct = actor.permissions ?? [];
78
+ const fromRoles = expandRoles(actor.roles ?? [], map);
79
+ return [...new Set([...direct, ...fromRoles])].sort();
80
+ };
81
+
82
+ export const actorHas = (
83
+ actor: Actor | null,
84
+ permission: Permission,
85
+ map: RoleMap = roleMap,
86
+ ): boolean => actorPermissions(actor, map).some((grant) => grantMatches(grant, permission));
87
+
88
+ /** For the `/_x` dashboard: which roles would satisfy a permission. */
89
+ export const rolesGranting = (permission: string, map: RoleMap = roleMap): readonly string[] =>
90
+ Object.keys(map)
91
+ .filter((role) => expandRoles([role], map).some((grant) => grantMatches(grant, permission)))
92
+ .sort();
@@ -0,0 +1,127 @@
1
+ // Proof that one policy covers every surface. Each adapter is the same three lines:
2
+ // evaluate, map a denial to that surface's error shape, return `undefined` when
3
+ // allowed. Adding a fifth surface means adding an adapter HERE and nothing else โ€” no
4
+ // new policy model, no second authz path, no per-surface exceptions.
5
+ //
6
+ // The shapes are declared structurally rather than imported: `@ultimat3/http` is a
7
+ // sibling tier, and jobs/realtime/mcp are higher tiers that import this package.
8
+
9
+ import { forbidden } from './errors';
10
+ import { codeOf, type EvaluateArgs, evaluate, type PolicyEvaluation, reasonOf } from './evaluate';
11
+ import type { Policy } from './policy';
12
+
13
+ export type Surface = 'http' | 'live' | 'job' | 'mcp';
14
+
15
+ export interface HttpDenial {
16
+ readonly surface: 'http';
17
+ readonly status: 403;
18
+ /** RFC-9457 fields `@ultimat3/http` renders verbatim. */
19
+ readonly problem: {
20
+ readonly title: string;
21
+ readonly status: 403;
22
+ readonly detail: string;
23
+ readonly code: string;
24
+ };
25
+ }
26
+
27
+ export interface LiveDenial {
28
+ readonly surface: 'live';
29
+ /** WebSocket close code in the private range; the client stops resubscribing. */
30
+ readonly close: 4403;
31
+ readonly code: string;
32
+ readonly reason: string;
33
+ }
34
+
35
+ export interface JobDenial {
36
+ readonly surface: 'job';
37
+ readonly outcome: 'failed';
38
+ /** Authz denials are never retried: the answer will not change on attempt two. */
39
+ readonly retryable: false;
40
+ readonly code: string;
41
+ readonly reason: string;
42
+ }
43
+
44
+ export interface McpDenial {
45
+ readonly surface: 'mcp';
46
+ readonly isError: true;
47
+ readonly content: readonly { readonly type: 'text'; readonly text: string }[];
48
+ }
49
+
50
+ const reason = (evaluation: PolicyEvaluation): string => reasonOf(evaluation.decision) ?? 'denied';
51
+
52
+ const code = (evaluation: PolicyEvaluation): string => codeOf(evaluation.decision) ?? 'X_FORBIDDEN';
53
+
54
+ export const enforceHttp = <I>(
55
+ policy: Policy<I>,
56
+ args: EvaluateArgs<I>,
57
+ ): HttpDenial | undefined => {
58
+ const evaluation = evaluate(policy, args);
59
+ if (evaluation.allowed) return undefined;
60
+ return {
61
+ surface: 'http',
62
+ status: 403,
63
+ problem: {
64
+ title: 'policy denied this actor',
65
+ status: 403,
66
+ detail: reason(evaluation),
67
+ code: code(evaluation),
68
+ },
69
+ };
70
+ };
71
+
72
+ export const enforceLive = <I>(
73
+ policy: Policy<I>,
74
+ args: EvaluateArgs<I>,
75
+ ): LiveDenial | undefined => {
76
+ const evaluation = evaluate(policy, args);
77
+ if (evaluation.allowed) return undefined;
78
+ return { surface: 'live', close: 4403, code: code(evaluation), reason: reason(evaluation) };
79
+ };
80
+
81
+ export const enforceJob = <I>(policy: Policy<I>, args: EvaluateArgs<I>): JobDenial | undefined => {
82
+ const evaluation = evaluate(policy, args);
83
+ if (evaluation.allowed) return undefined;
84
+ return {
85
+ surface: 'job',
86
+ outcome: 'failed',
87
+ retryable: false,
88
+ code: code(evaluation),
89
+ reason: reason(evaluation),
90
+ };
91
+ };
92
+
93
+ export const enforceMcp = <I>(policy: Policy<I>, args: EvaluateArgs<I>): McpDenial | undefined => {
94
+ const evaluation = evaluate(policy, args);
95
+ if (evaluation.allowed) return undefined;
96
+ return {
97
+ surface: 'mcp',
98
+ isError: true,
99
+ // An MCP client is an agent: the text has to say what was denied and why.
100
+ content: [{ type: 'text', text: `${code(evaluation)}: ${reason(evaluation)}` }],
101
+ };
102
+ };
103
+
104
+ export type SurfaceDenial = HttpDenial | LiveDenial | JobDenial | McpDenial;
105
+
106
+ type Adapter = <I>(policy: Policy<I>, args: EvaluateArgs<I>) => SurfaceDenial | undefined;
107
+
108
+ const adapters: Readonly<Record<Surface, Adapter>> = {
109
+ http: enforceHttp,
110
+ live: enforceLive,
111
+ job: enforceJob,
112
+ mcp: enforceMcp,
113
+ };
114
+
115
+ /** Dispatcher for code that is generic over surfaces (the action projector). */
116
+ export const enforce = <I>(
117
+ surface: Surface,
118
+ policy: Policy<I>,
119
+ args: EvaluateArgs<I>,
120
+ ): SurfaceDenial | undefined => adapters[surface](policy, args);
121
+
122
+ /** For call sites that would rather throw than branch. Same decision, same reason. */
123
+ export const assertAllowed = <I>(policy: Policy<I>, args: EvaluateArgs<I>): PolicyEvaluation => {
124
+ const evaluation = evaluate(policy, args);
125
+ if (!evaluation.allowed) throw forbidden(policy.label, reason(evaluation));
126
+ return evaluation;
127
+ };
@@ -0,0 +1,79 @@
1
+ // `policyMatrix()` turns "who can do this?" into a table a test can assert on in one
2
+ // expression. `x g policy` generates a test that calls it, so every policy ships with
3
+ // its allow/deny matrix and a change to a role shows up as a diff in that table.
4
+ import { type EvaluateArgs, evaluate, reasonOf } from './evaluate';
5
+ import type { Policy } from './policy';
6
+ import type { Actor } from './roles';
7
+
8
+ export interface NamedActor {
9
+ readonly name: string;
10
+ readonly actor: Actor | null;
11
+ }
12
+
13
+ export interface MatrixRow {
14
+ readonly actor: string;
15
+ readonly allowed: boolean;
16
+ readonly reason: string | null;
17
+ readonly deciding: string | null;
18
+ }
19
+
20
+ export interface PolicyMatrix {
21
+ readonly label: string;
22
+ readonly rows: readonly MatrixRow[];
23
+ /** `{ owner: true, viewer: false }` โ€” the shape an assertion reads best. */
24
+ readonly verdicts: Readonly<Record<string, boolean>>;
25
+ allowedFor(name: string): boolean;
26
+ /** Fixed-width table for a snapshot test or the dev dashboard. */
27
+ toTable(): string;
28
+ }
29
+
30
+ export interface MatrixArgs<I> extends Omit<EvaluateArgs<I>, 'actor'> {
31
+ readonly actors: readonly NamedActor[];
32
+ }
33
+
34
+ export const policyMatrix = <I>(policy: Policy<I>, args: MatrixArgs<I>): PolicyMatrix => {
35
+ const rows = args.actors.map((entry): MatrixRow => {
36
+ const evaluation = evaluate(policy, {
37
+ input: args.input,
38
+ actor: entry.actor,
39
+ ...(args.ctx === undefined ? {} : { ctx: args.ctx }),
40
+ });
41
+ return {
42
+ actor: entry.name,
43
+ allowed: evaluation.allowed,
44
+ reason: reasonOf(evaluation.decision),
45
+ deciding: evaluation.deciding?.label ?? null,
46
+ };
47
+ });
48
+
49
+ const verdicts: Record<string, boolean> = {};
50
+ for (const row of rows) verdicts[row.actor] = row.allowed;
51
+
52
+ const width = Math.max(5, ...rows.map((row) => row.actor.length));
53
+ return {
54
+ label: policy.label,
55
+ rows,
56
+ verdicts,
57
+ allowedFor: (name) => verdicts[name] ?? false,
58
+ toTable: () =>
59
+ rows
60
+ .map((row) =>
61
+ `${row.actor.padEnd(width)} ${row.allowed ? 'allow' : 'deny '} ${row.reason ?? ''}`.trimEnd(),
62
+ )
63
+ .join('\n'),
64
+ };
65
+ };
66
+
67
+ /** Builds an actor for tests without asserting anything about core's Actor shape. */
68
+ export const testActor = (
69
+ name: string,
70
+ init: { roles?: readonly string[]; permissions?: readonly string[]; orgId?: string } = {},
71
+ ): NamedActor => ({
72
+ name,
73
+ actor: {
74
+ id: name,
75
+ roles: init.roles ?? [],
76
+ permissions: init.permissions ?? [],
77
+ orgId: init.orgId ?? null,
78
+ } as unknown as Actor,
79
+ });