@ultimat3/admin 27.1.0 → 27.2.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 +1 -1
- package/package.json +17 -17
- package/src/actor.ts +1 -0
- package/src/authz.ts +6 -0
- package/src/batch-job.ts +6 -0
- package/src/mcp.ts +8 -1
- package/src/policy-bridge.ts +76 -42
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
|
|
3
|
+
"version": "27.2.1",
|
|
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
|
|
38
|
-
"@ultimat3/ai": "27.1
|
|
39
|
-
"@ultimat3/cache": "27.1
|
|
40
|
-
"@ultimat3/core": "27.1
|
|
41
|
-
"@ultimat3/db": "27.1
|
|
42
|
-
"@ultimat3/entity": "27.1
|
|
43
|
-
"@ultimat3/http": "27.1
|
|
44
|
-
"@ultimat3/i18n": "27.1
|
|
45
|
-
"@ultimat3/jobs": "27.1
|
|
46
|
-
"@ultimat3/mcp": "27.1
|
|
47
|
-
"@ultimat3/money": "27.1
|
|
48
|
-
"@ultimat3/policy": "27.1
|
|
49
|
-
"@ultimat3/query": "27.1
|
|
50
|
-
"@ultimat3/render": "27.1
|
|
51
|
-
"@ultimat3/schema": "27.1
|
|
52
|
-
"@ultimat3/ui": "27.1
|
|
37
|
+
"@ultimat3/action": "27.2.1",
|
|
38
|
+
"@ultimat3/ai": "27.2.1",
|
|
39
|
+
"@ultimat3/cache": "27.2.1",
|
|
40
|
+
"@ultimat3/core": "27.2.1",
|
|
41
|
+
"@ultimat3/db": "27.2.1",
|
|
42
|
+
"@ultimat3/entity": "27.2.1",
|
|
43
|
+
"@ultimat3/http": "27.2.1",
|
|
44
|
+
"@ultimat3/i18n": "27.2.1",
|
|
45
|
+
"@ultimat3/jobs": "27.2.1",
|
|
46
|
+
"@ultimat3/mcp": "27.2.1",
|
|
47
|
+
"@ultimat3/money": "27.2.1",
|
|
48
|
+
"@ultimat3/policy": "27.2.1",
|
|
49
|
+
"@ultimat3/query": "27.2.1",
|
|
50
|
+
"@ultimat3/render": "27.2.1",
|
|
51
|
+
"@ultimat3/schema": "27.2.1",
|
|
52
|
+
"@ultimat3/ui": "27.2.1"
|
|
53
53
|
}
|
|
54
54
|
}
|
package/src/actor.ts
CHANGED
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({
|
|
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
|
},
|
package/src/policy-bridge.ts
CHANGED
|
@@ -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,29 @@ 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
|
-
|
|
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
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One evaluated clause as the line `/_x` prints: ` can(job:read): allow`, indented by depth, its
|
|
46
|
+
* reason after a refusal. A `TraceEntry` through `String()` read `[object Object]`.
|
|
47
|
+
*/
|
|
48
|
+
function traceLine(entry: unknown): string {
|
|
49
|
+
if (typeof entry !== 'object' || entry === null) return String(entry);
|
|
50
|
+
const { label, depth, allowed, reason } = entry as Partial<
|
|
51
|
+
Record<'label' | 'depth' | 'allowed' | 'reason', unknown>
|
|
52
|
+
>;
|
|
53
|
+
const indent = ' '.repeat(typeof depth === 'number' && depth > 0 ? depth : 0);
|
|
54
|
+
const verdict = allowed === true ? 'allow' : 'deny';
|
|
55
|
+
const why = typeof reason === 'string' && reason !== '' ? ` (${reason})` : '';
|
|
56
|
+
return `${indent}${String(label)}: ${verdict}${why}`;
|
|
57
|
+
}
|
|
35
58
|
|
|
36
59
|
/**
|
|
37
60
|
* `evaluate()`'s result is read structurally: the policy layer owns its own decision type,
|
|
@@ -51,12 +74,15 @@ function readDecision(permission: string, result: unknown): AdminDecision {
|
|
|
51
74
|
};
|
|
52
75
|
const verdict = result === true || bag.allowed === true;
|
|
53
76
|
const reason = typeof inner.reason === 'string' ? inner.reason : 'admin.policy.evaluated';
|
|
54
|
-
const trace = Array.isArray(bag.trace) ? bag.trace.map(
|
|
77
|
+
const trace = Array.isArray(bag.trace) ? bag.trace.map(traceLine) : [];
|
|
55
78
|
return verdict ? adminAllowed(permission, reason, trace) : adminDenied(permission, reason, trace);
|
|
56
79
|
}
|
|
57
80
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
81
|
+
/**
|
|
82
|
+
* `resource:verb`, the resource itself colon-scoped or not (`admin:support:write`) — the shape
|
|
83
|
+
* `Permission` types and `can()` takes. Anything else is denied, never thrown.
|
|
84
|
+
*/
|
|
85
|
+
const isPermission = (value: string): value is Permission => /^[^:]+(?::[^:]+)+$/.test(value);
|
|
60
86
|
|
|
61
87
|
/**
|
|
62
88
|
* Declare the admin's OWN permissions (`ADMIN_PERMISSIONS`) and the ones a mount DERIVES —
|
|
@@ -99,34 +125,34 @@ const evaluated = (
|
|
|
99
125
|
* whose rules read the row or the tenant passes `policyAuthz({ policies })` instead.
|
|
100
126
|
*/
|
|
101
127
|
export function roleAuthz(): AdminAuthz {
|
|
102
|
-
return {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
return { decide: decideByRoleMap };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function decideByRoleMap({ permission, actor, subject }: AdminAuthzQuery): AdminDecision {
|
|
132
|
+
// Asked BEFORE `can()`, which throws on a name the registry lacks: a decision is an answer,
|
|
133
|
+
// and an undeclared permission is a refusal with its fix, never a 500 out of a nav render.
|
|
134
|
+
if (!isPermission(permission) || !isKnownPermission(permission)) {
|
|
135
|
+
return adminDenied(permission, 'admin.policy.missing', [
|
|
136
|
+
`"${permission}" is not a declared resource:verb permission`,
|
|
137
|
+
`fix: definePermissions(['${permission}']), then grant it to a role in defineRoles()`,
|
|
138
|
+
]);
|
|
139
|
+
}
|
|
140
|
+
// `can()` asserts the name against the registry `declareAdminPermissions` filled. The cast
|
|
141
|
+
// is the same one a route guard's bare string takes: the registry, not the type, is the check.
|
|
142
|
+
const own = evaluated(permission, can(permission as KnownPermission), actor, subject);
|
|
143
|
+
if (own.allowed) return own;
|
|
144
|
+
// The admin's implications, as `staticAuthz` applies them: a role granting `admin:destroy`
|
|
145
|
+
// holds `admin:write` and `admin:read` too. Without this the same grants answered two ways.
|
|
146
|
+
for (const implier of impliersOf(permission)) {
|
|
147
|
+
const carried = evaluated(implier, can(implier as KnownPermission), actor, subject);
|
|
148
|
+
if (carried.allowed) {
|
|
149
|
+
return adminAllowed(permission, carried.reason, [
|
|
150
|
+
...carried.trace,
|
|
151
|
+
`${permission} is implied by ${implier}`,
|
|
152
|
+
]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return own;
|
|
130
156
|
}
|
|
131
157
|
|
|
132
158
|
/**
|
|
@@ -149,20 +175,28 @@ export interface PolicyAuthzInput {
|
|
|
149
175
|
}
|
|
150
176
|
|
|
151
177
|
/**
|
|
152
|
-
*
|
|
153
|
-
*
|
|
178
|
+
* The map REFINES, the role map GRANTS. A permission with a policy here is decided by it (a row
|
|
179
|
+
* or tenant rule, or a `deny` that closes what a role grants); one the map omits is decided by
|
|
180
|
+
* the role map and the actor's direct grants, exactly as `roleAuthz` and the request pipeline
|
|
181
|
+
* decide it. Still closed: a permission nobody declared is denied with the fix in the trace, and
|
|
182
|
+
* a declared one is denied by `can()` when neither a role nor the actor's direct grants hold it.
|
|
183
|
+
*
|
|
184
|
+
* Until 27.2.0 an omitted permission was denied outright, so the framework's own screens needed
|
|
185
|
+
* the same grant twice — `job:read` in `defineRoles` AND `'job:read': can('job:read')` here — and
|
|
186
|
+
* an app that wrote only the first had a jobs dashboard every operator was refused.
|
|
154
187
|
*/
|
|
155
188
|
export function policyAuthz(input: PolicyAuthzInput): AdminAuthz {
|
|
156
189
|
return {
|
|
157
|
-
decide(
|
|
158
|
-
const policy = input.policies
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
]);
|
|
190
|
+
decide(query): AdminDecision {
|
|
191
|
+
const policy = Object.hasOwn(input.policies, query.permission)
|
|
192
|
+
? input.policies[query.permission]
|
|
193
|
+
: undefined;
|
|
194
|
+
if (policy !== undefined) {
|
|
195
|
+
return evaluated(query.permission, policy, query.actor, query.subject);
|
|
164
196
|
}
|
|
165
|
-
|
|
197
|
+
const decision = decideByRoleMap(query);
|
|
198
|
+
const by = `no policy mapped for "${query.permission}": decided by the role map`;
|
|
199
|
+
return { ...decision, trace: [by, ...decision.trace] };
|
|
166
200
|
},
|
|
167
201
|
};
|
|
168
202
|
}
|