@ultimat3/admin 27.1.0 → 27.2.0

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 CHANGED
@@ -86,7 +86,7 @@ closed `<details>` whose summary reads "N of M allowed".
86
86
  | `audit` | `postgresAuditLog()` (the table `x_admin_audit`) or `memoryAuditLog({ sinks })` | a memory ring |
87
87
 
88
88
  Three authz constructors, one interface: `roleAuthz()` (the default), `policyAuthz({ policies })`
89
- when a rule reads the row or the tenant, `singlePolicyAuthz(policy)` when one grant runs the whole
89
+ when a rule reads the row or the tenant (a permission the map omits falls back to the role map), `singlePolicyAuthz(policy)` when one grant runs the whole
90
90
  admin.
91
91
 
92
92
  ### No script
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/admin",
3
- "version": "27.1.0",
3
+ "version": "27.2.0",
4
4
  "description": "Two dashboards: the /_x framework dev panels and the generated, AI-first app admin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,21 +34,21 @@
34
34
  "test": "bun test"
35
35
  },
36
36
  "dependencies": {
37
- "@ultimat3/action": "27.1.0",
38
- "@ultimat3/ai": "27.1.0",
39
- "@ultimat3/cache": "27.1.0",
40
- "@ultimat3/core": "27.1.0",
41
- "@ultimat3/db": "27.1.0",
42
- "@ultimat3/entity": "27.1.0",
43
- "@ultimat3/http": "27.1.0",
44
- "@ultimat3/i18n": "27.1.0",
45
- "@ultimat3/jobs": "27.1.0",
46
- "@ultimat3/mcp": "27.1.0",
47
- "@ultimat3/money": "27.1.0",
48
- "@ultimat3/policy": "27.1.0",
49
- "@ultimat3/query": "27.1.0",
50
- "@ultimat3/render": "27.1.0",
51
- "@ultimat3/schema": "27.1.0",
52
- "@ultimat3/ui": "27.1.0"
37
+ "@ultimat3/action": "27.2.0",
38
+ "@ultimat3/ai": "27.2.0",
39
+ "@ultimat3/cache": "27.2.0",
40
+ "@ultimat3/core": "27.2.0",
41
+ "@ultimat3/db": "27.2.0",
42
+ "@ultimat3/entity": "27.2.0",
43
+ "@ultimat3/http": "27.2.0",
44
+ "@ultimat3/i18n": "27.2.0",
45
+ "@ultimat3/jobs": "27.2.0",
46
+ "@ultimat3/mcp": "27.2.0",
47
+ "@ultimat3/money": "27.2.0",
48
+ "@ultimat3/policy": "27.2.0",
49
+ "@ultimat3/query": "27.2.0",
50
+ "@ultimat3/render": "27.2.0",
51
+ "@ultimat3/schema": "27.2.0",
52
+ "@ultimat3/ui": "27.2.0"
53
53
  }
54
54
  }
