@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.
- package/dist/branch.object.d.ts +9 -1
- package/dist/branch.object.d.ts.map +1 -1
- package/dist/branch.object.js +14 -1
- package/dist/company-branch.object.d.ts +5 -4
- package/dist/company-branch.object.d.ts.map +1 -1
- package/dist/company-branch.object.js +6 -4
- package/dist/company-department.object.d.ts +6 -6
- package/dist/company-department.object.d.ts.map +1 -1
- package/dist/company-department.object.js +7 -6
- package/dist/department.object.d.ts +9 -1
- package/dist/department.object.d.ts.map +1 -1
- package/dist/department.object.js +14 -1
- package/dist/features/access/access-admin.access.d.ts.map +1 -1
- package/dist/features/access/access-admin.access.js +8 -1
- package/dist/features/access/index.d.ts +1 -0
- package/dist/features/access/index.d.ts.map +1 -1
- package/dist/features/access/index.js +10 -2
- package/dist/features/access/org-structure.access.d.ts +3 -0
- package/dist/features/access/org-structure.access.d.ts.map +1 -0
- package/dist/features/access/org-structure.access.js +41 -0
- package/dist/features/access/user-management.access.d.ts.map +1 -1
- package/dist/features/access/user-management.access.js +5 -0
- package/dist/features/audit/audit-read.service.d.ts +32 -1
- package/dist/features/audit/audit-read.service.d.ts.map +1 -1
- package/dist/features/audit/audit-read.service.js +67 -14
- package/dist/features/audit/auth-audit-sink.d.ts +19 -0
- package/dist/features/audit/auth-audit-sink.d.ts.map +1 -0
- package/dist/features/audit/auth-audit-sink.js +24 -0
- package/dist/features/audit/directory-label-resolver.d.ts +10 -1
- package/dist/features/audit/directory-label-resolver.d.ts.map +1 -1
- package/dist/features/audit/directory-label-resolver.js +28 -1
- package/dist/features/authz/grant-admin.controller.d.ts +4 -4
- package/dist/features/authz/grant-admin.controller.js +4 -4
- package/dist/features/authz/grant-admin.service.d.ts +9 -8
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.service.js +20 -24
- package/dist/features/authz/grant-ceiling.d.ts +35 -16
- package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
- package/dist/features/authz/grant-ceiling.js +44 -29
- package/dist/features/authz/role-binding.d.ts +25 -24
- package/dist/features/authz/role-binding.d.ts.map +1 -1
- package/dist/features/authz/role-binding.js +31 -44
- package/dist/features/identity-feature.module.d.ts +0 -11
- package/dist/features/identity-feature.module.d.ts.map +1 -1
- package/dist/features/identity-feature.module.js +90 -1
- package/dist/features/membership/membership.controller.d.ts.map +1 -1
- package/dist/features/membership/membership.controller.js +20 -6
- package/dist/features/membership/membership.service.d.ts +16 -10
- package/dist/features/membership/membership.service.d.ts.map +1 -1
- package/dist/features/membership/membership.service.js +61 -21
- package/dist/features/membership/posting.service.d.ts +25 -6
- package/dist/features/membership/posting.service.d.ts.map +1 -1
- package/dist/features/membership/posting.service.js +63 -16
- package/dist/features/org-bootstrap/org-bootstrap-file.d.ts +6 -2
- package/dist/features/org-bootstrap/org-bootstrap-file.d.ts.map +1 -1
- package/dist/features/org-bootstrap/org-bootstrap-file.js +54 -11
- package/dist/features/org-bootstrap/org-bootstrap-plan.d.ts +30 -11
- package/dist/features/org-bootstrap/org-bootstrap-plan.d.ts.map +1 -1
- package/dist/features/org-bootstrap/org-bootstrap-plan.js +172 -68
- package/dist/features/org-bootstrap/org-state.reader.d.ts.map +1 -1
- package/dist/features/org-bootstrap/org-state.reader.js +56 -49
- package/dist/features/org-bootstrap/run-org-bootstrap.d.ts +6 -1
- package/dist/features/org-bootstrap/run-org-bootstrap.d.ts.map +1 -1
- package/dist/features/org-bootstrap/run-org-bootstrap.js +81 -92
- package/dist/features/org-structure/org-catalog.controller.d.ts +29 -6
- package/dist/features/org-structure/org-catalog.controller.d.ts.map +1 -1
- package/dist/features/org-structure/org-catalog.controller.js +105 -17
- package/dist/features/org-structure/org-catalog.service.d.ts +99 -18
- package/dist/features/org-structure/org-catalog.service.d.ts.map +1 -1
- package/dist/features/org-structure/org-catalog.service.js +327 -51
- package/dist/features/org-structure/org-unit-name.d.ts +31 -0
- package/dist/features/org-structure/org-unit-name.d.ts.map +1 -0
- package/dist/features/org-structure/org-unit-name.js +50 -0
- package/dist/features/role/role.controller.d.ts +28 -13
- package/dist/features/role/role.controller.d.ts.map +1 -1
- package/dist/features/role/role.controller.js +54 -14
- package/dist/features/role/role.service.d.ts +76 -21
- package/dist/features/role/role.service.d.ts.map +1 -1
- package/dist/features/role/role.service.js +204 -27
- package/dist/features/security-policy/security-policy-version-bump.d.ts +41 -0
- package/dist/features/security-policy/security-policy-version-bump.d.ts.map +1 -0
- package/dist/features/security-policy/security-policy-version-bump.js +65 -0
- package/dist/features/security-policy/security-policy.controller.d.ts +51 -0
- package/dist/features/security-policy/security-policy.controller.d.ts.map +1 -0
- package/dist/features/security-policy/security-policy.controller.js +121 -0
- package/dist/features/security-policy/security-policy.service.d.ts +94 -0
- package/dist/features/security-policy/security-policy.service.d.ts.map +1 -0
- package/dist/features/security-policy/security-policy.service.js +429 -0
- package/dist/features/service-account/service-account.service.d.ts +13 -3
- package/dist/features/service-account/service-account.service.d.ts.map +1 -1
- package/dist/features/service-account/service-account.service.js +48 -10
- package/dist/features/service-account/service-account.store.d.ts +11 -1
- package/dist/features/service-account/service-account.store.d.ts.map +1 -1
- package/dist/features/service-account/service-account.store.js +20 -1
- package/dist/features/user/signin-admin.tokens.d.ts +11 -0
- package/dist/features/user/signin-admin.tokens.d.ts.map +1 -0
- package/dist/features/user/signin-admin.tokens.js +13 -0
- package/dist/features/user/user-mfa-status.d.ts +38 -0
- package/dist/features/user/user-mfa-status.d.ts.map +1 -0
- package/dist/features/user/user-mfa-status.js +47 -0
- package/dist/features/user/user-signin-admin.service.d.ts +48 -0
- package/dist/features/user/user-signin-admin.service.d.ts.map +1 -0
- package/dist/features/user/user-signin-admin.service.js +232 -0
- package/dist/features/user/user.controller.d.ts +29 -2
- package/dist/features/user/user.controller.d.ts.map +1 -1
- package/dist/features/user/user.controller.js +71 -3
- package/dist/features/user/user.service.d.ts +40 -14
- package/dist/features/user/user.service.d.ts.map +1 -1
- package/dist/features/user/user.service.js +143 -10
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/objects.d.ts.map +1 -1
- package/dist/objects.js +2 -0
- package/dist/role.object.d.ts +1 -1
- package/dist/role.object.d.ts.map +1 -1
- package/dist/role.object.js +6 -15
- package/dist/security-policy.object.d.ts +43 -0
- package/dist/security-policy.object.d.ts.map +1 -0
- package/dist/security-policy.object.js +107 -0
- package/dist/user.object.d.ts +6 -0
- package/dist/user.object.d.ts.map +1 -1
- package/dist/user.object.js +13 -1
- package/migrations/1710000000103-AddUserLoginName.cjs +87 -0
- package/migrations/1710000000104-CreateSecurityPolicy.cjs +90 -0
- package/migrations/1710000000105-OwnDepartmentsAndBranches.cjs +327 -0
- package/migrations/1710000000106-OwnRolesPerCompany.cjs +622 -0
- 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.
|
|
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.
|
|
40
|
-
"@astram/
|
|
41
|
-
"@astram/
|
|
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",
|