@astram/identity 0.12.0 → 0.14.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/features/authz/grant-admin.service.d.ts +1 -1
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.service.js +8 -0
- package/dist/features/authz/grant-ceiling.d.ts +11 -3
- package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
- package/dist/features/authz/grant-ceiling.js +20 -3
- package/dist/features/authz/record-authorization.d.ts +9 -1
- package/dist/features/authz/record-authorization.d.ts.map +1 -1
- package/dist/features/authz/record-authorization.js +24 -3
- package/dist/features/company/company-hierarchy.service.d.ts +6 -0
- package/dist/features/company/company-hierarchy.service.d.ts.map +1 -1
- package/dist/features/company/company-hierarchy.service.js +19 -0
- package/dist/features/identity-feature.module.d.ts +7 -1
- package/dist/features/identity-feature.module.d.ts.map +1 -1
- package/dist/features/identity-feature.module.js +25 -5
- package/dist/features/membership/membership.service.d.ts +9 -0
- package/dist/features/membership/membership.service.d.ts.map +1 -1
- package/dist/features/membership/membership.service.js +40 -0
- package/dist/features/membership/posting.service.d.ts +18 -0
- package/dist/features/membership/posting.service.d.ts.map +1 -1
- package/dist/features/membership/posting.service.js +85 -27
- package/dist/features/org-bootstrap/org-bootstrap-error.d.ts +20 -0
- package/dist/features/org-bootstrap/org-bootstrap-error.d.ts.map +1 -0
- package/dist/features/org-bootstrap/org-bootstrap-error.js +24 -0
- package/dist/features/org-bootstrap/org-bootstrap-file.d.ts +58 -0
- package/dist/features/org-bootstrap/org-bootstrap-file.d.ts.map +1 -0
- package/dist/features/org-bootstrap/org-bootstrap-file.js +323 -0
- package/dist/features/org-bootstrap/org-bootstrap-plan.d.ts +79 -0
- package/dist/features/org-bootstrap/org-bootstrap-plan.d.ts.map +1 -0
- package/dist/features/org-bootstrap/org-bootstrap-plan.js +288 -0
- package/dist/features/org-bootstrap/org-bootstrap-runner.harness.d.ts +259 -0
- package/dist/features/org-bootstrap/org-bootstrap-runner.harness.d.ts.map +1 -0
- package/dist/features/org-bootstrap/org-bootstrap-runner.harness.js +604 -0
- package/dist/features/org-bootstrap/org-bootstrap-runner.mocks.d.ts +24 -0
- package/dist/features/org-bootstrap/org-bootstrap-runner.mocks.d.ts.map +1 -0
- package/dist/features/org-bootstrap/org-bootstrap-runner.mocks.js +39 -0
- package/dist/features/org-bootstrap/org-bootstrap.manifest.d.ts +19 -0
- package/dist/features/org-bootstrap/org-bootstrap.manifest.d.ts.map +1 -0
- package/dist/features/org-bootstrap/org-bootstrap.manifest.js +30 -0
- package/dist/features/org-bootstrap/org-bootstrap.tokens.d.ts +8 -0
- package/dist/features/org-bootstrap/org-bootstrap.tokens.d.ts.map +1 -0
- package/dist/features/org-bootstrap/org-bootstrap.tokens.js +10 -0
- package/dist/features/org-bootstrap/org-names.d.ts +12 -0
- package/dist/features/org-bootstrap/org-names.d.ts.map +1 -0
- package/dist/features/org-bootstrap/org-names.js +26 -0
- package/dist/features/org-bootstrap/org-state.reader.d.ts +15 -0
- package/dist/features/org-bootstrap/org-state.reader.d.ts.map +1 -0
- package/dist/features/org-bootstrap/org-state.reader.js +225 -0
- package/dist/features/org-bootstrap/root-password-source.d.ts +9 -0
- package/dist/features/org-bootstrap/root-password-source.d.ts.map +1 -0
- package/dist/features/org-bootstrap/root-password-source.js +57 -0
- package/dist/features/org-bootstrap/run-org-bootstrap.d.ts +42 -0
- package/dist/features/org-bootstrap/run-org-bootstrap.d.ts.map +1 -0
- package/dist/features/org-bootstrap/run-org-bootstrap.js +688 -0
- package/dist/features/org-bootstrap/set-password-links.d.ts +30 -0
- package/dist/features/org-bootstrap/set-password-links.d.ts.map +1 -0
- package/dist/features/org-bootstrap/set-password-links.js +55 -0
- package/dist/features/org-structure/org-catalog.service.d.ts +13 -0
- package/dist/features/org-structure/org-catalog.service.d.ts.map +1 -1
- package/dist/features/org-structure/org-catalog.service.js +40 -0
- package/dist/features/role/role-owner-transition.d.ts +17 -0
- package/dist/features/role/role-owner-transition.d.ts.map +1 -0
- package/dist/features/role/role-owner-transition.js +23 -0
- package/dist/features/role/role.controller.d.ts +1 -1
- package/dist/features/role/role.controller.d.ts.map +1 -1
- package/dist/features/role/role.controller.js +6 -4
- package/dist/features/role/role.service.d.ts +8 -2
- package/dist/features/role/role.service.d.ts.map +1 -1
- package/dist/features/role/role.service.js +13 -3
- package/dist/features/user/credential-provisioner.token.d.ts +8 -0
- package/dist/features/user/credential-provisioner.token.d.ts.map +1 -0
- package/dist/features/user/credential-provisioner.token.js +10 -0
- package/dist/features/user/user.controller.d.ts +1 -3
- package/dist/features/user/user.controller.d.ts.map +1 -1
- package/dist/features/user/user.controller.js +2 -2
- package/dist/features/user/user.service.d.ts +28 -5
- package/dist/features/user/user.service.d.ts.map +1 -1
- package/dist/features/user/user.service.js +45 -12
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/internal.d.ts +2 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +3 -1
- package/dist/role.object.d.ts +1 -0
- package/dist/role.object.d.ts.map +1 -1
- package/dist/role.object.js +8 -0
- package/migrations/1710000000101-AddRoleOwnerCompany.cjs +98 -0
- package/package.json +4 -4
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OrgBootstrapError = void 0;
|
|
4
|
+
exports.runOrgBootstrap = runOrgBootstrap;
|
|
5
|
+
/**
|
|
6
|
+
* G-009 T10: the org bootstrap runner (SPEC R2, R3, R5-R11, R13; ADR-0042).
|
|
7
|
+
*
|
|
8
|
+
* `runOrgBootstrap(app, opts)` applies a declarative org file against the consumer's own application
|
|
9
|
+
* context, through the framework's existing services, never raw SQL (the exceptions are the role-owner
|
|
10
|
+
* claim and the root flag, both under the run lock and audited). Each step runs as its own principal,
|
|
11
|
+
* bound to the declared operation `shield:org-bootstrap` (Shield #9): control-plane steps root-flagged,
|
|
12
|
+
* people steps non-root and anchored at one company. The principal reaches the request-scoped services
|
|
13
|
+
* through `registerRequestByContextId`, so no service signature changes.
|
|
14
|
+
*
|
|
15
|
+
* Sequence (apply): parse -> operator/ttl checks -> GET_LOCK on a dedicated connection -> read state and
|
|
16
|
+
* plan under the lock -> plan-dependent refusals -> writes in R10 order -> links -> RELEASE_LOCK.
|
|
17
|
+
* A dry run takes no lock and writes nothing.
|
|
18
|
+
*/
|
|
19
|
+
const node_fs_1 = require("node:fs");
|
|
20
|
+
const core_1 = require("@nestjs/core");
|
|
21
|
+
const engine_1 = require("@astram/engine");
|
|
22
|
+
const internal_1 = require("@astram/engine/internal");
|
|
23
|
+
const guard_1 = require("@astram/guard");
|
|
24
|
+
const grant_admin_service_js_1 = require("../authz/grant-admin.service.js");
|
|
25
|
+
const company_service_js_1 = require("../company/company.service.js");
|
|
26
|
+
const framework_schemas_token_js_1 = require("../framework-schemas.token.js");
|
|
27
|
+
const membership_service_js_1 = require("../membership/membership.service.js");
|
|
28
|
+
const posting_service_js_1 = require("../membership/posting.service.js");
|
|
29
|
+
const org_catalog_service_js_1 = require("../org-structure/org-catalog.service.js");
|
|
30
|
+
const role_owner_transition_js_1 = require("../role/role-owner-transition.js");
|
|
31
|
+
const role_service_js_1 = require("../role/role.service.js");
|
|
32
|
+
const credential_provisioner_token_js_1 = require("../user/credential-provisioner.token.js");
|
|
33
|
+
const user_service_js_1 = require("../user/user.service.js");
|
|
34
|
+
const org_bootstrap_file_js_1 = require("./org-bootstrap-file.js");
|
|
35
|
+
const org_bootstrap_error_js_1 = require("./org-bootstrap-error.js");
|
|
36
|
+
const org_bootstrap_plan_js_1 = require("./org-bootstrap-plan.js");
|
|
37
|
+
const org_bootstrap_manifest_js_1 = require("./org-bootstrap.manifest.js");
|
|
38
|
+
const org_bootstrap_tokens_js_1 = require("./org-bootstrap.tokens.js");
|
|
39
|
+
const org_names_js_1 = require("./org-names.js");
|
|
40
|
+
const org_state_reader_js_1 = require("./org-state.reader.js");
|
|
41
|
+
const root_password_source_js_1 = require("./root-password-source.js");
|
|
42
|
+
const set_password_links_js_1 = require("./set-password-links.js");
|
|
43
|
+
var org_bootstrap_error_js_2 = require("./org-bootstrap-error.js");
|
|
44
|
+
Object.defineProperty(exports, "OrgBootstrapError", { enumerable: true, get: function () { return org_bootstrap_error_js_2.OrgBootstrapError; } });
|
|
45
|
+
const STEP = {
|
|
46
|
+
tenants: 'tenants',
|
|
47
|
+
companies: 'companies',
|
|
48
|
+
orgUnits: 'org-units',
|
|
49
|
+
roles: 'roles',
|
|
50
|
+
users: 'users',
|
|
51
|
+
postings: 'postings',
|
|
52
|
+
};
|
|
53
|
+
// ---------------------------------------------------------------------------------------------
|
|
54
|
+
function loadFile(file) {
|
|
55
|
+
if (typeof file !== 'string')
|
|
56
|
+
return file;
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse((0, node_fs_1.readFileSync)(file, 'utf8'));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Never echo the path content or the parser message: it can quote the file.
|
|
62
|
+
throw new org_bootstrap_file_js_1.OrgBootstrapFileError([
|
|
63
|
+
{ path: '(file)', message: 'cannot be read or is not valid JSON' },
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function flatten(file) {
|
|
68
|
+
const companies = [];
|
|
69
|
+
const walk = (c, parent, tenant) => {
|
|
70
|
+
const path = parent === undefined ? c.name : `${parent}/${c.name}`;
|
|
71
|
+
companies.push({ path, parentPath: parent, tenant, node: c });
|
|
72
|
+
for (const child of c.children ?? [])
|
|
73
|
+
walk(child, path, tenant);
|
|
74
|
+
};
|
|
75
|
+
for (const t of file.tenants)
|
|
76
|
+
walk(t, undefined, t.name);
|
|
77
|
+
const users = [];
|
|
78
|
+
for (const c of companies) {
|
|
79
|
+
for (const u of c.node.users ?? []) {
|
|
80
|
+
users.push({
|
|
81
|
+
username: u.username,
|
|
82
|
+
email: u.email,
|
|
83
|
+
company: u.company === undefined ? c.path : `${c.tenant}/${u.company}`,
|
|
84
|
+
tenant: c.tenant,
|
|
85
|
+
postings: u.postings ?? [],
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { companies, users };
|
|
90
|
+
}
|
|
91
|
+
const postingPath = (user, company, p) => `user:${user}@${company} role:${p.role}${p.department ? ` dept:${p.department}` : ''}${p.branch ? ` branch:${p.branch}` : ''}`;
|
|
92
|
+
function printPlan(plan, out) {
|
|
93
|
+
for (const e of plan.entries) {
|
|
94
|
+
out(`${e.status.padEnd(11)} ${e.kind} ${e.path}${e.detail === undefined ? '' : ` - ${e.detail}`}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// ---------------------------------------------------------------------------------------------
|
|
98
|
+
async function runOrgBootstrap(app, opts) {
|
|
99
|
+
const env = opts.env ?? process.env;
|
|
100
|
+
const out = opts.out ?? ((line) => void process.stdout.write(`${line}\n`));
|
|
101
|
+
const apply = opts.apply === true;
|
|
102
|
+
await app.init();
|
|
103
|
+
let pool;
|
|
104
|
+
let schemas;
|
|
105
|
+
let provisioner;
|
|
106
|
+
let grantAdmin;
|
|
107
|
+
let tokenStore;
|
|
108
|
+
try {
|
|
109
|
+
pool = app.get(org_bootstrap_tokens_js_1.IDENTITY_AUTHZ_POOL, { strict: false });
|
|
110
|
+
schemas = app.get(framework_schemas_token_js_1.IDENTITY_FRAMEWORK_SCHEMAS, { strict: false });
|
|
111
|
+
provisioner = app.get(credential_provisioner_token_js_1.IDENTITY_CREDENTIAL_PROVISIONER, {
|
|
112
|
+
strict: false,
|
|
113
|
+
});
|
|
114
|
+
grantAdmin = app.get(grant_admin_service_js_1.GrantAdminService, { strict: false });
|
|
115
|
+
if (apply)
|
|
116
|
+
tokenStore = app.get(guard_1.SHIELD_RESET_TOKEN_STORE, { strict: false });
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
throw new org_bootstrap_error_js_1.OrgBootstrapError('not_wired', 'IdentityFeatureModule is not available in this application: wire IdentityFeatureModule.forRoot(...) and ShieldModule (auth enabled) into the AppModule this script creates.');
|
|
120
|
+
}
|
|
121
|
+
const catalog = await grantAdmin.listCatalog();
|
|
122
|
+
const file = (0, org_bootstrap_file_js_1.parseOrgBootstrapFile)(loadFile(opts.file), {
|
|
123
|
+
grantableKeys: new Set([...catalog.catalog.map((r) => r.key), ...catalog.wildcards]),
|
|
124
|
+
});
|
|
125
|
+
const readState = () => new org_state_reader_js_1.OrgStateReader({
|
|
126
|
+
query: async (sql, params) => (await pool.query(sql, params)),
|
|
127
|
+
schemas,
|
|
128
|
+
}).read(file);
|
|
129
|
+
const planFor = (state) => (0, org_bootstrap_plan_js_1.planOrgBootstrap)(file, state, { claimUnownedRoles: opts.claimUnownedRoles === true });
|
|
130
|
+
// ---- dry run: plan only, no lock, no write ----
|
|
131
|
+
if (!apply) {
|
|
132
|
+
const state = await readState();
|
|
133
|
+
const plan = planFor(state);
|
|
134
|
+
printPlan(plan, out);
|
|
135
|
+
const ids = new Ids(pool, schemas);
|
|
136
|
+
const rootIds = [];
|
|
137
|
+
for (const r of file.roots) {
|
|
138
|
+
if (!state.users.some((u) => (0, org_names_js_1.nameKey)(u.username) === (0, org_names_js_1.nameKey)(r.username)))
|
|
139
|
+
continue;
|
|
140
|
+
const id = await ids.user(r.username).catch(() => undefined);
|
|
141
|
+
if (id !== undefined)
|
|
142
|
+
rootIds.push(id);
|
|
143
|
+
}
|
|
144
|
+
return { applied: false, plan, written: {}, links: 0, rootIds };
|
|
145
|
+
}
|
|
146
|
+
// ---- checks that need no state ----
|
|
147
|
+
const operator = (opts.operator ?? '').trim() || (env.ASTRAM_BOOTSTRAP_OPERATOR ?? '').trim();
|
|
148
|
+
if (operator === '') {
|
|
149
|
+
throw new org_bootstrap_error_js_1.OrgBootstrapError('operator_required', '--apply needs the human operator: pass the operator or set ASTRAM_BOOTSTRAP_OPERATOR.');
|
|
150
|
+
}
|
|
151
|
+
const ttlHours = (0, set_password_links_js_1.validateLinkTtlHours)(opts.linkTtlHours ?? set_password_links_js_1.DEFAULT_LINK_TTL_HOURS);
|
|
152
|
+
// R1-13: two roots on one password variable would share a password; refused before any write.
|
|
153
|
+
(0, root_password_source_js_1.assertDistinctRootPasswordVariables)(file.roots.map((r) => r.username));
|
|
154
|
+
// ---- the run lock, on a dedicated connection ----
|
|
155
|
+
const lockName = `astram_org_bootstrap:${schemas.app}`;
|
|
156
|
+
const lockConn = await pool.getConnection();
|
|
157
|
+
try {
|
|
158
|
+
const got = (await lockConn.query('SELECT GET_LOCK(?, ?) AS acquired', [
|
|
159
|
+
lockName,
|
|
160
|
+
opts.lockTimeoutSeconds ?? 30,
|
|
161
|
+
]));
|
|
162
|
+
if (Number(got[0]?.acquired) !== 1) {
|
|
163
|
+
throw new org_bootstrap_error_js_1.OrgBootstrapError('lock_not_acquired', 'Another org bootstrap run holds the lock; nothing was written. Retry when it finishes.');
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
return await applyUnderLock();
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
try {
|
|
170
|
+
await lockConn.query('SELECT RELEASE_LOCK(?) AS released', [lockName]);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// The lock is session-scoped: it ends with the connection. Never mask the run's outcome.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
await lockConn.release();
|
|
179
|
+
}
|
|
180
|
+
// ===========================================================================================
|
|
181
|
+
async function applyUnderLock() {
|
|
182
|
+
const state = await readState();
|
|
183
|
+
const plan = planFor(state);
|
|
184
|
+
printPlan(plan, out);
|
|
185
|
+
const status = new Map();
|
|
186
|
+
for (const e of plan.entries)
|
|
187
|
+
status.set(`${e.kind}\u0000${e.path}`, e.status);
|
|
188
|
+
const st = (kind, path) => status.get(`${kind}\u0000${path}`);
|
|
189
|
+
// ---- refusals that need the plan ----
|
|
190
|
+
if (plan.conflicts.length > 0) {
|
|
191
|
+
throw new org_bootstrap_error_js_1.OrgBootstrapError('plan_conflict', `The plan has ${plan.conflicts.length} conflict(s); nothing was written: ${plan.conflicts
|
|
192
|
+
.map((c) => c.detail ?? c.path)
|
|
193
|
+
.join('; ')}`);
|
|
194
|
+
}
|
|
195
|
+
const rootNames = new Set(file.roots.map((r) => (0, org_names_js_1.nameKey)(r.username)));
|
|
196
|
+
const isRootName = (username) => rootNames.has((0, org_names_js_1.nameKey)(username));
|
|
197
|
+
const rootsNeedingPassword = file.roots
|
|
198
|
+
.filter((r) => st('credential', `user:${r.username}`) === 'create')
|
|
199
|
+
.map((r) => r.username);
|
|
200
|
+
const passwords = await (0, root_password_source_js_1.resolveRootPasswords)(rootsNeedingPassword, env, opts.promptRootPassword);
|
|
201
|
+
// R1-1: a re-issued link is a credential-recovery path, so only for a user with no membership
|
|
202
|
+
// outside the file's tenants (never another tenant's account). N6: and only when the stored email
|
|
203
|
+
// still equals the file's (a drifted email is reported, OD-5, and gets no link either).
|
|
204
|
+
const lockedInState = (username) => state.users.some((u) => (0, org_names_js_1.nameKey)(u.username) === (0, org_names_js_1.nameKey)(username) &&
|
|
205
|
+
u.credential === 'locked' &&
|
|
206
|
+
!u.membershipsOutside &&
|
|
207
|
+
(0, org_names_js_1.nameKey)(u.email) === (0, org_names_js_1.nameKey)(fileEmailOf(username) ?? ''));
|
|
208
|
+
// R1-6: an existing user's login hint is the STORED email, never the file's spelling of it.
|
|
209
|
+
const hintFor = (username, fileEmail) => {
|
|
210
|
+
const stored = state.users.find((u) => (0, org_names_js_1.nameKey)(u.username) === (0, org_names_js_1.nameKey)(username));
|
|
211
|
+
if (stored === undefined)
|
|
212
|
+
return (0, guard_1.canonicalEmail)(fileEmail);
|
|
213
|
+
if ((0, org_names_js_1.nameKey)(stored.email) !== (0, org_names_js_1.nameKey)(fileEmail)) {
|
|
214
|
+
// The planner makes this a conflict; assert it so a credential is never minted for another email.
|
|
215
|
+
throw new Error('stored email differs from the file');
|
|
216
|
+
}
|
|
217
|
+
return (0, guard_1.canonicalEmail)(stored.email);
|
|
218
|
+
};
|
|
219
|
+
const declaredUsernames = [...file.roots.map((r) => r.username)];
|
|
220
|
+
const flat = flatten(file);
|
|
221
|
+
for (const u of flat.users)
|
|
222
|
+
declaredUsernames.push(u.username);
|
|
223
|
+
function fileEmailOf(username) {
|
|
224
|
+
const key = (0, org_names_js_1.nameKey)(username);
|
|
225
|
+
return (file.roots.find((r) => (0, org_names_js_1.nameKey)(r.username) === key)?.email ??
|
|
226
|
+
flat.users.find((u) => (0, org_names_js_1.nameKey)(u.username) === key)?.email);
|
|
227
|
+
}
|
|
228
|
+
const mayIssueLinks = plan.entries.some((e) => e.status === 'create' &&
|
|
229
|
+
(e.kind === 'user' || e.kind === 'credential') &&
|
|
230
|
+
!isRootName(e.path.slice('user:'.length))) ||
|
|
231
|
+
(opts.reissueLinks === true && declaredUsernames.some((u) => lockedInState(u)));
|
|
232
|
+
let linkBase = '';
|
|
233
|
+
if (mayIssueLinks) {
|
|
234
|
+
linkBase = (0, set_password_links_js_1.resolveSetPasswordBase)(env);
|
|
235
|
+
if (opts.isTTY === undefined ? process.stdout.isTTY !== true : !opts.isTTY) {
|
|
236
|
+
if (opts.linksToStdout !== true) {
|
|
237
|
+
throw new org_bootstrap_error_js_1.OrgBootstrapError('links_need_terminal', 'Set-password links are printed only to a terminal; pass --links-to-stdout to print them anyway.');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// ---- writes ----
|
|
242
|
+
const written = {};
|
|
243
|
+
const count = (kind, n = 1) => {
|
|
244
|
+
written[kind] = (written[kind] ?? 0) + n;
|
|
245
|
+
};
|
|
246
|
+
const ids = new Ids(pool, schemas);
|
|
247
|
+
const sink = new engine_1.MariaDbAuditSink({ schema: schemas.audit });
|
|
248
|
+
const operationAttributes = (stepKey) => ({
|
|
249
|
+
operation: org_bootstrap_manifest_js_1.ORG_BOOTSTRAP_OPERATION,
|
|
250
|
+
step: stepKey,
|
|
251
|
+
onBehalfOf: operator,
|
|
252
|
+
});
|
|
253
|
+
const auditOn = (query, tenantId, actionKey, stepKey, attributes) => sink.record({
|
|
254
|
+
type: 'admin_action',
|
|
255
|
+
occurredAtMs: Date.now(),
|
|
256
|
+
actorId: org_bootstrap_manifest_js_1.ORG_BOOTSTRAP_PRINCIPAL_ID,
|
|
257
|
+
tenantId,
|
|
258
|
+
action: { key: actionKey },
|
|
259
|
+
attributes: { ...attributes, ...operationAttributes(stepKey) },
|
|
260
|
+
}, query);
|
|
261
|
+
const principal = (stepKey, anchor, subtree, isRoot) => (0, internal_1.bindOperation)({
|
|
262
|
+
kind: 'user',
|
|
263
|
+
id: org_bootstrap_manifest_js_1.ORG_BOOTSTRAP_PRINCIPAL_ID,
|
|
264
|
+
tenantId: anchor,
|
|
265
|
+
activeScopeNodeId: anchor,
|
|
266
|
+
scopeSubtreeNodeIds: [...subtree],
|
|
267
|
+
roleIds: [],
|
|
268
|
+
isRoot,
|
|
269
|
+
}, { operationKey: org_bootstrap_manifest_js_1.ORG_BOOTSTRAP_OPERATION, stepKey, onBehalfOf: operator });
|
|
270
|
+
const service = async (token, as) => {
|
|
271
|
+
const contextId = core_1.ContextIdFactory.create();
|
|
272
|
+
app.registerRequestByContextId({ principal: as }, contextId);
|
|
273
|
+
return app.resolve(token, contextId, { strict: false });
|
|
274
|
+
};
|
|
275
|
+
/** Non-root people principal anchored at a company (its file subtree). */
|
|
276
|
+
const anchoredAt = async (stepKey, companyPath, withDescendants = true) => {
|
|
277
|
+
const anchor = await ids.company(companyPath);
|
|
278
|
+
// A company being created is not in its parent's subtree yet: its creator sees the parent only.
|
|
279
|
+
const under = withDescendants
|
|
280
|
+
? flat.companies
|
|
281
|
+
.filter((c) => (0, org_names_js_1.pathKey)(c.path).startsWith(`${(0, org_names_js_1.pathKey)(companyPath)}/`))
|
|
282
|
+
.map((c) => ids.company(c.path))
|
|
283
|
+
: [];
|
|
284
|
+
return principal(stepKey, anchor, [anchor, ...(await Promise.all(under))], false);
|
|
285
|
+
};
|
|
286
|
+
const rootAt = async (stepKey, tenantName) => {
|
|
287
|
+
const anchor = await ids.company(tenantName);
|
|
288
|
+
return principal(stepKey, anchor, [anchor], true);
|
|
289
|
+
};
|
|
290
|
+
const step = async (stepKey, work) => {
|
|
291
|
+
try {
|
|
292
|
+
await work();
|
|
293
|
+
}
|
|
294
|
+
catch (cause) {
|
|
295
|
+
if (cause instanceof org_bootstrap_error_js_1.OrgBootstrapError)
|
|
296
|
+
throw cause;
|
|
297
|
+
// Step key and error class only: a driver message can quote values or secrets.
|
|
298
|
+
const kind = cause instanceof Error ? cause.name : 'error';
|
|
299
|
+
throw new org_bootstrap_error_js_1.OrgBootstrapError('step_failed', `Step "${stepKey}" failed (${kind}); the run stopped. Re-running converges onto what was written.`, { step: stepKey, written: { ...written } });
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
const lockedNow = new Map();
|
|
303
|
+
// 1. companies: top-level by a root-flagged `tenants` principal, children by a non-root
|
|
304
|
+
// principal anchored at the parent (createCompany stamps the parent: D5).
|
|
305
|
+
await step(STEP.tenants, async () => {
|
|
306
|
+
for (const c of flat.companies) {
|
|
307
|
+
if (st('company', c.path) !== 'create')
|
|
308
|
+
continue;
|
|
309
|
+
if (c.parentPath === undefined) {
|
|
310
|
+
const actor = principal(STEP.tenants, org_bootstrap_manifest_js_1.ORG_BOOTSTRAP_PRINCIPAL_ID, [], true);
|
|
311
|
+
const companies = await service(company_service_js_1.CompanyService, actor);
|
|
312
|
+
const tenantId = String(await companies.createCompany(actor, c.node.name));
|
|
313
|
+
ids.set('company', c.path, tenantId);
|
|
314
|
+
// R1-10: the tenant's own audit view shows its creation (the service audits under the
|
|
315
|
+
// reserved operation id, which no tenant can read).
|
|
316
|
+
await auditOn((sql, params) => pool.query(sql, params), tenantId, 'company.created', STEP.tenants, { companyUuid: tenantId, name: c.node.name });
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
const actor = await anchoredAt(STEP.companies, c.parentPath, false);
|
|
320
|
+
const companies = await service(company_service_js_1.CompanyService, actor);
|
|
321
|
+
ids.set('company', c.path, String(await companies.createCompany(actor, c.node.name)));
|
|
322
|
+
}
|
|
323
|
+
count('company');
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
// 2. org units: catalog rows, then the company junctions
|
|
327
|
+
await step(STEP.orgUnits, async () => {
|
|
328
|
+
const seenDept = new Set();
|
|
329
|
+
const seenBranch = new Set();
|
|
330
|
+
for (const c of flat.companies) {
|
|
331
|
+
const actor = await rootAt(STEP.orgUnits, c.tenant);
|
|
332
|
+
const catalogSvc = await service(org_catalog_service_js_1.OrgCatalogService, actor);
|
|
333
|
+
for (const d of c.node.departments ?? []) {
|
|
334
|
+
if (seenDept.has((0, org_names_js_1.nameKey)(d)))
|
|
335
|
+
continue;
|
|
336
|
+
seenDept.add((0, org_names_js_1.nameKey)(d));
|
|
337
|
+
if (st('department', `dept:${d}`) !== 'create')
|
|
338
|
+
continue;
|
|
339
|
+
ids.set('department', d, String(await catalogSvc.ensureDepartment(d)));
|
|
340
|
+
count('department');
|
|
341
|
+
}
|
|
342
|
+
for (const b of c.node.branches ?? []) {
|
|
343
|
+
if (seenBranch.has((0, org_names_js_1.nameKey)(b)))
|
|
344
|
+
continue;
|
|
345
|
+
seenBranch.add((0, org_names_js_1.nameKey)(b));
|
|
346
|
+
if (st('branch', `branch:${b}`) !== 'create')
|
|
347
|
+
continue;
|
|
348
|
+
ids.set('branch', b, String(await catalogSvc.ensureBranch(b)));
|
|
349
|
+
count('branch');
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
for (const c of flat.companies) {
|
|
353
|
+
const actor = await rootAt(STEP.orgUnits, c.tenant);
|
|
354
|
+
const catalogSvc = await service(org_catalog_service_js_1.OrgCatalogService, actor);
|
|
355
|
+
for (const d of c.node.departments ?? []) {
|
|
356
|
+
if (st('company_department', `${c.path}#dept:${d}`) !== 'create')
|
|
357
|
+
continue;
|
|
358
|
+
await catalogSvc.attachDepartment(await ids.company(c.path), await ids.department(d));
|
|
359
|
+
count('company_department');
|
|
360
|
+
}
|
|
361
|
+
for (const b of c.node.branches ?? []) {
|
|
362
|
+
if (st('company_branch', `${c.path}#branch:${b}`) !== 'create')
|
|
363
|
+
continue;
|
|
364
|
+
await catalogSvc.attachBranch(await ids.company(c.path), await ids.branch(b));
|
|
365
|
+
count('company_branch');
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
// 3. roles (owner stamped by RoleService), owner claims, then grants through GrantAdminService
|
|
370
|
+
await step(STEP.roles, async () => {
|
|
371
|
+
for (const tenant of file.tenants) {
|
|
372
|
+
const actor = await rootAt(STEP.roles, tenant.name);
|
|
373
|
+
const roles = await service(role_service_js_1.RoleService, actor);
|
|
374
|
+
const declared = [];
|
|
375
|
+
const seen = new Set();
|
|
376
|
+
for (const c of flat.companies.filter((x) => x.tenant === tenant.name)) {
|
|
377
|
+
for (const r of c.node.roles ?? []) {
|
|
378
|
+
if (seen.has((0, org_names_js_1.nameKey)(r.name)))
|
|
379
|
+
continue;
|
|
380
|
+
seen.add((0, org_names_js_1.nameKey)(r.name));
|
|
381
|
+
declared.push(r);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
for (const r of declared) {
|
|
385
|
+
if (st('role', `role:${r.name}`) !== 'create')
|
|
386
|
+
continue;
|
|
387
|
+
ids.set('role', r.name, String(await roles.createRole(r.name, actor)));
|
|
388
|
+
count('role');
|
|
389
|
+
}
|
|
390
|
+
for (const r of declared) {
|
|
391
|
+
if (st('role_claim', `role:${r.name}`) !== 'create')
|
|
392
|
+
continue;
|
|
393
|
+
await claimRole(await ids.role(r.name), await ids.company(tenant.name));
|
|
394
|
+
count('role_claim');
|
|
395
|
+
}
|
|
396
|
+
for (const r of declared) {
|
|
397
|
+
for (const key of r.permissions) {
|
|
398
|
+
if (st('grant', `role:${r.name}#${key}`) !== 'create')
|
|
399
|
+
continue;
|
|
400
|
+
await grantAdmin.grant(await ids.role(r.name), key, actor);
|
|
401
|
+
count('grant');
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
// 4. users: roots (root-flagged, no company: root holds no membership), then non-root users
|
|
407
|
+
// anchored at their company; a new user's locked credential is created inside the call.
|
|
408
|
+
await step(STEP.users, async () => {
|
|
409
|
+
for (const r of file.roots) {
|
|
410
|
+
if (st('user', `user:${r.username}`) !== 'create')
|
|
411
|
+
continue;
|
|
412
|
+
const home = await ids.company(r.homeCompany);
|
|
413
|
+
const actor = principal(STEP.users, home, [home], true);
|
|
414
|
+
const users = await service(user_service_js_1.UserService, actor);
|
|
415
|
+
const created = await users.createUserWithCredential(actor, {
|
|
416
|
+
username: r.username,
|
|
417
|
+
email: r.email,
|
|
418
|
+
});
|
|
419
|
+
ids.set('user', r.username, String(created.id));
|
|
420
|
+
count('user');
|
|
421
|
+
}
|
|
422
|
+
for (const u of flat.users) {
|
|
423
|
+
if (st('user', `user:${u.username}`) !== 'create')
|
|
424
|
+
continue;
|
|
425
|
+
const actor = await anchoredAt(STEP.users, u.company);
|
|
426
|
+
const users = await service(user_service_js_1.UserService, actor);
|
|
427
|
+
const companyUuid = await ids.company(u.company);
|
|
428
|
+
const created = await users.createUserWithCredential(actor, {
|
|
429
|
+
username: u.username,
|
|
430
|
+
email: u.email,
|
|
431
|
+
companyUuid,
|
|
432
|
+
});
|
|
433
|
+
ids.set('user', u.username, String(created.id));
|
|
434
|
+
count('user');
|
|
435
|
+
if (created.credential === 'locked') {
|
|
436
|
+
lockedNow.set((0, org_names_js_1.nameKey)(u.username), {
|
|
437
|
+
principalId: String(created.id),
|
|
438
|
+
tenantId: companyUuid,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// memberships of EXISTING users in a company of the file: created active, never activated
|
|
443
|
+
for (const u of flat.users) {
|
|
444
|
+
if (st('membership', `user:${u.username}@${u.company}`) !== 'create')
|
|
445
|
+
continue;
|
|
446
|
+
// Anchored at the file's tenant (its whole subtree), not the target company: an existing user
|
|
447
|
+
// is visible only where they already hold a membership, e.g. a parent of the target company.
|
|
448
|
+
// Still one tenant; the engine's create wall keeps the new membership inside that subtree.
|
|
449
|
+
const actor = await anchoredAt(STEP.users, u.tenant);
|
|
450
|
+
const memberships = await service(membership_service_js_1.MembershipService, actor);
|
|
451
|
+
const userUuid = await ids.user(u.username);
|
|
452
|
+
const companyUuid = await ids.company(u.company);
|
|
453
|
+
ids.set('membership', `${userUuid}@${companyUuid}`, String(await memberships.addActiveMembership(actor, userUuid, companyUuid)));
|
|
454
|
+
count('membership');
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
// 5. postings: add-only, as the anchored non-root principal, with the roles the file declares
|
|
458
|
+
// for this tenant (and no other tenant's) as `operationRoleIds`.
|
|
459
|
+
await step(STEP.postings, async () => {
|
|
460
|
+
for (const u of flat.users) {
|
|
461
|
+
const toAdd = u.postings.filter((p) => st('posting', postingPath(u.username, u.company, p)) === 'create');
|
|
462
|
+
if (toAdd.length === 0)
|
|
463
|
+
continue;
|
|
464
|
+
const actor = await anchoredAt(STEP.postings, u.company);
|
|
465
|
+
const userUuid = await ids.user(u.username);
|
|
466
|
+
const companyUuid = await ids.company(u.company);
|
|
467
|
+
// A read-only lookup, not `listMemberships`: list SQL scoping does not consult gate 7, so an
|
|
468
|
+
// operation-bound non-root principal could see no rows there (spec P1 "Matching").
|
|
469
|
+
const membershipUuid = await ids.membership(userUuid, companyUuid);
|
|
470
|
+
// R1-7: the roles declared on THIS company and its ancestors in the file (the resolution the
|
|
471
|
+
// file parser uses for postings), not every role of the tenant.
|
|
472
|
+
const operationRoleIds = new Set();
|
|
473
|
+
const here = (0, org_names_js_1.pathKey)(u.company);
|
|
474
|
+
for (const c of flat.companies) {
|
|
475
|
+
const there = (0, org_names_js_1.pathKey)(c.path);
|
|
476
|
+
if (here !== there && !here.startsWith(`${there}/`))
|
|
477
|
+
continue;
|
|
478
|
+
for (const r of c.node.roles ?? [])
|
|
479
|
+
operationRoleIds.add(await ids.role(r.name));
|
|
480
|
+
}
|
|
481
|
+
const inputs = [];
|
|
482
|
+
for (const p of toAdd) {
|
|
483
|
+
inputs.push({
|
|
484
|
+
roleUuid: await ids.role(p.role),
|
|
485
|
+
...(p.department ? { departmentUuid: await ids.department(p.department) } : {}),
|
|
486
|
+
...(p.branch ? { branchUuid: await ids.branch(p.branch) } : {}),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
const postings = await service(posting_service_js_1.PostingService, actor);
|
|
490
|
+
await postings.addPostings(actor, membershipUuid, inputs, { operationRoleIds });
|
|
491
|
+
count('posting', toAdd.length);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
// 6. credentials of roots and of existing users that have none, then the root flag
|
|
495
|
+
await step(STEP.users, async () => {
|
|
496
|
+
for (const r of file.roots) {
|
|
497
|
+
if (st('credential', `user:${r.username}`) !== 'create')
|
|
498
|
+
continue;
|
|
499
|
+
const principalId = await ids.user(r.username);
|
|
500
|
+
const tenantId = await ids.company(r.homeCompany);
|
|
501
|
+
const hint = hintFor(r.username, r.email);
|
|
502
|
+
// R1-5: the credential and its audit row commit together or not at all.
|
|
503
|
+
await inTransaction(async (conn) => {
|
|
504
|
+
const q = (sql, params) => conn.query(sql, params);
|
|
505
|
+
await provisioner.provisionPassword({ principalId, hint, tenantId, password: passwords.get(r.username) }, q);
|
|
506
|
+
// AC-6 / R6: the credential the run created is attributable; never the password or hash.
|
|
507
|
+
await auditOn(q, tenantId, 'credential.provisioned', STEP.users, {
|
|
508
|
+
userUuid: principalId,
|
|
509
|
+
kind: 'password',
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
count('credential');
|
|
513
|
+
}
|
|
514
|
+
for (const u of flat.users) {
|
|
515
|
+
if (st('credential', `user:${u.username}`) !== 'create')
|
|
516
|
+
continue;
|
|
517
|
+
const principalId = await ids.user(u.username);
|
|
518
|
+
const tenantId = await ids.company(u.company);
|
|
519
|
+
const hint = hintFor(u.username, u.email);
|
|
520
|
+
await inTransaction(async (conn) => {
|
|
521
|
+
const q = (sql, params) => conn.query(sql, params);
|
|
522
|
+
await provisioner.provisionLocked({ principalId, hint, tenantId }, q);
|
|
523
|
+
await auditOn(q, tenantId, 'credential.provisioned', STEP.users, {
|
|
524
|
+
userUuid: principalId,
|
|
525
|
+
kind: 'locked',
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
lockedNow.set((0, org_names_js_1.nameKey)(u.username), { principalId, tenantId });
|
|
529
|
+
count('credential');
|
|
530
|
+
}
|
|
531
|
+
for (const r of file.roots) {
|
|
532
|
+
if (st('root_flag', `user:${r.username}`) !== 'create')
|
|
533
|
+
continue;
|
|
534
|
+
await setPlatformSuperuser(await ids.user(r.username), await ids.company(r.homeCompany));
|
|
535
|
+
count('root_flag');
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
// 7. set-password links, after every other write
|
|
539
|
+
let links = 0;
|
|
540
|
+
if (mayIssueLinks) {
|
|
541
|
+
await step(STEP.users, async () => {
|
|
542
|
+
const targets = [];
|
|
543
|
+
const consider = async (username, companyPath) => {
|
|
544
|
+
const fresh = lockedNow.get((0, org_names_js_1.nameKey)(username));
|
|
545
|
+
if (fresh !== undefined) {
|
|
546
|
+
targets.push({ username, ...fresh, invalidateFirst: false });
|
|
547
|
+
}
|
|
548
|
+
else if (opts.reissueLinks === true && lockedInState(username)) {
|
|
549
|
+
targets.push({
|
|
550
|
+
username,
|
|
551
|
+
principalId: await ids.user(username),
|
|
552
|
+
tenantId: await ids.company(companyPath),
|
|
553
|
+
invalidateFirst: true,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
for (const r of file.roots)
|
|
558
|
+
await consider(r.username, r.homeCompany);
|
|
559
|
+
for (const u of flat.users)
|
|
560
|
+
await consider(u.username, u.company);
|
|
561
|
+
links = await (0, set_password_links_js_1.issueSetPasswordLinks)({
|
|
562
|
+
store: tokenStore,
|
|
563
|
+
targets,
|
|
564
|
+
base: linkBase,
|
|
565
|
+
ttlHours,
|
|
566
|
+
out,
|
|
567
|
+
audit: (target, expiresAtMs) => auditOn((sql, params) => pool.query(sql, params), target.tenantId, 'credential.setup_link_issued', STEP.users, { userUuid: target.principalId, expiresAtMs }),
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
// 8. root wiring (R9): print each root's uuid and the line to set
|
|
572
|
+
const rootIds = [];
|
|
573
|
+
for (const r of file.roots) {
|
|
574
|
+
const id = await ids.user(r.username).catch(() => undefined);
|
|
575
|
+
if (id === undefined)
|
|
576
|
+
continue;
|
|
577
|
+
rootIds.push(id);
|
|
578
|
+
out(`root ${r.username}: ${id}`);
|
|
579
|
+
}
|
|
580
|
+
if (rootIds.length > 0)
|
|
581
|
+
out(`SHIELD_ROOT_IDS=${rootIds.join(',')}`);
|
|
582
|
+
return { applied: true, plan, written, links, rootIds };
|
|
583
|
+
// ----- raw writes allowed by the spec (R5, R13), both under the run lock and audited -----
|
|
584
|
+
async function inTransaction(work) {
|
|
585
|
+
const conn = await pool.getConnection();
|
|
586
|
+
try {
|
|
587
|
+
await conn.beginTransaction();
|
|
588
|
+
try {
|
|
589
|
+
await work(conn);
|
|
590
|
+
await conn.commit();
|
|
591
|
+
}
|
|
592
|
+
catch (cause) {
|
|
593
|
+
await conn.rollback();
|
|
594
|
+
throw cause;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
finally {
|
|
598
|
+
await conn.release();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
async function claimRole(roleUuid, tenantUuid) {
|
|
602
|
+
const tenantUuids = await Promise.all(file.tenants.map((t) => ids.company(t.name)));
|
|
603
|
+
await inTransaction(async (conn) => {
|
|
604
|
+
// R1-8: serialise on the role row, then re-check inside the transaction that nobody outside the
|
|
605
|
+
// file's tenants holds it (the plan-time read is not enough: it ran before other writers).
|
|
606
|
+
await conn.query(`SELECT r_uuid FROM \`${schemas.app}\`.\`roles\` WHERE r_uuid = ? FOR UPDATE`, [roleUuid]);
|
|
607
|
+
const held = (await conn.query(`SELECT 1 AS held FROM \`${schemas.app}\`.\`membership_posting\` mp ` +
|
|
608
|
+
`JOIN \`${schemas.app}\`.\`company_membership\` cm ON cm.cm_uuid = mp.mp_membership_uuid ` +
|
|
609
|
+
`WHERE mp.mp_role_uuid = ? AND cm.cm_company_uuid NOT IN (` +
|
|
610
|
+
`SELECT sc.descendant_uuid FROM \`${schemas.authz}\`.\`scope_closure\` sc WHERE sc.ancestor_uuid IN (${tenantUuids.map(() => '?').join(', ')})) LIMIT 1`, [roleUuid, ...tenantUuids]));
|
|
611
|
+
if (held.length > 0) {
|
|
612
|
+
const refusal = new Error('role is held outside the file tenants');
|
|
613
|
+
refusal.name = 'RoleHeldOutsideError';
|
|
614
|
+
throw refusal;
|
|
615
|
+
}
|
|
616
|
+
await (0, role_owner_transition_js_1.transitionRoleOwner)((sql, params) => conn.query(sql, params), roleUuid, null, tenantUuid, (action, attributes, query) => auditOn(query, tenantUuid, action, 'roles-claim', { ...attributes }), schemas);
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
async function setPlatformSuperuser(userUuid, tenantUuid) {
|
|
620
|
+
await inTransaction(async (conn) => {
|
|
621
|
+
await conn.query(`UPDATE \`${schemas.app}\`.\`users\` SET u_is_platform_superuser = 1 WHERE u_uuid = ?`, [userUuid]);
|
|
622
|
+
await auditOn((sql, params) => conn.query(sql, params), tenantUuid, 'user.platform_superuser_set', STEP.users, { userUuid });
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
// ---------------------------------------------------------------------------------------------
|
|
628
|
+
/**
|
|
629
|
+
* Ids of the rows the run touches: filled from service return values for what the run creates, read
|
|
630
|
+
* with parameterised SELECTs over the authz pool for what already exists (names matched by the
|
|
631
|
+
* column collation). A name that resolves to nothing throws (a plan/state mismatch), never guesses.
|
|
632
|
+
*/
|
|
633
|
+
class Ids {
|
|
634
|
+
pool;
|
|
635
|
+
schemas;
|
|
636
|
+
known = new Map();
|
|
637
|
+
constructor(pool, schemas) {
|
|
638
|
+
this.pool = pool;
|
|
639
|
+
this.schemas = schemas;
|
|
640
|
+
}
|
|
641
|
+
key(kind, name) {
|
|
642
|
+
return `${kind}\u0000${kind === 'company' ? (0, org_names_js_1.pathKey)(name) : kind === 'membership' ? name : (0, org_names_js_1.nameKey)(name)}`;
|
|
643
|
+
}
|
|
644
|
+
set(kind, name, id) {
|
|
645
|
+
this.known.set(this.key(kind, name), id);
|
|
646
|
+
}
|
|
647
|
+
peek(kind, name) {
|
|
648
|
+
return this.known.get(this.key(kind, name));
|
|
649
|
+
}
|
|
650
|
+
async lookup(kind, name, sql, params, column) {
|
|
651
|
+
const cached = this.peek(kind, name);
|
|
652
|
+
if (cached !== undefined)
|
|
653
|
+
return cached;
|
|
654
|
+
const rows = (await this.pool.query(sql, params));
|
|
655
|
+
// LIMIT 2: a second row means the natural key is ambiguous; never pick one (R1-3).
|
|
656
|
+
if (rows.length > 1)
|
|
657
|
+
throw new Error(`${kind} is ambiguous`);
|
|
658
|
+
const id = rows[0]?.[column];
|
|
659
|
+
if (typeof id !== 'string' || id === '')
|
|
660
|
+
throw new Error(`${kind} not found`);
|
|
661
|
+
this.set(kind, name, id);
|
|
662
|
+
return id;
|
|
663
|
+
}
|
|
664
|
+
async company(path) {
|
|
665
|
+
const cached = this.peek('company', path);
|
|
666
|
+
if (cached !== undefined)
|
|
667
|
+
return cached;
|
|
668
|
+
const segments = path.split('/');
|
|
669
|
+
const name = segments[segments.length - 1];
|
|
670
|
+
const parent = segments.length > 1 ? await this.company(segments.slice(0, -1).join('/')) : null;
|
|
671
|
+
return this.lookup('company', path, `SELECT c_uuid FROM \`${this.schemas.app}\`.\`companies\` WHERE c_name = ? AND c_parent_uuid <=> ? LIMIT 2`, [name, parent], 'c_uuid');
|
|
672
|
+
}
|
|
673
|
+
department(name) {
|
|
674
|
+
return this.lookup('department', name, `SELECT d_uuid FROM \`${this.schemas.app}\`.\`departments\` WHERE d_name = ? LIMIT 2`, [name], 'd_uuid');
|
|
675
|
+
}
|
|
676
|
+
branch(name) {
|
|
677
|
+
return this.lookup('branch', name, `SELECT b_uuid FROM \`${this.schemas.app}\`.\`branches\` WHERE b_name = ? LIMIT 2`, [name], 'b_uuid');
|
|
678
|
+
}
|
|
679
|
+
role(name) {
|
|
680
|
+
return this.lookup('role', name, `SELECT r_uuid FROM \`${this.schemas.app}\`.\`roles\` WHERE r_name = ? LIMIT 2`, [name], 'r_uuid');
|
|
681
|
+
}
|
|
682
|
+
user(username) {
|
|
683
|
+
return this.lookup('user', username, `SELECT u_uuid FROM \`${this.schemas.app}\`.\`users\` WHERE u_username = ? LIMIT 2`, [username], 'u_uuid');
|
|
684
|
+
}
|
|
685
|
+
membership(userUuid, companyUuid) {
|
|
686
|
+
return this.lookup('membership', `${userUuid}@${companyUuid}`, `SELECT cm_uuid FROM \`${this.schemas.app}\`.\`company_membership\` WHERE cm_user_uuid = ? AND cm_company_uuid = ? LIMIT 2`, [userUuid, companyUuid], 'cm_uuid');
|
|
687
|
+
}
|
|
688
|
+
}
|