@astram/identity 0.3.1 → 0.5.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/dist/company-membership.object.d.ts.map +1 -1
- package/dist/company-membership.object.js +3 -2
- package/dist/company.object.d.ts.map +1 -1
- package/dist/company.object.js +8 -3
- package/dist/features/access/index.d.ts.map +1 -1
- package/dist/features/access/index.js +1 -5
- package/dist/features/audit/audit-read.service.d.ts.map +1 -1
- package/dist/features/audit/audit-read.service.js +13 -6
- package/dist/features/audit/audit.controller.d.ts.map +1 -1
- package/dist/features/audit/audit.controller.js +2 -0
- package/dist/features/audit/mariadb-audit-reader.d.ts.map +1 -1
- package/dist/features/authz/access-bundle-reconcile.job.d.ts.map +1 -1
- package/dist/features/authz/catalog-reconcile.job.d.ts.map +1 -1
- package/dist/features/authz/catalog-reconcile.job.js +1 -3
- package/dist/features/authz/db-permission-facts.adapter.d.ts +24 -4
- package/dist/features/authz/db-permission-facts.adapter.d.ts.map +1 -1
- package/dist/features/authz/db-permission-facts.adapter.js +92 -5
- package/dist/features/authz/grant-admin.controller.d.ts +8 -5
- package/dist/features/authz/grant-admin.controller.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.controller.js +22 -15
- package/dist/features/authz/grant-admin.service.d.ts +57 -11
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.service.js +163 -85
- package/dist/features/authz/grant-ceiling.d.ts +111 -0
- package/dist/features/authz/grant-ceiling.d.ts.map +1 -0
- package/dist/features/authz/grant-ceiling.js +248 -0
- package/dist/features/authz/mariadb-catalog-store.d.ts.map +1 -1
- package/dist/features/authz/mariadb-catalog-store.js +15 -3
- package/dist/features/authz/permission-version.bumper.d.ts +45 -1
- package/dist/features/authz/permission-version.bumper.d.ts.map +1 -1
- package/dist/features/authz/permission-version.bumper.js +106 -16
- package/dist/features/authz/record-authorization.d.ts.map +1 -1
- package/dist/features/authz/role-binding.d.ts +36 -0
- package/dist/features/authz/role-binding.d.ts.map +1 -0
- package/dist/features/authz/role-binding.js +53 -0
- package/dist/features/company/company-hierarchy.service.d.ts +46 -0
- package/dist/features/company/company-hierarchy.service.d.ts.map +1 -0
- package/dist/features/company/company-hierarchy.service.js +100 -0
- package/dist/features/company/company.controller.d.ts +23 -5
- package/dist/features/company/company.controller.d.ts.map +1 -1
- package/dist/features/company/company.controller.js +47 -12
- package/dist/features/company/company.service.d.ts +28 -12
- package/dist/features/company/company.service.d.ts.map +1 -1
- package/dist/features/company/company.service.js +61 -14
- package/dist/features/cross-tenant/cross-tenant-grant.controller.d.ts.map +1 -1
- package/dist/features/cross-tenant/cross-tenant-grant.controller.js +2 -0
- package/dist/features/identity-feature.module.d.ts.map +1 -1
- package/dist/features/identity-feature.module.js +123 -6
- package/dist/features/membership/membership.controller.d.ts +6 -5
- package/dist/features/membership/membership.controller.d.ts.map +1 -1
- package/dist/features/membership/membership.controller.js +34 -22
- package/dist/features/membership/membership.service.d.ts +20 -14
- package/dist/features/membership/membership.service.d.ts.map +1 -1
- package/dist/features/membership/membership.service.js +62 -29
- package/dist/features/membership/posting-version-bump.d.ts +5 -0
- package/dist/features/membership/posting-version-bump.d.ts.map +1 -1
- package/dist/features/membership/posting.service.d.ts +20 -24
- package/dist/features/membership/posting.service.d.ts.map +1 -1
- package/dist/features/membership/posting.service.js +101 -64
- package/dist/features/org-structure/org-catalog.controller.d.ts.map +1 -1
- package/dist/features/org-structure/org-catalog.controller.js +2 -0
- package/dist/features/org-structure/org-catalog.service.d.ts.map +1 -1
- package/dist/features/org-structure/org-catalog.service.js +4 -1
- package/dist/features/policy/policy.controller.d.ts.map +1 -1
- package/dist/features/policy/policy.controller.js +2 -0
- package/dist/features/role/role.controller.d.ts +3 -2
- package/dist/features/role/role.controller.d.ts.map +1 -1
- package/dist/features/role/role.controller.js +15 -10
- package/dist/features/role/role.service.d.ts +16 -8
- package/dist/features/role/role.service.d.ts.map +1 -1
- package/dist/features/role/role.service.js +24 -10
- package/dist/features/scope/scope-closure.service.d.ts +44 -4
- package/dist/features/scope/scope-closure.service.d.ts.map +1 -1
- package/dist/features/scope/scope-closure.service.js +215 -82
- package/dist/features/service-account/service-account.controller.d.ts +63 -0
- package/dist/features/service-account/service-account.controller.d.ts.map +1 -0
- package/dist/features/service-account/service-account.controller.js +295 -0
- package/dist/features/service-account/service-account.service.d.ts +209 -0
- package/dist/features/service-account/service-account.service.d.ts.map +1 -0
- package/dist/features/service-account/service-account.service.js +528 -0
- package/dist/features/service-account/service-account.store.d.ts +91 -0
- package/dist/features/service-account/service-account.store.d.ts.map +1 -0
- package/dist/features/service-account/service-account.store.js +158 -0
- package/dist/features/share/share.controller.d.ts.map +1 -1
- package/dist/features/share/share.controller.js +2 -0
- package/dist/features/share/share.service.d.ts.map +1 -1
- package/dist/features/shared/identity-error.interceptor.d.ts +24 -0
- package/dist/features/shared/identity-error.interceptor.d.ts.map +1 -0
- package/dist/features/shared/identity-error.interceptor.js +82 -0
- package/dist/features/shared/identity-errors.d.ts +23 -0
- package/dist/features/shared/identity-errors.d.ts.map +1 -0
- package/dist/features/shared/identity-errors.js +41 -0
- package/dist/features/user/session-revocation-check.d.ts +11 -0
- package/dist/features/user/session-revocation-check.d.ts.map +1 -0
- package/dist/features/user/session-revocation-check.js +21 -0
- package/dist/features/user/user.controller.d.ts +11 -6
- package/dist/features/user/user.controller.d.ts.map +1 -1
- package/dist/features/user/user.controller.js +35 -17
- package/dist/features/user/user.service.d.ts +55 -14
- package/dist/features/user/user.service.d.ts.map +1 -1
- package/dist/features/user/user.service.js +143 -16
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/membership-posting.object.d.ts.map +1 -1
- package/dist/membership-posting.object.js +4 -2
- package/dist/objects.d.ts.map +1 -1
- package/dist/objects.js +14 -3
- package/dist/role.object.d.ts.map +1 -1
- package/dist/role.object.js +3 -0
- package/dist/service-account.object.d.ts +31 -0
- package/dist/service-account.object.d.ts.map +1 -0
- package/dist/service-account.object.js +75 -0
- package/dist/user.object.d.ts.map +1 -1
- package/dist/user.object.js +3 -0
- package/migrations/1710000000018-CreateServiceAccount.cjs +53 -0
- package/migrations/1710000000019-AddSessionMustReenroll.cjs +35 -0
- package/package.json +4 -4
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ServiceAccountService = exports.ServiceAccountInputError = exports.ServiceAccountConflictError = exports.ServiceAccountCeilingError = exports.ServiceAccountNotFoundError = void 0;
|
|
4
|
+
exports.ceilingOf = ceilingOf;
|
|
5
|
+
exports.ceilingAllowsNode = ceilingAllowsNode;
|
|
6
|
+
exports.ceilingAllowsRole = ceilingAllowsRole;
|
|
7
|
+
/**
|
|
8
|
+
* ServiceAccountService — the business logic for the Service Accounts admin surface (ADR-0033):
|
|
9
|
+
* create/edit machine accounts and issue/rotate/revoke their `ask_…` API keys.
|
|
10
|
+
*
|
|
11
|
+
* SECURITY (the invariants this service upholds):
|
|
12
|
+
* - OWN-TENANT ONLY: every read/update runs through {@link ServiceAccountStore}'s subtree wall; a
|
|
13
|
+
* cross-tenant id is NOT FOUND (the service throws {@link ServiceAccountNotFoundError} → 404, no
|
|
14
|
+
* existence leak). `sa_tenant_id` is server-stamped from the admin's session on create.
|
|
15
|
+
* - CEILING (ADR-0029, least privilege): a NON-ROOT admin may only grant an account a SCOPE that is
|
|
16
|
+
* ⊆ their own reachable subtree, and ROLES that are ⊆ the roles they themselves hold — an admin
|
|
17
|
+
* cannot mint an agent more powerful than themselves. Root bypasses (spans all).
|
|
18
|
+
* - HASH-ONLY KEYS: a key's secret is high-entropy random, hashed via the injected PasswordHasher
|
|
19
|
+
* (Argon2id) before it touches the store; the RAW key `ask_<id>_<secret>` is returned to the
|
|
20
|
+
* caller EXACTLY ONCE (at issue/rotate) and never stored, logged, or audited.
|
|
21
|
+
* - AUDITED: create/update/issue/rotate/revoke each emit an audit event (account id + key version,
|
|
22
|
+
* NEVER the secret). Best-effort — an audit failure never fails a legitimate operation.
|
|
23
|
+
*/
|
|
24
|
+
const node_crypto_1 = require("node:crypto");
|
|
25
|
+
const core_1 = require("@astram/core");
|
|
26
|
+
/** The account id was not in the caller's subtree (unknown or cross-tenant) → 404 (no leak). */
|
|
27
|
+
class ServiceAccountNotFoundError extends Error {
|
|
28
|
+
constructor() {
|
|
29
|
+
super('service account not found');
|
|
30
|
+
this.name = 'ServiceAccountNotFoundError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
exports.ServiceAccountNotFoundError = ServiceAccountNotFoundError;
|
|
34
|
+
/** The requested scope/roles exceed the caller's own authority (ADR-0029) → 403. */
|
|
35
|
+
class ServiceAccountCeilingError extends Error {
|
|
36
|
+
constructor(message) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = 'ServiceAccountCeilingError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
exports.ServiceAccountCeilingError = ServiceAccountCeilingError;
|
|
42
|
+
/** The account changed between this edit's read and its write (a concurrent edit) → 409. */
|
|
43
|
+
class ServiceAccountConflictError extends Error {
|
|
44
|
+
constructor() {
|
|
45
|
+
super('This agent was changed by someone else just now — reload and try again.');
|
|
46
|
+
this.name = 'ServiceAccountConflictError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
exports.ServiceAccountConflictError = ServiceAccountConflictError;
|
|
50
|
+
/** A malformed request (empty label, no fields to update, etc.) → 400. */
|
|
51
|
+
class ServiceAccountInputError extends Error {
|
|
52
|
+
constructor(message) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = 'ServiceAccountInputError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
exports.ServiceAccountInputError = ServiceAccountInputError;
|
|
58
|
+
const SECRET_BYTES = 32; // 256-bit secret; base64url ≈ 43 chars
|
|
59
|
+
/** A high-entropy, URL-safe secret for a new key. */
|
|
60
|
+
function mintSecret() {
|
|
61
|
+
return (0, node_crypto_1.randomBytes)(SECRET_BYTES).toString('base64url');
|
|
62
|
+
}
|
|
63
|
+
/** The admin's own reachable subtree as a SubtreeScope for the store's wall. */
|
|
64
|
+
function scopeOf(admin) {
|
|
65
|
+
const nodeIds = admin.scopeSubtreeNodeIds.length ? admin.scopeSubtreeNodeIds : [admin.tenantId];
|
|
66
|
+
return { nodeIds, isRoot: admin.isRoot === true };
|
|
67
|
+
}
|
|
68
|
+
function ceilingOf(admin, heldRoleIds) {
|
|
69
|
+
return {
|
|
70
|
+
isRoot: admin.isRoot === true,
|
|
71
|
+
nodeIds: new Set(scopeOf(admin).nodeIds),
|
|
72
|
+
roleIds: new Set(heldRoleIds),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function ceilingAllowsNode(c, nodeId) {
|
|
76
|
+
return c.isRoot || c.nodeIds.has(nodeId);
|
|
77
|
+
}
|
|
78
|
+
function ceilingAllowsRole(c, roleId) {
|
|
79
|
+
return c.isRoot || c.roleIds.has(roleId);
|
|
80
|
+
}
|
|
81
|
+
/** Dedupe an id list keeping first-seen order (drops empty strings). */
|
|
82
|
+
function uniqueIds(ids) {
|
|
83
|
+
return [...new Set(ids.filter((x) => typeof x === 'string' && x.length > 0))];
|
|
84
|
+
}
|
|
85
|
+
/** `requested` then the `preserved` ids not already in it (first-seen order, no duplicates). */
|
|
86
|
+
function mergeIds(requested, preserved) {
|
|
87
|
+
return uniqueIds([...requested, ...preserved]);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Order nodes depth-first (parent before children, siblings by name) and drop any `parentId` that
|
|
91
|
+
* is not itself in the set (a non-root admin's top node shows as a root — and no out-of-ceiling
|
|
92
|
+
* handle leaves the server). Cycle-safe: each node is emitted once.
|
|
93
|
+
*/
|
|
94
|
+
function treeOrder(nodes) {
|
|
95
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
96
|
+
const norm = nodes.map((n) => ({
|
|
97
|
+
id: n.id,
|
|
98
|
+
name: n.name,
|
|
99
|
+
parentId: n.parentId !== null && ids.has(n.parentId) && n.parentId !== n.id ? n.parentId : null,
|
|
100
|
+
}));
|
|
101
|
+
const byName = (a, b) => a.name.localeCompare(b.name);
|
|
102
|
+
const children = new Map();
|
|
103
|
+
for (const n of norm) {
|
|
104
|
+
const list = children.get(n.parentId) ?? [];
|
|
105
|
+
list.push(n);
|
|
106
|
+
children.set(n.parentId, list);
|
|
107
|
+
}
|
|
108
|
+
for (const list of children.values())
|
|
109
|
+
list.sort(byName);
|
|
110
|
+
const out = [];
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
const visit = (n) => {
|
|
113
|
+
if (seen.has(n.id))
|
|
114
|
+
return;
|
|
115
|
+
seen.add(n.id);
|
|
116
|
+
out.push(n);
|
|
117
|
+
for (const c of children.get(n.id) ?? [])
|
|
118
|
+
visit(c);
|
|
119
|
+
};
|
|
120
|
+
for (const r of children.get(null) ?? [])
|
|
121
|
+
visit(r);
|
|
122
|
+
// A parent cycle has no null-parent entry point: emit the leftovers as roots so none is lost.
|
|
123
|
+
for (const n of [...norm].sort(byName)) {
|
|
124
|
+
if (!seen.has(n.id))
|
|
125
|
+
visit({ ...n, parentId: null });
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
const DELETED_ROLE = 'Deleted role';
|
|
130
|
+
const DELETED_NODE = 'Deleted company';
|
|
131
|
+
/** Quote a list of names for a message: ‘A’, ‘B’ and ‘C’. */
|
|
132
|
+
function quoteNames(names) {
|
|
133
|
+
const q = names.map((n) => `‘${n}’`);
|
|
134
|
+
return q.length <= 1 ? (q[0] ?? '') : `${q.slice(0, -1).join(', ')} and ${q[q.length - 1]}`;
|
|
135
|
+
}
|
|
136
|
+
class ServiceAccountService {
|
|
137
|
+
accounts;
|
|
138
|
+
keys;
|
|
139
|
+
hasher;
|
|
140
|
+
audit;
|
|
141
|
+
resolveName;
|
|
142
|
+
heldRoles;
|
|
143
|
+
now;
|
|
144
|
+
constructor(accounts, keys, hasher, audit, resolveName, heldRoles, now = Date.now) {
|
|
145
|
+
this.accounts = accounts;
|
|
146
|
+
this.keys = keys;
|
|
147
|
+
this.hasher = hasher;
|
|
148
|
+
this.audit = audit;
|
|
149
|
+
this.resolveName = resolveName;
|
|
150
|
+
this.heldRoles = heldRoles;
|
|
151
|
+
this.now = now;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Rows → views, resolving role + company NAMES in two batched reads (no N+1). Labels follow the
|
|
155
|
+
* id order; an id that no longer resolves gets a fallback label, never the id (ADR-0012).
|
|
156
|
+
*/
|
|
157
|
+
async toViews(rows) {
|
|
158
|
+
if (rows.length === 0)
|
|
159
|
+
return [];
|
|
160
|
+
const [roles, nodes] = await Promise.all([
|
|
161
|
+
this.accounts.rolesByIds(rows.flatMap((r) => r.roleIds)),
|
|
162
|
+
this.accounts.nodesByIds(rows.flatMap((r) => r.scopeSubtreeNodeIds)),
|
|
163
|
+
]);
|
|
164
|
+
const roleName = new Map(roles.map((r) => [r.id, r.name]));
|
|
165
|
+
const nodeName = new Map(nodes.map((n) => [n.id, n.name]));
|
|
166
|
+
return Promise.all(rows.map(async (row) => ({
|
|
167
|
+
id: row.id,
|
|
168
|
+
label: row.label,
|
|
169
|
+
scopeSubtreeNodeIds: row.scopeSubtreeNodeIds,
|
|
170
|
+
scopeLabels: row.scopeSubtreeNodeIds.map((id) => nodeName.get(id) ?? DELETED_NODE),
|
|
171
|
+
roleIds: row.roleIds,
|
|
172
|
+
roleLabels: row.roleIds.map((id) => roleName.get(id) ?? DELETED_ROLE),
|
|
173
|
+
status: row.status,
|
|
174
|
+
createdByName: row.createdBy ? await this.resolveName(row.createdBy) : null,
|
|
175
|
+
createdAtMs: row.createdAtMs,
|
|
176
|
+
updatedAtMs: row.updatedAtMs,
|
|
177
|
+
})));
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The caller's ceiling with roles resolved LIVE ({@link HeldRoleResolver}). Root needs no role
|
|
181
|
+
* read. A resolver failure propagates (→ 503): fail closed, never an empty-or-stale ceiling.
|
|
182
|
+
*/
|
|
183
|
+
async ceilingFor(admin) {
|
|
184
|
+
if (admin.isRoot === true)
|
|
185
|
+
return ceilingOf(admin, []);
|
|
186
|
+
return ceilingOf(admin, await this.heldRoles(admin.id, admin.tenantId));
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Enforce the ADR-0029 ceiling ({@link ceilingOf}): every requested node must be within the
|
|
190
|
+
* ceiling's subtree and every requested role one the admin holds. Root bypasses. On violation
|
|
191
|
+
* throws a 403 — never with an id (ADR-0012), and NAMING an offender only when it is already on
|
|
192
|
+
* the agent being edited (`onAgent`, whose labels the caller sees anyway). Any other offender is
|
|
193
|
+
* described generically: the role/company catalog reads are not caller-scoped, so resolving a
|
|
194
|
+
* name for an arbitrary id would turn the 403 into a cross-tenant name/existence oracle.
|
|
195
|
+
*/
|
|
196
|
+
async assertWithinCeiling(ceiling, scopeSubtreeNodeIds, roleIds, onAgent = {
|
|
197
|
+
nodes: [],
|
|
198
|
+
roles: [],
|
|
199
|
+
}) {
|
|
200
|
+
if (ceiling.isRoot)
|
|
201
|
+
return;
|
|
202
|
+
const badNodes = uniqueIds(scopeSubtreeNodeIds).filter((n) => !ceilingAllowsNode(ceiling, n));
|
|
203
|
+
if (badNodes.length > 0) {
|
|
204
|
+
const what = await this.describeOffenders(badNodes, onAgent.nodes, (ids) => this.accounts.nodesByIds(ids), 'company', 'companies');
|
|
205
|
+
throw new ServiceAccountCeilingError(`You can't give this agent access to ${what} — outside the companies you can reach.`);
|
|
206
|
+
}
|
|
207
|
+
const badRoles = uniqueIds(roleIds).filter((r) => !ceilingAllowsRole(ceiling, r));
|
|
208
|
+
if (badRoles.length > 0) {
|
|
209
|
+
const what = await this.describeOffenders(badRoles, onAgent.roles, (ids) => this.accounts.rolesByIds(ids), 'role', 'roles');
|
|
210
|
+
throw new ServiceAccountCeilingError(`You can't give this agent ${what} — you don't hold ${badRoles.length === 1 ? 'it' : 'them'}.`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* "the role ‘A’", "‘A’ and 2 other roles", "a role" … — names only offenders already on the agent.
|
|
215
|
+
*/
|
|
216
|
+
async describeOffenders(bad, onAgent, lookup, noun, plural) {
|
|
217
|
+
const known = new Set(onAgent);
|
|
218
|
+
const nameable = bad.filter((id) => known.has(id));
|
|
219
|
+
const found = nameable.length > 0
|
|
220
|
+
? new Map((await lookup(nameable)).map((x) => [x.id, x.name]))
|
|
221
|
+
: new Map();
|
|
222
|
+
const names = uniqueIds(nameable.map((id) => found.get(id) ?? '').filter((n) => n.length > 0));
|
|
223
|
+
const rest = bad.length - names.length;
|
|
224
|
+
if (names.length === 0)
|
|
225
|
+
return rest === 1 ? `a ${noun}` : `${rest} ${plural}`;
|
|
226
|
+
const named = `the ${names.length === 1 ? noun : plural} ${quoteNames(names)}`;
|
|
227
|
+
return rest === 0
|
|
228
|
+
? named
|
|
229
|
+
: `${named} and ${rest === 1 ? `1 other ${noun}` : `${rest} other ${plural}`}`;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Resolve names for audit (ids already authorised for this caller: within ceiling, or on the
|
|
233
|
+
* agent). Best-effort like the audit itself: it runs AFTER the write, so a lookup failure falls
|
|
234
|
+
* back to placeholder names instead of failing a write that already happened.
|
|
235
|
+
*/
|
|
236
|
+
async auditNames(roleIds, nodeIds) {
|
|
237
|
+
let roles = [];
|
|
238
|
+
let nodes = [];
|
|
239
|
+
try {
|
|
240
|
+
[roles, nodes] = await Promise.all([
|
|
241
|
+
roleIds.length ? this.accounts.rolesByIds(roleIds) : Promise.resolve([]),
|
|
242
|
+
nodeIds.length ? this.accounts.nodesByIds(nodeIds) : Promise.resolve([]),
|
|
243
|
+
]);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// fall through with no names → DELETED_* placeholders
|
|
247
|
+
}
|
|
248
|
+
const roleName = new Map(roles.map((r) => [r.id, r.name]));
|
|
249
|
+
const nodeName = new Map(nodes.map((n) => [n.id, n.name]));
|
|
250
|
+
return {
|
|
251
|
+
roles: (ids) => ids.map((id) => roleName.get(id) ?? DELETED_ROLE),
|
|
252
|
+
nodes: (ids) => ids.map((id) => nodeName.get(id) ?? DELETED_NODE),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* What the caller may grant (the picker source for create/edit), computed from the SAME
|
|
257
|
+
* {@link ceilingOf} the write check enforces. Root → every role + every company; non-root → the
|
|
258
|
+
* roles they hold + the nodes of their reachable subtree. Nodes come back tree-ordered with a
|
|
259
|
+
* `parentId` only when the parent is also assignable.
|
|
260
|
+
*/
|
|
261
|
+
async assignable(admin) {
|
|
262
|
+
const ceiling = await this.ceilingFor(admin);
|
|
263
|
+
const [roles, nodes] = ceiling.isRoot
|
|
264
|
+
? await Promise.all([this.accounts.allRoles(), this.accounts.allNodes()])
|
|
265
|
+
: await Promise.all([
|
|
266
|
+
this.accounts.rolesByIds([...ceiling.roleIds]),
|
|
267
|
+
this.accounts.nodesByIds([...ceiling.nodeIds]),
|
|
268
|
+
]);
|
|
269
|
+
return {
|
|
270
|
+
// Defensive re-filter through the same predicates (a store that over-returns can't widen it).
|
|
271
|
+
roles: roles
|
|
272
|
+
.filter((r) => ceilingAllowsRole(ceiling, r.id))
|
|
273
|
+
.map((r) => ({ id: r.id, name: r.name })),
|
|
274
|
+
scopeNodes: treeOrder(nodes.filter((n) => ceilingAllowsNode(ceiling, n.id))),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/** List the admin's own-tenant accounts (by label; ids are handles). */
|
|
278
|
+
async list(admin) {
|
|
279
|
+
const rows = await this.accounts.list(scopeOf(admin));
|
|
280
|
+
return this.toViews(rows);
|
|
281
|
+
}
|
|
282
|
+
/** Fetch one own-tenant account; cross-tenant/unknown → 404. */
|
|
283
|
+
async get(admin, id) {
|
|
284
|
+
const row = await this.accounts.getById(id, scopeOf(admin));
|
|
285
|
+
if (row === null)
|
|
286
|
+
throw new ServiceAccountNotFoundError();
|
|
287
|
+
const [view] = await this.toViews([row]);
|
|
288
|
+
return view;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Create an account. `tenantId` is the admin's (server-stamped) — an account is created in the
|
|
292
|
+
* admin's OWN tenant only (a non-root admin cannot pick another tenant). Scope defaults to the
|
|
293
|
+
* admin's whole subtree when omitted; whatever is requested must pass the ceiling.
|
|
294
|
+
*/
|
|
295
|
+
async create(admin, input) {
|
|
296
|
+
const label = input.label?.trim();
|
|
297
|
+
if (!label)
|
|
298
|
+
throw new ServiceAccountInputError('a label is required');
|
|
299
|
+
// Default the scope to the admin's own subtree (least surprise; still least-privilege-bounded).
|
|
300
|
+
const requestedScope = uniqueIds(input.scopeSubtreeNodeIds ?? []);
|
|
301
|
+
const scopeSubtree = requestedScope.length > 0
|
|
302
|
+
? requestedScope
|
|
303
|
+
: admin.scopeSubtreeNodeIds.length
|
|
304
|
+
? uniqueIds(admin.scopeSubtreeNodeIds)
|
|
305
|
+
: [admin.tenantId];
|
|
306
|
+
const roleIds = uniqueIds(input.roleIds ?? []);
|
|
307
|
+
await this.assertWithinCeiling(await this.ceilingFor(admin), scopeSubtree, roleIds);
|
|
308
|
+
const id = (0, node_crypto_1.randomUUID)();
|
|
309
|
+
const createdAtMs = this.now();
|
|
310
|
+
await this.accounts.insert({
|
|
311
|
+
id,
|
|
312
|
+
tenantId: admin.tenantId, // SERVER-STAMPED — never client input
|
|
313
|
+
label,
|
|
314
|
+
scopeSubtreeNodeIds: scopeSubtree,
|
|
315
|
+
roleIds,
|
|
316
|
+
status: 'active',
|
|
317
|
+
createdBy: admin.id,
|
|
318
|
+
createdAtMs,
|
|
319
|
+
});
|
|
320
|
+
const names = await this.auditNames(roleIds, scopeSubtree);
|
|
321
|
+
await this.safeAudit('service_account.created', admin, createdAtMs, {
|
|
322
|
+
accountId: id,
|
|
323
|
+
label,
|
|
324
|
+
scopeNodeCount: scopeSubtree.length,
|
|
325
|
+
roleCount: roleIds.length,
|
|
326
|
+
// WHAT was granted, by name (human-readable audit; names are not secrets).
|
|
327
|
+
roles: names.roles(roleIds),
|
|
328
|
+
scope: names.nodes(scopeSubtree),
|
|
329
|
+
});
|
|
330
|
+
return this.get(admin, id);
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Edit an account's label/scope/roles/status (own-tenant; ceiling re-checked).
|
|
334
|
+
*
|
|
335
|
+
* Scope/roles semantics (Phase 4, confirmed 2026-10-08):
|
|
336
|
+
* - `scopeSubtreeNodeIds: []` → 400 (an agent needs at least one scope node).
|
|
337
|
+
* - What is REQUESTED must be ⊆ the editor's ceiling, else 403 — whatever the request shape
|
|
338
|
+
* (duplicates, mixed lists, or re-sending an item the agent already has but the editor can't
|
|
339
|
+
* grant). Nothing is written on a 403.
|
|
340
|
+
* - NON-ROOT: the agent's EXISTING items outside the editor's ceiling are PRESERVED —
|
|
341
|
+
* stored = requested ∪ (existing \ ceiling). A lower-ceiling admin can't strip access they could
|
|
342
|
+
* never grant back; the preserved items come from the stored row, never the request, so the
|
|
343
|
+
* merge can't add anything new. ROOT replaces with exactly what it sends.
|
|
344
|
+
*/
|
|
345
|
+
async update(admin, id, patch) {
|
|
346
|
+
const hasField = patch.label !== undefined ||
|
|
347
|
+
patch.scopeSubtreeNodeIds !== undefined ||
|
|
348
|
+
patch.roleIds !== undefined ||
|
|
349
|
+
patch.status !== undefined;
|
|
350
|
+
if (!hasField)
|
|
351
|
+
throw new ServiceAccountInputError('no fields to update');
|
|
352
|
+
if (patch.label !== undefined && !patch.label.trim()) {
|
|
353
|
+
throw new ServiceAccountInputError('label cannot be empty');
|
|
354
|
+
}
|
|
355
|
+
if (patch.status !== undefined && patch.status !== 'active' && patch.status !== 'disabled') {
|
|
356
|
+
throw new ServiceAccountInputError("status must be 'active' or 'disabled'");
|
|
357
|
+
}
|
|
358
|
+
const requestedScope = patch.scopeSubtreeNodeIds !== undefined ? uniqueIds(patch.scopeSubtreeNodeIds) : undefined;
|
|
359
|
+
const requestedRoles = patch.roleIds !== undefined ? uniqueIds(patch.roleIds) : undefined;
|
|
360
|
+
if (requestedScope !== undefined && requestedScope.length === 0) {
|
|
361
|
+
throw new ServiceAccountInputError('choose at least one scope node');
|
|
362
|
+
}
|
|
363
|
+
// Own-tenant existence first (cross-tenant/unknown → 404, before any ceiling message).
|
|
364
|
+
const existing = await this.accounts.getById(id, scopeOf(admin));
|
|
365
|
+
if (existing === null)
|
|
366
|
+
throw new ServiceAccountNotFoundError();
|
|
367
|
+
// Re-check the ceiling for whatever is REQUESTED (an edit can't escalate either).
|
|
368
|
+
const ceiling = await this.ceilingFor(admin);
|
|
369
|
+
await this.assertWithinCeiling(ceiling, requestedScope ?? [], requestedRoles ?? [], {
|
|
370
|
+
nodes: existing.scopeSubtreeNodeIds,
|
|
371
|
+
roles: existing.roleIds,
|
|
372
|
+
});
|
|
373
|
+
// Preserve-merge (non-root only): keep the agent's existing out-of-ceiling items. Source is the
|
|
374
|
+
// STORED row, so nothing the editor sent can reach this branch.
|
|
375
|
+
const preservedScope = requestedScope !== undefined && !ceiling.isRoot
|
|
376
|
+
? existing.scopeSubtreeNodeIds.filter((n) => !ceilingAllowsNode(ceiling, n))
|
|
377
|
+
: [];
|
|
378
|
+
const preservedRoles = requestedRoles !== undefined && !ceiling.isRoot
|
|
379
|
+
? existing.roleIds.filter((r) => !ceilingAllowsRole(ceiling, r))
|
|
380
|
+
: [];
|
|
381
|
+
// Strictly after the row's last edit, so two edits in the same millisecond still differ and the
|
|
382
|
+
// optimistic guard below can tell them apart.
|
|
383
|
+
const updatedAtMs = Math.max(this.now(), (existing.updatedAtMs ?? 0) + 1);
|
|
384
|
+
const normalized = {
|
|
385
|
+
...(patch.label !== undefined ? { label: patch.label.trim() } : {}),
|
|
386
|
+
...(requestedScope !== undefined
|
|
387
|
+
? { scopeSubtreeNodeIds: mergeIds(requestedScope, preservedScope) }
|
|
388
|
+
: {}),
|
|
389
|
+
...(requestedRoles !== undefined
|
|
390
|
+
? { roleIds: mergeIds(requestedRoles, preservedRoles) }
|
|
391
|
+
: {}),
|
|
392
|
+
...(patch.status !== undefined ? { status: patch.status } : {}),
|
|
393
|
+
};
|
|
394
|
+
// The preserve-merge is a read-modify-write: guard it so a concurrent edit (e.g. root revoking an
|
|
395
|
+
// out-of-ceiling role) can't be undone by re-writing the stale `existing` set.
|
|
396
|
+
const merging = !ceiling.isRoot && (requestedScope !== undefined || requestedRoles !== undefined);
|
|
397
|
+
const changed = merging
|
|
398
|
+
? await this.accounts.update(id, scopeOf(admin), normalized, updatedAtMs, existing.updatedAtMs)
|
|
399
|
+
: await this.accounts.update(id, scopeOf(admin), normalized, updatedAtMs);
|
|
400
|
+
if (changed === 0) {
|
|
401
|
+
if (merging && (await this.accounts.getById(id, scopeOf(admin))) !== null) {
|
|
402
|
+
throw new ServiceAccountConflictError();
|
|
403
|
+
}
|
|
404
|
+
throw new ServiceAccountNotFoundError();
|
|
405
|
+
}
|
|
406
|
+
// WHAT changed, by name: diff the stored sets (added ⊆ ceiling or root; removed were on the agent).
|
|
407
|
+
const diff = (before, after) => after === undefined
|
|
408
|
+
? { added: [], removed: [] }
|
|
409
|
+
: {
|
|
410
|
+
added: after.filter((x) => !before.includes(x)),
|
|
411
|
+
removed: before.filter((x) => !after.includes(x)),
|
|
412
|
+
};
|
|
413
|
+
const roleDiff = diff(existing.roleIds, normalized.roleIds);
|
|
414
|
+
const scopeDiff = diff(existing.scopeSubtreeNodeIds, normalized.scopeSubtreeNodeIds);
|
|
415
|
+
const names = await this.auditNames([...roleDiff.added, ...roleDiff.removed], [...scopeDiff.added, ...scopeDiff.removed]);
|
|
416
|
+
await this.safeAudit('service_account.updated', admin, updatedAtMs, {
|
|
417
|
+
accountId: id,
|
|
418
|
+
fields: Object.keys(normalized),
|
|
419
|
+
...(roleDiff.added.length > 0 ? { rolesAdded: names.roles(roleDiff.added) } : {}),
|
|
420
|
+
...(roleDiff.removed.length > 0 ? { rolesRemoved: names.roles(roleDiff.removed) } : {}),
|
|
421
|
+
...(scopeDiff.added.length > 0 ? { scopeAdded: names.nodes(scopeDiff.added) } : {}),
|
|
422
|
+
...(scopeDiff.removed.length > 0 ? { scopeRemoved: names.nodes(scopeDiff.removed) } : {}),
|
|
423
|
+
...(patch.status !== undefined ? { status: patch.status } : {}),
|
|
424
|
+
...(preservedScope.length > 0 ? { preservedScopeNodeCount: preservedScope.length } : {}),
|
|
425
|
+
...(preservedRoles.length > 0 ? { preservedRoleCount: preservedRoles.length } : {}),
|
|
426
|
+
});
|
|
427
|
+
return this.get(admin, id);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Issue a NEW key for an account (own-tenant). Mints `ask_<id>_<secret>`, hashes the secret, and
|
|
431
|
+
* persists the hash as the next version — carrying the ACCOUNT's current scope/roles onto the key
|
|
432
|
+
* (so the key's Principal reflects the account as it stands). Returns the raw key ONCE.
|
|
433
|
+
* ADR-0033 §4 (security hardening): the caller must give a FUTURE `expiresAtMs`, or opt in to a
|
|
434
|
+
* non-expiring key explicitly with `neverExpires: true` — otherwise 400, nothing minted.
|
|
435
|
+
*/
|
|
436
|
+
async issueKey(admin, id, opts = {}) {
|
|
437
|
+
const expiresAtMs = this.resolveExpiry(opts);
|
|
438
|
+
const account = await this.accounts.getById(id, scopeOf(admin));
|
|
439
|
+
if (account === null)
|
|
440
|
+
throw new ServiceAccountNotFoundError();
|
|
441
|
+
return this.mintFor(account, admin, 'service_account.key_issued', expiresAtMs);
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* The key's expiry from the request: a future epoch-ms, or `undefined` when the caller said
|
|
445
|
+
* `neverExpires: true`. Neither, both, or a past/invalid time → ServiceAccountInputError (400).
|
|
446
|
+
*/
|
|
447
|
+
resolveExpiry(opts) {
|
|
448
|
+
const hasExpiry = opts.expiresAtMs !== undefined;
|
|
449
|
+
if (hasExpiry && opts.neverExpires === true) {
|
|
450
|
+
throw new ServiceAccountInputError('Give either an expiry or neverExpires: true, not both.');
|
|
451
|
+
}
|
|
452
|
+
if (opts.neverExpires === true)
|
|
453
|
+
return undefined;
|
|
454
|
+
if (!hasExpiry) {
|
|
455
|
+
throw new ServiceAccountInputError('Choose when the key expires (expiresAtMs), or set neverExpires: true for a key that never expires.');
|
|
456
|
+
}
|
|
457
|
+
const ms = opts.expiresAtMs;
|
|
458
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= this.now()) {
|
|
459
|
+
throw new ServiceAccountInputError('expiresAtMs must be a time in the future (epoch-ms).');
|
|
460
|
+
}
|
|
461
|
+
return ms;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Rotate an account's key: mint a new version (the old version stops being the current one —
|
|
465
|
+
* `getByAccountId` returns the highest, so the previous key no longer authenticates). Returns the
|
|
466
|
+
* new raw key ONCE.
|
|
467
|
+
*/
|
|
468
|
+
async rotateKey(admin, id, opts = {}) {
|
|
469
|
+
const expiresAtMs = this.resolveExpiry(opts);
|
|
470
|
+
const account = await this.accounts.getById(id, scopeOf(admin));
|
|
471
|
+
if (account === null)
|
|
472
|
+
throw new ServiceAccountNotFoundError();
|
|
473
|
+
return this.mintFor(account, admin, 'service_account.key_rotated', expiresAtMs);
|
|
474
|
+
}
|
|
475
|
+
/** Revoke ALL of an account's keys (own-tenant) — fail-closed on the next request. Idempotent. */
|
|
476
|
+
async revokeKeys(admin, id) {
|
|
477
|
+
const account = await this.accounts.getById(id, scopeOf(admin));
|
|
478
|
+
if (account === null)
|
|
479
|
+
throw new ServiceAccountNotFoundError();
|
|
480
|
+
const atMs = this.now();
|
|
481
|
+
await this.keys.revoke(id, atMs);
|
|
482
|
+
await this.safeAudit('service_account.key_revoked', admin, atMs, { accountId: id });
|
|
483
|
+
}
|
|
484
|
+
/** Shared mint path for issue + rotate: version bump, hash, putVersion, audit, one-time reveal. */
|
|
485
|
+
async mintFor(account, admin, auditAction, expiresAtMs) {
|
|
486
|
+
const secret = mintSecret();
|
|
487
|
+
const version = (await this.keys.currentVersion(account.id)) + 1;
|
|
488
|
+
const secretHash = await this.hasher.hash(secret);
|
|
489
|
+
const createdAtMs = this.now();
|
|
490
|
+
await this.keys.putVersion({
|
|
491
|
+
accountId: account.id,
|
|
492
|
+
tenantId: account.tenantId,
|
|
493
|
+
scopeSubtreeNodeIds: account.scopeSubtreeNodeIds,
|
|
494
|
+
roleIds: account.roleIds,
|
|
495
|
+
secretHash,
|
|
496
|
+
version,
|
|
497
|
+
...(expiresAtMs !== undefined ? { expiresAtMs } : {}),
|
|
498
|
+
createdAtMs,
|
|
499
|
+
});
|
|
500
|
+
await this.safeAudit(auditAction, admin, createdAtMs, {
|
|
501
|
+
accountId: account.id,
|
|
502
|
+
version,
|
|
503
|
+
expiresAtMs: expiresAtMs ?? null,
|
|
504
|
+
});
|
|
505
|
+
return {
|
|
506
|
+
apiKey: `${core_1.API_KEY_PREFIX}${account.id}_${secret}`,
|
|
507
|
+
accountLabel: account.label,
|
|
508
|
+
version,
|
|
509
|
+
expiresAtMs: expiresAtMs ?? null,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
/** Best-effort audit — a sink throw must never fail a legitimate operation. */
|
|
513
|
+
async safeAudit(action, admin, occurredAtMs, attributes) {
|
|
514
|
+
try {
|
|
515
|
+
await this.audit.record({
|
|
516
|
+
action,
|
|
517
|
+
actorId: admin.id,
|
|
518
|
+
tenantId: admin.tenantId,
|
|
519
|
+
occurredAtMs,
|
|
520
|
+
attributes,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
// swallow — auditing is best-effort here (the durable sink already logs its own failures).
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
exports.ServiceAccountService = ServiceAccountService;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ServiceAccountStore — the account-row CRUD for `shield_app.service_account` (ADR-0033). Separate
|
|
3
|
+
* from the guard's `MariaDbApiKeyStore` (which owns the HASHED KEYS in `shield_authz`): this store
|
|
4
|
+
* owns the ACCOUNT (label, scope, roles, status), the guard store owns its credentials.
|
|
5
|
+
*
|
|
6
|
+
* OWN-TENANT WALL: every read/update is scoped to the caller's subtree — a `SubtreeScope` splices a
|
|
7
|
+
* `sa_tenant_id IN (?, …)` predicate (mirroring `grant-admin.service.ts`). A root scope drops the
|
|
8
|
+
* predicate (spans all). A non-root with an empty subtree matches nothing (fail-closed, never a
|
|
9
|
+
* leak). A cross-tenant id therefore returns null → the service maps it to 404 (no existence leak).
|
|
10
|
+
*
|
|
11
|
+
* Every query is parameterized (node ids `?`-bound, never interpolated → injection-free). Server-
|
|
12
|
+
* stamped `sa_tenant_id` (from the session) on create — never client input.
|
|
13
|
+
*/
|
|
14
|
+
import type { Pool } from 'mariadb';
|
|
15
|
+
/** The caller's reachable subtree (threaded from `req.principal`) — same shape as grant-admin. */
|
|
16
|
+
export interface SubtreeScope {
|
|
17
|
+
readonly nodeIds: readonly string[];
|
|
18
|
+
readonly isRoot: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** A persisted service account (raw row → view; JSON columns parsed). Ids are handles, not shown. */
|
|
21
|
+
export interface ServiceAccountRow {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly tenantId: string;
|
|
24
|
+
readonly label: string;
|
|
25
|
+
readonly scopeSubtreeNodeIds: readonly string[];
|
|
26
|
+
readonly roleIds: readonly string[];
|
|
27
|
+
readonly status: string;
|
|
28
|
+
readonly createdBy: string | null;
|
|
29
|
+
readonly createdAtMs: number;
|
|
30
|
+
readonly updatedAtMs: number | null;
|
|
31
|
+
}
|
|
32
|
+
/** A role as the picker / view needs it: handle + display name (ADR-0012 — the name is shown). */
|
|
33
|
+
export interface RoleOption {
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly name: string;
|
|
36
|
+
}
|
|
37
|
+
/** A scope node (company) as the picker needs it: handle + name + parent handle (for indent). */
|
|
38
|
+
export interface ScopeNodeOption {
|
|
39
|
+
readonly id: string;
|
|
40
|
+
readonly name: string;
|
|
41
|
+
readonly parentId: string | null;
|
|
42
|
+
}
|
|
43
|
+
export declare class ServiceAccountStore {
|
|
44
|
+
private readonly pool;
|
|
45
|
+
constructor(pool: Pool);
|
|
46
|
+
/** Roles by id (unknown ids are simply absent — the caller supplies a fallback label). */
|
|
47
|
+
rolesByIds(ids: readonly string[]): Promise<RoleOption[]>;
|
|
48
|
+
/** Every role in the catalog (ROOT's assignable set). */
|
|
49
|
+
allRoles(): Promise<RoleOption[]>;
|
|
50
|
+
/** Companies (scope nodes) by id, with their parent handle. Unknown ids are absent. */
|
|
51
|
+
nodesByIds(ids: readonly string[]): Promise<ScopeNodeOption[]>;
|
|
52
|
+
/** Every company (ROOT's assignable scope — root reaches all). */
|
|
53
|
+
allNodes(): Promise<ScopeNodeOption[]>;
|
|
54
|
+
/** List the caller's own-tenant accounts (newest first). Root sees all. */
|
|
55
|
+
list(scope: SubtreeScope): Promise<ServiceAccountRow[]>;
|
|
56
|
+
/** Fetch one account BY id, scoped to the caller's subtree. Cross-tenant/unknown → null (404). */
|
|
57
|
+
getById(id: string, scope: SubtreeScope): Promise<ServiceAccountRow | null>;
|
|
58
|
+
/**
|
|
59
|
+
* Insert a new account. `tenantId` is server-stamped (from the session) — never client input.
|
|
60
|
+
* `scopeSubtreeNodeIds`/`roleIds` are stored as JSON. Idempotency is the caller's (a fresh uuid
|
|
61
|
+
* per create); the id is unique by construction.
|
|
62
|
+
*/
|
|
63
|
+
insert(input: {
|
|
64
|
+
id: string;
|
|
65
|
+
tenantId: string;
|
|
66
|
+
label: string;
|
|
67
|
+
scopeSubtreeNodeIds: readonly string[];
|
|
68
|
+
roleIds: readonly string[];
|
|
69
|
+
status: string;
|
|
70
|
+
createdBy: string;
|
|
71
|
+
createdAtMs: number;
|
|
72
|
+
}): Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Update an account's mutable fields (label/scope/roles/status), scoped to the caller's subtree.
|
|
75
|
+
* Returns the number of rows changed — 0 means the id was not in the caller's subtree (→ 404).
|
|
76
|
+
* Only the provided fields are set; `sa_updated_at_ms` is always stamped.
|
|
77
|
+
*/
|
|
78
|
+
update(id: string, scope: SubtreeScope, patch: {
|
|
79
|
+
label?: string;
|
|
80
|
+
scopeSubtreeNodeIds?: readonly string[];
|
|
81
|
+
roleIds?: readonly string[];
|
|
82
|
+
status?: string;
|
|
83
|
+
}, updatedAtMs: number,
|
|
84
|
+
/**
|
|
85
|
+
* Optimistic guard: when given, the row is only written if `sa_updated_at_ms` still equals this
|
|
86
|
+
* value (NULL-safe). A concurrent edit makes it return 0, so a read-modify-write can't resurrect
|
|
87
|
+
* access another admin just removed.
|
|
88
|
+
*/
|
|
89
|
+
expectedUpdatedAtMs?: number | null): Promise<number>;
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=service-account.store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service-account.store.d.ts","sourceRoot":"","sources":["../../../src/features/service-account/service-account.store.ts"],"names":[],"mappings":"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"}
|