@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 +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 +55 -39
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.
|
|
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.
|
|
38
|
-
"@ultimat3/ai": "27.
|
|
39
|
-
"@ultimat3/cache": "27.
|
|
40
|
-
"@ultimat3/core": "27.
|
|
41
|
-
"@ultimat3/db": "27.
|
|
42
|
-
"@ultimat3/entity": "27.
|
|
43
|
-
"@ultimat3/http": "27.
|
|
44
|
-
"@ultimat3/i18n": "27.
|
|
45
|
-
"@ultimat3/jobs": "27.
|
|
46
|
-
"@ultimat3/mcp": "27.
|
|
47
|
-
"@ultimat3/money": "27.
|
|
48
|
-
"@ultimat3/policy": "27.
|
|
49
|
-
"@ultimat3/query": "27.
|
|
50
|
-
"@ultimat3/render": "27.
|
|
51
|
-
"@ultimat3/schema": "27.
|
|
52
|
-
"@ultimat3/ui": "27.
|
|
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
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
153
|
-
*
|
|
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(
|
|
158
|
-
const policy = input.policies
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
}
|