@ultimat3/admin 1.0.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/LICENSE +21 -0
- package/README.md +115 -0
- package/package.json +48 -0
- package/src/action-gate.ts +202 -0
- package/src/actions.tsx +94 -0
- package/src/admin.ts +186 -0
- package/src/ai-panes.ts +139 -0
- package/src/audit.ts +183 -0
- package/src/authz.ts +129 -0
- package/src/crud.ts +278 -0
- package/src/detail.tsx +121 -0
- package/src/dev/data.ts +344 -0
- package/src/dev/facts.ts +180 -0
- package/src/dev/index.ts +47 -0
- package/src/dev/panel-cache.ts +47 -0
- package/src/dev/panel-db.ts +59 -0
- package/src/dev/panel-jobs.ts +66 -0
- package/src/dev/panel-live.ts +46 -0
- package/src/dev/panel-mail.ts +51 -0
- package/src/dev/panel-manifest.ts +39 -0
- package/src/dev/panel-policy.ts +61 -0
- package/src/dev/panel-routes.ts +43 -0
- package/src/dev/panel-timeline.ts +82 -0
- package/src/dev/panel.ts +58 -0
- package/src/dev/server.ts +189 -0
- package/src/entity-columns.ts +95 -0
- package/src/errors.ts +145 -0
- package/src/fields.ts +151 -0
- package/src/form.tsx +97 -0
- package/src/index.ts +214 -0
- package/src/layout.tsx +104 -0
- package/src/list.tsx +120 -0
- package/src/mcp-tools.ts +201 -0
- package/src/mcp.ts +304 -0
- package/src/nav.ts +97 -0
- package/src/pagination.ts +152 -0
- package/src/permissions.ts +92 -0
- package/src/policy-bridge.ts +64 -0
- package/src/registry.ts +181 -0
- package/src/resource.ts +321 -0
- package/src/routes.ts +35 -0
- package/src/search.ts +108 -0
- package/src/theme.ts +59 -0
- package/src/validate.ts +65 -0
- package/src/widget-value.ts +217 -0
- package/src/widgets.tsx +262 -0
package/src/ai-panes.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Opt-in AI panes. Every pane is off until named in `enable`, and every pane declares the
|
|
2
|
+
// scope it needs — an anomaly pane that can read job metrics cannot also read rows, and the
|
|
3
|
+
// NL-query pane is bounded to the read-only DB tool. Nothing here runs without a runner.
|
|
4
|
+
|
|
5
|
+
import type { Gateway } from '@ultimat3/ai';
|
|
6
|
+
import { ADMIN_READ } from './permissions';
|
|
7
|
+
import type { AdminJobSummary } from './registry';
|
|
8
|
+
|
|
9
|
+
/** What a pane is allowed to look at. Checked by the caller against the actor's grants. */
|
|
10
|
+
export type AiPaneScope = 'jobs:read' | 'metrics:read' | 'db:read-only';
|
|
11
|
+
|
|
12
|
+
export interface AiPaneRequest {
|
|
13
|
+
readonly system: string;
|
|
14
|
+
readonly user: string;
|
|
15
|
+
readonly scopes: readonly AiPaneScope[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The narrow slice of an AI gateway a pane uses. `Gateway` from @ultimat3/ai is adapted to
|
|
20
|
+
* it by the host app, so a pane cannot reach a tool the app did not hand it.
|
|
21
|
+
*/
|
|
22
|
+
export interface AiRunner {
|
|
23
|
+
complete(request: AiPaneRequest): Promise<string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type GatewayAdapter = (gateway: Gateway) => AiRunner;
|
|
27
|
+
|
|
28
|
+
export interface AiPaneFacts {
|
|
29
|
+
readonly jobs: readonly AdminJobSummary[];
|
|
30
|
+
/** Queue depth per queue name, as the jobs panel already reports it. */
|
|
31
|
+
readonly queueDepth: Readonly<Record<string, number>>;
|
|
32
|
+
/** Failures per queue over the pane's window. */
|
|
33
|
+
readonly failures: Readonly<Record<string, number>>;
|
|
34
|
+
/** The operator's question, for the NL-query pane. Empty for the others. */
|
|
35
|
+
readonly question: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AiPane {
|
|
39
|
+
readonly key: string;
|
|
40
|
+
readonly titleKey: string;
|
|
41
|
+
readonly descriptionKey: string;
|
|
42
|
+
readonly scopes: readonly AiPaneScope[];
|
|
43
|
+
/** Admin permissions the viewer needs on top of the scope. */
|
|
44
|
+
readonly permissions: readonly string[];
|
|
45
|
+
readonly enabled: boolean;
|
|
46
|
+
request(facts: AiPaneFacts): AiPaneRequest;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const facts = (input: AiPaneFacts): string =>
|
|
50
|
+
JSON.stringify(
|
|
51
|
+
{
|
|
52
|
+
queueDepth: input.queueDepth,
|
|
53
|
+
failures: input.failures,
|
|
54
|
+
jobs: input.jobs.map((job) => ({ name: job.name, queue: job.queue ?? 'default' })),
|
|
55
|
+
},
|
|
56
|
+
null,
|
|
57
|
+
2,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const anomalyPane: AiPane = {
|
|
61
|
+
key: 'anomaly',
|
|
62
|
+
titleKey: 'admin.ai.anomaly.title',
|
|
63
|
+
descriptionKey: 'admin.ai.anomaly.description',
|
|
64
|
+
scopes: ['jobs:read', 'metrics:read'],
|
|
65
|
+
permissions: [ADMIN_READ],
|
|
66
|
+
enabled: false,
|
|
67
|
+
request(input): AiPaneRequest {
|
|
68
|
+
return {
|
|
69
|
+
system:
|
|
70
|
+
'You review background-job metrics. Report only deviations you can point at a number for. No advice.',
|
|
71
|
+
user: `Queue and failure counts:\n${facts(input)}\n\nList anomalies as: queue, metric, observed, expected.`,
|
|
72
|
+
scopes: ['jobs:read', 'metrics:read'],
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const nlQueryPane: AiPane = {
|
|
78
|
+
key: 'nl-query',
|
|
79
|
+
titleKey: 'admin.ai.nl-query.title',
|
|
80
|
+
descriptionKey: 'admin.ai.nl-query.description',
|
|
81
|
+
// The DB tool this pane targets is the read-only one behind the /_x DB panel.
|
|
82
|
+
scopes: ['db:read-only'],
|
|
83
|
+
permissions: [ADMIN_READ],
|
|
84
|
+
enabled: false,
|
|
85
|
+
request(input): AiPaneRequest {
|
|
86
|
+
return {
|
|
87
|
+
system:
|
|
88
|
+
'You write one read-only Postgres SELECT. No DML, no DDL, no CTE that writes. Return SQL only.',
|
|
89
|
+
user: input.question,
|
|
90
|
+
scopes: ['db:read-only'],
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const forecastPane: AiPane = {
|
|
96
|
+
key: 'backlog-forecast',
|
|
97
|
+
titleKey: 'admin.ai.forecast.title',
|
|
98
|
+
descriptionKey: 'admin.ai.forecast.description',
|
|
99
|
+
scopes: ['jobs:read', 'metrics:read'],
|
|
100
|
+
permissions: [ADMIN_READ],
|
|
101
|
+
enabled: false,
|
|
102
|
+
request(input): AiPaneRequest {
|
|
103
|
+
return {
|
|
104
|
+
system:
|
|
105
|
+
'You forecast queue drain time from depth and throughput. State the assumption behind each number.',
|
|
106
|
+
user: `Current state:\n${facts(input)}\n\nFor each queue: minutes to drain, and the throughput you assumed.`,
|
|
107
|
+
scopes: ['jobs:read', 'metrics:read'],
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const AI_PANES: readonly AiPane[] = [anomalyPane, nlQueryPane, forecastPane];
|
|
113
|
+
|
|
114
|
+
export interface AiPanesOptions {
|
|
115
|
+
/** Pane keys to switch on. Anything not listed stays off. */
|
|
116
|
+
readonly enable?: readonly string[];
|
|
117
|
+
readonly runner?: AiRunner;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The pane list for the admin shell: same shape whether AI is on or off. */
|
|
121
|
+
export function aiPanes(opts: AiPanesOptions = {}): readonly AiPane[] {
|
|
122
|
+
const enable = new Set(opts.enable ?? []);
|
|
123
|
+
return AI_PANES.map((pane) => ({ ...pane, enabled: enable.has(pane.key) }));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type AiPaneResult =
|
|
127
|
+
| { readonly ok: true; readonly text: string }
|
|
128
|
+
| { readonly ok: false; readonly reason: string };
|
|
129
|
+
|
|
130
|
+
/** A disabled pane, or a missing runner, is a refusal — never a silent no-op. */
|
|
131
|
+
export async function runAiPane(
|
|
132
|
+
pane: AiPane,
|
|
133
|
+
input: AiPaneFacts,
|
|
134
|
+
runner: AiRunner | undefined,
|
|
135
|
+
): Promise<AiPaneResult> {
|
|
136
|
+
if (!pane.enabled) return { ok: false, reason: 'admin.ai.disabled' };
|
|
137
|
+
if (runner === undefined) return { ok: false, reason: 'admin.ai.no-runner' };
|
|
138
|
+
return { ok: true, text: await runner.complete(pane.request(input)) };
|
|
139
|
+
}
|
package/src/audit.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Append-only audit log: actor, operation, entity, before/after diff, requestId, timestamp.
|
|
2
|
+
// If it isn't logged, it didn't happen — so denied and failed attempts are logged too, and
|
|
3
|
+
// there is deliberately no update or delete on this interface.
|
|
4
|
+
|
|
5
|
+
import type { AdminActor, AdminDecision } from './authz';
|
|
6
|
+
import type { AdminRow } from './registry';
|
|
7
|
+
|
|
8
|
+
export const REDACTED = '[redacted]';
|
|
9
|
+
|
|
10
|
+
export interface AuditFieldDiff {
|
|
11
|
+
readonly field: string;
|
|
12
|
+
readonly before: unknown;
|
|
13
|
+
readonly after: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type AuditOutcome = 'allowed' | 'denied' | 'failed';
|
|
17
|
+
|
|
18
|
+
export interface AuditEntry {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
/** ISO-8601 UTC. Stored UTC, formatted per viewer zone at the edge. */
|
|
21
|
+
readonly at: string;
|
|
22
|
+
readonly requestId: string;
|
|
23
|
+
readonly actor: { readonly id: string; readonly roles: readonly string[] };
|
|
24
|
+
/** `list` | `create` | `update` | `delete`, or an action name. */
|
|
25
|
+
readonly operation: string;
|
|
26
|
+
readonly kind: 'operation' | 'action';
|
|
27
|
+
readonly entity: string;
|
|
28
|
+
readonly entityId: string | null;
|
|
29
|
+
readonly permission: string;
|
|
30
|
+
readonly outcome: AuditOutcome;
|
|
31
|
+
/** i18n key or policy rule name explaining the outcome. */
|
|
32
|
+
readonly reason: string;
|
|
33
|
+
readonly diff: readonly AuditFieldDiff[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AuditDraft {
|
|
37
|
+
readonly requestId: string;
|
|
38
|
+
readonly actor: AdminActor;
|
|
39
|
+
readonly operation: string;
|
|
40
|
+
readonly kind: 'operation' | 'action';
|
|
41
|
+
readonly entity: string;
|
|
42
|
+
readonly entityId?: string | null;
|
|
43
|
+
readonly permission: string;
|
|
44
|
+
readonly outcome: AuditOutcome;
|
|
45
|
+
readonly reason: string;
|
|
46
|
+
readonly diff?: readonly AuditFieldDiff[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Where entries go beyond memory: a table, stdout as JSON lines, an OTel log. */
|
|
50
|
+
export interface AuditSink {
|
|
51
|
+
write(entry: AuditEntry): Promise<void> | void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AuditLog {
|
|
55
|
+
append(draft: AuditDraft): Promise<AuditEntry>;
|
|
56
|
+
/** Newest first. A copy — the log cannot be mutated through what it hands out. */
|
|
57
|
+
entries(query?: {
|
|
58
|
+
readonly entity?: string;
|
|
59
|
+
readonly actorId?: string;
|
|
60
|
+
readonly limit?: number;
|
|
61
|
+
}): readonly AuditEntry[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AuditLogOptions {
|
|
65
|
+
readonly sinks?: readonly AuditSink[];
|
|
66
|
+
/** Injected so tests get deterministic timestamps and ids. */
|
|
67
|
+
readonly now?: () => Date;
|
|
68
|
+
readonly nextId?: () => string;
|
|
69
|
+
/** Ring size. The memory log is a dev/inspection buffer, not the system of record. */
|
|
70
|
+
readonly capacity?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function auditEntry(draft: AuditDraft, id: string, at: Date): AuditEntry {
|
|
74
|
+
return {
|
|
75
|
+
id,
|
|
76
|
+
at: at.toISOString(),
|
|
77
|
+
requestId: draft.requestId,
|
|
78
|
+
actor: { id: draft.actor.id, roles: draft.actor.roles ?? [] },
|
|
79
|
+
operation: draft.operation,
|
|
80
|
+
kind: draft.kind,
|
|
81
|
+
entity: draft.entity,
|
|
82
|
+
entityId: draft.entityId ?? null,
|
|
83
|
+
permission: draft.permission,
|
|
84
|
+
outcome: draft.outcome,
|
|
85
|
+
reason: draft.reason,
|
|
86
|
+
diff: draft.diff ?? [],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function memoryAuditLog(opts: AuditLogOptions = {}): AuditLog {
|
|
91
|
+
const now = opts.now ?? ((): Date => new Date());
|
|
92
|
+
const nextId = opts.nextId ?? ((): string => crypto.randomUUID());
|
|
93
|
+
const capacity = opts.capacity ?? 1000;
|
|
94
|
+
const sinks = opts.sinks ?? [];
|
|
95
|
+
const log: AuditEntry[] = [];
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
async append(draft: AuditDraft): Promise<AuditEntry> {
|
|
99
|
+
// Timestamp first: the entry is stamped when it happened, not when the id generator
|
|
100
|
+
// got around to it.
|
|
101
|
+
const at = now();
|
|
102
|
+
const entry = auditEntry(draft, nextId(), at);
|
|
103
|
+
log.push(entry);
|
|
104
|
+
if (log.length > capacity) log.splice(0, log.length - capacity);
|
|
105
|
+
for (const sink of sinks) await sink.write(entry);
|
|
106
|
+
return entry;
|
|
107
|
+
},
|
|
108
|
+
entries(query = {}): readonly AuditEntry[] {
|
|
109
|
+
const filtered = log.filter(
|
|
110
|
+
(entry) =>
|
|
111
|
+
(query.entity === undefined || entry.entity === query.entity) &&
|
|
112
|
+
(query.actorId === undefined || entry.actor.id === query.actorId),
|
|
113
|
+
);
|
|
114
|
+
const newestFirst = [...filtered].reverse();
|
|
115
|
+
return query.limit === undefined ? newestFirst : newestFirst.slice(0, query.limit);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const same = (a: unknown, b: unknown): boolean => {
|
|
121
|
+
if (a === b) return true;
|
|
122
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
123
|
+
if (a === null || b === null || a === undefined || b === undefined) return false;
|
|
124
|
+
if (typeof a === 'object' && typeof b === 'object') {
|
|
125
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Shallow field-by-field diff of the row before and after a mutation. Only changed fields
|
|
132
|
+
* appear — a diff nobody can read is a diff nobody reads. Sensitive fields are recorded as
|
|
133
|
+
* having changed, with their values replaced.
|
|
134
|
+
*/
|
|
135
|
+
export function diffRows(
|
|
136
|
+
before: AdminRow | null,
|
|
137
|
+
after: AdminRow | null,
|
|
138
|
+
opts: { readonly redact?: readonly string[] } = {},
|
|
139
|
+
): readonly AuditFieldDiff[] {
|
|
140
|
+
const redact = new Set(opts.redact ?? []);
|
|
141
|
+
const names = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]);
|
|
142
|
+
const out: AuditFieldDiff[] = [];
|
|
143
|
+
|
|
144
|
+
for (const field of [...names].sort()) {
|
|
145
|
+
const from = before === null ? undefined : before[field];
|
|
146
|
+
const to = after === null ? undefined : after[field];
|
|
147
|
+
if (same(from, to)) continue;
|
|
148
|
+
out.push(
|
|
149
|
+
redact.has(field)
|
|
150
|
+
? {
|
|
151
|
+
field,
|
|
152
|
+
before: from === undefined ? undefined : REDACTED,
|
|
153
|
+
after: to === undefined ? undefined : REDACTED,
|
|
154
|
+
}
|
|
155
|
+
: { field, before: from, after: to },
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The draft for a denied attempt. Denials are the entries an auditor actually wants. */
|
|
162
|
+
export function deniedDraft(input: {
|
|
163
|
+
readonly requestId: string;
|
|
164
|
+
readonly actor: AdminActor;
|
|
165
|
+
readonly operation: string;
|
|
166
|
+
readonly kind: 'operation' | 'action';
|
|
167
|
+
readonly entity: string;
|
|
168
|
+
readonly entityId?: string | null;
|
|
169
|
+
readonly decision: AdminDecision;
|
|
170
|
+
}): AuditDraft {
|
|
171
|
+
return {
|
|
172
|
+
requestId: input.requestId,
|
|
173
|
+
actor: input.actor,
|
|
174
|
+
operation: input.operation,
|
|
175
|
+
kind: input.kind,
|
|
176
|
+
entity: input.entity,
|
|
177
|
+
entityId: input.entityId ?? null,
|
|
178
|
+
permission: input.decision.permission,
|
|
179
|
+
outcome: 'denied',
|
|
180
|
+
reason: input.decision.reason,
|
|
181
|
+
diff: [],
|
|
182
|
+
};
|
|
183
|
+
}
|
package/src/authz.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// ONE authz seam for the whole admin. Every surface — the rendered button, the HTTP call
|
|
2
|
+
// behind it, the MCP tool, the nav item, the search result — asks this interface and nobody
|
|
3
|
+
// re-implements the question. That is the invariant the admin exists to keep: the UI cannot
|
|
4
|
+
// show what the call would refuse.
|
|
5
|
+
//
|
|
6
|
+
// The interface is structural on purpose: `policyAuthz()` in policy-bridge.ts adapts
|
|
7
|
+
// @ultimat3/policy into it, a host app can supply its own, and tests can supply a stub —
|
|
8
|
+
// but there is still exactly one decision per request.
|
|
9
|
+
|
|
10
|
+
import { ADMIN_PERMISSION_SPEC, type AdminPermission } from './permissions';
|
|
11
|
+
|
|
12
|
+
export interface AdminActor {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly roles?: readonly string[];
|
|
15
|
+
/** BCP-47. Drives every `t()` call and every `Intl` format in the admin. */
|
|
16
|
+
readonly locale?: string;
|
|
17
|
+
/** IANA zone. The admin refuses to render a timestamp without one. */
|
|
18
|
+
readonly timeZone?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What a decision is about: an entity row, an action's input, or nothing (a page). */
|
|
22
|
+
export interface AdminSubject {
|
|
23
|
+
readonly entity?: string;
|
|
24
|
+
readonly id?: string;
|
|
25
|
+
readonly input?: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AdminDecision {
|
|
29
|
+
readonly allowed: boolean;
|
|
30
|
+
readonly permission: string;
|
|
31
|
+
/** An i18n key or a policy rule name — never a sentence. Rendered by the caller. */
|
|
32
|
+
readonly reason: string;
|
|
33
|
+
/** Rule-by-rule trace, shown verbatim in the `/_x` policy panel. */
|
|
34
|
+
readonly trace: readonly string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AdminAuthzQuery {
|
|
38
|
+
readonly permission: string;
|
|
39
|
+
readonly actor: AdminActor;
|
|
40
|
+
readonly subject?: AdminSubject;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AdminAuthz {
|
|
44
|
+
decide(query: AdminAuthzQuery): AdminDecision;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function allowed(
|
|
48
|
+
permission: string,
|
|
49
|
+
reason: string,
|
|
50
|
+
trace: readonly string[] = [],
|
|
51
|
+
): AdminDecision {
|
|
52
|
+
return { allowed: true, permission, reason, trace };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function denied(
|
|
56
|
+
permission: string,
|
|
57
|
+
reason: string,
|
|
58
|
+
trace: readonly string[] = [],
|
|
59
|
+
): AdminDecision {
|
|
60
|
+
return { allowed: false, permission, reason, trace };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Every permission must hold. The first denial wins, and carries its own reason. */
|
|
64
|
+
export function decideAll(
|
|
65
|
+
authz: AdminAuthz,
|
|
66
|
+
permissions: readonly string[],
|
|
67
|
+
actor: AdminActor,
|
|
68
|
+
subject?: AdminSubject,
|
|
69
|
+
): AdminDecision {
|
|
70
|
+
const trace: string[] = [];
|
|
71
|
+
for (const permission of permissions) {
|
|
72
|
+
const decision = authz.decide(
|
|
73
|
+
subject === undefined ? { permission, actor } : { permission, actor, subject },
|
|
74
|
+
);
|
|
75
|
+
trace.push(`${permission}: ${decision.allowed ? 'allow' : 'deny'} (${decision.reason})`);
|
|
76
|
+
if (!decision.allowed)
|
|
77
|
+
return denied(permission, decision.reason, [...trace, ...decision.trace]);
|
|
78
|
+
}
|
|
79
|
+
const last = permissions[permissions.length - 1] ?? '';
|
|
80
|
+
return allowed(last, 'admin.policy.all-granted', trace);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isAllowed(
|
|
84
|
+
authz: AdminAuthz,
|
|
85
|
+
permissions: readonly string[],
|
|
86
|
+
actor: AdminActor,
|
|
87
|
+
subject?: AdminSubject,
|
|
88
|
+
): boolean {
|
|
89
|
+
return decideAll(authz, permissions, actor, subject).allowed;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const impliedBy = (permission: string): readonly string[] => {
|
|
93
|
+
// Widened: `permission` is any string at runtime, so the lookup really can miss.
|
|
94
|
+
const spec: { readonly implies: readonly string[] } | undefined =
|
|
95
|
+
ADMIN_PERMISSION_SPEC[permission as AdminPermission];
|
|
96
|
+
return spec?.implies ?? [];
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Transitive closure of the admin permission implications (`destroy` ⇒ `write` ⇒ `read`). */
|
|
100
|
+
export function expandPermissions(granted: readonly string[]): readonly string[] {
|
|
101
|
+
const out = new Set<string>();
|
|
102
|
+
const walk = (permission: string): void => {
|
|
103
|
+
if (out.has(permission)) return;
|
|
104
|
+
out.add(permission);
|
|
105
|
+
for (const next of impliedBy(permission)) walk(next);
|
|
106
|
+
};
|
|
107
|
+
for (const permission of granted) walk(permission);
|
|
108
|
+
return [...out].sort();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A grant-list authz for tests, seeds, and `x dev --actor`. Production always goes through
|
|
113
|
+
* `policyAuthz()` so the app's real rules (ownership, org scoping) are the ones evaluated.
|
|
114
|
+
*/
|
|
115
|
+
export function staticAuthz(granted: readonly string[]): AdminAuthz {
|
|
116
|
+
const set = new Set(expandPermissions(granted));
|
|
117
|
+
return {
|
|
118
|
+
decide({ permission }): AdminDecision {
|
|
119
|
+
return set.has(permission)
|
|
120
|
+
? allowed(permission, 'admin.policy.granted', [`static: ${permission} in grant list`])
|
|
121
|
+
: denied(permission, 'admin.policy.not-granted', [
|
|
122
|
+
`static: ${permission} not in [${[...set].join(', ')}]`,
|
|
123
|
+
]);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** No actor, no grants. The default for an unauthenticated request. */
|
|
129
|
+
export const anonymousAuthz: AdminAuthz = staticAuthz([]);
|