@astram/identity 0.16.0 → 0.17.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.
Files changed (128) hide show
  1. package/dist/branch.object.d.ts +9 -1
  2. package/dist/branch.object.d.ts.map +1 -1
  3. package/dist/branch.object.js +14 -1
  4. package/dist/company-branch.object.d.ts +5 -4
  5. package/dist/company-branch.object.d.ts.map +1 -1
  6. package/dist/company-branch.object.js +6 -4
  7. package/dist/company-department.object.d.ts +6 -6
  8. package/dist/company-department.object.d.ts.map +1 -1
  9. package/dist/company-department.object.js +7 -6
  10. package/dist/department.object.d.ts +9 -1
  11. package/dist/department.object.d.ts.map +1 -1
  12. package/dist/department.object.js +14 -1
  13. package/dist/features/access/access-admin.access.d.ts.map +1 -1
  14. package/dist/features/access/access-admin.access.js +8 -1
  15. package/dist/features/access/index.d.ts +1 -0
  16. package/dist/features/access/index.d.ts.map +1 -1
  17. package/dist/features/access/index.js +10 -2
  18. package/dist/features/access/org-structure.access.d.ts +3 -0
  19. package/dist/features/access/org-structure.access.d.ts.map +1 -0
  20. package/dist/features/access/org-structure.access.js +41 -0
  21. package/dist/features/access/user-management.access.d.ts.map +1 -1
  22. package/dist/features/access/user-management.access.js +5 -0
  23. package/dist/features/audit/audit-read.service.d.ts +32 -1
  24. package/dist/features/audit/audit-read.service.d.ts.map +1 -1
  25. package/dist/features/audit/audit-read.service.js +67 -14
  26. package/dist/features/audit/auth-audit-sink.d.ts +19 -0
  27. package/dist/features/audit/auth-audit-sink.d.ts.map +1 -0
  28. package/dist/features/audit/auth-audit-sink.js +24 -0
  29. package/dist/features/audit/directory-label-resolver.d.ts +10 -1
  30. package/dist/features/audit/directory-label-resolver.d.ts.map +1 -1
  31. package/dist/features/audit/directory-label-resolver.js +28 -1
  32. package/dist/features/authz/grant-admin.controller.d.ts +4 -4
  33. package/dist/features/authz/grant-admin.controller.js +4 -4
  34. package/dist/features/authz/grant-admin.service.d.ts +9 -8
  35. package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
  36. package/dist/features/authz/grant-admin.service.js +20 -24
  37. package/dist/features/authz/grant-ceiling.d.ts +35 -16
  38. package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
  39. package/dist/features/authz/grant-ceiling.js +44 -29
  40. package/dist/features/authz/role-binding.d.ts +25 -24
  41. package/dist/features/authz/role-binding.d.ts.map +1 -1
  42. package/dist/features/authz/role-binding.js +31 -44
  43. package/dist/features/identity-feature.module.d.ts +0 -11
  44. package/dist/features/identity-feature.module.d.ts.map +1 -1
  45. package/dist/features/identity-feature.module.js +90 -1
  46. package/dist/features/membership/membership.controller.d.ts.map +1 -1
  47. package/dist/features/membership/membership.controller.js +20 -6
  48. package/dist/features/membership/membership.service.d.ts +16 -10
  49. package/dist/features/membership/membership.service.d.ts.map +1 -1
  50. package/dist/features/membership/membership.service.js +61 -21
  51. package/dist/features/membership/posting.service.d.ts +25 -6
  52. package/dist/features/membership/posting.service.d.ts.map +1 -1
  53. package/dist/features/membership/posting.service.js +63 -16
  54. package/dist/features/org-bootstrap/org-bootstrap-file.d.ts +6 -2
  55. package/dist/features/org-bootstrap/org-bootstrap-file.d.ts.map +1 -1
  56. package/dist/features/org-bootstrap/org-bootstrap-file.js +54 -11
  57. package/dist/features/org-bootstrap/org-bootstrap-plan.d.ts +30 -11
  58. package/dist/features/org-bootstrap/org-bootstrap-plan.d.ts.map +1 -1
  59. package/dist/features/org-bootstrap/org-bootstrap-plan.js +172 -68
  60. package/dist/features/org-bootstrap/org-state.reader.d.ts.map +1 -1
  61. package/dist/features/org-bootstrap/org-state.reader.js +56 -49
  62. package/dist/features/org-bootstrap/run-org-bootstrap.d.ts +6 -1
  63. package/dist/features/org-bootstrap/run-org-bootstrap.d.ts.map +1 -1
  64. package/dist/features/org-bootstrap/run-org-bootstrap.js +81 -92
  65. package/dist/features/org-structure/org-catalog.controller.d.ts +29 -6
  66. package/dist/features/org-structure/org-catalog.controller.d.ts.map +1 -1
  67. package/dist/features/org-structure/org-catalog.controller.js +105 -17
  68. package/dist/features/org-structure/org-catalog.service.d.ts +99 -18
  69. package/dist/features/org-structure/org-catalog.service.d.ts.map +1 -1
  70. package/dist/features/org-structure/org-catalog.service.js +327 -51
  71. package/dist/features/org-structure/org-unit-name.d.ts +31 -0
  72. package/dist/features/org-structure/org-unit-name.d.ts.map +1 -0
  73. package/dist/features/org-structure/org-unit-name.js +50 -0
  74. package/dist/features/role/role.controller.d.ts +28 -13
  75. package/dist/features/role/role.controller.d.ts.map +1 -1
  76. package/dist/features/role/role.controller.js +54 -14
  77. package/dist/features/role/role.service.d.ts +76 -21
  78. package/dist/features/role/role.service.d.ts.map +1 -1
  79. package/dist/features/role/role.service.js +204 -27
  80. package/dist/features/security-policy/security-policy-version-bump.d.ts +41 -0
  81. package/dist/features/security-policy/security-policy-version-bump.d.ts.map +1 -0
  82. package/dist/features/security-policy/security-policy-version-bump.js +65 -0
  83. package/dist/features/security-policy/security-policy.controller.d.ts +51 -0
  84. package/dist/features/security-policy/security-policy.controller.d.ts.map +1 -0
  85. package/dist/features/security-policy/security-policy.controller.js +121 -0
  86. package/dist/features/security-policy/security-policy.service.d.ts +94 -0
  87. package/dist/features/security-policy/security-policy.service.d.ts.map +1 -0
  88. package/dist/features/security-policy/security-policy.service.js +429 -0
  89. package/dist/features/service-account/service-account.service.d.ts +13 -3
  90. package/dist/features/service-account/service-account.service.d.ts.map +1 -1
  91. package/dist/features/service-account/service-account.service.js +48 -10
  92. package/dist/features/service-account/service-account.store.d.ts +11 -1
  93. package/dist/features/service-account/service-account.store.d.ts.map +1 -1
  94. package/dist/features/service-account/service-account.store.js +20 -1
  95. package/dist/features/user/signin-admin.tokens.d.ts +11 -0
  96. package/dist/features/user/signin-admin.tokens.d.ts.map +1 -0
  97. package/dist/features/user/signin-admin.tokens.js +13 -0
  98. package/dist/features/user/user-mfa-status.d.ts +38 -0
  99. package/dist/features/user/user-mfa-status.d.ts.map +1 -0
  100. package/dist/features/user/user-mfa-status.js +47 -0
  101. package/dist/features/user/user-signin-admin.service.d.ts +48 -0
  102. package/dist/features/user/user-signin-admin.service.d.ts.map +1 -0
  103. package/dist/features/user/user-signin-admin.service.js +232 -0
  104. package/dist/features/user/user.controller.d.ts +29 -2
  105. package/dist/features/user/user.controller.d.ts.map +1 -1
  106. package/dist/features/user/user.controller.js +71 -3
  107. package/dist/features/user/user.service.d.ts +40 -14
  108. package/dist/features/user/user.service.d.ts.map +1 -1
  109. package/dist/features/user/user.service.js +143 -10
  110. package/dist/index.d.ts +1 -0
  111. package/dist/index.d.ts.map +1 -1
  112. package/dist/index.js +3 -1
  113. package/dist/objects.d.ts.map +1 -1
  114. package/dist/objects.js +2 -0
  115. package/dist/role.object.d.ts +1 -1
  116. package/dist/role.object.d.ts.map +1 -1
  117. package/dist/role.object.js +6 -15
  118. package/dist/security-policy.object.d.ts +43 -0
  119. package/dist/security-policy.object.d.ts.map +1 -0
  120. package/dist/security-policy.object.js +107 -0
  121. package/dist/user.object.d.ts +6 -0
  122. package/dist/user.object.d.ts.map +1 -1
  123. package/dist/user.object.js +13 -1
  124. package/migrations/1710000000103-AddUserLoginName.cjs +87 -0
  125. package/migrations/1710000000104-CreateSecurityPolicy.cjs +90 -0
  126. package/migrations/1710000000105-OwnDepartmentsAndBranches.cjs +327 -0
  127. package/migrations/1710000000106-OwnRolesPerCompany.cjs +622 -0
  128. package/package.json +4 -4
