@ultimat3/admin 22.15.0 → 24.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/CLAUDE.md +83 -91
- package/README.md +282 -89
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +141 -68
- package/src/action-input.ts +119 -0
- package/src/action-label.ts +9 -0
- package/src/action-row.ts +37 -0
- package/src/actions.tsx +89 -66
- package/src/actor.ts +53 -0
- package/src/admin.module.scss +298 -0
- package/src/admin.ts +187 -23
- package/src/audit-pg.ts +207 -0
- package/src/audit-schema.ts +47 -0
- package/src/audit.ts +109 -22
- package/src/authz.ts +11 -0
- package/src/batch-bar.tsx +66 -0
- package/src/batch-job.ts +152 -0
- package/src/batch-matching.ts +87 -0
- package/src/batch-queue.ts +90 -0
- package/src/batch.ts +396 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +224 -0
- package/src/crud.ts +172 -176
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +27 -12
- package/src/dev/facts.ts +28 -1
- package/src/dev/index.ts +1 -0
- package/src/dev/panel-cache.ts +8 -3
- package/src/dev/panel-db.ts +37 -5
- package/src/dev/panel-jobs.ts +8 -0
- package/src/dev/panel-routes.ts +10 -0
- package/src/dev/panel-timeline.ts +7 -2
- package/src/dev/panel.ts +10 -2
- package/src/dev/server.ts +16 -11
- package/src/dev/sql-runner.ts +31 -0
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +92 -5
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +132 -0
- package/src/form.tsx +96 -49
- package/src/index.ts +199 -9
- package/src/jobs/fleet-repo.ts +155 -0
- package/src/jobs/job-actions.ts +172 -0
- package/src/jobs/job-entities.ts +118 -0
- package/src/jobs/job-labels.ts +108 -0
- package/src/jobs/job-repo.ts +137 -0
- package/src/jobs/job-resources.ts +211 -0
- package/src/jobs/job-where.ts +170 -0
- package/src/jobs/operator.ts +34 -0
- package/src/jobs/overview-data.ts +129 -0
- package/src/jobs/overview-tab.tsx +61 -0
- package/src/jobs/overview.tsx +189 -0
- package/src/json-text.ts +52 -0
- package/src/keyset-seek.ts +189 -0
- package/src/layout.tsx +55 -43
- package/src/list-columns.tsx +53 -0
- package/src/list-filter-bar.tsx +207 -0
- package/src/list-filters.ts +242 -0
- package/src/list-request.ts +129 -0
- package/src/list-scope-tabs.tsx +60 -0
- package/src/list-scope.ts +113 -0
- package/src/list.tsx +111 -72
- package/src/mcp-tools.ts +57 -13
- package/src/mcp.ts +112 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/pagination.ts +10 -4
- package/src/policy-bridge.ts +115 -18
- package/src/registry.ts +140 -9
- package/src/related.ts +105 -0
- package/src/relations.ts +240 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +195 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +194 -0
- package/src/resource.ts +129 -79
- package/src/route-config.ts +71 -0
- package/src/routes.ts +55 -54
- package/src/row-scope-write.ts +130 -0
- package/src/row-version.ts +20 -0
- package/src/screen-action.tsx +333 -0
- package/src/screen-frame.tsx +167 -0
- package/src/screen-home.tsx +201 -0
- package/src/screen-lookup.tsx +157 -0
- package/src/screen-resource.tsx +441 -0
- package/src/screen-system.tsx +75 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +22 -10
- package/src/stylesheet-scope.ts +8 -0
- package/src/test-ctx.ts +31 -0
- package/src/validate.ts +3 -0
- package/src/widget-value.ts +36 -8
- package/src/widgets.tsx +84 -16
package/src/admin.ts
CHANGED
|
@@ -2,21 +2,40 @@
|
|
|
2
2
|
// each action off the entity it names, builds the nav, and returns the route table plus the
|
|
3
3
|
// audit log and authz every surface then shares. Nothing here queries or renders; it wires.
|
|
4
4
|
|
|
5
|
+
// For its side effect: the admin's stylesheets are claimed for the surface that serves them.
|
|
6
|
+
import './stylesheet-scope';
|
|
7
|
+
import { ANONYMOUS_ADMIN_ACTOR, requestActor } from './actor';
|
|
5
8
|
import { type AuditLog, memoryAuditLog } from './audit';
|
|
6
9
|
import type { AdminActor, AdminAuthz } from './authz';
|
|
10
|
+
import { batchPlan } from './batch';
|
|
11
|
+
import { adminBatchJob } from './batch-job';
|
|
12
|
+
import { adminCatalogKeys } from './catalog-keys';
|
|
7
13
|
import type { CrudCtx } from './crud';
|
|
8
14
|
import { permissionsForOperation } from './crud';
|
|
9
|
-
import {
|
|
15
|
+
import { type AdminDescription, describeAdmin } from './describe';
|
|
16
|
+
import {
|
|
17
|
+
AdminActionDuplicateError,
|
|
18
|
+
AdminFieldUnsupportedError,
|
|
19
|
+
AdminPagePathInvalidError,
|
|
20
|
+
AdminRepoUnboundError,
|
|
21
|
+
} from './errors';
|
|
22
|
+
import { JOB_PERMISSION_NOUN, JOBS_GROUP, JOBS_PATH, jobsAdmin } from './jobs/job-resources';
|
|
23
|
+
import { registerAdminMount } from './mounts';
|
|
10
24
|
import { adminNav, type NavGroup, type NavItem, type NavOptions, visibleNav } from './nav';
|
|
11
25
|
import { type AdminCustomPage, type AdminPageComponent, pageNavItems, pageRoutes } from './pages';
|
|
12
|
-
import type
|
|
13
|
-
import
|
|
26
|
+
import { ADMIN_OPERATIONS, type AdminOperation } from './permissions';
|
|
27
|
+
import { declareAdminPermissions, roleAuthz } from './policy-bridge';
|
|
28
|
+
import type { AdminAction, AdminDb, AdminEntity, AdminRepo, AdminRow } from './registry';
|
|
29
|
+
import { relatedOf } from './related';
|
|
30
|
+
import { adminRepoFor, adminTablesOf } from './repo-entity';
|
|
14
31
|
import {
|
|
15
32
|
type AdminResource,
|
|
16
33
|
type AdminResourceOptions,
|
|
17
34
|
adminResource,
|
|
18
35
|
resourceFor,
|
|
19
36
|
} from './resource';
|
|
37
|
+
import { LOOKUP_SEGMENT } from './resource-list';
|
|
38
|
+
import { mountAdminRoutes } from './route-config';
|
|
20
39
|
import { type AdminBranding, adminBranding, type ThemeAttributes, themeAttributes } from './theme';
|
|
21
40
|
|
|
22
41
|
/** The host app's auth hook: it owns sessions, the admin owns what a session may do. */
|
|
@@ -27,6 +46,8 @@ export interface AdminAuth {
|
|
|
27
46
|
|
|
28
47
|
export type AdminView =
|
|
29
48
|
| 'list'
|
|
49
|
+
/** A resource read as a TARGET: the picker every filter and input referencing it searches. */
|
|
50
|
+
| 'lookup'
|
|
30
51
|
| 'detail'
|
|
31
52
|
| 'create'
|
|
32
53
|
| 'edit'
|
|
@@ -56,16 +77,27 @@ export interface AdminRoute {
|
|
|
56
77
|
|
|
57
78
|
export interface DefineAdminInput {
|
|
58
79
|
readonly entities: readonly AdminEntity[];
|
|
59
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* The app's `database()` handle, passed once. Every entity's rows are read and written through
|
|
82
|
+
* its own table there — tenancy, soft delete and sealing included — so an app writes no adapter.
|
|
83
|
+
* An entity the handle does not carry needs `resources.<entity>.repo`, or it is
|
|
84
|
+
* `X_ADMIN_REPO_UNBOUND` here, at declaration.
|
|
85
|
+
*/
|
|
86
|
+
readonly db?: AdminDb;
|
|
87
|
+
/** Per-entity overrides, keyed by entity name. `repo` replaces the handle's table for one. */
|
|
60
88
|
readonly resources?: Readonly<Record<string, AdminResourceOptions>>;
|
|
61
89
|
/** Registered actions. Each is attached to `action.entity`, or the global toolbar. */
|
|
62
90
|
readonly actions?: readonly AdminAction[];
|
|
63
|
-
readonly jobs?: readonly AdminJobSummary[];
|
|
64
91
|
/** Screens the generator would never write. Declared here so they are not a second surface. */
|
|
65
92
|
readonly pages?: readonly AdminCustomPage[];
|
|
66
93
|
readonly nav?: NavOptions;
|
|
67
94
|
readonly branding?: Partial<AdminBranding>;
|
|
68
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Both halves default. `actor`: the one the HTTP pipeline resolved for this request. `authz`:
|
|
97
|
+
* the app's role map (`roleAuthz()`) — every permission is "does a role this actor holds grant
|
|
98
|
+
* it". Pass `policyAuthz({ policies })` when a rule reads the row or the tenant.
|
|
99
|
+
*/
|
|
100
|
+
readonly auth?: Partial<AdminAuth>;
|
|
69
101
|
readonly audit?: AuditLog;
|
|
70
102
|
/** Mount point. Every route path is prefixed with it. */
|
|
71
103
|
readonly basePath?: string;
|
|
@@ -78,7 +110,6 @@ export interface AdminApp {
|
|
|
78
110
|
readonly resources: readonly AdminResource[];
|
|
79
111
|
/** Actions with no entity: imports, backfills, anything app-wide. */
|
|
80
112
|
readonly globalActions: readonly AdminAction[];
|
|
81
|
-
readonly jobs: readonly AdminJobSummary[];
|
|
82
113
|
readonly nav: readonly NavGroup[];
|
|
83
114
|
readonly routes: readonly AdminRoute[];
|
|
84
115
|
readonly audit: AuditLog;
|
|
@@ -88,12 +119,29 @@ export interface AdminApp {
|
|
|
88
119
|
/** Nav for one actor, with everything they cannot open removed. */
|
|
89
120
|
navFor(ctx: CrudCtx): readonly NavGroup[];
|
|
90
121
|
ctx(input: { readonly actor: AdminActor; readonly requestId: string }): CrudCtx;
|
|
122
|
+
/**
|
|
123
|
+
* The handle for the request in flight: `auth.actor(request)`, with anonymous as a real actor a
|
|
124
|
+
* decision refuses. What every mounted screen and every write is decided against.
|
|
125
|
+
*/
|
|
126
|
+
requestCtx(request: Request): Promise<CrudCtx>;
|
|
127
|
+
/**
|
|
128
|
+
* This admin as plain data — resources with their filters, sorts, scopes and row scope, routes
|
|
129
|
+
* with their permissions. What the manifest records; JSON-safe and sorted.
|
|
130
|
+
*/
|
|
131
|
+
describe(): AdminDescription;
|
|
132
|
+
/**
|
|
133
|
+
* Every catalog key this declaration derives, sorted and distinct — what the `i18n` step asks
|
|
134
|
+
* each of the app's catalogs for (`catalog-keys.ts`).
|
|
135
|
+
*/
|
|
136
|
+
catalogKeys(): readonly string[];
|
|
91
137
|
}
|
|
92
138
|
|
|
93
139
|
const VIEW_OPERATION: Readonly<
|
|
94
140
|
Record<Exclude<AdminView, 'jobs' | 'audit' | 'dashboard' | 'page'>, AdminOperation>
|
|
95
141
|
> = {
|
|
96
142
|
list: 'list',
|
|
143
|
+
// A lookup IS a list read of the target — same gate, same row scope, same audit entry.
|
|
144
|
+
lookup: 'list',
|
|
97
145
|
detail: 'detail',
|
|
98
146
|
create: 'create',
|
|
99
147
|
edit: 'update',
|
|
@@ -114,11 +162,12 @@ function resourceRoutes(basePath: string, resource: AdminResource): readonly Adm
|
|
|
114
162
|
view,
|
|
115
163
|
entity: resource.name,
|
|
116
164
|
titleKey: resource.titleKey,
|
|
117
|
-
permissions: permissionsForOperation(resource.
|
|
165
|
+
permissions: permissionsForOperation(resource.permission, op),
|
|
118
166
|
});
|
|
119
167
|
};
|
|
120
168
|
|
|
121
169
|
add('list', base);
|
|
170
|
+
add('lookup', `${base}/${LOOKUP_SEGMENT}`);
|
|
122
171
|
add('create', `${base}/new`);
|
|
123
172
|
add('detail', `${base}/:id`);
|
|
124
173
|
add('edit', `${base}/:id/edit`);
|
|
@@ -187,24 +236,112 @@ function assertUniqueActionNames(
|
|
|
187
236
|
}
|
|
188
237
|
}
|
|
189
238
|
|
|
239
|
+
/**
|
|
240
|
+
* The repo one resource reads through: its own `repo:` override, else the entity's table in the
|
|
241
|
+
* app's handle. Neither is refused HERE — a resource with no repo mounts four routes that all fail
|
|
242
|
+
* on the first row they ask for, and nothing would say so until an operator opened one.
|
|
243
|
+
*/
|
|
244
|
+
function repoFor(
|
|
245
|
+
entity: AdminEntity,
|
|
246
|
+
override: AdminRepo<AdminRow> | undefined,
|
|
247
|
+
tables: ReadonlyMap<string, Parameters<typeof adminRepoFor>[1]>,
|
|
248
|
+
): AdminRepo<AdminRow> {
|
|
249
|
+
if (override !== undefined) return override;
|
|
250
|
+
const table = tables.get(entity.$name);
|
|
251
|
+
if (table === undefined) {
|
|
252
|
+
throw new AdminRepoUnboundError({ entity: entity.$name, handles: [...tables.keys()].sort() });
|
|
253
|
+
}
|
|
254
|
+
return adminRepoFor(entity, table);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* A batch action belongs to a resource — the batch bar is a list's — and a declared threshold is a
|
|
259
|
+
* count. An action with a threshold declares the one `admin.batch` job here, at declaration, so a
|
|
260
|
+
* process that loads this admin is a process that can run its queued chunks.
|
|
261
|
+
*/
|
|
262
|
+
function assertBatchActions(
|
|
263
|
+
resources: readonly AdminResource[],
|
|
264
|
+
declared: readonly AdminAction[],
|
|
265
|
+
): void {
|
|
266
|
+
for (const action of declared) {
|
|
267
|
+
if (action.batch !== undefined && action.entity === undefined) {
|
|
268
|
+
// The closest shipped code is a field's, and its cause renders `<entity>.<field>:` — so the
|
|
269
|
+
// two halves name WHERE the option was written, never a column of an entity called "admin".
|
|
270
|
+
throw new AdminFieldUnsupportedError({
|
|
271
|
+
entity: 'defineAdmin',
|
|
272
|
+
field: `actions["${action.name}"].batch`,
|
|
273
|
+
cause:
|
|
274
|
+
"declares batch with no entity: the batch bar is a resource list's, and a global action belongs to none",
|
|
275
|
+
fix: `name the resource the batch runs over — { name: '${action.name}', entity: '<entity>', batch: true } — or drop batch`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
for (const action of [...resources.flatMap((resource) => resource.actions), ...declared]) {
|
|
280
|
+
if (action.matching !== undefined && action.batch === undefined) {
|
|
281
|
+
throw new AdminFieldUnsupportedError({
|
|
282
|
+
entity: 'defineAdmin',
|
|
283
|
+
field: `actions["${action.name}"].matching`,
|
|
284
|
+
cause:
|
|
285
|
+
'declares matching without batch: "all matching" is an option of the batch bar, and this action is not in it',
|
|
286
|
+
fix: `add batch: true to { name: '${action.name}', … }, or drop matching`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
for (const action of resources.flatMap((resource) => resource.actions)) {
|
|
291
|
+
if (batchPlan(action) !== null) adminBatchJob();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The overview's sidebar link, gated on the route's own pair so the link and the page agree. */
|
|
296
|
+
const jobsOverviewNavItem = (): NavItem & { readonly group: string } => ({
|
|
297
|
+
key: 'jobs',
|
|
298
|
+
labelKey: 'admin.jobs.title',
|
|
299
|
+
href: JOBS_PATH,
|
|
300
|
+
entity: null,
|
|
301
|
+
permissions: permissionsForOperation(JOB_PERMISSION_NOUN, 'list'),
|
|
302
|
+
group: JOBS_GROUP,
|
|
303
|
+
});
|
|
304
|
+
|
|
190
305
|
/** Derive the whole admin from the registries. */
|
|
191
306
|
export function defineAdmin(input: DefineAdminInput): AdminApp {
|
|
192
307
|
const basePath = input.basePath ?? '/admin';
|
|
193
308
|
const branding = adminBranding(input.branding ?? {});
|
|
194
309
|
const audit = input.audit ?? memoryAuditLog();
|
|
195
|
-
|
|
196
|
-
|
|
310
|
+
// The jobs dashboard is declared for every admin: four resources and their actions, each an
|
|
311
|
+
// ordinary declaration an app could have written — and an app's `resources:` entry for one of
|
|
312
|
+
// them (`x_jobs: { rows }`) overrides it as it would its own.
|
|
313
|
+
const jobs = jobsAdmin(basePath);
|
|
314
|
+
const actions = [...(input.actions ?? []), ...jobs.actions];
|
|
315
|
+
const overrides: Readonly<Record<string, AdminResourceOptions>> = {
|
|
316
|
+
...jobs.resources,
|
|
317
|
+
...Object.fromEntries(
|
|
318
|
+
Object.entries(input.resources ?? {}).map(([name, own]) => [
|
|
319
|
+
name,
|
|
320
|
+
{ ...(Object.hasOwn(jobs.resources, name) ? jobs.resources[name] : {}), ...own },
|
|
321
|
+
]),
|
|
322
|
+
),
|
|
323
|
+
};
|
|
324
|
+
const tables = input.db === undefined ? new Map() : adminTablesOf(input.db);
|
|
325
|
+
const auth: AdminAuth = {
|
|
326
|
+
actor: input.auth?.actor ?? (() => requestActor().actor),
|
|
327
|
+
authz: input.auth?.authz ?? roleAuthz(),
|
|
328
|
+
};
|
|
197
329
|
|
|
198
|
-
const resources = input.entities.map((entity) => {
|
|
330
|
+
const resources = [...input.entities, ...jobs.entities].map((entity) => {
|
|
199
331
|
const own = actions.filter((action) => action.entity === entity.$name);
|
|
200
|
-
const opts = overrides[entity.$name];
|
|
332
|
+
const opts = Object.hasOwn(overrides, entity.$name) ? overrides[entity.$name] : undefined;
|
|
201
333
|
return adminResource<AdminRow>(entity, {
|
|
202
334
|
...(opts ?? {}),
|
|
335
|
+
repo: repoFor(entity, opts?.repo, tables),
|
|
203
336
|
actions: [...(opts?.actions ?? []), ...own],
|
|
204
337
|
});
|
|
205
338
|
});
|
|
206
339
|
|
|
207
340
|
assertUniqueActionNames(resources, actions);
|
|
341
|
+
// Refused where they are written, not on the first detail page: a `related` name that is not a
|
|
342
|
+
// `hasMany` of the entity, a batch threshold that is not a count.
|
|
343
|
+
for (const resource of resources) relatedOf(resources, resource);
|
|
344
|
+
assertBatchActions(resources, actions);
|
|
208
345
|
|
|
209
346
|
const pages = input.pages ?? [];
|
|
210
347
|
const navOptions = input.nav ?? {};
|
|
@@ -212,7 +349,7 @@ export function defineAdmin(input: DefineAdminInput): AdminApp {
|
|
|
212
349
|
...(navOptions.extra ?? []),
|
|
213
350
|
...pageNavItems(pages),
|
|
214
351
|
];
|
|
215
|
-
const nav = adminNav(resources, { ...navOptions, extra });
|
|
352
|
+
const nav = adminNav(resources, { ...navOptions, extra: [...extra, jobsOverviewNavItem()] });
|
|
216
353
|
assertUniqueResourcePaths(basePath, resources);
|
|
217
354
|
const generated: AdminRoute[] = [
|
|
218
355
|
{
|
|
@@ -230,11 +367,11 @@ export function defineAdmin(input: DefineAdminInput): AdminApp {
|
|
|
230
367
|
permissions: permissionsForOperation('admin', 'search'),
|
|
231
368
|
},
|
|
232
369
|
{
|
|
233
|
-
path: `${basePath}
|
|
370
|
+
path: `${basePath}${JOBS_PATH}`,
|
|
234
371
|
view: 'jobs',
|
|
235
372
|
entity: null,
|
|
236
373
|
titleKey: 'admin.jobs.title',
|
|
237
|
-
permissions: permissionsForOperation(
|
|
374
|
+
permissions: permissionsForOperation(JOB_PERMISSION_NOUN, 'list'),
|
|
238
375
|
},
|
|
239
376
|
{
|
|
240
377
|
path: `${basePath}/audit`,
|
|
@@ -256,26 +393,53 @@ export function defineAdmin(input: DefineAdminInput): AdminApp {
|
|
|
256
393
|
),
|
|
257
394
|
];
|
|
258
395
|
|
|
259
|
-
|
|
396
|
+
// Every permission a route or an action names is one the policy layer must be able to hear
|
|
397
|
+
// about, whichever authz decides it: `can()` refuses a name its registry lacks.
|
|
398
|
+
declareAdminPermissions([
|
|
399
|
+
...routes.flatMap((route) => route.permissions),
|
|
400
|
+
...resources.flatMap((resource) =>
|
|
401
|
+
ADMIN_OPERATIONS.flatMap((op) => permissionsForOperation(resource.permission, op)),
|
|
402
|
+
),
|
|
403
|
+
...[...resources.flatMap((resource) => resource.actions), ...actions].map(
|
|
404
|
+
(action) => action.permission,
|
|
405
|
+
),
|
|
406
|
+
]);
|
|
407
|
+
|
|
408
|
+
const ctx = ({ actor, requestId }: { actor: AdminActor; requestId: string }): CrudCtx => ({
|
|
409
|
+
actor,
|
|
410
|
+
requestId,
|
|
411
|
+
audit,
|
|
412
|
+
authz: auth.authz,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const app: AdminApp = {
|
|
260
416
|
basePath,
|
|
261
417
|
branding,
|
|
262
418
|
theme: themeAttributes(branding),
|
|
263
419
|
resources,
|
|
264
420
|
globalActions: actions.filter((action) => action.entity === undefined),
|
|
265
|
-
jobs: input.jobs ?? [],
|
|
266
421
|
nav,
|
|
267
422
|
routes,
|
|
268
423
|
audit,
|
|
269
|
-
authz:
|
|
270
|
-
auth
|
|
424
|
+
authz: auth.authz,
|
|
425
|
+
auth,
|
|
271
426
|
resource(name: string): AdminResource {
|
|
272
427
|
return resourceFor(resources, name);
|
|
273
428
|
},
|
|
274
|
-
navFor(
|
|
275
|
-
return visibleNav(nav, resources,
|
|
429
|
+
navFor(crud: CrudCtx): readonly NavGroup[] {
|
|
430
|
+
return visibleNav(nav, resources, crud);
|
|
276
431
|
},
|
|
277
|
-
ctx
|
|
278
|
-
|
|
432
|
+
ctx,
|
|
433
|
+
async requestCtx(request: Request): Promise<CrudCtx> {
|
|
434
|
+
const actor = await auth.actor(request);
|
|
435
|
+
return ctx({ actor: actor ?? ANONYMOUS_ADMIN_ACTOR, requestId: requestActor().requestId });
|
|
279
436
|
},
|
|
437
|
+
describe: () => describeAdmin(basePath, resources, routes, audit.kind),
|
|
438
|
+
catalogKeys: () => adminCatalogKeys(app),
|
|
280
439
|
};
|
|
440
|
+
// In the framework's one route list, as what they are: mounted, each with its permissions.
|
|
441
|
+
mountAdminRoutes(basePath, routes);
|
|
442
|
+
// Declared is mountable: a host serves every admin in this registry under its base path.
|
|
443
|
+
registerAdminMount(app);
|
|
444
|
+
return app;
|
|
281
445
|
}
|
package/src/audit-pg.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// The durable audit log: one framework table, one insert per entry, read back by keyset. What
|
|
2
|
+
// `defineAdmin({ audit: postgresAuditLog() })` names — the memory log is a ring that forgets at
|
|
3
|
+
// every restart, and a row's history that ends at the last deploy is not a history.
|
|
4
|
+
|
|
5
|
+
import { isUltimateError, uuid } from '@ultimat3/core';
|
|
6
|
+
import { type DbClient, db, withTransaction } from '@ultimat3/db';
|
|
7
|
+
import {
|
|
8
|
+
AUDIT_READ_OPERATIONS,
|
|
9
|
+
type AuditDraft,
|
|
10
|
+
type AuditEntry,
|
|
11
|
+
type AuditFieldDiff,
|
|
12
|
+
type AuditLog,
|
|
13
|
+
type AuditOutcome,
|
|
14
|
+
type AuditQuery,
|
|
15
|
+
type AuditSink,
|
|
16
|
+
auditEntry,
|
|
17
|
+
auditLimit,
|
|
18
|
+
isAllowedRead,
|
|
19
|
+
} from './audit';
|
|
20
|
+
import { ADMIN_AUDIT_TABLE } from './audit-schema';
|
|
21
|
+
import { jsonText } from './json-text';
|
|
22
|
+
|
|
23
|
+
const SUBJECT = 'postgresAuditLog';
|
|
24
|
+
|
|
25
|
+
/** `recorded_at` is the database's clock and not a parameter: the gap to `at` is the audit lag. */
|
|
26
|
+
export const SQL_ADMIN_AUDIT_INSERT = `
|
|
27
|
+
insert into x_admin_audit (
|
|
28
|
+
id, at, request_id, actor_id, actor_roles, org_id,
|
|
29
|
+
operation, kind, entity, entity_id, permission, outcome, reason, diff
|
|
30
|
+
) values (
|
|
31
|
+
$1::uuid, $2::timestamptz, $3, $4, $5::jsonb, $6,
|
|
32
|
+
$7, $8, $9, $10, $11, $12, $13, $14::jsonb
|
|
33
|
+
)
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const COLUMNS =
|
|
37
|
+
'id, at, request_id, actor_id, actor_roles, org_id, operation, kind, entity, entity_id, permission, outcome, reason, diff';
|
|
38
|
+
|
|
39
|
+
interface AuditRow {
|
|
40
|
+
readonly id: string;
|
|
41
|
+
readonly at: Date | string;
|
|
42
|
+
readonly request_id: string;
|
|
43
|
+
readonly actor_id: string;
|
|
44
|
+
readonly actor_roles: unknown;
|
|
45
|
+
readonly org_id: string | null;
|
|
46
|
+
readonly operation: string;
|
|
47
|
+
readonly kind: string;
|
|
48
|
+
readonly entity: string;
|
|
49
|
+
readonly entity_id: string | null;
|
|
50
|
+
readonly permission: string;
|
|
51
|
+
readonly outcome: string;
|
|
52
|
+
readonly reason: string;
|
|
53
|
+
readonly diff: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A `jsonb` column, whichever way the driver hands it back: parsed, or as its text. */
|
|
57
|
+
const parsed = (value: unknown): unknown => {
|
|
58
|
+
if (typeof value !== 'string') return value;
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(value);
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const OUTCOMES: readonly AuditOutcome[] = ['allowed', 'denied', 'failed'];
|
|
67
|
+
|
|
68
|
+
const entryOf = (row: AuditRow): AuditEntry => {
|
|
69
|
+
const roles = parsed(row.actor_roles);
|
|
70
|
+
const diff = parsed(row.diff);
|
|
71
|
+
const outcome = OUTCOMES.find((known) => known === row.outcome) ?? 'failed';
|
|
72
|
+
return {
|
|
73
|
+
id: row.id,
|
|
74
|
+
at: (row.at instanceof Date ? row.at : new Date(row.at)).toISOString(),
|
|
75
|
+
requestId: row.request_id,
|
|
76
|
+
actor: {
|
|
77
|
+
id: row.actor_id,
|
|
78
|
+
roles: Array.isArray(roles) ? roles.map(String) : [],
|
|
79
|
+
...(row.org_id === null ? {} : { orgId: row.org_id }),
|
|
80
|
+
},
|
|
81
|
+
operation: row.operation,
|
|
82
|
+
kind: row.kind === 'action' ? 'action' : 'operation',
|
|
83
|
+
entity: row.entity,
|
|
84
|
+
entityId: row.entity_id,
|
|
85
|
+
permission: row.permission,
|
|
86
|
+
outcome,
|
|
87
|
+
reason: row.reason,
|
|
88
|
+
diff: Array.isArray(diff) ? (diff as readonly AuditFieldDiff[]) : [],
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The `where` of one read, built from the predicates the query actually names — never
|
|
94
|
+
* `($1 is null or entity = $1)`, which hides the index from the planner. Every fragment is fixed
|
|
95
|
+
* text; only values are bound.
|
|
96
|
+
*/
|
|
97
|
+
function selectFor(query: AuditQuery, limit: number): { text: string; values: unknown[] } {
|
|
98
|
+
const where: string[] = [];
|
|
99
|
+
const values: unknown[] = [];
|
|
100
|
+
const bind = (value: unknown): string => {
|
|
101
|
+
values.push(value);
|
|
102
|
+
return `$${String(values.length)}`;
|
|
103
|
+
};
|
|
104
|
+
if (query.entity !== undefined) where.push(`entity = ${bind(query.entity)}`);
|
|
105
|
+
if (query.entityId !== undefined) where.push(`entity_id = ${bind(query.entityId)}`);
|
|
106
|
+
if (query.actorId !== undefined) where.push(`actor_id = ${bind(query.actorId)}`);
|
|
107
|
+
if (query.orgId !== undefined) where.push(`org_id = ${bind(query.orgId)}`);
|
|
108
|
+
if (query.before !== undefined) {
|
|
109
|
+
where.push(
|
|
110
|
+
`(at, id) < (${bind(query.before.at)}::timestamptz, ${bind(query.before.id)}::uuid)`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (query.changes === true) {
|
|
114
|
+
const reads = AUDIT_READ_OPERATIONS.map((operation) => bind(operation)).join(', ');
|
|
115
|
+
where.push(`(kind = 'action' or outcome <> 'allowed' or operation not in (${reads}))`);
|
|
116
|
+
}
|
|
117
|
+
const filter = where.length === 0 ? '' : ` where ${where.join(' and ')}`;
|
|
118
|
+
return {
|
|
119
|
+
text: `select ${COLUMNS} from ${ADMIN_AUDIT_TABLE}${filter} order by at desc, id desc limit ${bind(limit)}`,
|
|
120
|
+
values,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface PostgresAuditLogOptions {
|
|
125
|
+
/**
|
|
126
|
+
* The database to write to. Omitted — the ordinary case — it is the process's own (`db()`), so
|
|
127
|
+
* an entry joins whatever transaction is open and every role writes through the pool it booted.
|
|
128
|
+
*/
|
|
129
|
+
readonly client?: DbClient;
|
|
130
|
+
/**
|
|
131
|
+
* Persist the reads that were allowed too — every list page, every row opened. Off by default:
|
|
132
|
+
* that is one insert per GET, and a trail of who LOOKED is a decision an app makes. A refused
|
|
133
|
+
* read is an event and is always written.
|
|
134
|
+
*/
|
|
135
|
+
readonly reads?: boolean;
|
|
136
|
+
readonly sinks?: readonly AuditSink[];
|
|
137
|
+
readonly now?: () => Date;
|
|
138
|
+
/** Must answer a uuid: the column is one. Omitted, a time-ordered v7. */
|
|
139
|
+
readonly nextId?: () => string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The audit log an admin names to keep its trail: `defineAdmin({ audit: postgresAuditLog() })`.
|
|
144
|
+
*
|
|
145
|
+
* One `insert` per entry, and `atomic` puts a write and its entry in one transaction — so a row
|
|
146
|
+
* never changes without the entry that says who changed it. A repository pinned to its own client
|
|
147
|
+
* cannot join that transaction (`X_REPO_CLIENT_PINNED`, raised before its first statement is
|
|
148
|
+
* sent): the write then runs as it would have with no log, and its entry follows it.
|
|
149
|
+
*/
|
|
150
|
+
export function postgresAuditLog(options: PostgresAuditLogOptions = {}): AuditLog {
|
|
151
|
+
const now = options.now ?? ((): Date => new Date());
|
|
152
|
+
const nextId = options.nextId ?? uuid;
|
|
153
|
+
const sinks = options.sinks ?? [];
|
|
154
|
+
const reads = options.reads === true;
|
|
155
|
+
// Resolved per statement, never captured: inside `atomic` it is the open transaction.
|
|
156
|
+
const client = (): DbClient => (options.client === undefined ? db() : options.client);
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
kind: 'postgres',
|
|
160
|
+
async append(draft: AuditDraft): Promise<AuditEntry> {
|
|
161
|
+
const entry = auditEntry(draft, nextId(), now());
|
|
162
|
+
if (reads || !isAllowedRead(entry)) {
|
|
163
|
+
// A plain `{ text, values }`, the shape `pgExecutorFor` hands the same client: the text
|
|
164
|
+
// is this file's constant and every value is bound.
|
|
165
|
+
await client().query({
|
|
166
|
+
text: SQL_ADMIN_AUDIT_INSERT,
|
|
167
|
+
values: [
|
|
168
|
+
entry.id,
|
|
169
|
+
entry.at,
|
|
170
|
+
entry.requestId,
|
|
171
|
+
entry.actor.id,
|
|
172
|
+
JSON.stringify(entry.actor.roles),
|
|
173
|
+
entry.actor.orgId ?? null,
|
|
174
|
+
entry.operation,
|
|
175
|
+
entry.kind,
|
|
176
|
+
entry.entity,
|
|
177
|
+
entry.entityId,
|
|
178
|
+
entry.permission,
|
|
179
|
+
entry.outcome,
|
|
180
|
+
entry.reason,
|
|
181
|
+
// `jsonText`, not `JSON.stringify`: a `money()` diff holds a bigint, which the
|
|
182
|
+
// second throws on — after the write it describes has already happened.
|
|
183
|
+
jsonText(entry.diff),
|
|
184
|
+
],
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
for (const sink of sinks) await sink.write(entry);
|
|
188
|
+
return entry;
|
|
189
|
+
},
|
|
190
|
+
async entries(query = {}): Promise<readonly AuditEntry[]> {
|
|
191
|
+
const select = selectFor(query, auditLimit(SUBJECT, query));
|
|
192
|
+
const rows = await client().query<AuditRow>(select);
|
|
193
|
+
return rows.map(entryOf);
|
|
194
|
+
},
|
|
195
|
+
async atomic<T>(run: () => Promise<T>): Promise<T> {
|
|
196
|
+
try {
|
|
197
|
+
return await withTransaction(
|
|
198
|
+
run,
|
|
199
|
+
options.client === undefined ? {} : { client: options.client },
|
|
200
|
+
);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (isUltimateError(error) && error.code === 'X_REPO_CLIENT_PINNED') return run();
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The DDL of the admin's one framework table, and nothing else: a LEAF module, so the boot's
|
|
2
|
+
// `FRAMEWORK_SCHEMA` (`@ultimat3/cli`) can apply it in every role through `@ultimat3/admin/schema`
|
|
3
|
+
// without pulling one screen, one view or one stylesheet into a worker's module graph.
|
|
4
|
+
|
|
5
|
+
/** The table. One name, read by this DDL and by `audit-pg.ts`'s insert and select. */
|
|
6
|
+
export const ADMIN_AUDIT_TABLE = 'x_admin_audit';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Applied by the boot (`@ultimat3/cli`'s `FRAMEWORK_SCHEMA`), never by an app migration — the rule
|
|
10
|
+
* every framework table follows. A plain table: there is no `appendOnly` entity modifier in this
|
|
11
|
+
* tree, so "append-only" is `audit-pg.ts` issuing an `insert` and a `select` and nothing else.
|
|
12
|
+
*
|
|
13
|
+
* **Two indexes.** `(entity, entity_id, at desc, id desc)` is the detail page's history card: one
|
|
14
|
+
* row's trail, newest first, a keyset seek with no sort. `(at desc, id desc)` is the audit screen.
|
|
15
|
+
* `actor_id` and `org_id` have none — both are filters over the second index's order, and an index
|
|
16
|
+
* is a write cost paid on every audited request.
|
|
17
|
+
*
|
|
18
|
+
* **`diff` is what the admin already redacted.** A sealed column and a `sensitive` field reach it
|
|
19
|
+
* as `[redacted]` (`crud-input.ts`'s `rowDiff`), so no plaintext is ever a value in this table.
|
|
20
|
+
*
|
|
21
|
+
* **No retention and no purge**, for `x_audit`'s reason: how long a trail is kept is a legal
|
|
22
|
+
* question with a different answer per app.
|
|
23
|
+
*/
|
|
24
|
+
export const SQL_ADMIN_AUDIT_TABLE = `
|
|
25
|
+
create table if not exists x_admin_audit (
|
|
26
|
+
id uuid primary key,
|
|
27
|
+
at timestamptz not null,
|
|
28
|
+
request_id text not null,
|
|
29
|
+
actor_id text not null,
|
|
30
|
+
actor_roles jsonb not null,
|
|
31
|
+
org_id text,
|
|
32
|
+
operation text not null,
|
|
33
|
+
kind text not null,
|
|
34
|
+
entity text not null,
|
|
35
|
+
entity_id text,
|
|
36
|
+
permission text not null,
|
|
37
|
+
outcome text not null,
|
|
38
|
+
reason text not null,
|
|
39
|
+
diff jsonb not null,
|
|
40
|
+
recorded_at timestamptz not null default now()
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
create index if not exists x_admin_audit_entity_idx
|
|
44
|
+
on x_admin_audit (entity, entity_id, at desc, id desc);
|
|
45
|
+
|
|
46
|
+
create index if not exists x_admin_audit_at_idx on x_admin_audit (at desc, id desc);
|
|
47
|
+
`;
|