@astram/identity 0.8.0 → 0.9.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/shared/identity-error.interceptor.d.ts.map +1 -1
- package/dist/features/shared/identity-error.interceptor.js +2 -0
- package/dist/features/shared/identity-errors.d.ts +8 -0
- package/dist/features/shared/identity-errors.d.ts.map +1 -1
- package/dist/features/shared/identity-errors.js +13 -1
- package/dist/features/user/user.service.d.ts +4 -0
- package/dist/features/user/user.service.d.ts.map +1 -1
- package/dist/features/user/user.service.js +51 -10
- package/migrations/1710000000021-AddUsersEmailUniqueIndex.cjs +145 -0
- package/package.json +3 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"identity-error.interceptor.d.ts","sourceRoot":"","sources":["../../../src/features/shared/identity-error.interceptor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAQL,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAA0B,KAAK,UAAU,EAAE,MAAM,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"identity-error.interceptor.d.ts","sourceRoot":"","sources":["../../../src/features/shared/identity-error.interceptor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAQL,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAA0B,KAAK,UAAU,EAAE,MAAM,MAAM,CAAC;AAsB/D;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAyBzD;AAED,qBACa,wBAAyB,YAAW,eAAe;IAC9D,SAAS,CAAC,QAAQ,EAAE,gBAAgB,EAAE,IAAI,EAAE,WAAW,GAAG,UAAU,CAAC,OAAO,CAAC;CAK9E"}
|
|
@@ -55,6 +55,8 @@ function toIdentityHttpError(err) {
|
|
|
55
55
|
return new common_1.ConflictException(err.message);
|
|
56
56
|
if (err instanceof identity_errors_js_1.IdentityBadRequestError)
|
|
57
57
|
return new common_1.BadRequestException(err.message);
|
|
58
|
+
if (err instanceof identity_errors_js_1.IdentityUnavailableError)
|
|
59
|
+
return new common_1.ServiceUnavailableException(err.message);
|
|
58
60
|
if (err instanceof guard_1.ScopeCycleError) {
|
|
59
61
|
return new common_1.ConflictException('That move would put an organisation inside its own sub-tree.');
|
|
60
62
|
}
|
|
@@ -16,6 +16,14 @@ export declare class IdentityNotFoundError extends Error {
|
|
|
16
16
|
export declare class IdentityConflictError extends Error {
|
|
17
17
|
constructor(message: string);
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* A store the action needs could not answer (a deadlock, a lock timeout, an outage) and nothing
|
|
21
|
+
* was changed (→ 503, retryable). The message is fixed: never the driver's text, which can name
|
|
22
|
+
* the values written.
|
|
23
|
+
*/
|
|
24
|
+
export declare class IdentityUnavailableError extends Error {
|
|
25
|
+
constructor(message: string);
|
|
26
|
+
}
|
|
19
27
|
/** The request is malformed (→ 400). */
|
|
20
28
|
export declare class IdentityBadRequestError extends Error {
|
|
21
29
|
constructor(message: string);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"identity-errors.d.ts","sourceRoot":"","sources":["../../../src/features/shared/identity-errors.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,qEAAqE;AACrE,qBAAa,sBAAuB,SAAQ,KAAK;gBACnC,OAAO,EAAE,MAAM;CAI5B;AAED,6FAA6F;AAC7F,qBAAa,qBAAsB,SAAQ,KAAK;gBAClC,OAAO,EAAE,MAAM;CAI5B;AAED,uFAAuF;AACvF,qBAAa,qBAAsB,SAAQ,KAAK;gBAClC,OAAO,EAAE,MAAM;CAI5B;AAED,wCAAwC;AACxC,qBAAa,uBAAwB,SAAQ,KAAK;gBACpC,OAAO,EAAE,MAAM;CAI5B"}
|
|
1
|
+
{"version":3,"file":"identity-errors.d.ts","sourceRoot":"","sources":["../../../src/features/shared/identity-errors.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,qEAAqE;AACrE,qBAAa,sBAAuB,SAAQ,KAAK;gBACnC,OAAO,EAAE,MAAM;CAI5B;AAED,6FAA6F;AAC7F,qBAAa,qBAAsB,SAAQ,KAAK;gBAClC,OAAO,EAAE,MAAM;CAI5B;AAED,uFAAuF;AACvF,qBAAa,qBAAsB,SAAQ,KAAK;gBAClC,OAAO,EAAE,MAAM;CAI5B;AAED;;;;GAIG;AACH,qBAAa,wBAAyB,SAAQ,KAAK;gBACrC,OAAO,EAAE,MAAM;CAI5B;AAED,wCAAwC;AACxC,qBAAa,uBAAwB,SAAQ,KAAK;gBACpC,OAAO,EAAE,MAAM;CAI5B"}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Messages are user-facing: they name no raw ids (ADR-0012) and carry no store detail.
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.IdentityBadRequestError = exports.IdentityConflictError = exports.IdentityNotFoundError = exports.IdentityForbiddenError = void 0;
|
|
9
|
+
exports.IdentityBadRequestError = exports.IdentityUnavailableError = exports.IdentityConflictError = exports.IdentityNotFoundError = exports.IdentityForbiddenError = void 0;
|
|
10
10
|
/** The caller may not perform this action on this target (→ 403). */
|
|
11
11
|
class IdentityForbiddenError extends Error {
|
|
12
12
|
constructor(message) {
|
|
@@ -31,6 +31,18 @@ class IdentityConflictError extends Error {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
exports.IdentityConflictError = IdentityConflictError;
|
|
34
|
+
/**
|
|
35
|
+
* A store the action needs could not answer (a deadlock, a lock timeout, an outage) and nothing
|
|
36
|
+
* was changed (→ 503, retryable). The message is fixed: never the driver's text, which can name
|
|
37
|
+
* the values written.
|
|
38
|
+
*/
|
|
39
|
+
class IdentityUnavailableError extends Error {
|
|
40
|
+
constructor(message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = 'IdentityUnavailableError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
exports.IdentityUnavailableError = IdentityUnavailableError;
|
|
34
46
|
/** The request is malformed (→ 400). */
|
|
35
47
|
class IdentityBadRequestError extends Error {
|
|
36
48
|
constructor(message) {
|
|
@@ -45,6 +45,8 @@ export declare class UserService {
|
|
|
45
45
|
* Create a user. With `companyUuid`, the user is created AND attached (one active membership) in
|
|
46
46
|
* ONE transaction — the onboarding path for a non-root admin, whose membership create is checked
|
|
47
47
|
* by the engine's create wall (a company outside the caller's subtree ⇒ 403, nothing persists).
|
|
48
|
+
* A username already taken (KI #87: also a concurrent create of the same one; the unique index
|
|
49
|
+
* decides) ⇒ {@link IdentityConflictError} (409), nothing persists.
|
|
48
50
|
*/
|
|
49
51
|
createUser(actor: Principal, input: {
|
|
50
52
|
username: string;
|
|
@@ -64,6 +66,8 @@ export declare class UserService {
|
|
|
64
66
|
* (engine shape, fail closed). Reset links are burned BEFORE (a failure aborts the change).
|
|
65
67
|
*/
|
|
66
68
|
private changeEmail;
|
|
69
|
+
/** The helper call with the audit row on its transaction (see {@link changeEmail}). */
|
|
70
|
+
private applyEmailChange;
|
|
67
71
|
/**
|
|
68
72
|
* After a status change (security hardening Phase 3): bump every tenant the user is a member of
|
|
69
73
|
* (the next request reloads its grants: none while disabled, back when re-enabled), and, when the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"user.service.d.ts","sourceRoot":"","sources":["../../../src/features/user/user.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAoB,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACjF,OAAO,KAAK,EAAc,SAAS,EAAE,MAAM,cAAc,CAAC;AAC1D,OAAO,EAOL,KAAK,mBAAmB,
|
|
1
|
+
{"version":3,"file":"user.service.d.ts","sourceRoot":"","sources":["../../../src/features/user/user.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAoB,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACjF,OAAO,KAAK,EAAc,SAAS,EAAE,MAAM,cAAc,CAAC;AAC1D,OAAO,EAOL,KAAK,mBAAmB,EAExB,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,cAAc,EACpB,MAAM,eAAe,CAAC;AACvB,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,uCAAuC,CAAC;AAC/C,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1E,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAqCzD,4EAA4E;AAC5E,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBACa,WAAW;IAMpB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAGxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IACY,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAMtE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;IAK1B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAI7B,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC;IAzBlC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgC;IACvD,4FAA4F;IAC5F,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA4B;gBAGpC,OAAO,EAAE,cAAc,EACvB,OAAO,EAAE,YAAY,EAGrB,WAAW,CAAC,GAAE,eAAe,GAAG,IAAI,aAAA,EACK,WAAW,CAAC,GAAE,WAAW,GAAG,IAAI,aAAA,EAMzE,QAAQ,CAAC,GAAE,cAAc,GAAG,IAAI,aAAA,EAKhC,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA,EAIvC,cAAc,CAAC,GAAE,mBAAmB,GAAG,IAAI,aAAA;IAG9D;;;;;;OAMG;IACG,UAAU,CACd,KAAK,EAAE,SAAS,EAChB,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,GAC5D,OAAO,CAAC,QAAQ,CAAC;IAkBpB,uEAAuE;IACvE,SAAS,CAAC,MAAM,CAAC,EAAE,UAAU;IAI7B,0BAA0B;IAC1B,OAAO,CAAC,IAAI,EAAE,MAAM;IAIpB,mGAAmG;IAC7F,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAsCjF;;;;;;OAMG;YACW,WAAW;IAyBzB,uFAAuF;IACvF,OAAO,CAAC,gBAAgB;IA8BxB;;;;;;;;;OASG;YACW,iBAAiB;IAY/B,uDAAuD;IACjD,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAO/D,6FAA6F;YAC/E,kBAAkB;CAkBjC"}
|
|
@@ -22,6 +22,31 @@ const list_query_js_1 = require("../shared/list-query.js");
|
|
|
22
22
|
const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
|
|
23
23
|
const identity_errors_js_1 = require("../shared/identity-errors.js");
|
|
24
24
|
const email_address_changer_token_js_1 = require("./email-address-changer.token.js");
|
|
25
|
+
/**
|
|
26
|
+
* KI #87: `users` has two unique keys besides the PK, `IDX_users_username` (migration 001) and
|
|
27
|
+
* `uq_users_email` (021, `utf8mb4_unicode_ci`: folds case, accents, width, trailing spaces). The
|
|
28
|
+
* engine passes the driver's duplicate-key error through unchanged; this service, which knows
|
|
29
|
+
* those are the only keys a user write can collide on, answers it as a 409 with a fixed text.
|
|
30
|
+
* Recognised by errno/code only: the driver's message names the value and is never echoed.
|
|
31
|
+
*/
|
|
32
|
+
function isDuplicateKey(err) {
|
|
33
|
+
if (typeof err !== 'object' || err === null)
|
|
34
|
+
return false;
|
|
35
|
+
const e = err;
|
|
36
|
+
return e.errno === 1062 || e.code === 'ER_DUP_ENTRY';
|
|
37
|
+
}
|
|
38
|
+
const USERNAME_OR_EMAIL_TAKEN = 'That username or email is already in use.';
|
|
39
|
+
/** Run a user write; a duplicate key becomes {@link IdentityConflictError}, all else rethrows. */
|
|
40
|
+
async function mappingDuplicates(write) {
|
|
41
|
+
try {
|
|
42
|
+
return await write();
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (isDuplicateKey(err))
|
|
46
|
+
throw new identity_errors_js_1.IdentityConflictError(USERNAME_OR_EMAIL_TAKEN);
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
25
50
|
const USER_SEARCH_FIELDS = ['username', 'email'];
|
|
26
51
|
const USER_SORT_FIELDS = ['username', 'email', 'status', 'createdAt'];
|
|
27
52
|
/**
|
|
@@ -67,19 +92,21 @@ let UserService = UserService_1 = class UserService {
|
|
|
67
92
|
* Create a user. With `companyUuid`, the user is created AND attached (one active membership) in
|
|
68
93
|
* ONE transaction — the onboarding path for a non-root admin, whose membership create is checked
|
|
69
94
|
* by the engine's create wall (a company outside the caller's subtree ⇒ 403, nothing persists).
|
|
95
|
+
* A username already taken (KI #87: also a concurrent create of the same one; the unique index
|
|
96
|
+
* decides) ⇒ {@link IdentityConflictError} (409), nothing persists.
|
|
70
97
|
*/
|
|
71
98
|
async createUser(actor, input) {
|
|
72
99
|
if (!input.companyUuid) {
|
|
73
|
-
return this.records.create('user', { fields: { username: input.username } });
|
|
100
|
+
return mappingDuplicates(() => this.records.create('user', { fields: { username: input.username } }));
|
|
74
101
|
}
|
|
75
102
|
const companyUuid = input.companyUuid;
|
|
76
|
-
return this.records.transaction(async (tx) => {
|
|
103
|
+
return mappingDuplicates(() => this.records.transaction(async (tx) => {
|
|
77
104
|
const id = await tx.create('user', { fields: { username: input.username } });
|
|
78
105
|
await tx.create('company_membership', {
|
|
79
106
|
fields: { userUuid: id, companyUuid, isActive: true, status: 'active' },
|
|
80
107
|
});
|
|
81
108
|
return id;
|
|
82
|
-
});
|
|
109
|
+
}));
|
|
83
110
|
}
|
|
84
111
|
/** List users (tenant-scoped by the pipe), with paging/search/sort. */
|
|
85
112
|
listUsers(params) {
|
|
@@ -121,7 +148,7 @@ let UserService = UserService_1 = class UserService {
|
|
|
121
148
|
if (emailChanged)
|
|
122
149
|
await this.changeEmail(actor, uuid, newEmail);
|
|
123
150
|
if (Object.keys(fields).length > 0) {
|
|
124
|
-
await this.records.update('user', uuid, { fields });
|
|
151
|
+
await mappingDuplicates(() => this.records.update('user', uuid, { fields }));
|
|
125
152
|
}
|
|
126
153
|
if (statusChanged || wantsInactive)
|
|
127
154
|
await this.applyStatusChange(uuid, String(patch.status));
|
|
@@ -143,7 +170,26 @@ let UserService = UserService_1 = class UserService {
|
|
|
143
170
|
}
|
|
144
171
|
if (this.resetTokens)
|
|
145
172
|
await this.resetTokens.invalidateAllForPrincipal(uuid);
|
|
146
|
-
|
|
173
|
+
let outcome;
|
|
174
|
+
try {
|
|
175
|
+
outcome = await this.applyEmailChange(changer, actor, uuid, newEmail);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// The helper's contract: a throw is a store error (a 1213 deadlock, the named-lock timeout,
|
|
179
|
+
// the audit write), rolled back. Retryable → 503; the driver text (it can name the address)
|
|
180
|
+
// is neither returned nor logged.
|
|
181
|
+
this.logger.warn('admin email change could not be applied (store error); nothing changed');
|
|
182
|
+
throw new identity_errors_js_1.IdentityUnavailableError('The email address could not be changed right now. Try again.');
|
|
183
|
+
}
|
|
184
|
+
if (outcome.status === 'unavailable') {
|
|
185
|
+
throw new identity_errors_js_1.IdentityConflictError('That email address is already in use.');
|
|
186
|
+
}
|
|
187
|
+
if (outcome.status !== 'changed')
|
|
188
|
+
throw new identity_errors_js_1.IdentityNotFoundError('User not found.');
|
|
189
|
+
}
|
|
190
|
+
/** The helper call with the audit row on its transaction (see {@link changeEmail}). */
|
|
191
|
+
applyEmailChange(changer, actor, uuid, newEmail) {
|
|
192
|
+
return changer.change({
|
|
147
193
|
principalId: uuid,
|
|
148
194
|
newEmail,
|
|
149
195
|
withinTransaction: async (query, result) => {
|
|
@@ -165,11 +211,6 @@ let UserService = UserService_1 = class UserService {
|
|
|
165
211
|
await this.emailAudit.record(event, (sql, params) => query(sql, params));
|
|
166
212
|
},
|
|
167
213
|
});
|
|
168
|
-
if (outcome.status === 'unavailable') {
|
|
169
|
-
throw new identity_errors_js_1.IdentityConflictError('That email address is already in use.');
|
|
170
|
-
}
|
|
171
|
-
if (outcome.status !== 'changed')
|
|
172
|
-
throw new identity_errors_js_1.IdentityNotFoundError('User not found.');
|
|
173
214
|
}
|
|
174
215
|
/**
|
|
175
216
|
* After a status change (security hardening Phase 3): bump every tenant the user is a member of
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework identity migration — `UNIQUE INDEX uq_users_email (u_email)` on `shield_app.users`
|
|
3
|
+
* (KI #87, follow-up to security hardening Phase 12 / KI #14).
|
|
4
|
+
*
|
|
5
|
+
* WHY: `u_email` (VARCHAR(255) NULL, created by `...001`) had no index and no uniqueness, so two
|
|
6
|
+
* users could hold the same address (a concurrent create, a direct SQL write) and every collision
|
|
7
|
+
* read scanned the table. The email is a credential attribute (forgot-password mails it), so
|
|
8
|
+
* uniqueness is now a database guarantee: a duplicate write fails with ER_DUP_ENTRY (1062).
|
|
9
|
+
*
|
|
10
|
+
* WHAT COUNTS AS EQUAL is the column collation, `utf8mb4_unicode_ci` (PAD SPACE), probed live on
|
|
11
|
+
* MariaDB 11.4: case (`A` = `a`), trailing spaces (`a@x.com ` = `a@x.com`), full-width forms
|
|
12
|
+
* (`a` = `a`), accents (`é` = `e`), `ß` = `ss`, ligatures and ignorable characters (a zero-width
|
|
13
|
+
* space, a trailing no-break space) all fold. NOT folded: LEADING spaces and other trailing
|
|
14
|
+
* whitespace (tab, newline). The app writes `canonicalEmail` (trim → NFKC → lower-case), so those
|
|
15
|
+
* never reach the column through the framework.
|
|
16
|
+
*
|
|
17
|
+
* Steps (inside the runner's ONE transaction for this migration, `@astram/migrate`):
|
|
18
|
+
* 0. Skip entirely when `uq_users_email` already exists as a UNIQUE index (idempotent re-run).
|
|
19
|
+
* 1. Normalise empty / whitespace-only `u_email` to NULL (`''` and `' '` are collation-equal
|
|
20
|
+
* and would collide; NULLs may repeat in a UNIQUE index).
|
|
21
|
+
* 2. PRE-CHECK: `GROUP BY u_email HAVING COUNT(*) > 1` (grouped by the same collation the index
|
|
22
|
+
* uses). Any group ⇒ the migration THROWS, listing each group as a MASKED address
|
|
23
|
+
* (`a***@example.com`) and a count — never a user id, never a full address. The throw rolls
|
|
24
|
+
* the transaction back, so step 1 is undone too and NO row is changed. Duplicates are never
|
|
25
|
+
* auto-resolved: the operator gives each user a distinct address (e.g. the admin user edit)
|
|
26
|
+
* and re-runs.
|
|
27
|
+
* 3. `ADD UNIQUE INDEX IF NOT EXISTS` (DDL: implicit commit of step 1). A duplicate written
|
|
28
|
+
* between step 2 and the build fails the build with 1062; it is reported the same masked way
|
|
29
|
+
* (the driver's own message would echo the full address). On THAT late path step 1 is already
|
|
30
|
+
* committed (blanks stay NULL); the report says so, and a re-run is safe.
|
|
31
|
+
*
|
|
32
|
+
* `down` drops the index only (`DROP INDEX IF EXISTS`). The NULL normalisation is NOT reverted:
|
|
33
|
+
* an empty address and NULL mean the same thing (no email), and the original values are gone.
|
|
34
|
+
*
|
|
35
|
+
* Literal schema/table/column names, parameterized values, no ids. Run by the DDL account
|
|
36
|
+
* (`shield_migrate`: DDL + DML on `shield_app`). `shield_core` / `shield_app_crud` need no new
|
|
37
|
+
* grant. Sorts after `...001` (creates `users`) and `...020` (the latest), before the dev seeds
|
|
38
|
+
* (`...099`/`...100`, distinct addresses).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const SCHEMA = 'shield_app';
|
|
42
|
+
const TABLE = 'users';
|
|
43
|
+
const COLUMN = 'u_email';
|
|
44
|
+
const INDEX = 'uq_users_email';
|
|
45
|
+
const MIGRATION = '1710000000021-AddUsersEmailUniqueIndex';
|
|
46
|
+
/** Groups listed in the failure message; the rest are counted. */
|
|
47
|
+
const MAX_LISTED_GROUPS = 50;
|
|
48
|
+
const ER_DUP_ENTRY = 1062;
|
|
49
|
+
|
|
50
|
+
/** Same rule as `@astram/guard` `maskEmail` (kept local: a migration loads no runtime package). */
|
|
51
|
+
function maskEmail(value) {
|
|
52
|
+
const at = value.lastIndexOf('@');
|
|
53
|
+
if (at <= 0 || at === value.length - 1) return '***';
|
|
54
|
+
return `${value.slice(0, 1)}***@${value.slice(at + 1)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `null` = no index of that name; otherwise whether it is UNIQUE. */
|
|
58
|
+
async function indexState(query) {
|
|
59
|
+
const rows = await query(
|
|
60
|
+
`SELECT NON_UNIQUE AS non_unique
|
|
61
|
+
FROM information_schema.STATISTICS
|
|
62
|
+
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME = ?
|
|
63
|
+
LIMIT 1`,
|
|
64
|
+
[SCHEMA, TABLE, INDEX],
|
|
65
|
+
);
|
|
66
|
+
if (!Array.isArray(rows) || rows.length === 0) return null;
|
|
67
|
+
return { unique: Number(rows[0].non_unique) === 0 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Duplicate groups by the column collation: `[{ masked, count }]`, largest first. */
|
|
71
|
+
async function duplicateGroups(query) {
|
|
72
|
+
const rows = await query(
|
|
73
|
+
`SELECT ${COLUMN} AS sample, COUNT(*) AS n
|
|
74
|
+
FROM \`${SCHEMA}\`.\`${TABLE}\`
|
|
75
|
+
WHERE ${COLUMN} IS NOT NULL
|
|
76
|
+
GROUP BY ${COLUMN}
|
|
77
|
+
HAVING COUNT(*) > 1
|
|
78
|
+
ORDER BY n DESC`,
|
|
79
|
+
);
|
|
80
|
+
// The group's representative is any member; trim it so a padded one masks the same way.
|
|
81
|
+
return (Array.isArray(rows) ? rows : []).map((r) => ({
|
|
82
|
+
masked: maskEmail(String(r.sample).trim()),
|
|
83
|
+
count: Number(r.n),
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function duplicateError(groups, emptiesNormalised) {
|
|
88
|
+
const listed = groups.slice(0, MAX_LISTED_GROUPS).map((g) => ` - ${g.masked}: ${g.count} users`);
|
|
89
|
+
if (groups.length > MAX_LISTED_GROUPS) {
|
|
90
|
+
listed.push(` - ... and ${groups.length - MAX_LISTED_GROUPS} more group(s)`);
|
|
91
|
+
}
|
|
92
|
+
const changed = emptiesNormalised
|
|
93
|
+
? 'No index was created and no duplicate was modified (empty or whitespace-only addresses were set to NULL).'
|
|
94
|
+
: 'No index was created and no row was changed.';
|
|
95
|
+
return new Error(
|
|
96
|
+
`${MIGRATION}: cannot add UNIQUE index ${INDEX} on ${SCHEMA}.${TABLE}.${COLUMN}: ` +
|
|
97
|
+
`${groups.length} email address(es) are held by more than one user ` +
|
|
98
|
+
'(compared as the column collation utf8mb4_unicode_ci does: case-, accent-, width- and ' +
|
|
99
|
+
'trailing-space-insensitive; addresses masked):\n' +
|
|
100
|
+
`${listed.join('\n')}\n` +
|
|
101
|
+
`${changed} Give each of these users a distinct address or clear it (for example with the ` +
|
|
102
|
+
'administrator user edit, PUT /identity/users/:uuid), then re-run astram-migrate. ' +
|
|
103
|
+
'See "Upgrading from 0.9.x" in the create-astram-app README.',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function up({ query }) {
|
|
108
|
+
const state = await indexState(query);
|
|
109
|
+
if (state !== null) {
|
|
110
|
+
if (state.unique) return; // already applied (idempotent re-run)
|
|
111
|
+
throw new Error(
|
|
112
|
+
`${MIGRATION}: ${SCHEMA}.${TABLE} has a NON-unique index named ${INDEX}; ` +
|
|
113
|
+
'drop or rename it, then re-run astram-migrate.',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await query(
|
|
118
|
+
`UPDATE \`${SCHEMA}\`.\`${TABLE}\`
|
|
119
|
+
SET ${COLUMN} = NULL
|
|
120
|
+
WHERE ${COLUMN} IS NOT NULL AND ${COLUMN} REGEXP '^[[:space:]]*$'`,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const groups = await duplicateGroups(query);
|
|
124
|
+
if (groups.length > 0) throw duplicateError(groups, false);
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
await query(
|
|
128
|
+
`ALTER TABLE \`${SCHEMA}\`.\`${TABLE}\` ADD UNIQUE INDEX IF NOT EXISTS \`${INDEX}\` (${COLUMN})`,
|
|
129
|
+
);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (err === null || typeof err !== 'object' || err.errno !== ER_DUP_ENTRY) throw err;
|
|
132
|
+
// A duplicate was written after the pre-check. Never surface the driver message (full address).
|
|
133
|
+
const late = await duplicateGroups(query);
|
|
134
|
+
if (late.length > 0) throw duplicateError(late, true);
|
|
135
|
+
throw new Error(
|
|
136
|
+
`${MIGRATION}: a duplicate ${COLUMN} was written while the index was built; re-run astram-migrate.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function down({ query }) {
|
|
142
|
+
await query(`ALTER TABLE \`${SCHEMA}\`.\`${TABLE}\` DROP INDEX IF EXISTS \`${INDEX}\``);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
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.9.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/guard": "0.11.0",
|
|
40
39
|
"@astram/core": "1.4.0",
|
|
41
|
-
"@astram/engine": "0.5.2"
|
|
40
|
+
"@astram/engine": "0.5.2",
|
|
41
|
+
"@astram/guard": "0.11.2"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
44
|
"@nestjs/common": "^11",
|