@@ -0,0 +1,622 @@
1
+ /**
2
+ * Framework identity migration — each company OWNS its roles (G-013, spec §4).
3
+ *
4
+ * WHY: `roles` was a global catalog (`UNIQUE (r_name)`): one "Manager" row was posted in many
5
+ * companies and an edit reached all of them. After this migration a role belongs to exactly one
6
+ * company (`r_owner_company_uuid`, added by `...101`), is assigned only in that company, and its
7
+ * name is unique only within the company. Shared roles are split per using company with the same
8
+ * effective permissions, so nobody gains or loses access.
9
+ *
10
+ * "Using companies" of a role = the companies of the memberships whose postings name it plus the
11
+ * `sa_tenant_id` of the service accounts whose `sa_role_ids` contain it (only existing companies:
12
+ * the owner FK needs one). "C in the subtree of X" = a `scope_closure` row (ancestor X,
13
+ * descendant C); the closure holds the depth-0 self row.
14
+ *
15
+ * Steps (idempotent; a half-applied state is completed, a re-run changes nothing):
16
+ * 1. DROP the global unique `IDX_roles_name` when present (copies share the name).
17
+ * 2. KEEP a role with no using company, or whose owner is one of its using companies.
18
+ * RE-OWN otherwise (owner NULL, or the `...101` top-level tenant while only child companies
19
+ * use it): the original goes to the using company with the smallest `c_uuid` (compare-and-set
20
+ * on the owner read, so a re-run never overwrites).
21
+ * 3. SPLIT: every other using company C gets a copy (an existing role C owns with the same name,
22
+ * column collation, is reused, else one is inserted with `UUID()`, same name and created_at).
23
+ * For the copy, creates before moves:
24
+ * - every `role_permission` row (new `rp_uuid`, attribution columns copied);
25
+ * - `record_permission_rule` rows whose subject is the original (kind `role`): copied when
26
+ * `rpr_tenant_uuid` IS NULL or C is in its subtree. A policy group (`rpr_policy_uuid`) is
27
+ * copied whole, in ONE statement, with one new policy uuid per (group, copy), and only when
28
+ * every row of the group names the original and passes the predicate (a partial group
29
+ * would drop an AND condition and widen the rule). Other subjects and `everyone` rows are
30
+ * never copied;
31
+ * - `record_share` (grantee kind `role`): copied when `rs_tenant_uuid` is in C's subtree;
32
+ * - `cross_tenant_grant` (grantee kind `role`): copied when C is in the subtree of
33
+ * `ctg_grantee_tenant_uuid` (owning tenant, scope, actions, status, expiry unchanged);
34
+ * - `security_policy` (target kind `role`): copied when `sp_platform = 1` or C is in the
35
+ * subtree of `sp_scope_node_uuid` (a copy's holders stay required to use 2FA);
36
+ * each insert skips a row the copy already has, so a re-run adds nothing. Rows failing their
37
+ * predicate are counted as skipped. Then C's twin postings already on the copy are removed
38
+ * (resume safety, as `...105`), C's postings and C's service accounts' `sa_role_ids` move to
39
+ * the copy, and the permission version of C and of the original's owner is incremented
40
+ * (once per company per run; `PermissionVersionBumper`'s statement; Redis is not touched,
41
+ * its copy expires within the TTL). A re-run finds no role used off its owner and does
42
+ * nothing (no bump).
43
+ * 4. ADD UNIQUE `IDX_roles_owner_name (r_owner_company_uuid, r_name)` when absent; duplicates
44
+ * fail with their names, nothing indexed. NULL owners do not collide (root-only legacy rows).
45
+ * 5. PRINT a summary per re-owned or split role: name, owner, each copy's owner company, counts
46
+ * of postings and service accounts repointed and rows copied/skipped per table. Names and
47
+ * counts only, no uuids. These writes bypass the engine and write no `audit_log` rows (the DDL
48
+ * account has no audit path): keep this output with the upgrade record (runbook §6).
49
+ * Snapshots `identity_credential.ic_roles` and `service_account_key.sak_role_ids` are not rewritten.
50
+ *
51
+ * `down`: refuses (no change) while a role name is held by more than one role and the global
52
+ * unique is absent; otherwise drops the per-company unique and re-adds the global unique (IF
53
+ * [NOT] EXISTS each). Copies are not merged back and owners are not reverted.
54
+ *
55
+ * Names: databases from `ctx.schemas` (`frameworkSchemasForMigration`): app (roles, postings,
56
+ * memberships, service accounts, companies, security policies) and authz (role_permission, rules,
57
+ * shares, cross-tenant grants, closure, permission version), so the four-database, namespace and
58
+ * single layouts all work. Literal table/column/index names, every value `?`-bound, no hardcoded
59
+ * ids. Sorts after `...101` (owner column), `...104` (security_policy) and `...105` (the latest).
60
+ */
61
+
62
+ const { frameworkSchemasForMigration } = require('@astram/core');
63
+
64
+ const MIGRATION = '1710000000106-OwnRolesPerCompany';
65
+ const GLOBAL_UNIQUE = 'IDX_roles_name';
66
+ const COMPANY_UNIQUE = 'IDX_roles_owner_name';
67
+ /** Duplicate groups listed in a failure message; the rest are counted. */
68
+ const MAX_LISTED = 50;
69
+ const TABLES = [
70
+ 'role_permission',
71
+ 'record_permission_rule',
72
+ 'record_share',
73
+ 'cross_tenant_grant',
74
+ 'security_policy',
75
+ ];
76
+
77
+ const rowsOf = (result) => (Array.isArray(result) ? result : []);
78
+ const affected = (result) =>
79
+ result && typeof result === 'object' && !Array.isArray(result)
80
+ ? Number(result.affectedRows ?? 0)
81
+ : 0;
82
+ const sameId = (a, b) =>
83
+ typeof a === 'string' && typeof b === 'string' && a.toLowerCase() === b.toLowerCase();
84
+ const tbl = (db, table) => `\`${db}\`.\`${table}\``;
85
+
86
+ const hasIndex = async (query, APP, index) =>
87
+ rowsOf(
88
+ await query(
89
+ `SELECT 1 AS n FROM information_schema.STATISTICS
90
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'roles' AND INDEX_NAME = ? LIMIT 1`,
91
+ [APP, index],
92
+ ),
93
+ ).length > 0;
94
+
95
+ /** One (role, company) row per using company, ordered by role then company (MIN first). */
96
+ async function usages(query, N) {
97
+ return rowsOf(
98
+ await query(`SELECT u.role AS role, u.company AS company, r.r_name AS name,
99
+ r.r_owner_company_uuid AS owner
100
+ FROM (SELECT mp.mp_role_uuid AS role, cm.cm_company_uuid AS company
101
+ FROM ${N.MP} mp
102
+ JOIN ${N.CM} cm ON cm.cm_uuid = mp.mp_membership_uuid
103
+ UNION
104
+ SELECT sr.r_uuid AS role, sa.sa_tenant_id AS company
105
+ FROM ${N.SA} sa
106
+ JOIN ${N.COMPANIES} c ON c.c_uuid = sa.sa_tenant_id
107
+ JOIN ${N.ROLES} sr ON JSON_CONTAINS(sa.sa_role_ids, JSON_QUOTE(sr.r_uuid))) u
108
+ JOIN ${N.ROLES} r ON r.r_uuid = u.role
109
+ ORDER BY u.role, u.company`),
110
+ );
111
+ }
112
+
113
+ async function findOwned(query, N, company, name) {
114
+ const rows = rowsOf(
115
+ await query(
116
+ `SELECT r_uuid AS uuid FROM ${N.ROLES} WHERE r_owner_company_uuid = ? AND r_name = ? ORDER BY r_uuid LIMIT 1`,
117
+ [company, name],
118
+ ),
119
+ );
120
+ return rows.length > 0 ? String(rows[0].uuid) : null;
121
+ }
122
+
123
+ /** The copy of `role` owned by `company`: reused when present, else inserted. */
124
+ async function ensureCopy(query, N, role, company, name) {
125
+ let copy = await findOwned(query, N, company, name);
126
+ if (copy !== null) return copy;
127
+ await query(
128
+ `INSERT INTO ${N.ROLES} (r_uuid, r_name, r_owner_company_uuid, r_created_at)
129
+ SELECT UUID(), src.r_name, ?, src.r_created_at FROM ${N.ROLES} src WHERE src.r_uuid = ?`,
130
+ [company, role],
131
+ );
132
+ copy = await findOwned(query, N, company, name);
133
+ if (copy === null) {
134
+ throw new Error(`${MIGRATION}: could not create a company-owned role copy; re-run.`);
135
+ }
136
+ return copy;
137
+ }
138
+
139
+ async function copyRolePermissions(query, N, role, copy) {
140
+ const result = await query(
141
+ `INSERT INTO ${N.RP} (rp_uuid, rp_role_uuid, rp_perm_key, rp_created_by, rp_created_at, rp_granted_by, rp_granted_at)
142
+ SELECT UUID(), ?, src.rp_perm_key, src.rp_created_by, src.rp_created_at, src.rp_granted_by, src.rp_granted_at
143
+ FROM ${N.RP} src
144
+ WHERE src.rp_role_uuid = ?
145
+ AND NOT EXISTS (SELECT 1 FROM ${N.RP} d WHERE d.rp_role_uuid = ? AND d.rp_perm_key = src.rp_perm_key)`,
146
+ [copy, role, copy],
147
+ );
148
+ return { copied: affected(result), skipped: 0 };
149
+ }
150
+
151
+ /** Rules naming `subject` (kind role), each with its predicate for `company` and group size. */
152
+ async function rulesOf(query, N, subject, company) {
153
+ return rowsOf(
154
+ await query(
155
+ `SELECT src.rpr_uuid AS id, src.rpr_policy_uuid AS policy, src.rpr_tenant_uuid AS tenant,
156
+ src.rpr_type_key AS type_key, src.rpr_action AS action, src.rpr_effect AS effect,
157
+ src.rpr_field AS field, src.rpr_operator AS operator, src.rpr_value AS value,
158
+ CASE WHEN src.rpr_tenant_uuid IS NULL OR EXISTS (SELECT 1 FROM ${N.SC} sc
159
+ WHERE sc.ancestor_uuid = src.rpr_tenant_uuid AND sc.descendant_uuid = ?)
160
+ THEN 1 ELSE 0 END AS applies,
161
+ (SELECT COUNT(*) FROM ${N.RPR} g WHERE g.rpr_policy_uuid = src.rpr_policy_uuid) AS group_size
162
+ FROM ${N.RPR} src
163
+ WHERE src.rpr_subject_kind = 'role' AND src.rpr_subject_value = ?
164
+ ORDER BY src.rpr_policy_uuid, src.rpr_uuid`,
165
+ [company, subject],
166
+ ),
167
+ );
168
+ }
169
+
170
+ const ruleSig = (r) =>
171
+ JSON.stringify([
172
+ r.tenant ?? null,
173
+ r.type_key,
174
+ r.action,
175
+ r.effect,
176
+ r.field ?? null,
177
+ r.operator ?? null,
178
+ r.value ?? null,
179
+ ]);
180
+
181
+ /** Group rules by policy uuid (ungrouped rows each their own unit); signature per unit. */
182
+ function units(rows) {
183
+ const groups = new Map();
184
+ const singles = [];
185
+ for (const r of rows) {
186
+ if (r.policy === null || r.policy === undefined) {
187
+ singles.push({ rows: [r], sig: ruleSig(r) });
188
+ continue;
189
+ }
190
+ const key = String(r.policy);
191
+ const g = groups.get(key) ?? { policy: key, rows: [] };
192
+ g.rows.push(r);
193
+ groups.set(key, g);
194
+ }
195
+ const grouped = [...groups.values()].map((g) => ({
196
+ ...g,
197
+ sig: g.rows.map(ruleSig).sort().join('\n'),
198
+ }));
199
+ return { singles, grouped };
200
+ }
201
+
202
+ const take = (multiset, sig) => {
203
+ const n = multiset.get(sig) ?? 0;
204
+ if (n === 0) return false;
205
+ multiset.set(sig, n - 1);
206
+ return true;
207
+ };
208
+ const countSigs = (list) => {
209
+ const m = new Map();
210
+ for (const u of list) m.set(u.sig, (m.get(u.sig) ?? 0) + 1);
211
+ return m;
212
+ };
213
+
214
+ const RULE_COLUMNS = `rpr_uuid, rpr_policy_uuid, rpr_tenant_uuid, rpr_type_key, rpr_action, rpr_effect,
215
+ rpr_subject_kind, rpr_subject_value, rpr_field, rpr_operator, rpr_value, rpr_created_at`;
216
+ const RULE_VALUES = `src.rpr_tenant_uuid, src.rpr_type_key, src.rpr_action, src.rpr_effect,
217
+ 'role', ?, src.rpr_field, src.rpr_operator, src.rpr_value, src.rpr_created_at`;
218
+
219
+ async function copyRules(query, N, role, copy, company) {
220
+ const source = units(await rulesOf(query, N, role, company));
221
+ const existing = units(await rulesOf(query, N, copy, company));
222
+ const haveSingles = countSigs(existing.singles);
223
+ const haveGroups = countSigs(existing.grouped);
224
+ let copied = 0;
225
+ let skipped = 0;
226
+
227
+ for (const u of source.singles) {
228
+ const r = u.rows[0];
229
+ if (Number(r.applies) !== 1) {
230
+ skipped += 1;
231
+ continue;
232
+ }
233
+ if (take(haveSingles, u.sig)) continue;
234
+ copied += affected(
235
+ await query(
236
+ `INSERT INTO ${N.RPR} (${RULE_COLUMNS})
237
+ SELECT UUID(), NULL, ${RULE_VALUES} FROM ${N.RPR} src WHERE src.rpr_uuid = ?`,
238
+ [copy, r.id],
239
+ ),
240
+ );
241
+ }
242
+
243
+ for (const g of source.grouped) {
244
+ const whole = g.rows.every(
245
+ (r) => Number(r.applies) === 1 && Number(r.group_size) === g.rows.length,
246
+ );
247
+ if (!whole) {
248
+ skipped += g.rows.length;
249
+ continue;
250
+ }
251
+ if (take(haveGroups, g.sig)) continue;
252
+ const fresh = rowsOf(await query('SELECT UUID() AS uuid'));
253
+ if (fresh.length === 0) throw new Error(`${MIGRATION}: UUID() returned no row.`);
254
+ copied += affected(
255
+ await query(
256
+ `INSERT INTO ${N.RPR} (${RULE_COLUMNS})
257
+ SELECT UUID(), ?, ${RULE_VALUES} FROM ${N.RPR} src
258
+ WHERE src.rpr_policy_uuid = ? AND src.rpr_subject_kind = 'role' AND src.rpr_subject_value = ?`,
259
+ [String(fresh[0].uuid), copy, g.policy, role],
260
+ ),
261
+ );
262
+ }
263
+ return { copied, skipped };
264
+ }
265
+
266
+ /**
267
+ * Tenant-scoped tables copied by one set-based statement each. `pred` holds one `?` (the copy's
268
+ * owner company); `dup` matches an equivalent row already naming the copy (`d`).
269
+ */
270
+ const SCOPED = [
271
+ {
272
+ label: 'record_share',
273
+ table: (N) => N.RS,
274
+ kind: 'rs_grantee_kind',
275
+ value: 'rs_grantee_uuid',
276
+ pred: (N) =>
277
+ `EXISTS (SELECT 1 FROM ${N.SC} sc WHERE sc.ancestor_uuid = ? AND sc.descendant_uuid = src.rs_tenant_uuid)`,
278
+ columns: `rs_uuid, rs_tenant_uuid, rs_type_key, rs_record_uuid, rs_grantee_kind, rs_grantee_uuid,
279
+ rs_action, rs_granted_by, rs_created_at`,
280
+ values: `UUID(), src.rs_tenant_uuid, src.rs_type_key, src.rs_record_uuid, 'role', ?,
281
+ src.rs_action, src.rs_granted_by, src.rs_created_at`,
282
+ dup: `d.rs_tenant_uuid = src.rs_tenant_uuid AND d.rs_type_key = src.rs_type_key
283
+ AND d.rs_record_uuid = src.rs_record_uuid AND d.rs_action = src.rs_action`,
284
+ },
285
+ {
286
+ label: 'cross_tenant_grant',
287
+ table: (N) => N.CTG,
288
+ kind: 'ctg_grantee_kind',
289
+ value: 'ctg_grantee_uuid',
290
+ pred: (N) =>
291
+ `EXISTS (SELECT 1 FROM ${N.SC} sc WHERE sc.ancestor_uuid = src.ctg_grantee_tenant_uuid AND sc.descendant_uuid = ?)`,
292
+ columns: `ctg_uuid, ctg_owning_tenant_uuid, ctg_grantee_tenant_uuid, ctg_grantee_kind,
293
+ ctg_grantee_uuid, ctg_type_key, ctg_scope, ctg_record_uuid, ctg_allowed_actions, ctg_status,
294
+ ctg_offered_by, ctg_expires_at_ms, ctg_created_at, ctg_accepted_at, ctg_revoked_at`,
295
+ values: `UUID(), src.ctg_owning_tenant_uuid, src.ctg_grantee_tenant_uuid, 'role',
296
+ ?, src.ctg_type_key, src.ctg_scope, src.ctg_record_uuid, src.ctg_allowed_actions, src.ctg_status,
297
+ src.ctg_offered_by, src.ctg_expires_at_ms, src.ctg_created_at, src.ctg_accepted_at, src.ctg_revoked_at`,
298
+ dup: `d.ctg_owning_tenant_uuid = src.ctg_owning_tenant_uuid
299
+ AND d.ctg_grantee_tenant_uuid = src.ctg_grantee_tenant_uuid AND d.ctg_type_key = src.ctg_type_key
300
+ AND d.ctg_scope = src.ctg_scope AND d.ctg_record_uuid <=> src.ctg_record_uuid
301
+ AND d.ctg_allowed_actions = src.ctg_allowed_actions AND d.ctg_status = src.ctg_status
302
+ AND d.ctg_offered_by = src.ctg_offered_by AND d.ctg_expires_at_ms = src.ctg_expires_at_ms`,
303
+ },
304
+ {
305
+ label: 'security_policy',
306
+ table: (N) => N.SP,
307
+ kind: 'sp_target_kind',
308
+ value: 'sp_target_value',
309
+ pred: (N) =>
310
+ `(src.sp_platform = 1 OR EXISTS (SELECT 1 FROM ${N.SC} sc WHERE sc.ancestor_uuid = src.sp_scope_node_uuid AND sc.descendant_uuid = ?))`,
311
+ columns: `sp_uuid, sp_scope_node_uuid, sp_platform, sp_target_kind, sp_target_value, sp_grace_days,
312
+ sp_created_at_node, sp_created_by, sp_created_at_ms, sp_updated_at_ms`,
313
+ values: `UUID(), src.sp_scope_node_uuid, src.sp_platform, 'role', ?, src.sp_grace_days,
314
+ src.sp_created_at_node, src.sp_created_by, src.sp_created_at_ms, src.sp_updated_at_ms`,
315
+ dup: `d.sp_scope_node_uuid <=> src.sp_scope_node_uuid AND d.sp_platform = src.sp_platform
316
+ AND d.sp_grace_days = src.sp_grace_days AND d.sp_created_by = src.sp_created_by
317
+ AND d.sp_created_at_ms = src.sp_created_at_ms`,
318
+ },
319
+ ];
320
+
321
+ async function copyScoped(query, N, s, role, copy, company) {
322
+ const T = s.table(N);
323
+ const pred = s.pred(N);
324
+ const counts = rowsOf(
325
+ await query(
326
+ `SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN ${pred} THEN 1 ELSE 0 END), 0) AS applies
327
+ FROM ${T} src WHERE src.${s.kind} = 'role' AND src.${s.value} = ?`,
328
+ [company, role],
329
+ ),
330
+ )[0] ?? { total: 0, applies: 0 };
331
+ const result = await query(
332
+ `INSERT INTO ${T} (${s.columns})
333
+ SELECT ${s.values}
334
+ FROM ${T} src
335
+ WHERE src.${s.kind} = 'role' AND src.${s.value} = ? AND ${pred}
336
+ AND NOT EXISTS (SELECT 1 FROM ${T} d
337
+ WHERE d.${s.kind} = 'role' AND d.${s.value} = ? AND ${s.dup})`,
338
+ [copy, role, company, copy],
339
+ );
340
+ return {
341
+ copied: affected(result),
342
+ skipped: Number(counts.total ?? 0) - Number(counts.applies ?? 0),
343
+ };
344
+ }
345
+
346
+ function parseRoleIds(value) {
347
+ let v = value;
348
+ if (typeof v === 'string') {
349
+ try {
350
+ v = JSON.parse(v);
351
+ } catch {
352
+ v = null;
353
+ }
354
+ }
355
+ if (!Array.isArray(v)) {
356
+ throw new Error(
357
+ `${MIGRATION}: a service account's role list is not a JSON array; fix it and re-run.`,
358
+ );
359
+ }
360
+ return v;
361
+ }
362
+
363
+ /** Move `company`'s postings and service accounts from `role` to `copy`. */
364
+ async function repoint(query, N, role, copy, company) {
365
+ // Resume safety (as `...105`): a membership may already hold the same posting on the copy
366
+ // (a partial earlier run, or the old app adding it again). Repointing its twin would break
367
+ // `IDX_mp_unique` (1062) and fail every re-run, so the twin on the original goes first; the
368
+ // posting on the copy keeps the assignment.
369
+ await query(
370
+ `DELETE mp FROM ${N.MP} mp
371
+ JOIN ${N.CM} cm ON cm.cm_uuid = mp.mp_membership_uuid
372
+ JOIN ${N.MP} twin ON twin.mp_membership_uuid = mp.mp_membership_uuid
373
+ AND twin.mp_department_uuid <=> mp.mp_department_uuid
374
+ AND twin.mp_branch_uuid <=> mp.mp_branch_uuid
375
+ AND twin.mp_role_uuid = ?
376
+ WHERE mp.mp_role_uuid = ? AND cm.cm_company_uuid = ?`,
377
+ [copy, role, company],
378
+ );
379
+ const postings = affected(
380
+ await query(
381
+ `UPDATE ${N.MP} mp
382
+ JOIN ${N.CM} cm ON cm.cm_uuid = mp.mp_membership_uuid
383
+ SET mp.mp_role_uuid = ?
384
+ WHERE mp.mp_role_uuid = ? AND cm.cm_company_uuid = ?`,
385
+ [copy, role, company],
386
+ ),
387
+ );
388
+
389
+ const accounts = rowsOf(
390
+ await query(
391
+ `SELECT sa_id AS id, sa_role_ids AS roles FROM ${N.SA}
392
+ WHERE sa_tenant_id = ? AND JSON_CONTAINS(sa_role_ids, JSON_QUOTE(?)) ORDER BY sa_id`,
393
+ [company, role],
394
+ ),
395
+ );
396
+ let serviceAccounts = 0;
397
+ for (const a of accounts) {
398
+ const next = [];
399
+ for (const id of parseRoleIds(a.roles)) {
400
+ const mapped = id === role ? copy : id;
401
+ if (!next.includes(mapped)) next.push(mapped);
402
+ }
403
+ serviceAccounts += affected(
404
+ await query(
405
+ `UPDATE ${N.SA} SET sa_role_ids = ?
406
+ WHERE sa_id = ? AND JSON_CONTAINS(sa_role_ids, JSON_QUOTE(?))`,
407
+ [JSON.stringify(next), a.id, role],
408
+ ),
409
+ );
410
+ }
411
+ return { postings, serviceAccounts };
412
+ }
413
+
414
+ async function companyName(query, N, cache, uuid) {
415
+ if (cache.has(uuid)) return cache.get(uuid);
416
+ const rows = rowsOf(
417
+ await query(`SELECT c_name AS name FROM ${N.COMPANIES} WHERE c_uuid = ? LIMIT 1`, [uuid]),
418
+ );
419
+ const name = rows.length > 0 ? JSON.stringify(String(rows[0].name)) : 'an unnamed company';
420
+ cache.set(uuid, name);
421
+ return name;
422
+ }
423
+
424
+ /** Steps 2–3: keep / re-own / split every used role; returns the summary records. */
425
+ async function ownAndSplit(query, N) {
426
+ const byRole = new Map();
427
+ for (const u of await usages(query, N)) {
428
+ const key = String(u.role);
429
+ const entry = byRole.get(key) ?? {
430
+ role: key,
431
+ name: String(u.name),
432
+ owner: u.owner ?? null,
433
+ companies: [],
434
+ };
435
+ if (!entry.companies.some((c) => sameId(c, u.company))) entry.companies.push(String(u.company));
436
+ byRole.set(key, entry);
437
+ }
438
+
439
+ const bumped = new Set();
440
+ const bump = async (company) => {
441
+ const key = company.toLowerCase();
442
+ if (bumped.has(key)) return;
443
+ await query(
444
+ `INSERT INTO ${N.APV} (apv_tenant_uuid, apv_version)
445
+ VALUES (?, 1)
446
+ ON DUPLICATE KEY UPDATE apv_version = apv_version + 1`,
447
+ [company],
448
+ );
449
+ bumped.add(key);
450
+ };
451
+
452
+ const report = [];
453
+ for (const e of byRole.values()) {
454
+ const before = e.owner;
455
+ let owner = before;
456
+ if (!e.companies.some((c) => sameId(c, owner))) {
457
+ owner = e.companies[0];
458
+ await query(
459
+ `UPDATE ${N.ROLES} SET r_owner_company_uuid = ? WHERE r_uuid = ? AND r_owner_company_uuid <=> ?`,
460
+ [owner, e.role, before],
461
+ );
462
+ }
463
+ const others = e.companies.filter((c) => !sameId(c, owner));
464
+ if (others.length === 0 && sameId(owner, before)) continue;
465
+
466
+ const entry = { name: e.name, before, owner, copies: [] };
467
+ for (const company of others) {
468
+ const copy = await ensureCopy(query, N, e.role, company, e.name);
469
+ const copied = {};
470
+ const skipped = {};
471
+ const rp = await copyRolePermissions(query, N, e.role, copy);
472
+ copied.role_permission = rp.copied;
473
+ skipped.role_permission = rp.skipped;
474
+ const rules = await copyRules(query, N, e.role, copy, company);
475
+ copied.record_permission_rule = rules.copied;
476
+ skipped.record_permission_rule = rules.skipped;
477
+ for (const s of SCOPED) {
478
+ const r = await copyScoped(query, N, s, e.role, copy, company);
479
+ copied[s.label] = r.copied;
480
+ skipped[s.label] = r.skipped;
481
+ }
482
+ const moved = await repoint(query, N, e.role, copy, company);
483
+ await bump(company);
484
+ await bump(owner);
485
+ entry.copies.push({ company, ...moved, copied, skipped });
486
+ }
487
+ report.push(entry);
488
+ }
489
+ return { report, bumped: bumped.size };
490
+ }
491
+
492
+ async function printSummary(query, N, { report, bumped }) {
493
+ const names = new Map();
494
+ if (report.length === 0) {
495
+ console.log(`${MIGRATION}: no role is used outside its owner company; nothing to re-own or split.`);
496
+ return;
497
+ }
498
+ let copies = 0;
499
+ for (const r of report) {
500
+ const owner = await companyName(query, N, names, r.owner);
501
+ const was = sameId(r.before, r.owner)
502
+ ? ''
503
+ : r.before === null
504
+ ? ' (was ownerless)'
505
+ : ` (re-owned from ${await companyName(query, N, names, r.before)})`;
506
+ const head =
507
+ r.copies.length === 0
508
+ ? `${MIGRATION}: role ${JSON.stringify(r.name)} now owned by ${owner}${was}.`
509
+ : `${MIGRATION}: role ${JSON.stringify(r.name)} split: original owned by ${owner}${was}; ${r.copies.length} copy(ies):`;
510
+ console.log(head);
511
+ for (const c of r.copies) {
512
+ copies += 1;
513
+ const tables = TABLES.map(
514
+ (t) => `${t} ${c.copied[t]} copied${t === 'role_permission' ? '' : `, ${c.skipped[t]} skipped`}`,
515
+ ).join('; ');
516
+ console.log(
517
+ ` - copy owned by ${await companyName(query, N, names, c.company)}: ` +
518
+ `${c.postings} posting(s), ${c.serviceAccounts} service account(s) repointed; ${tables}`,
519
+ );
520
+ }
521
+ }
522
+ console.log(
523
+ `${MIGRATION}: ${report.length} role(s) re-owned or split, ${copies} copy(ies) created or ` +
524
+ `completed; permission version incremented for ${bumped} compan(ies). Keep this output ` +
525
+ `with the upgrade record (runbook §6).`,
526
+ );
527
+ }
528
+
529
+ function duplicateError(APP, groups, scope, tail) {
530
+ const listed = groups.slice(0, MAX_LISTED).map((g) => ` - "${g.name}": ${g.n} rows`);
531
+ if (groups.length > MAX_LISTED) listed.push(` - ... and ${groups.length - MAX_LISTED} more`);
532
+ return new Error(
533
+ `${MIGRATION}: ${groups.length} role name(s) occur more than once ${scope} in ${APP}.roles ` +
534
+ `(compared as the column collation does: case- and accent-insensitive):\n` +
535
+ `${listed.join('\n')}\n${tail}`,
536
+ );
537
+ }
538
+
539
+ /** Step 4: per-company unique when absent; duplicates fail with their names. */
540
+ async function addCompanyUnique(query, N) {
541
+ if (await hasIndex(query, N.APP, COMPANY_UNIQUE)) return;
542
+ const groups = rowsOf(
543
+ await query(`SELECT MIN(r_name) AS name, COUNT(*) AS n
544
+ FROM ${N.ROLES}
545
+ WHERE r_owner_company_uuid IS NOT NULL
546
+ GROUP BY r_owner_company_uuid, r_name
547
+ HAVING COUNT(*) > 1
548
+ ORDER BY n DESC`),
549
+ );
550
+ if (groups.length > 0) {
551
+ throw duplicateError(
552
+ N.APP,
553
+ groups,
554
+ 'within one company',
555
+ 'No unique index was created. Rename or remove the duplicates, then re-run astram-migrate.',
556
+ );
557
+ }
558
+ await query(
559
+ `CREATE UNIQUE INDEX ${COMPANY_UNIQUE} ON ${N.ROLES} (r_owner_company_uuid, r_name)`,
560
+ );
561
+ }
562
+
563
+ function namesOf(ctx) {
564
+ const schemas = frameworkSchemasForMigration(ctx, process.env);
565
+ const APP = schemas.app;
566
+ const AUTHZ = schemas.authz;
567
+ return {
568
+ APP,
569
+ ROLES: tbl(APP, 'roles'),
570
+ COMPANIES: tbl(APP, 'companies'),
571
+ MP: tbl(APP, 'membership_posting'),
572
+ CM: tbl(APP, 'company_membership'),
573
+ SA: tbl(APP, 'service_account'),
574
+ SP: tbl(APP, 'security_policy'),
575
+ RP: tbl(AUTHZ, 'role_permission'),
576
+ RPR: tbl(AUTHZ, 'record_permission_rule'),
577
+ RS: tbl(AUTHZ, 'record_share'),
578
+ CTG: tbl(AUTHZ, 'cross_tenant_grant'),
579
+ SC: tbl(AUTHZ, 'scope_closure'),
580
+ APV: tbl(AUTHZ, 'authz_permission_version'),
581
+ };
582
+ }
583
+
584
+ async function up(ctx) {
585
+ const { query } = ctx;
586
+ const N = namesOf(ctx);
587
+ if (await hasIndex(query, N.APP, GLOBAL_UNIQUE)) {
588
+ await query(`DROP INDEX ${GLOBAL_UNIQUE} ON ${N.ROLES}`);
589
+ }
590
+ const result = await ownAndSplit(query, N);
591
+ await addCompanyUnique(query, N);
592
+ await printSummary(query, N, result);
593
+ }
594
+
595
+ async function down(ctx) {
596
+ const { query } = ctx;
597
+ const N = namesOf(ctx);
598
+ // Pre-check before any change: the global unique cannot come back while a name is held by more
599
+ // than one role (a split copy, or two companies' own roles).
600
+ if (!(await hasIndex(query, N.APP, GLOBAL_UNIQUE))) {
601
+ const groups = rowsOf(
602
+ await query(`SELECT MIN(r_name) AS name, COUNT(*) AS n
603
+ FROM ${N.ROLES}
604
+ GROUP BY r_name
605
+ HAVING COUNT(*) > 1
606
+ ORDER BY n DESC`),
607
+ );
608
+ if (groups.length > 0) {
609
+ throw duplicateError(
610
+ N.APP,
611
+ groups,
612
+ 'across companies',
613
+ 'Nothing was rolled back: the global unique name index cannot be restored while these ' +
614
+ 'exist (split copies are not merged back). Rename or remove them first.',
615
+ );
616
+ }
617
+ }
618
+ await query(`DROP INDEX IF EXISTS ${COMPANY_UNIQUE} ON ${N.ROLES}`);
619
+ await query(`CREATE UNIQUE INDEX IF NOT EXISTS ${GLOBAL_UNIQUE} ON ${N.ROLES} (r_name)`);
620
+ }
621
+
622
+ module.exports = { up, down };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astram/identity",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "license": "MIT",
5
5
  "description": "Astram framework — the framework-owned identity + tenancy model. Hosts the identity @ObjectType classes (user, company, membership, posting), framework-authored identity migrations, and the identity resolver. Top of the dependency graph: depends on @astram/engine (for @ObjectType/@Field decorators) + @astram/guard / @astram/core (for the auth ports).",
6
6
  "type": "commonjs",
@@ -36,9 +36,9 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "mariadb": "^3.5.4",
39
- "@astram/core": "1.10.0",
40
- "@astram/engine": "0.10.2",
41
- "@astram/guard": "0.19.0"
39
+ "@astram/core": "1.11.0",
40
+ "@astram/guard": "0.20.0",
41
+ "@astram/engine": "0.11.0"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@nestjs/common": "^11",