@ultimat3/admin 22.14.0 → 23.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 +73 -80
- package/README.md +276 -87
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +127 -28
- 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 +92 -0
- package/src/batch.ts +431 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +166 -0
- package/src/crud.ts +129 -177
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +24 -6
- package/src/dev/facts.ts +27 -0
- 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.ts +6 -0
- package/src/dev/server.ts +15 -7
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +83 -0
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +91 -0
- package/src/form.tsx +78 -45
- package/src/index.ts +198 -9
- package/src/jobs/fleet-repo.ts +143 -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 +103 -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/layout.tsx +55 -43
- package/src/list-columns.tsx +53 -0
- package/src/list-filter-bar.tsx +207 -0
- package/src/list-filters.ts +217 -0
- package/src/list-request.ts +127 -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 +108 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/policy-bridge.ts +110 -15
- package/src/registry.ts +138 -8
- package/src/related.ts +105 -0
- package/src/relations.ts +224 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +204 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +185 -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/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 +393 -0
- package/src/screen-system.tsx +69 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +13 -9
- 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 -12
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// The audit half of a CRUD call that did NOT write: a refusal, an input the schema rejected, a
|
|
2
|
+
// row that was not there, a repo that threw. Each leaves its entry and answers the result the
|
|
3
|
+
// caller renders — so `crud.ts` reads as the five steps of each operation and nothing else.
|
|
4
|
+
|
|
5
|
+
import { type AuditEntry, type AuditFieldDiff, deniedDraft } from './audit';
|
|
6
|
+
import type { AdminDecision } from './authz';
|
|
7
|
+
import type { CrudCtx, CrudResult } from './crud';
|
|
8
|
+
import type { AdminOperation } from './permissions';
|
|
9
|
+
import type { AdminRow } from './registry';
|
|
10
|
+
import type { AdminResource } from './resource';
|
|
11
|
+
import type { ValidationIssue } from './validate';
|
|
12
|
+
|
|
13
|
+
export const deniedEntry = (
|
|
14
|
+
resource: { readonly name: string },
|
|
15
|
+
op: AdminOperation,
|
|
16
|
+
ctx: CrudCtx,
|
|
17
|
+
decision: AdminDecision,
|
|
18
|
+
id: string | null,
|
|
19
|
+
) =>
|
|
20
|
+
deniedDraft({
|
|
21
|
+
requestId: ctx.requestId,
|
|
22
|
+
actor: ctx.actor,
|
|
23
|
+
operation: op,
|
|
24
|
+
kind: 'operation',
|
|
25
|
+
entity: resource.name,
|
|
26
|
+
entityId: id,
|
|
27
|
+
decision,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export async function refuse<Row extends AdminRow>(
|
|
31
|
+
resource: AdminResource<Row>,
|
|
32
|
+
op: AdminOperation,
|
|
33
|
+
ctx: CrudCtx,
|
|
34
|
+
decision: AdminDecision,
|
|
35
|
+
id: string | null,
|
|
36
|
+
confirmationRequired = false,
|
|
37
|
+
): Promise<CrudResult<Row>> {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
kind: 'denied',
|
|
41
|
+
decision,
|
|
42
|
+
confirmationRequired,
|
|
43
|
+
audit: await ctx.audit.append(deniedEntry(resource, op, ctx, decision, id)),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The reason on a `failed` entry. A key, never the database's message. */
|
|
48
|
+
const WRITE_FAILED_REASON = 'admin.audit.write-failed';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Run a repo WRITE and append its entry — as one unit where the log can make it one — and leave a
|
|
52
|
+
* `failed` entry behind if either throws.
|
|
53
|
+
*
|
|
54
|
+
* `AuditOutcome` has declared a `failed` member all along and this file emitted it in exactly one
|
|
55
|
+
* place — `invalid()`, for a VALIDATION issue. A constraint violation, a statement that timed out
|
|
56
|
+
* after committing, a connection dropped mid-write: each left no entry at all, which is the case
|
|
57
|
+
* an auditor opens the log for. Both siblings already do this and each states the rule
|
|
58
|
+
* (`search.ts`, `action-gate.ts`).
|
|
59
|
+
*
|
|
60
|
+
* A mutation cannot append BEFORE the call the way a read does — that would record a write which
|
|
61
|
+
* may never have happened. So the write and its entry go inside `audit.atomic`: a durable log
|
|
62
|
+
* opens a transaction, so a row never changes without the entry naming who changed it, and an
|
|
63
|
+
* entry never describes a write that rolled back. The `failed` entry is written OUTSIDE it, after
|
|
64
|
+
* the rollback — inside, it would be rolled back with the write it reports. Nothing about the
|
|
65
|
+
* thrown value is read or rendered; the caller owns it, and an audit reason is a key.
|
|
66
|
+
*/
|
|
67
|
+
export async function auditedWrite<T>(
|
|
68
|
+
// Only the NAME is read, so this stays invariance-free: `AdminResource<Row>` at four call
|
|
69
|
+
// sites would need the generic threaded through for nothing.
|
|
70
|
+
resource: { readonly name: string },
|
|
71
|
+
op: AdminOperation,
|
|
72
|
+
ctx: CrudCtx,
|
|
73
|
+
entityId: string | null,
|
|
74
|
+
decision: AdminDecision,
|
|
75
|
+
write: () => Promise<T>,
|
|
76
|
+
/** The diff of what the write did, and the id of the row it left — known only afterwards. */
|
|
77
|
+
written: (value: T) => {
|
|
78
|
+
readonly entityId: string | null;
|
|
79
|
+
readonly diff: readonly AuditFieldDiff[];
|
|
80
|
+
},
|
|
81
|
+
): Promise<{ readonly value: T; readonly audit: AuditEntry }> {
|
|
82
|
+
const entry = {
|
|
83
|
+
requestId: ctx.requestId,
|
|
84
|
+
actor: ctx.actor,
|
|
85
|
+
operation: op,
|
|
86
|
+
kind: 'operation',
|
|
87
|
+
entity: resource.name,
|
|
88
|
+
permission: decision.permission,
|
|
89
|
+
} as const;
|
|
90
|
+
try {
|
|
91
|
+
return await ctx.audit.atomic(async () => {
|
|
92
|
+
const value = await write();
|
|
93
|
+
const audit = await ctx.audit.append({
|
|
94
|
+
...entry,
|
|
95
|
+
...written(value),
|
|
96
|
+
outcome: 'allowed',
|
|
97
|
+
reason: decision.reason,
|
|
98
|
+
});
|
|
99
|
+
return { value, audit };
|
|
100
|
+
});
|
|
101
|
+
} catch (error) {
|
|
102
|
+
await ctx.audit.append({
|
|
103
|
+
...entry,
|
|
104
|
+
entityId,
|
|
105
|
+
outcome: 'failed',
|
|
106
|
+
reason: WRITE_FAILED_REASON,
|
|
107
|
+
diff: [],
|
|
108
|
+
});
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The key a write to a row that is not there is logged with. */
|
|
114
|
+
const ROW_MISSING_REASON = 'admin.error.row-missing';
|
|
115
|
+
|
|
116
|
+
export async function missingRow<Row extends AdminRow>(
|
|
117
|
+
resource: AdminResource<Row>,
|
|
118
|
+
op: AdminOperation,
|
|
119
|
+
ctx: CrudCtx,
|
|
120
|
+
id: string,
|
|
121
|
+
decision: AdminDecision,
|
|
122
|
+
): Promise<CrudResult<Row>> {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
kind: 'missing',
|
|
126
|
+
audit: await ctx.audit.append({
|
|
127
|
+
requestId: ctx.requestId,
|
|
128
|
+
actor: ctx.actor,
|
|
129
|
+
operation: op,
|
|
130
|
+
kind: 'operation',
|
|
131
|
+
entity: resource.name,
|
|
132
|
+
entityId: id,
|
|
133
|
+
permission: decision.permission,
|
|
134
|
+
outcome: 'failed',
|
|
135
|
+
reason: ROW_MISSING_REASON,
|
|
136
|
+
diff: [],
|
|
137
|
+
}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function invalid<Row extends AdminRow>(
|
|
142
|
+
resource: AdminResource<Row>,
|
|
143
|
+
op: AdminOperation,
|
|
144
|
+
ctx: CrudCtx,
|
|
145
|
+
id: string | null,
|
|
146
|
+
issues: readonly ValidationIssue[],
|
|
147
|
+
decision: AdminDecision,
|
|
148
|
+
): Promise<CrudResult<Row>> {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
kind: 'invalid',
|
|
152
|
+
issues,
|
|
153
|
+
audit: await ctx.audit.append({
|
|
154
|
+
requestId: ctx.requestId,
|
|
155
|
+
actor: ctx.actor,
|
|
156
|
+
operation: op,
|
|
157
|
+
kind: 'operation',
|
|
158
|
+
entity: resource.name,
|
|
159
|
+
entityId: id,
|
|
160
|
+
permission: decision.permission,
|
|
161
|
+
outcome: 'failed',
|
|
162
|
+
reason: 'admin.error.invalid-input',
|
|
163
|
+
diff: [],
|
|
164
|
+
}),
|
|
165
|
+
};
|
|
166
|
+
}
|
package/src/crud.ts
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
// Views and MCP tools both call these, so there is one ordering of those five steps in the
|
|
3
3
|
// admin rather than one per surface.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import type { AuditEntry, AuditLog } from './audit';
|
|
6
6
|
import { type AdminActor, type AdminAuthz, type AdminDecision, decideAll } from './authz';
|
|
7
|
-
import {
|
|
7
|
+
import { onlyFor, rowDiff, splitSecrets, withoutTenant, withTenant } from './crud-input';
|
|
8
|
+
import { auditedWrite, deniedEntry, invalid, missingRow, refuse } from './crud-outcome';
|
|
9
|
+
import { type AdminListRequest, findRow, listWhere } from './list-scope';
|
|
10
|
+
import { type AdminPage, fetchPage } from './pagination';
|
|
8
11
|
import {
|
|
9
12
|
type AdminOperation,
|
|
10
13
|
adminPermissionFor,
|
|
@@ -15,6 +18,8 @@ import {
|
|
|
15
18
|
} from './permissions';
|
|
16
19
|
import type { AdminRow } from './registry';
|
|
17
20
|
import { type AdminResource, repoOf } from './resource';
|
|
21
|
+
import type { AdminScope } from './resource-list';
|
|
22
|
+
import { outOfScopeDecision, writeOutsideScope } from './row-scope-write';
|
|
18
23
|
import { type ValidationIssue, validateInput } from './validate';
|
|
19
24
|
|
|
20
25
|
export interface CrudCtx {
|
|
@@ -38,10 +43,22 @@ export type CrudResult<Row extends AdminRow> =
|
|
|
38
43
|
readonly kind: 'invalid';
|
|
39
44
|
readonly issues: readonly ValidationIssue[];
|
|
40
45
|
readonly audit: AuditEntry;
|
|
41
|
-
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* An update or a delete of a row that is not there — gone, never existed, or outside this
|
|
49
|
+
* actor's row scope, which are one answer on purpose. Nothing was written; the attempt is on
|
|
50
|
+
* the log as `failed`, like every other write that did not happen.
|
|
51
|
+
*/
|
|
52
|
+
| { readonly ok: false; readonly kind: 'missing'; readonly audit: AuditEntry };
|
|
42
53
|
|
|
43
54
|
export type ListResult<Row extends AdminRow> =
|
|
44
|
-
| {
|
|
55
|
+
| {
|
|
56
|
+
readonly ok: true;
|
|
57
|
+
readonly page: AdminPage<Row>;
|
|
58
|
+
/** The scope the page was read under — the request's, or the resource's default. */
|
|
59
|
+
readonly scope: AdminScope | null;
|
|
60
|
+
readonly audit: AuditEntry;
|
|
61
|
+
}
|
|
45
62
|
| {
|
|
46
63
|
readonly ok: false;
|
|
47
64
|
readonly kind: 'denied';
|
|
@@ -51,7 +68,11 @@ export type ListResult<Row extends AdminRow> =
|
|
|
51
68
|
|
|
52
69
|
/** Both gates, always in this order: the admin-level one, then the entity-level one. */
|
|
53
70
|
export function permissionsForOperation(entity: string, op: AdminOperation): readonly string[] {
|
|
54
|
-
|
|
71
|
+
const gate = adminPermissionFor(op);
|
|
72
|
+
const own = entityPermissionFor(entity, op);
|
|
73
|
+
// The dashboard and the search are gated on the admin itself, where the two halves are one
|
|
74
|
+
// permission: `admin:read + admin:read` was what `x routes` printed for both.
|
|
75
|
+
return gate === own ? [gate] : [gate, own];
|
|
55
76
|
}
|
|
56
77
|
|
|
57
78
|
export function decideOperation(
|
|
@@ -81,7 +102,7 @@ export function decideOperation(
|
|
|
81
102
|
trace: [`operations: ${resource.name} does not offer ${op}`],
|
|
82
103
|
};
|
|
83
104
|
}
|
|
84
|
-
return decideAll(ctx.authz, permissionsForOperation(resource.
|
|
105
|
+
return decideAll(ctx.authz, permissionsForOperation(resource.permission, op), ctx.actor, {
|
|
85
106
|
entity: resource.name,
|
|
86
107
|
...(id === undefined ? {} : { id }),
|
|
87
108
|
...(row === undefined ? {} : { row }),
|
|
@@ -100,35 +121,8 @@ export function canOperate(resource: AdminResource, op: AdminOperation, ctx: Cru
|
|
|
100
121
|
return decideOperation(resource, op, ctx).allowed;
|
|
101
122
|
}
|
|
102
123
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
async function refuse<Row extends AdminRow>(
|
|
107
|
-
resource: AdminResource<Row>,
|
|
108
|
-
op: AdminOperation,
|
|
109
|
-
ctx: CrudCtx,
|
|
110
|
-
decision: AdminDecision,
|
|
111
|
-
id: string | null,
|
|
112
|
-
confirmationRequired = false,
|
|
113
|
-
): Promise<CrudResult<Row>> {
|
|
114
|
-
return {
|
|
115
|
-
ok: false,
|
|
116
|
-
kind: 'denied',
|
|
117
|
-
decision,
|
|
118
|
-
confirmationRequired,
|
|
119
|
-
audit: await ctx.audit.append(
|
|
120
|
-
deniedDraft({
|
|
121
|
-
requestId: ctx.requestId,
|
|
122
|
-
actor: ctx.actor,
|
|
123
|
-
operation: op,
|
|
124
|
-
kind: 'operation',
|
|
125
|
-
entity: resource.name,
|
|
126
|
-
entityId: id,
|
|
127
|
-
decision,
|
|
128
|
-
}),
|
|
129
|
-
),
|
|
130
|
-
};
|
|
131
|
-
}
|
|
124
|
+
/** The key a required sealed column is refused with when a create names no value for it. */
|
|
125
|
+
const SECRET_REQUIRED_REASON = 'admin.error.secret-required';
|
|
132
126
|
|
|
133
127
|
/**
|
|
134
128
|
* The one read that logged nothing, in either direction: `audit.ts` says denied and failed attempts
|
|
@@ -139,17 +133,30 @@ async function refuse<Row extends AdminRow>(
|
|
|
139
133
|
export async function adminList<Row extends AdminRow>(
|
|
140
134
|
resource: AdminResource<Row>,
|
|
141
135
|
ctx: CrudCtx,
|
|
142
|
-
req:
|
|
136
|
+
req: AdminListRequest = {},
|
|
143
137
|
): Promise<ListResult<Row>> {
|
|
144
138
|
const decision = decideOperation(resource, 'list', ctx);
|
|
145
139
|
if (!decision.allowed) {
|
|
146
|
-
|
|
147
|
-
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
kind: 'denied',
|
|
143
|
+
decision,
|
|
144
|
+
audit: await ctx.audit.append(deniedEntry(resource, 'list', ctx, decision, null)),
|
|
145
|
+
};
|
|
148
146
|
}
|
|
149
|
-
|
|
147
|
+
// Composed AFTER the decision: an actor who may not list the table learns nothing about which
|
|
148
|
+
// scopes it declares from the refusal they get.
|
|
149
|
+
const { scope, where } = listWhere(resource, ctx.actor, req);
|
|
150
|
+
const page = await fetchPage(resource, {
|
|
151
|
+
...(req.cursor === undefined ? {} : { cursor: req.cursor }),
|
|
152
|
+
...(req.limit === undefined ? {} : { limit: req.limit }),
|
|
153
|
+
...(req.sort === undefined ? {} : { sort: req.sort }),
|
|
154
|
+
...(where.length === 0 ? {} : { where }),
|
|
155
|
+
});
|
|
150
156
|
return {
|
|
151
157
|
ok: true,
|
|
152
158
|
page,
|
|
159
|
+
scope: scope ?? null,
|
|
153
160
|
audit: await ctx.audit.append({
|
|
154
161
|
requestId: ctx.requestId,
|
|
155
162
|
actor: ctx.actor,
|
|
@@ -171,8 +178,9 @@ export async function adminDetail<Row extends AdminRow>(
|
|
|
171
178
|
): Promise<CrudResult<Row>> {
|
|
172
179
|
// The row is loaded BEFORE the guard, the shape `packages/action/src/invoke.ts` uses for a
|
|
173
180
|
// row-level `policy`: a rule that decides about a row cannot decide without one, and the
|
|
174
|
-
// predicate has to stay synchronous. A denial still returns no row.
|
|
175
|
-
|
|
181
|
+
// predicate has to stay synchronous. A denial still returns no row. Through the row scope: a
|
|
182
|
+
// row this actor's `rows` leaves out is not found, exactly as a row that does not exist.
|
|
183
|
+
const row = await findRow(resource, ctx.actor, id);
|
|
176
184
|
const decision = decideOperation(resource, 'detail', ctx, id, row);
|
|
177
185
|
if (!decision.allowed) return refuse(resource, 'detail', ctx, decision, id);
|
|
178
186
|
return {
|
|
@@ -192,51 +200,6 @@ export async function adminDetail<Row extends AdminRow>(
|
|
|
192
200
|
};
|
|
193
201
|
}
|
|
194
202
|
|
|
195
|
-
/** The reason on a `failed` entry. A key, never the database's message. */
|
|
196
|
-
const WRITE_FAILED_REASON = 'admin.audit.write-failed';
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Run a repo WRITE, and leave a `failed` entry behind if it throws.
|
|
200
|
-
*
|
|
201
|
-
* `AuditOutcome` has declared a `failed` member all along and this file emitted it in exactly one
|
|
202
|
-
* place — `invalid()`, for a VALIDATION issue. A constraint violation, a statement that timed out
|
|
203
|
-
* after committing, a connection dropped mid-write: each left no entry at all, which is the case
|
|
204
|
-
* an auditor opens the log for. Both siblings already do this and each states the rule
|
|
205
|
-
* (`search.ts`, `action-gate.ts`).
|
|
206
|
-
*
|
|
207
|
-
* A mutation cannot append BEFORE the call the way a read does — that would record a write which
|
|
208
|
-
* may never have happened. So: try, record, re-throw UNCHANGED. Nothing about the thrown value is
|
|
209
|
-
* read or rendered; the caller owns it, and an audit reason is a key, not a database message.
|
|
210
|
-
*/
|
|
211
|
-
async function auditedWrite<T>(
|
|
212
|
-
// Only the NAME is read, so this stays invariance-free: `AdminResource<Row>` at four call
|
|
213
|
-
// sites would need the generic threaded through for nothing.
|
|
214
|
-
resource: { readonly name: string },
|
|
215
|
-
op: AdminOperation,
|
|
216
|
-
ctx: CrudCtx,
|
|
217
|
-
entityId: string | null,
|
|
218
|
-
decision: AdminDecision,
|
|
219
|
-
run: () => Promise<T>,
|
|
220
|
-
): Promise<T> {
|
|
221
|
-
try {
|
|
222
|
-
return await run();
|
|
223
|
-
} catch (error) {
|
|
224
|
-
await ctx.audit.append({
|
|
225
|
-
requestId: ctx.requestId,
|
|
226
|
-
actor: ctx.actor,
|
|
227
|
-
operation: op,
|
|
228
|
-
kind: 'operation',
|
|
229
|
-
entity: resource.name,
|
|
230
|
-
entityId,
|
|
231
|
-
permission: decision.permission,
|
|
232
|
-
outcome: 'failed',
|
|
233
|
-
reason: WRITE_FAILED_REASON,
|
|
234
|
-
diff: [],
|
|
235
|
-
});
|
|
236
|
-
throw error;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
203
|
export async function adminCreate<Row extends AdminRow>(
|
|
241
204
|
resource: AdminResource<Row>,
|
|
242
205
|
ctx: CrudCtx,
|
|
@@ -245,28 +208,43 @@ export async function adminCreate<Row extends AdminRow>(
|
|
|
245
208
|
const decision = decideOperation(resource, 'create', ctx);
|
|
246
209
|
if (!decision.allowed) return refuse(resource, 'create', ctx, decision, null);
|
|
247
210
|
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const row = await auditedWrite(resource, 'create', ctx, null, decision, () =>
|
|
252
|
-
repoOf(resource).create(parsed.value),
|
|
211
|
+
const { open, secrets } = splitSecrets(
|
|
212
|
+
resource,
|
|
213
|
+
withTenant(resource, ctx, onlyFor(resource, 'create', input)),
|
|
253
214
|
);
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
215
|
+
const parsed = await validateInput(resource.entity.$schema, open);
|
|
216
|
+
const unset: readonly ValidationIssue[] = resource.secretFields
|
|
217
|
+
.filter((field) => field.required && !Object.hasOwn(secrets, field.name))
|
|
218
|
+
.map((field) => ({
|
|
219
|
+
path: field.name,
|
|
220
|
+
message: 'a value is required: this column is sealed and has no default',
|
|
221
|
+
messageKey: SECRET_REQUIRED_REASON,
|
|
222
|
+
}));
|
|
223
|
+
if (!parsed.ok || unset.length > 0) {
|
|
224
|
+
const issues = [...(parsed.ok ? [] : parsed.issues), ...unset];
|
|
225
|
+
return invalid(resource, 'create', ctx, null, issues, decision);
|
|
226
|
+
}
|
|
227
|
+
// `rows` narrows a write's RESULT too: a row this actor could not then read is not theirs to
|
|
228
|
+
// create. Judged over what the schema validated, before the repo is asked for anything.
|
|
229
|
+
const outside = writeOutsideScope(resource, ctx.actor, parsed.value);
|
|
230
|
+
if (outside !== null) {
|
|
231
|
+
const permission = entityPermissionFor(resource.permission, 'create');
|
|
232
|
+
return refuse(resource, 'create', ctx, outOfScopeDecision(permission, outside), null);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const { value: row, audit } = await auditedWrite(
|
|
236
|
+
resource,
|
|
237
|
+
'create',
|
|
238
|
+
ctx,
|
|
239
|
+
null,
|
|
240
|
+
decision,
|
|
241
|
+
() => repoOf(resource).create({ ...parsed.value, ...secrets }),
|
|
242
|
+
(made) => ({
|
|
243
|
+
entityId: String(made[resource.idField] ?? ''),
|
|
244
|
+
diff: rowDiff(resource, null, made, secrets),
|
|
268
245
|
}),
|
|
269
|
-
|
|
246
|
+
);
|
|
247
|
+
return { ok: true, row, audit };
|
|
270
248
|
}
|
|
271
249
|
|
|
272
250
|
export async function adminUpdate<Row extends AdminRow>(
|
|
@@ -278,11 +256,18 @@ export async function adminUpdate<Row extends AdminRow>(
|
|
|
278
256
|
// `before` was already loaded here, just after the guard rather than before it — so the rule
|
|
279
257
|
// that decides whether this actor may touch THIS row never saw the row.
|
|
280
258
|
const repo = repoOf(resource);
|
|
281
|
-
const before = await
|
|
259
|
+
const before = await findRow(resource, ctx.actor, id);
|
|
282
260
|
const decision = decideOperation(resource, 'update', ctx, id, before);
|
|
283
261
|
if (!decision.allowed) return refuse(resource, 'update', ctx, decision, id);
|
|
262
|
+
if (before === null) return missingRow(resource, 'update', ctx, id, decision);
|
|
284
263
|
|
|
285
|
-
|
|
264
|
+
// `{ ...before }` carries no sealed value — a row's sealed properties are non-enumerable — and
|
|
265
|
+
// `$schema` has no member for one, so the merged object is exactly what the schema describes.
|
|
266
|
+
const { open, secrets } = splitSecrets(
|
|
267
|
+
resource,
|
|
268
|
+
withoutTenant(resource, onlyFor(resource, 'update', patch)),
|
|
269
|
+
);
|
|
270
|
+
const parsed = await validateInput(resource.entity.$schema, { ...before, ...open });
|
|
286
271
|
if (!parsed.ok) return invalid(resource, 'update', ctx, id, parsed.issues, decision);
|
|
287
272
|
|
|
288
273
|
// Write what the schema validated, not the caller's raw patch — a field the schema would
|
|
@@ -294,31 +279,31 @@ export async function adminUpdate<Row extends AdminRow>(
|
|
|
294
279
|
// — the exact thing the paragraph above says cannot happen. Over MCP the transport refuses
|
|
295
280
|
// those keys (`additionalProperties: false`), but `callAdminTool` and `adminUpdate` are both
|
|
296
281
|
// public API and `mcp.ts` keeps its own gate for a direct call and a future transport.
|
|
297
|
-
const submittedKeys = Object.keys(
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
actor: ctx.actor,
|
|
312
|
-
operation: 'update',
|
|
313
|
-
kind: 'operation',
|
|
314
|
-
entity: resource.name,
|
|
315
|
-
entityId: id,
|
|
316
|
-
permission: decision.permission,
|
|
317
|
-
outcome: 'allowed',
|
|
318
|
-
reason: decision.reason,
|
|
319
|
-
diff: diffRows(before, after, { redact: redactedFields(resource) }),
|
|
320
|
-
}),
|
|
282
|
+
const submittedKeys = Object.keys(open);
|
|
283
|
+
// The same rule as a create: an update may not MOVE a row out of the actor's own scope.
|
|
284
|
+
const outside = writeOutsideScope(resource, ctx.actor, parsed.value, submittedKeys);
|
|
285
|
+
if (outside !== null) {
|
|
286
|
+
const permission = entityPermissionFor(resource.permission, 'update');
|
|
287
|
+
return refuse(resource, 'update', ctx, outOfScopeDecision(permission, outside), id);
|
|
288
|
+
}
|
|
289
|
+
const validatedPatch: Readonly<Record<string, unknown>> = {
|
|
290
|
+
...Object.fromEntries(
|
|
291
|
+
submittedKeys
|
|
292
|
+
.filter((key) => Object.hasOwn(parsed.value, key))
|
|
293
|
+
.map((key) => [key, parsed.value[key]]),
|
|
294
|
+
),
|
|
295
|
+
...secrets,
|
|
321
296
|
};
|
|
297
|
+
const { value: after, audit } = await auditedWrite(
|
|
298
|
+
resource,
|
|
299
|
+
'update',
|
|
300
|
+
ctx,
|
|
301
|
+
id,
|
|
302
|
+
decision,
|
|
303
|
+
() => repo.update(id, validatedPatch),
|
|
304
|
+
(changed) => ({ entityId: id, diff: rowDiff(resource, before, changed, secrets) }),
|
|
305
|
+
);
|
|
306
|
+
return { ok: true, row: after, audit };
|
|
322
307
|
}
|
|
323
308
|
|
|
324
309
|
/** Destructive: the caller must echo `confirmationToken(entity, id)` or nothing happens. */
|
|
@@ -329,9 +314,10 @@ export async function adminDestroy<Row extends AdminRow>(
|
|
|
329
314
|
confirmation: string | undefined,
|
|
330
315
|
): Promise<CrudResult<Row>> {
|
|
331
316
|
const repo = repoOf(resource);
|
|
332
|
-
const before = await
|
|
317
|
+
const before = await findRow(resource, ctx.actor, id);
|
|
333
318
|
const decision = decideOperation(resource, 'delete', ctx, id, before);
|
|
334
319
|
if (!decision.allowed) return refuse(resource, 'delete', ctx, decision, id);
|
|
320
|
+
if (before === null) return missingRow(resource, 'delete', ctx, id, decision);
|
|
335
321
|
|
|
336
322
|
const expected = confirmationToken(resource.name, id);
|
|
337
323
|
if (isDestructive('delete') && confirmation !== expected) {
|
|
@@ -350,48 +336,14 @@ export async function adminDestroy<Row extends AdminRow>(
|
|
|
350
336
|
);
|
|
351
337
|
}
|
|
352
338
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
entityId: id,
|
|
364
|
-
permission: decision.permission,
|
|
365
|
-
outcome: 'allowed',
|
|
366
|
-
reason: decision.reason,
|
|
367
|
-
diff: diffRows(before, null, { redact: redactedFields(resource) }),
|
|
368
|
-
}),
|
|
369
|
-
};
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
async function invalid<Row extends AdminRow>(
|
|
373
|
-
resource: AdminResource<Row>,
|
|
374
|
-
op: AdminOperation,
|
|
375
|
-
ctx: CrudCtx,
|
|
376
|
-
id: string | null,
|
|
377
|
-
issues: readonly ValidationIssue[],
|
|
378
|
-
decision: AdminDecision,
|
|
379
|
-
): Promise<CrudResult<Row>> {
|
|
380
|
-
return {
|
|
381
|
-
ok: false,
|
|
382
|
-
kind: 'invalid',
|
|
383
|
-
issues,
|
|
384
|
-
audit: await ctx.audit.append({
|
|
385
|
-
requestId: ctx.requestId,
|
|
386
|
-
actor: ctx.actor,
|
|
387
|
-
operation: op,
|
|
388
|
-
kind: 'operation',
|
|
389
|
-
entity: resource.name,
|
|
390
|
-
entityId: id,
|
|
391
|
-
permission: decision.permission,
|
|
392
|
-
outcome: 'failed',
|
|
393
|
-
reason: 'admin.error.invalid-input',
|
|
394
|
-
diff: [],
|
|
395
|
-
}),
|
|
396
|
-
};
|
|
339
|
+
const { audit } = await auditedWrite(
|
|
340
|
+
resource,
|
|
341
|
+
'delete',
|
|
342
|
+
ctx,
|
|
343
|
+
id,
|
|
344
|
+
decision,
|
|
345
|
+
() => repo.destroy(id),
|
|
346
|
+
() => ({ entityId: id, diff: rowDiff(resource, before, null) }),
|
|
347
|
+
);
|
|
348
|
+
return { ok: true, row: null, audit };
|
|
397
349
|
}
|