@astram/identity 0.7.2 → 0.8.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/identity-feature.module.d.ts.map +1 -1
- package/dist/features/identity-feature.module.js +7 -0
- package/dist/features/user/email-address-changer.token.d.ts +8 -0
- package/dist/features/user/email-address-changer.token.d.ts.map +1 -0
- package/dist/features/user/email-address-changer.token.js +10 -0
- package/dist/features/user/user.service.d.ts +18 -2
- package/dist/features/user/user.service.d.ts.map +1 -1
- package/dist/features/user/user.service.js +66 -8
- package/migrations/1710000000020-CreateEmailChangeToken.cjs +62 -0
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"identity-feature.module.d.ts","sourceRoot":"","sources":["../../src/features/identity-feature.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpE,OAAO,EAYL,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"identity-feature.module.d.ts","sourceRoot":"","sources":["../../src/features/identity-feature.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpE,OAAO,EAYL,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AA+FxB;;;;;;GAMG;AACH,wBAAgB,0CAA0C,CAAC,SAAS,EAAE,OAAO,GAAG,IAAI,CAUnF;AAiBD;;;;;;;;;;GAUG;AACH,qBACa,qBAAqB;IAChC;;;;;;;;;;;;;;OAcG;IACH,MAAM,CAAC,OAAO,CAAC,EACb,OAAO,EACP,YAAiB,GAClB,GAAE;QACD,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;QAC5B,YAAY,CAAC,EAAE,SAAS,eAAe,EAAE,CAAC;KACtC,GAAG,aAAa;CAoevB"}
|
|
@@ -21,6 +21,7 @@ const company_hierarchy_service_js_1 = require("./company/company-hierarchy.serv
|
|
|
21
21
|
const role_service_js_1 = require("./role/role.service.js");
|
|
22
22
|
const role_controller_js_1 = require("./role/role.controller.js");
|
|
23
23
|
const user_service_js_1 = require("./user/user.service.js");
|
|
24
|
+
const email_address_changer_token_js_1 = require("./user/email-address-changer.token.js");
|
|
24
25
|
const user_controller_js_1 = require("./user/user.controller.js");
|
|
25
26
|
const membership_controller_js_1 = require("./membership/membership.controller.js");
|
|
26
27
|
const membership_service_js_1 = require("./membership/membership.service.js");
|
|
@@ -475,6 +476,12 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
475
476
|
org_catalog_service_js_1.OrgCatalogService,
|
|
476
477
|
record_resolver_js_1.RecordResolverService,
|
|
477
478
|
{ provide: posting_version_bump_js_1.POSTING_VERSION_BUMP, useValue: postingVersionBump },
|
|
479
|
+
// Phase 12 (KI #14): the admin email change goes through guard's ONE email-change helper
|
|
480
|
+
// (collision refusal + login-hint follow in one transaction), over the shared authz pool.
|
|
481
|
+
{
|
|
482
|
+
provide: email_address_changer_token_js_1.IDENTITY_EMAIL_ADDRESS_CHANGER,
|
|
483
|
+
useValue: new guard_1.MariaDbEmailAddressChanger(authzPool),
|
|
484
|
+
},
|
|
478
485
|
// ADR-0035 (GAPS #6): attach the guard's Redis permission-version cache to the ONE shared
|
|
479
486
|
// bumper, so every committed bump (postings, grants, hierarchy) is published to Redis,
|
|
480
487
|
// where gate 5 reads it. Provided by the auth surface under `auth: true` (through the
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DI token for the administrator path's email changer (security hardening Phase 12, KI #14):
|
|
3
|
+
* guard's `MariaDbEmailAddressChanger`, built by `IdentityFeatureModule.forRoot()` over the ONE
|
|
4
|
+
* shared authz pool and injected into `UserService`. It is the same class guard's
|
|
5
|
+
* `POST /auth/confirm-email` uses, so the collision and login-hint rules never drift.
|
|
6
|
+
*/
|
|
7
|
+
export declare const IDENTITY_EMAIL_ADDRESS_CHANGER: unique symbol;
|
|
8
|
+
//# sourceMappingURL=email-address-changer.token.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"email-address-changer.token.d.ts","sourceRoot":"","sources":["../../../src/features/user/email-address-changer.token.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,8BAA8B,eAA2C,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.IDENTITY_EMAIL_ADDRESS_CHANGER = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* DI token for the administrator path's email changer (security hardening Phase 12, KI #14):
|
|
6
|
+
* guard's `MariaDbEmailAddressChanger`, built by `IdentityFeatureModule.forRoot()` over the ONE
|
|
7
|
+
* shared authz pool and injected into `UserService`. It is the same class guard's
|
|
8
|
+
* `POST /auth/confirm-email` uses, so the collision and login-hint rules never drift.
|
|
9
|
+
*/
|
|
10
|
+
exports.IDENTITY_EMAIL_ADDRESS_CHANGER = Symbol('IDENTITY_EMAIL_ADDRESS_CHANGER');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { RecordsService, type RecordId } from '@astram/engine';
|
|
2
2
|
import type { Principal } from '@astram/core';
|
|
3
|
-
import { type EmailSender, type ResetTokenStore, type SessionSweeper } from '@astram/guard';
|
|
3
|
+
import { type EmailAddressChanger, type EmailSender, type ResetTokenStore, type SessionSweeper } from '@astram/guard';
|
|
4
4
|
import { type PostingVersionBump } from '../membership/posting-version-bump.js';
|
|
5
5
|
import { type ListParams } from '../shared/list-query.js';
|
|
6
6
|
import { GrantCeiling } from '../authz/grant-ceiling.js';
|
|
@@ -23,6 +23,11 @@ export interface UserPatch {
|
|
|
23
23
|
* - an email change burns the target's outstanding password-reset tokens BEFORE the change and
|
|
24
24
|
* notifies the OLD address after it (best-effort), when guard's reset-token store and email
|
|
25
25
|
* sender are wired (`ShieldModule.forRoot({ auth: true })`).
|
|
26
|
+
* - Phase 12 (KI #14): the email itself is written through guard's ONE email-change helper
|
|
27
|
+
* (`MariaDbEmailAddressChanger`, the same one `POST /auth/confirm-email` uses): canonical
|
|
28
|
+
* address (trim → NFKC → lower-case), collision refusal (409) and login-hint follow in ONE
|
|
29
|
+
* transaction, the user's pending self-service tokens burned in it, and an audit row in the
|
|
30
|
+
* engine's shape written on that transaction (fail closed). Not wired → the change is refused.
|
|
26
31
|
*/
|
|
27
32
|
export declare class UserService {
|
|
28
33
|
private readonly records;
|
|
@@ -31,8 +36,11 @@ export declare class UserService {
|
|
|
31
36
|
private readonly emailSender?;
|
|
32
37
|
private readonly sessions?;
|
|
33
38
|
private readonly versionBump?;
|
|
39
|
+
private readonly emailAddresses?;
|
|
34
40
|
private readonly logger;
|
|
35
|
-
|
|
41
|
+
/** The engine's audit sink, used on the email helper's transaction (no pool of its own). */
|
|
42
|
+
private readonly emailAudit;
|
|
43
|
+
constructor(records: RecordsService, ceiling: GrantCeiling, resetTokens?: (ResetTokenStore | null) | undefined, emailSender?: (EmailSender | null) | undefined, sessions?: (SessionSweeper | null) | undefined, versionBump?: (PostingVersionBump | null) | undefined, emailAddresses?: (EmailAddressChanger | null) | undefined);
|
|
36
44
|
/**
|
|
37
45
|
* Create a user. With `companyUuid`, the user is created AND attached (one active membership) in
|
|
38
46
|
* ONE transaction — the onboarding path for a non-root admin, whose membership create is checked
|
|
@@ -48,6 +56,14 @@ export declare class UserService {
|
|
|
48
56
|
getUser(uuid: string): Promise<import("@astram/engine").RecordView | null>;
|
|
49
57
|
/** Update a user (whitelisted patch; username/email/status changes need dominance — class doc). */
|
|
50
58
|
updateUser(actor: Principal, uuid: string, patch: UserPatch): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* Phase 12 (KI #14): write a dominated user's new (canonical) email through guard's
|
|
61
|
+
* email-change helper — ONE transaction that locks the user and credential rows, refuses a
|
|
62
|
+
* collision with another user's email or login hint (→ 409), moves the login hint when it
|
|
63
|
+
* equalled the old email, burns the user's pending self-service tokens, and writes the audit row
|
|
64
|
+
* (engine shape, fail closed). Reset links are burned BEFORE (a failure aborts the change).
|
|
65
|
+
*/
|
|
66
|
+
private changeEmail;
|
|
51
67
|
/**
|
|
52
68
|
* After a status change (security hardening Phase 3): bump every tenant the user is a member of
|
|
53
69
|
* (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,
|
|
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,EACxB,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;AAWzD,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;;;;OAIG;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;IAcpB,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;IAmCzB;;;;;;;;;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"}
|
|
@@ -21,6 +21,7 @@ const posting_version_bump_js_1 = require("../membership/posting-version-bump.js
|
|
|
21
21
|
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
|
+
const email_address_changer_token_js_1 = require("./email-address-changer.token.js");
|
|
24
25
|
const USER_SEARCH_FIELDS = ['username', 'email'];
|
|
25
26
|
const USER_SORT_FIELDS = ['username', 'email', 'status', 'createdAt'];
|
|
26
27
|
/**
|
|
@@ -36,6 +37,11 @@ const USER_SORT_FIELDS = ['username', 'email', 'status', 'createdAt'];
|
|
|
36
37
|
* - an email change burns the target's outstanding password-reset tokens BEFORE the change and
|
|
37
38
|
* notifies the OLD address after it (best-effort), when guard's reset-token store and email
|
|
38
39
|
* sender are wired (`ShieldModule.forRoot({ auth: true })`).
|
|
40
|
+
* - Phase 12 (KI #14): the email itself is written through guard's ONE email-change helper
|
|
41
|
+
* (`MariaDbEmailAddressChanger`, the same one `POST /auth/confirm-email` uses): canonical
|
|
42
|
+
* address (trim → NFKC → lower-case), collision refusal (409) and login-hint follow in ONE
|
|
43
|
+
* transaction, the user's pending self-service tokens burned in it, and an audit row in the
|
|
44
|
+
* engine's shape written on that transaction (fail closed). Not wired → the change is refused.
|
|
39
45
|
*/
|
|
40
46
|
let UserService = UserService_1 = class UserService {
|
|
41
47
|
records;
|
|
@@ -44,14 +50,18 @@ let UserService = UserService_1 = class UserService {
|
|
|
44
50
|
emailSender;
|
|
45
51
|
sessions;
|
|
46
52
|
versionBump;
|
|
53
|
+
emailAddresses;
|
|
47
54
|
logger = new common_1.Logger(UserService_1.name);
|
|
48
|
-
|
|
55
|
+
/** The engine's audit sink, used on the email helper's transaction (no pool of its own). */
|
|
56
|
+
emailAudit = new engine_1.MariaDbAuditSink({});
|
|
57
|
+
constructor(records, ceiling, resetTokens, emailSender, sessions, versionBump, emailAddresses) {
|
|
49
58
|
this.records = records;
|
|
50
59
|
this.ceiling = ceiling;
|
|
51
60
|
this.resetTokens = resetTokens;
|
|
52
61
|
this.emailSender = emailSender;
|
|
53
62
|
this.sessions = sessions;
|
|
54
63
|
this.versionBump = versionBump;
|
|
64
|
+
this.emailAddresses = emailAddresses;
|
|
55
65
|
}
|
|
56
66
|
/**
|
|
57
67
|
* Create a user. With `companyUuid`, the user is created AND attached (one active membership) in
|
|
@@ -91,23 +101,26 @@ let UserService = UserService_1 = class UserService {
|
|
|
91
101
|
if (usernameChanged)
|
|
92
102
|
fields.username = patch.username;
|
|
93
103
|
const oldEmail = typeof current.fields.email === 'string' ? current.fields.email : '';
|
|
94
|
-
|
|
104
|
+
// Phase 12 (A5): the address is canonicalised once; that value is compared, checked and written.
|
|
105
|
+
const newEmail = typeof patch?.email === 'string' ? (0, guard_1.canonicalEmail)(patch.email) : undefined;
|
|
106
|
+
const emailChanged = newEmail !== undefined && newEmail !== (0, guard_1.canonicalEmail)(oldEmail);
|
|
107
|
+
if (emailChanged && !(0, guard_1.isEmailShaped)(newEmail)) {
|
|
108
|
+
throw new identity_errors_js_1.IdentityBadRequestError('Enter a valid email address.');
|
|
109
|
+
}
|
|
95
110
|
const statusChanged = typeof patch?.status === 'string' && patch.status !== current.fields.status;
|
|
96
111
|
// A request for a non-active status ALWAYS runs the follow-up (Phase 3 review P1), even when
|
|
97
112
|
// the stored status already matches: re-sending "disable" repairs a disable whose bump or
|
|
98
113
|
// session revoke failed earlier (idempotent).
|
|
99
114
|
const wantsInactive = typeof patch?.status === 'string' && patch.status !== 'active';
|
|
100
|
-
if (emailChanged)
|
|
101
|
-
fields.email = patch.email;
|
|
102
115
|
if (statusChanged)
|
|
103
116
|
fields.status = patch.status;
|
|
104
117
|
if (usernameChanged || emailChanged || statusChanged || wantsInactive) {
|
|
105
118
|
await this.ceiling.assertDominates(actor, uuid);
|
|
106
119
|
}
|
|
120
|
+
// The email first (the step most likely to be refused, 409), through the ONE helper.
|
|
121
|
+
if (emailChanged)
|
|
122
|
+
await this.changeEmail(actor, uuid, newEmail);
|
|
107
123
|
if (Object.keys(fields).length > 0) {
|
|
108
|
-
// Burn outstanding reset links BEFORE the address changes (a failure aborts the change).
|
|
109
|
-
if (emailChanged && this.resetTokens)
|
|
110
|
-
await this.resetTokens.invalidateAllForPrincipal(uuid);
|
|
111
124
|
await this.records.update('user', uuid, { fields });
|
|
112
125
|
}
|
|
113
126
|
if (statusChanged || wantsInactive)
|
|
@@ -115,6 +128,49 @@ let UserService = UserService_1 = class UserService {
|
|
|
115
128
|
if (emailChanged && oldEmail)
|
|
116
129
|
await this.notifyEmailChanged(oldEmail);
|
|
117
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Phase 12 (KI #14): write a dominated user's new (canonical) email through guard's
|
|
133
|
+
* email-change helper — ONE transaction that locks the user and credential rows, refuses a
|
|
134
|
+
* collision with another user's email or login hint (→ 409), moves the login hint when it
|
|
135
|
+
* equalled the old email, burns the user's pending self-service tokens, and writes the audit row
|
|
136
|
+
* (engine shape, fail closed). Reset links are burned BEFORE (a failure aborts the change).
|
|
137
|
+
*/
|
|
138
|
+
async changeEmail(actor, uuid, newEmail) {
|
|
139
|
+
const changer = this.emailAddresses;
|
|
140
|
+
if (changer == null) {
|
|
141
|
+
// Fail closed: never an unchecked write that could collide or strand the login hint.
|
|
142
|
+
throw new Error('UserService: no email-address changer is wired; email changes are refused');
|
|
143
|
+
}
|
|
144
|
+
if (this.resetTokens)
|
|
145
|
+
await this.resetTokens.invalidateAllForPrincipal(uuid);
|
|
146
|
+
const outcome = await changer.change({
|
|
147
|
+
principalId: uuid,
|
|
148
|
+
newEmail,
|
|
149
|
+
withinTransaction: async (query, result) => {
|
|
150
|
+
const event = {
|
|
151
|
+
type: 'admin_action',
|
|
152
|
+
occurredAtMs: Date.now(),
|
|
153
|
+
actorId: actor.id,
|
|
154
|
+
tenantId: actor.tenantId,
|
|
155
|
+
action: { key: 'update' },
|
|
156
|
+
resource: { level: 'record', typeKey: 'user', tenantId: actor.tenantId, recordId: uuid },
|
|
157
|
+
attributes: {
|
|
158
|
+
op: 'update',
|
|
159
|
+
typeKey: 'user',
|
|
160
|
+
fields: ['email'],
|
|
161
|
+
newEmail: (0, guard_1.maskEmail)(newEmail),
|
|
162
|
+
loginHintMoved: result.hintMoved,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
await this.emailAudit.record(event, (sql, params) => query(sql, params));
|
|
166
|
+
},
|
|
167
|
+
});
|
|
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
|
+
}
|
|
118
174
|
/**
|
|
119
175
|
* After a status change (security hardening Phase 3): bump every tenant the user is a member of
|
|
120
176
|
* (the next request reloads its grants: none while disabled, back when re-enabled), and, when the
|
|
@@ -178,6 +234,8 @@ exports.UserService = UserService = UserService_1 = __decorate([
|
|
|
178
234
|
__param(4, (0, common_1.Inject)(guard_1.SHIELD_SESSION_SWEEPER)),
|
|
179
235
|
__param(5, (0, common_1.Optional)()),
|
|
180
236
|
__param(5, (0, common_1.Inject)(posting_version_bump_js_1.POSTING_VERSION_BUMP)),
|
|
237
|
+
__param(6, (0, common_1.Optional)()),
|
|
238
|
+
__param(6, (0, common_1.Inject)(email_address_changer_token_js_1.IDENTITY_EMAIL_ADDRESS_CHANGER)),
|
|
181
239
|
__metadata("design:paramtypes", [engine_1.RecordsService,
|
|
182
|
-
grant_ceiling_js_1.GrantCeiling, Object, Object, Object, Object])
|
|
240
|
+
grant_ceiling_js_1.GrantCeiling, Object, Object, Object, Object, Object])
|
|
183
241
|
], UserService);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework identity migration — `shield_identity.email_change_token` (security hardening
|
|
3
|
+
* Phase 12, KI #14: verified self-service email change).
|
|
4
|
+
*
|
|
5
|
+
* `PATCH /account/profile` with a new email no longer writes `shield_app.users.u_email`; it
|
|
6
|
+
* stores a single-use, 30-minute token here and mails a confirm link to the NEW address.
|
|
7
|
+
* `POST /auth/confirm-email` consumes the token and applies the change.
|
|
8
|
+
*
|
|
9
|
+
* Columns:
|
|
10
|
+
* - ect_token_hash: SHA-256 hex of the raw token (the raw token is never stored). PK, and
|
|
11
|
+
* stated UNIQUE explicitly (A11).
|
|
12
|
+
* - ect_principal_id: the user (`shield_app.users.u_uuid`). FK with ON DELETE CASCADE so a
|
|
13
|
+
* deleted user leaves no pending change behind. The type matches the parent exactly
|
|
14
|
+
* (VARCHAR(36), utf8mb4 / utf8mb4_unicode_ci) — InnoDB refuses an FK across mismatched
|
|
15
|
+
* string types or collations.
|
|
16
|
+
* - ect_new_email: the canonical new address (trim, NFKC, lower-case; A5).
|
|
17
|
+
* - ect_old_email: the user's email at request time (A3): confirm refuses when the current
|
|
18
|
+
* email no longer equals it (an admin or another flow changed it meanwhile).
|
|
19
|
+
* - ect_session_id: the requesting session (`session.s_session_id` is CHAR(64)); confirm keeps
|
|
20
|
+
* it and revokes every other session of the user (A7). No FK: a revoked/pruned session must
|
|
21
|
+
* not block or cascade the token.
|
|
22
|
+
* - ect_tenant_id: the requesting session's tenant (same type as `session.s_tenant_id`). The
|
|
23
|
+
* confirm step may run with no session, so its audit rows take the tenant from here
|
|
24
|
+
* (`shield_audit.audit_log.al_tenant_id` is NOT NULL).
|
|
25
|
+
* - ect_expires_at_ms / ect_consumed_at_ms / ect_created_at_ms: TTL, single use, audit time.
|
|
26
|
+
* The expiry index serves the cleanup that runs on every insert (A11).
|
|
27
|
+
*
|
|
28
|
+
* The runtime `shield_core` account already holds DML on `shield_identity.*` (no new grant).
|
|
29
|
+
* NOT in the @astram/identity object registry (engine-internal auth table) — literal column
|
|
30
|
+
* names. Idempotent (CREATE TABLE IF NOT EXISTS / DROP TABLE IF EXISTS).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const SCHEMA = 'shield_identity';
|
|
34
|
+
|
|
35
|
+
async function up({ query }) {
|
|
36
|
+
await query(
|
|
37
|
+
`CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`email_change_token\` (
|
|
38
|
+
ect_token_hash VARCHAR(64) NOT NULL,
|
|
39
|
+
ect_principal_id VARCHAR(36) NOT NULL,
|
|
40
|
+
ect_new_email VARCHAR(255) NOT NULL,
|
|
41
|
+
ect_old_email VARCHAR(255) NULL,
|
|
42
|
+
ect_session_id CHAR(64) NOT NULL,
|
|
43
|
+
ect_tenant_id VARCHAR(255) NOT NULL,
|
|
44
|
+
ect_expires_at_ms BIGINT NOT NULL,
|
|
45
|
+
ect_consumed_at_ms BIGINT NULL,
|
|
46
|
+
ect_created_at_ms BIGINT NOT NULL,
|
|
47
|
+
PRIMARY KEY (ect_token_hash),
|
|
48
|
+
UNIQUE KEY UQ_ect_token_hash (ect_token_hash),
|
|
49
|
+
INDEX idx_ect_principal (ect_principal_id),
|
|
50
|
+
INDEX idx_ect_expires (ect_expires_at_ms),
|
|
51
|
+
CONSTRAINT FK_email_change_token_user
|
|
52
|
+
FOREIGN KEY (ect_principal_id) REFERENCES \`shield_app\`.\`users\` (u_uuid)
|
|
53
|
+
ON DELETE CASCADE
|
|
54
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function down({ query }) {
|
|
59
|
+
await query(`DROP TABLE IF EXISTS \`${SCHEMA}\`.\`email_change_token\``);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
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.8.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,8 +36,8 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"mariadb": "^3.5.4",
|
|
39
|
+
"@astram/guard": "0.11.0",
|
|
39
40
|
"@astram/core": "1.4.0",
|
|
40
|
-
"@astram/guard": "0.10.2",
|
|
41
41
|
"@astram/engine": "0.5.2"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|