package/src/actor.ts CHANGED
@@ -21,6 +21,7 @@ export const adminActorFrom = (
21
21
  : {
22
22
  id: actor.id,
23
23
  roles: actor.roles,
24
+ ...(actor.permissions.length === 0 ? {} : { permissions: actor.permissions }),
24
25
  locale,
25
26
  timeZone,
26
27
  ...(actor.orgId === undefined ? {} : { orgId: actor.orgId }),
package/src/authz.ts CHANGED
@@ -20,6 +20,12 @@ export interface AdminActor {
20
20
  */
21
21
  readonly orgId?: string;
22
22
  readonly roles?: readonly string[];
23
+ /**
24
+ * Direct grants — `Actor.permissions`, held with or without a role (a break-glass account, a
25
+ * staff session not yet enrolled in MFA). The request pipeline decides on roles AND these; the
26
+ * admin read roles only, so a route the pipeline opened answered a 403 screen.
27
+ */
28
+ readonly permissions?: readonly string[];
23
29
  /** BCP-47. Drives every `t()` call and every `Intl` format in the admin. */
24
30
  readonly locale?: string;
25
31
  /** IANA zone. The admin refuses to render a timestamp without one. */
package/src/batch-job.ts CHANGED
@@ -39,6 +39,7 @@ const chunkInput = t.object({
39
39
  actor: t.object({
40
40
  id: t.string,
41
41
  roles: t.array(t.string),
42
+ permissions: t.optional(t.array(t.string)),
42
43
  orgId: t.optional(t.string),
43
44
  locale: t.optional(t.string),
44
45
  timeZone: t.optional(t.string),
@@ -58,6 +59,8 @@ export interface AdminBatchChunk {
58
59
  readonly actor: {
59
60
  readonly id: string;
60
61
  readonly roles: readonly string[];
62
+ /** Direct grants: an operator who holds the permission with no role runs the batch too. */
63
+ readonly permissions?: readonly string[] | undefined;
61
64
  readonly orgId?: string | undefined;
62
65
  readonly locale?: string | undefined;
63
66
  readonly timeZone?: string | undefined;
@@ -87,6 +90,7 @@ export async function runBatchChunk(chunk: AdminBatchChunk): Promise<readonly Ba
87
90
  const actor: AdminActor = {
88
91
  id: chunk.actor.id,
89
92
  roles: chunk.actor.roles,
93
+ ...(chunk.actor.permissions === undefined ? {} : { permissions: chunk.actor.permissions }),
90
94
  ...(chunk.actor.orgId === undefined ? {} : { orgId: chunk.actor.orgId }),
91
95
  ...(chunk.actor.locale === undefined ? {} : { locale: chunk.actor.locale }),
92
96
  ...(chunk.actor.timeZone === undefined ? {} : { timeZone: chunk.actor.timeZone }),
@@ -99,6 +103,7 @@ export async function runBatchChunk(chunk: AdminBatchChunk): Promise<readonly Ba
99
103
  actor: userActor({
100
104
  id: actor.id,
101
105
  roles: [...(actor.roles ?? [])],
106
+ permissions: [...(actor.permissions ?? [])],
102
107
  ...(actor.orgId === undefined ? {} : { orgId: actor.orgId }),
103
108
  }),
104
109
  },
@@ -143,6 +148,7 @@ export const batchEnqueue =
143
148
  actor: {
144
149
  id: actor.id,
145
150
  roles: [...(actor.roles ?? [])],
151
+ ...(actor.permissions === undefined ? {} : { permissions: [...actor.permissions] }),
146
152
  ...(actor.orgId === undefined ? {} : { orgId: actor.orgId }),
147
153
  ...(actor.locale === undefined ? {} : { locale: actor.locale }),
148
154
  ...(actor.timeZone === undefined ? {} : { timeZone: actor.timeZone }),
package/src/mcp.ts CHANGED
@@ -311,6 +311,8 @@ const inputSchema = (tool: AdminMcpTool): JsonSchema => ({
311
311
  const adminActorOf = (caller: McpCaller): AdminActor => ({
312
312
  id: caller.actor.id,
313
313
  roles: caller.actor.roles,
314
+ // Direct grants too: the pipeline decides on roles and `permissions` as one set.
315
+ ...(caller.actor.permissions.length === 0 ? {} : { permissions: caller.actor.permissions }),
314
316
  ...(caller.actor.orgId === undefined ? {} : { orgId: caller.actor.orgId }),
315
317
  });
316
318
 
@@ -453,7 +455,12 @@ export function adminMcp(opts: AdminMcpOptions): AppMcp {
453
455
  return actor === null
454
456
  ? null
455
457
  : {
456
- actor: agentActor({ id: actor.id, roles: actor.roles ?? [], orgId: actor.orgId }),
458
+ actor: agentActor({
459
+ id: actor.id,
460
+ roles: actor.roles ?? [],
461
+ permissions: actor.permissions ?? [],
462
+ orgId: actor.orgId,
463
+ }),
457
464
  scopes: adminTokenScopes(actor.tokenScopes),
458
465
  };
459
466
  },
@@ -15,6 +15,7 @@ import {
15
15
  import {
16
16
  type AdminActor,
17
17
  type AdminAuthz,
18
+ type AdminAuthzQuery,
18
19
  type AdminDecision,
19
20
  type AdminSubject,
20
21
  adminAllowed,
@@ -31,7 +32,14 @@ const policyActor = (actor: AdminActor): Actor =>
31
32
  // `orgId` rides along, and its absence used to be silent: every admin decision was evaluated
32
33
  // with `actor.orgId === undefined`, so an org-scoped rule could not fire and a role-only rule
33
34
  // allowed a row from another tenant.
34
- userActor({ id: actor.id, roles: actor.roles ?? [], orgId: actor.orgId });
35
+ // And the direct grants: `can()` reads roles and `permissions` as one set, exactly as the request
36
+ // pipeline does, so a permission held with no role opens the screen it opens the route for.
37
+ userActor({
38
+ id: actor.id,
39
+ roles: actor.roles ?? [],
40
+ permissions: actor.permissions ?? [],
41
+ orgId: actor.orgId,
42
+ });
35
43
 
36
44
  /**
37
45
  * `evaluate()`'s result is read structurally: the policy layer owns its own decision type,
@@ -99,34 +107,34 @@ const evaluated = (
99
107
  * whose rules read the row or the tenant passes `policyAuthz({ policies })` instead.
100
108
  */
101
109
  export function roleAuthz(): AdminAuthz {
102
- return {
103
- decide({ permission, actor, subject }): AdminDecision {
104
- // Asked BEFORE `can()`, which throws on a name the registry lacks: a decision is an answer,
105
- // and an undeclared permission is a refusal with its fix, never a 500 out of a nav render.
106
- if (!isPermission(permission) || !isKnownPermission(permission)) {
107
- return adminDenied(permission, 'admin.policy.missing', [
108
- `"${permission}" is not a declared resource:verb permission`,
109
- `fix: definePermissions(['${permission}']), then grant it to a role in defineRoles()`,
110
- ]);
111
- }
112
- // `can()` asserts the name against the registry `declareAdminPermissions` filled. The cast
113
- // is the same one a route guard's bare string takes: the registry, not the type, is the check.
114
- const own = evaluated(permission, can(permission as KnownPermission), actor, subject);
115
- if (own.allowed) return own;
116
- // The admin's implications, as `staticAuthz` applies them: a role granting `admin:destroy`
117
- // holds `admin:write` and `admin:read` too. Without this the same grants answered two ways.
118
- for (const implier of impliersOf(permission)) {
119
- const carried = evaluated(implier, can(implier as KnownPermission), actor, subject);
120
- if (carried.allowed) {
121
- return adminAllowed(permission, carried.reason, [
122
- ...carried.trace,
123
- `${permission} is implied by ${implier}`,
124
- ]);
125
- }
126
- }
127
- return own;
128
- },
129
- };
110
+ return { decide: decideByRoleMap };
111
+ }
112
+
113
+ function decideByRoleMap({ permission, actor, subject }: AdminAuthzQuery): AdminDecision {
114
+ // Asked BEFORE `can()`, which throws on a name the registry lacks: a decision is an answer,
115
+ // and an undeclared permission is a refusal with its fix, never a 500 out of a nav render.
116
+ if (!isPermission(permission) || !isKnownPermission(permission)) {
117
+ return adminDenied(permission, 'admin.policy.missing', [
118
+ `"${permission}" is not a declared resource:verb permission`,
119
+ `fix: definePermissions(['${permission}']), then grant it to a role in defineRoles()`,
120
+ ]);
121
+ }
122
+ // `can()` asserts the name against the registry `declareAdminPermissions` filled. The cast
123
+ // is the same one a route guard's bare string takes: the registry, not the type, is the check.
124
+ const own = evaluated(permission, can(permission as KnownPermission), actor, subject);
125
+ if (own.allowed) return own;
126
+ // The admin's implications, as `staticAuthz` applies them: a role granting `admin:destroy`
127
+ // holds `admin:write` and `admin:read` too. Without this the same grants answered two ways.
128
+ for (const implier of impliersOf(permission)) {
129
+ const carried = evaluated(implier, can(implier as KnownPermission), actor, subject);
130
+ if (carried.allowed) {
131
+ return adminAllowed(permission, carried.reason, [
132
+ ...carried.trace,
133
+ `${permission} is implied by ${implier}`,
134
+ ]);
135
+ }
136
+ }
137
+ return own;
130
138
  }
131
139
 
132
140
  /**
@@ -149,20 +157,28 @@ export interface PolicyAuthzInput {
149
157
  }
150
158
 
151
159
  /**
152
- * Closed by default: a permission with no registered policy is denied, with the fix in the
153
- * trace. An admin that fails open is worse than an admin that fails visibly.
160
+ * The map REFINES, the role map GRANTS. A permission with a policy here is decided by it (a row
161
+ * or tenant rule, or a `deny` that closes what a role grants); one the map omits is decided by
162
+ * the role map and the actor's direct grants, exactly as `roleAuthz` and the request pipeline
163
+ * decide it. Still closed: a permission nobody declared is denied with the fix in the trace, and
164
+ * a declared one is denied by `can()` when neither a role nor the actor's direct grants hold it.
165
+ *
166
+ * Until 27.2.0 an omitted permission was denied outright, so the framework's own screens needed
167
+ * the same grant twice — `job:read` in `defineRoles` AND `'job:read': can('job:read')` here — and
168
+ * an app that wrote only the first had a jobs dashboard every operator was refused.
154
169
  */
155
170
  export function policyAuthz(input: PolicyAuthzInput): AdminAuthz {
156
171
  return {
157
- decide({ permission, actor, subject }): AdminDecision {
158
- const policy = input.policies[permission];
159
- if (policy === undefined) {
160
- return adminDenied(permission, 'admin.policy.missing', [
161
- `no policy registered for "${permission}"`,
162
- `fix: definePermissions({ '${permission}': … }) or can('${permission}') on the action`,
163
- ]);
172
+ decide(query): AdminDecision {
173
+ const policy = Object.hasOwn(input.policies, query.permission)
174
+ ? input.policies[query.permission]
175
+ : undefined;
176
+ if (policy !== undefined) {
177
+ return evaluated(query.permission, policy, query.actor, query.subject);
164
178
  }
165
- return evaluated(permission, policy, actor, subject);
179
+ const decision = decideByRoleMap(query);
180
+ const by = `no policy mapped for "${query.permission}": decided by the role map`;
181
+ return { ...decision, trace: [by, ...decision.trace] };
166
182
  },
167
183
  };
168
184
  }