@astram/identity 0.19.0 → 0.21.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.
@@ -0,0 +1,109 @@
1
+ import type { ModuleRegistry, Principal } from '@astram/core';
2
+ import { type NotificationQuery, type NotificationPostInput, type NotificationPostResult } from '@astram/guard';
3
+ import type { IdentitySchemaNames } from '../../schema.js';
4
+ /** DI token: the module's one notifier (attached to `SHIELD_NOTIFICATIONS` when guard has it). */
5
+ export declare const ACCESS_CHANGE_NOTIFIER: unique symbol;
6
+ export declare const ACCESS_CHANGED_KIND = "shield.access_changed";
7
+ export declare const ACCESS_CHANGED_TITLE = "Your access changed";
8
+ export declare const ACCESS_NOTICE_SAVEPOINT = "shield_access_notice";
9
+ /** SPEC §10 limits (mirror guard's `NOTIFICATION_BODY_MAX` / `NOTIFICATION_RECIPIENTS_MAX`). */
10
+ export declare const ACCESS_NOTICE_LABELS_PER_LINE = 10;
11
+ export declare const ACCESS_NOTICE_BODY_MAX = 1000;
12
+ export declare const ACCESS_NOTICE_RECIPIENTS_MAX = 1000;
13
+ /** The part of guard's `NotificationService` the notifier uses. */
14
+ export interface AccessNoticePoster {
15
+ post(input: NotificationPostInput, opts?: {
16
+ query?: NotificationQuery;
17
+ }): Promise<NotificationPostResult>;
18
+ }
19
+ /** One user in one tenant (server-derived; never request input). */
20
+ export interface AccessPair {
21
+ readonly userId: string;
22
+ readonly tenantId: string;
23
+ }
24
+ /** A pair's effective exact keys at snapshot time. */
25
+ export interface AccessEntry extends AccessPair {
26
+ readonly keys: ReadonlySet<string>;
27
+ }
28
+ /** A successful snapshot, keyed by `pairKey(pair)`. */
29
+ export interface AccessSnapshot {
30
+ readonly ok: true;
31
+ readonly entries: ReadonlyMap<string, AccessEntry>;
32
+ }
33
+ /** A holder or snapshot read failed: carries counts and the error class only. */
34
+ export interface AccessReadFailure {
35
+ readonly ok: false;
36
+ /** Pairs the read was for (`null` when unknown, e.g. the holder read itself failed). */
37
+ readonly pairCount: number | null;
38
+ readonly errorClass: string;
39
+ }
40
+ export interface AccessNoticeResult {
41
+ readonly created: number;
42
+ readonly dropped: number;
43
+ }
44
+ export interface AccessChangeNotifierLogger {
45
+ warn(message: string): void;
46
+ }
47
+ /**
48
+ * The notice failed and its savepoint could not be rolled back: the caller's transaction is gone,
49
+ * so the caller fails and rolls back like any database error (SPEC v3 §5A). The message holds no
50
+ * value; the original error rides on `cause`.
51
+ */
52
+ export declare class AccessNoticeTransactionLostError extends Error {
53
+ constructor(cause: unknown);
54
+ }
55
+ /** Locale-independent code-point order (JS `<` compares UTF-16 code units). */
56
+ export declare function compareCodePoints(a: string, b: string): number;
57
+ /**
58
+ * SPEC §10 body: sorted labels, ≤ 10 per line, then labels folded into the `and N more` tail
59
+ * (from the line showing more, ties from the longer line) while the body exceeds 1000 characters.
60
+ * `null` when nothing was gained or lost.
61
+ */
62
+ export declare function accessNoticeBody(gained: readonly string[], lost: readonly string[]): string | null;
63
+ /**
64
+ * The step-1 generated label of a key with no catalog label (G-027 step 1 SPEC §10), computed as
65
+ * guard's catalog reconciler does (`catalog-reconciler.ts` `generatedText`, not exported): engine
66
+ * `permissionTextOf` over a one-action declaration, type label from the registry descriptor when
67
+ * it differs from the typeKey. An unresolvable key gets `"<Type>: <action>"`, never the raw key.
68
+ */
69
+ export declare function generatedPermissionLabel(registry: ModuleRegistry, key: string): string;
70
+ export declare class AccessChangeNotifier {
71
+ private readonly registry;
72
+ /** Injectable clock (epoch-ms) for the posting window, bound as `?` integers. */
73
+ private readonly now;
74
+ private service;
75
+ private readonly logger;
76
+ private readonly app;
77
+ private readonly authz;
78
+ constructor(registry: ModuleRegistry, schemas: IdentitySchemaNames, logger?: AccessChangeNotifierLogger,
79
+ /** Injectable clock (epoch-ms) for the posting window, bound as `?` integers. */
80
+ now?: () => number);
81
+ /** Bind guard's `NotificationService` (`null` ⇒ every method is a no-op). */
82
+ attach(service: AccessNoticePoster | null): void;
83
+ /** Whether a change by `actor` would be notified (service attached, not the org bootstrap). */
84
+ enabledFor(actor: Principal): boolean;
85
+ /**
86
+ * Users holding `roleUuid` through an in-effect posting of their active membership, with
87
+ * `u_status = 'active'` (the `readRoleIdsForTenant` predicates), per tenant. Never throws.
88
+ */
89
+ holdersOfRole(query: NotificationQuery, roleUuid: string): Promise<readonly AccessPair[] | AccessReadFailure>;
90
+ /**
91
+ * Effective keys per pair on the caller's open transaction (sees its uncommitted rows): one
92
+ * roles query for all pairs (chunked at 500), one `role_permission` query for all roles, then
93
+ * `expandGrantKeys`. A pair with no roles has an empty set. Never throws.
94
+ */
95
+ snapshot(query: NotificationQuery, pairs: readonly AccessPair[] | AccessReadFailure): Promise<AccessSnapshot | AccessReadFailure>;
96
+ /**
97
+ * Diff `before` and `after` per pair and post one notice to each changed user, under the
98
+ * `shield_access_notice` savepoint on `query`. Never throws. `dropped` = the pairs that would
99
+ * have been notified (on a snapshot failure: the pairs the failed read was for, when known).
100
+ */
101
+ notify(query: NotificationQuery, actor: Principal, before: AccessSnapshot | AccessReadFailure, after: AccessSnapshot | AccessReadFailure): Promise<AccessNoticeResult>;
102
+ /** One parameterized catalog read; a missing, blank or raw-key label ⇒ the generated label. */
103
+ private labelsOf;
104
+ private isBootstrap;
105
+ private warnDropped;
106
+ private rowsOf;
107
+ private pairsOf;
108
+ }
109
+ //# sourceMappingURL=access-change-notifier.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"access-change-notifier.d.ts","sourceRoot":"","sources":["../../../src/features/authz/access-change-notifier.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,421 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AccessChangeNotifier = exports.AccessNoticeTransactionLostError = exports.ACCESS_NOTICE_RECIPIENTS_MAX = exports.ACCESS_NOTICE_BODY_MAX = exports.ACCESS_NOTICE_LABELS_PER_LINE = exports.ACCESS_NOTICE_SAVEPOINT = exports.ACCESS_CHANGED_TITLE = exports.ACCESS_CHANGED_KIND = exports.ACCESS_CHANGE_NOTIFIER = void 0;
4
+ exports.compareCodePoints = compareCodePoints;
5
+ exports.accessNoticeBody = accessNoticeBody;
6
+ exports.generatedPermissionLabel = generatedPermissionLabel;
7
+ /**
8
+ * `AccessChangeNotifier` — G-027 step 3 (notifying-user SPEC §5A, §10, §11): one personal
9
+ * `shield.access_changed` notice to each user whose EFFECTIVE permission keys changed, posted on
10
+ * the change's own transaction `query` so a rolled-back change sends nothing.
11
+ *
12
+ * Effective keys follow `readRoleIdsForTenant` exactly (active membership `cm_is_active = 1`,
13
+ * `u_status = 'active'`, in-effect posting window bound as epoch-ms integers) → `role_permission`
14
+ * → `expandGrantKeys` against the registry's `listActions()`: a notice never claims access
15
+ * `decide()` would not give. Holders come from memberships only, so service accounts never appear.
16
+ *
17
+ * Failure model (SPEC v3 §5A): a notice failure never fails the change, with ONE exception.
18
+ * - `holdersOfRole` / `snapshot` never throw: they return an {@link AccessReadFailure} on a read
19
+ * error; `snapshot` passes a failure through. `notify` given a failure on either side logs and drops.
20
+ * - `notify` runs under `SAVEPOINT shield_access_notice` (label read + every `post`): any error ⇒
21
+ * `ROLLBACK TO SAVEPOINT`, one `[notifications] access-change notices dropped` warning with
22
+ * counts and the error class only (never SQL, values or labels), `{ created: 0, dropped }`.
23
+ * The caller's change continues and commits.
24
+ * - Exception: when that `ROLLBACK TO SAVEPOINT` itself fails, the database already ended the
25
+ * caller's transaction (e.g. a deadlock on the notice insert), so `notify` throws
26
+ * {@link AccessNoticeTransactionLostError} and the caller must fail and roll back: never catch it
27
+ * to continue, or the audit row and later writes would autocommit without the change.
28
+ * - Without an attached `NotificationService` (guard `auth: false`) every method is a no-op with
29
+ * no SQL; an actor bound to `shield:org-bootstrap` gets `notify` ⇒ `{ 0, 0 }` with no SQL
30
+ * (`enabledFor(actor)` lets a caller skip its snapshots too).
31
+ *
32
+ * Text (SPEC §10): title `Your access changed`; `You can now: a, b.` / `You can no longer: c.`
33
+ * joined by `\n`; labels sorted by code point, at most 10 per line then `, and N more.`, folded
34
+ * further while the body would exceed 1000 characters. No actor, other user, role, company, raw
35
+ * key or uuid. Users of one tenant with the same text share one `post` (≤ 1000 recipients).
36
+ */
37
+ const common_1 = require("@nestjs/common");
38
+ const engine_1 = require("@astram/engine");
39
+ const guard_1 = require("@astram/guard");
40
+ const org_bootstrap_manifest_js_1 = require("../org-bootstrap/org-bootstrap.manifest.js");
41
+ const db_permission_facts_adapter_js_1 = require("./db-permission-facts.adapter.js");
42
+ /** DI token: the module's one notifier (attached to `SHIELD_NOTIFICATIONS` when guard has it). */
43
+ exports.ACCESS_CHANGE_NOTIFIER = Symbol('IDENTITY_ACCESS_CHANGE_NOTIFIER');
44
+ exports.ACCESS_CHANGED_KIND = 'shield.access_changed';
45
+ exports.ACCESS_CHANGED_TITLE = 'Your access changed';
46
+ exports.ACCESS_NOTICE_SAVEPOINT = 'shield_access_notice';
47
+ /** SPEC §10 limits (mirror guard's `NOTIFICATION_BODY_MAX` / `NOTIFICATION_RECIPIENTS_MAX`). */
48
+ exports.ACCESS_NOTICE_LABELS_PER_LINE = 10;
49
+ exports.ACCESS_NOTICE_BODY_MAX = 1000;
50
+ exports.ACCESS_NOTICE_RECIPIENTS_MAX = 1000;
51
+ /** Pairs per batched roles query (2 placeholders each). */
52
+ const PAIR_CHUNK = 500;
53
+ /** Values per `IN (…)` list (roles, permission keys); review F-2. */
54
+ const IN_CHUNK = 1000;
55
+ /** `items` in slices of at most `size`. */
56
+ function chunksOf(items, size) {
57
+ const out = [];
58
+ for (let i = 0; i < items.length; i += size)
59
+ out.push(items.slice(i, i + size));
60
+ return out;
61
+ }
62
+ /**
63
+ * The notice failed and its savepoint could not be rolled back: the caller's transaction is gone,
64
+ * so the caller fails and rolls back like any database error (SPEC v3 §5A). The message holds no
65
+ * value; the original error rides on `cause`.
66
+ */
67
+ class AccessNoticeTransactionLostError extends Error {
68
+ constructor(cause) {
69
+ super('access-change notice: transaction lost', { cause });
70
+ this.name = 'AccessNoticeTransactionLostError';
71
+ }
72
+ }
73
+ exports.AccessNoticeTransactionLostError = AccessNoticeTransactionLostError;
74
+ /** The class name of an error, never its message (core 50 #10). */
75
+ function errorClassOf(err) {
76
+ if (err instanceof Error)
77
+ return err.constructor?.name || 'Error';
78
+ return typeof err;
79
+ }
80
+ function pairKey(p) {
81
+ return `${p.tenantId}|${p.userId}`;
82
+ }
83
+ /** Locale-independent code-point order (JS `<` compares UTF-16 code units). */
84
+ function compareCodePoints(a, b) {
85
+ const x = Array.from(a);
86
+ const y = Array.from(b);
87
+ const n = Math.min(x.length, y.length);
88
+ for (let i = 0; i < n; i++) {
89
+ const d = x[i].codePointAt(0) - y[i].codePointAt(0);
90
+ if (d !== 0)
91
+ return d;
92
+ }
93
+ return x.length - y.length;
94
+ }
95
+ function codePointLength(s) {
96
+ return Array.from(s).length;
97
+ }
98
+ // C0, DEL, C1 and the Unicode line/paragraph separators (review F-4): a label is one line of text.
99
+ // Built from a string so no raw separator character sits in the source.
100
+ const CONTROL_CHARS = new RegExp(`[\u0000-\u001f\u007f-\u009f${String.fromCharCode(0x2028, 0x2029)}]`, 'g');
101
+ function line(prefix, labels, shown) {
102
+ const rest = labels.length - shown;
103
+ const head = labels.slice(0, shown).join(', ');
104
+ if (rest <= 0)
105
+ return `${prefix}: ${head}.`;
106
+ return shown === 0 ? `${prefix}: ${rest} more.` : `${prefix}: ${head}, and ${rest} more.`;
107
+ }
108
+ /**
109
+ * SPEC §10 body: sorted labels, ≤ 10 per line, then labels folded into the `and N more` tail
110
+ * (from the line showing more, ties from the longer line) while the body exceeds 1000 characters.
111
+ * `null` when nothing was gained or lost.
112
+ */
113
+ function accessNoticeBody(gained, lost) {
114
+ const g = [...gained].sort(compareCodePoints);
115
+ const l = [...lost].sort(compareCodePoints);
116
+ if (g.length === 0 && l.length === 0)
117
+ return null;
118
+ let sg = Math.min(g.length, exports.ACCESS_NOTICE_LABELS_PER_LINE);
119
+ let sl = Math.min(l.length, exports.ACCESS_NOTICE_LABELS_PER_LINE);
120
+ const render = () => [
121
+ ...(g.length > 0 ? [line('You can now', g, sg)] : []),
122
+ ...(l.length > 0 ? [line('You can no longer', l, sl)] : []),
123
+ ].join('\n');
124
+ let body = render();
125
+ while (codePointLength(body) > exports.ACCESS_NOTICE_BODY_MAX && sg + sl > 0) {
126
+ const gl = g.length > 0 ? codePointLength(line('You can now', g, sg)) : 0;
127
+ const ll = l.length > 0 ? codePointLength(line('You can no longer', l, sl)) : 0;
128
+ if (sg > sl || (sg === sl && gl >= ll))
129
+ sg -= 1;
130
+ else
131
+ sl -= 1;
132
+ body = render();
133
+ }
134
+ return body;
135
+ }
136
+ function humanize(s) {
137
+ const spaced = s.replace(/_/g, ' ');
138
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
139
+ }
140
+ /**
141
+ * The step-1 generated label of a key with no catalog label (G-027 step 1 SPEC §10), computed as
142
+ * guard's catalog reconciler does (`catalog-reconciler.ts` `generatedText`, not exported): engine
143
+ * `permissionTextOf` over a one-action declaration, type label from the registry descriptor when
144
+ * it differs from the typeKey. An unresolvable key gets `"<Type>: <action>"`, never the raw key.
145
+ */
146
+ function generatedPermissionLabel(registry, key) {
147
+ const registered = registry.resolveByPermissionKey(key);
148
+ const parts = key.split(':');
149
+ const typeKey = registered?.action.typeKey ?? parts[1] ?? 'permission';
150
+ const actionKey = registered?.action.actionKey ?? parts[2] ?? 'use';
151
+ if (registered !== undefined) {
152
+ const descriptorLabel = registry.resolveObjectType(typeKey)?.label;
153
+ const label = descriptorLabel && descriptorLabel !== typeKey ? descriptorLabel : undefined;
154
+ const isCrud = guard_1.IDENTITY_ACTIONS.includes(actionKey);
155
+ try {
156
+ const [t] = (0, engine_1.permissionTextOf)({
157
+ typeKey,
158
+ ...(label !== undefined ? { label } : {}),
159
+ ...(isCrud ? {} : { crudActions: false, actions: [actionKey] }),
160
+ }).filter((p) => p.action === actionKey);
161
+ if (t)
162
+ return t.label;
163
+ }
164
+ catch (err) {
165
+ if (!(err instanceof engine_1.RegistrationError))
166
+ throw err;
167
+ }
168
+ }
169
+ return `${humanize(typeKey)}: ${actionKey.replace(/_/g, ' ')}`;
170
+ }
171
+ class AccessChangeNotifier {
172
+ registry;
173
+ now;
174
+ service = null;
175
+ logger;
176
+ app;
177
+ authz;
178
+ constructor(registry, schemas, logger,
179
+ /** Injectable clock (epoch-ms) for the posting window, bound as `?` integers. */
180
+ now = Date.now) {
181
+ this.registry = registry;
182
+ this.now = now;
183
+ this.logger = logger ?? new common_1.Logger('IdentityAccessChangeNotifier');
184
+ this.app = `\`${schemas.app}\``;
185
+ this.authz = `\`${schemas.authz}\``;
186
+ }
187
+ /** Bind guard's `NotificationService` (`null` ⇒ every method is a no-op). */
188
+ attach(service) {
189
+ this.service = service;
190
+ }
191
+ /** Whether a change by `actor` would be notified (service attached, not the org bootstrap). */
192
+ enabledFor(actor) {
193
+ return this.service !== null && !this.isBootstrap(actor);
194
+ }
195
+ /**
196
+ * Users holding `roleUuid` through an in-effect posting of their active membership, with
197
+ * `u_status = 'active'` (the `readRoleIdsForTenant` predicates), per tenant. Never throws.
198
+ */
199
+ async holdersOfRole(query, roleUuid) {
200
+ if (this.service === null)
201
+ return [];
202
+ try {
203
+ const nowMs = this.now();
204
+ const rows = await query(`SELECT DISTINCT cm.cm_user_uuid AS user_id, cm.cm_company_uuid AS tenant_id
205
+ FROM ${this.app}.\`company_membership\` cm
206
+ JOIN ${this.app}.\`users\` u ON u.u_uuid = cm.cm_user_uuid AND u.u_status = 'active'
207
+ JOIN ${this.app}.\`membership_posting\` mp ON mp.mp_membership_uuid = cm.cm_uuid
208
+ WHERE mp.mp_role_uuid = ? AND cm.cm_is_active = 1
209
+ AND (mp.mp_effective_from_ms IS NULL OR mp.mp_effective_from_ms <= ?)
210
+ AND (mp.mp_effective_until_ms IS NULL OR mp.mp_effective_until_ms > ?)`, [roleUuid, nowMs, nowMs]);
211
+ return this.pairsOf(rows);
212
+ }
213
+ catch (err) {
214
+ return { ok: false, pairCount: null, errorClass: errorClassOf(err) };
215
+ }
216
+ }
217
+ /**
218
+ * Effective keys per pair on the caller's open transaction (sees its uncommitted rows): one
219
+ * roles query for all pairs (chunked at 500), one `role_permission` query for all roles, then
220
+ * `expandGrantKeys`. A pair with no roles has an empty set. Never throws.
221
+ */
222
+ async snapshot(query, pairs) {
223
+ if (!Array.isArray(pairs))
224
+ return pairs;
225
+ const entries = new Map();
226
+ if (this.service === null)
227
+ return { ok: true, entries };
228
+ const wanted = new Map();
229
+ for (const p of pairs) {
230
+ const userId = (0, engine_1.canonicalUuid)(p.userId);
231
+ const tenantId = (0, engine_1.canonicalUuid)(p.tenantId);
232
+ if (userId === null || tenantId === null)
233
+ continue;
234
+ const pair = { userId, tenantId };
235
+ wanted.set(pairKey(pair), pair);
236
+ }
237
+ if (wanted.size === 0)
238
+ return { ok: true, entries };
239
+ try {
240
+ const nowMs = this.now();
241
+ const rolesByPair = new Map();
242
+ const all = [...wanted.values()];
243
+ for (let i = 0; i < all.length; i += PAIR_CHUNK) {
244
+ const chunk = all.slice(i, i + PAIR_CHUNK);
245
+ const where = chunk
246
+ .map(() => '(cm.cm_user_uuid = ? AND cm.cm_company_uuid = ?)')
247
+ .join(' OR ');
248
+ const rows = await query(`SELECT DISTINCT cm.cm_user_uuid AS user_id, cm.cm_company_uuid AS tenant_id,
249
+ mp.mp_role_uuid AS role_uuid
250
+ FROM ${this.app}.\`company_membership\` cm
251
+ JOIN ${this.app}.\`users\` u ON u.u_uuid = cm.cm_user_uuid AND u.u_status = 'active'
252
+ JOIN ${this.app}.\`membership_posting\` mp ON mp.mp_membership_uuid = cm.cm_uuid
253
+ WHERE (${where}) AND cm.cm_is_active = 1
254
+ AND (mp.mp_effective_from_ms IS NULL OR mp.mp_effective_from_ms <= ?)
255
+ AND (mp.mp_effective_until_ms IS NULL OR mp.mp_effective_until_ms > ?)`, [...chunk.flatMap((p) => [p.userId, p.tenantId]), nowMs, nowMs]);
256
+ for (const r of this.rowsOf(rows)) {
257
+ const userId = (0, engine_1.canonicalUuid)(r.user_id);
258
+ const tenantId = (0, engine_1.canonicalUuid)(r.tenant_id);
259
+ const role = (0, engine_1.canonicalUuid)(r.role_uuid);
260
+ if (userId === null || tenantId === null || role === null)
261
+ continue;
262
+ const k = pairKey({ userId, tenantId });
263
+ if (!wanted.has(k))
264
+ continue;
265
+ const set = rolesByPair.get(k) ?? new Set();
266
+ set.add(role);
267
+ rolesByPair.set(k, set);
268
+ }
269
+ }
270
+ const roles = [...new Set([...rolesByPair.values()].flatMap((s) => [...s]))];
271
+ const keysByRole = new Map();
272
+ for (const slice of chunksOf(roles, IN_CHUNK)) {
273
+ const rows = await query(`SELECT rp_role_uuid AS role_uuid, rp_perm_key FROM ${this.authz}.\`role_permission\`
274
+ WHERE rp_role_uuid IN (${slice.map(() => '?').join(', ')})`, slice);
275
+ for (const r of this.rowsOf(rows)) {
276
+ const role = (0, engine_1.canonicalUuid)(r.role_uuid);
277
+ if (role === null || typeof r.rp_perm_key !== 'string')
278
+ continue;
279
+ const list = keysByRole.get(role) ?? [];
280
+ list.push(r.rp_perm_key);
281
+ keysByRole.set(role, list);
282
+ }
283
+ }
284
+ const allKeys = this.registry.listActions().map((a) => a.permissionKey);
285
+ for (const [k, pair] of wanted) {
286
+ const raw = [...(rolesByPair.get(k) ?? [])].flatMap((role) => keysByRole.get(role) ?? []);
287
+ entries.set(k, { ...pair, keys: new Set((0, db_permission_facts_adapter_js_1.expandGrantKeys)(raw, allKeys)) });
288
+ }
289
+ return { ok: true, entries };
290
+ }
291
+ catch (err) {
292
+ return { ok: false, pairCount: wanted.size, errorClass: errorClassOf(err) };
293
+ }
294
+ }
295
+ /**
296
+ * Diff `before` and `after` per pair and post one notice to each changed user, under the
297
+ * `shield_access_notice` savepoint on `query`. Never throws. `dropped` = the pairs that would
298
+ * have been notified (on a snapshot failure: the pairs the failed read was for, when known).
299
+ */
300
+ async notify(query, actor, before, after) {
301
+ const service = this.service;
302
+ if (service === null || this.isBootstrap(actor))
303
+ return { created: 0, dropped: 0 };
304
+ if (!before.ok || !after.ok) {
305
+ const failure = (!before.ok ? before : after);
306
+ const known = [before, after].map((s) => (s.ok ? s.entries.size : (s.pairCount ?? 0)));
307
+ const dropped = Math.max(...known);
308
+ this.warnDropped(dropped, failure.errorClass);
309
+ return { created: 0, dropped };
310
+ }
311
+ const changes = [];
312
+ const keys = new Set([...before.entries.keys(), ...after.entries.keys()]);
313
+ for (const k of keys) {
314
+ const b = before.entries.get(k);
315
+ const a = after.entries.get(k);
316
+ const pair = (a ?? b);
317
+ const was = b?.keys ?? new Set();
318
+ const now = a?.keys ?? new Set();
319
+ const gained = [...now].filter((x) => !was.has(x));
320
+ const lost = [...was].filter((x) => !now.has(x));
321
+ if (gained.length > 0 || lost.length > 0) {
322
+ changes.push({ pair: { userId: pair.userId, tenantId: pair.tenantId }, gained, lost });
323
+ }
324
+ }
325
+ if (changes.length === 0)
326
+ return { created: 0, dropped: 0 };
327
+ try {
328
+ await query(`SAVEPOINT ${exports.ACCESS_NOTICE_SAVEPOINT}`);
329
+ }
330
+ catch (err) {
331
+ this.warnDropped(changes.length, errorClassOf(err));
332
+ return { created: 0, dropped: changes.length };
333
+ }
334
+ try {
335
+ const labels = await this.labelsOf(query, [
336
+ ...new Set(changes.flatMap((c) => [...c.gained, ...c.lost])),
337
+ ]);
338
+ // tenant + body ⇒ recipients (same text in the same tenant shares one post).
339
+ const groups = new Map();
340
+ for (const c of changes) {
341
+ const body = accessNoticeBody(c.gained.map((k) => labels.get(k)), c.lost.map((k) => labels.get(k)));
342
+ if (body === null)
343
+ continue;
344
+ const g = `${c.pair.tenantId}\n${body}`;
345
+ const group = groups.get(g) ?? { tenantId: c.pair.tenantId, body, recipients: [] };
346
+ group.recipients.push(c.pair.userId);
347
+ groups.set(g, group);
348
+ }
349
+ let created = 0;
350
+ for (const group of groups.values()) {
351
+ for (let i = 0; i < group.recipients.length; i += exports.ACCESS_NOTICE_RECIPIENTS_MAX) {
352
+ const res = await service.post({
353
+ tenantId: group.tenantId,
354
+ recipients: group.recipients.slice(i, i + exports.ACCESS_NOTICE_RECIPIENTS_MAX),
355
+ kind: exports.ACCESS_CHANGED_KIND,
356
+ title: exports.ACCESS_CHANGED_TITLE,
357
+ body: group.body,
358
+ }, { query });
359
+ created += res.created;
360
+ }
361
+ }
362
+ await query(`RELEASE SAVEPOINT ${exports.ACCESS_NOTICE_SAVEPOINT}`);
363
+ return { created, dropped: 0 };
364
+ }
365
+ catch (err) {
366
+ try {
367
+ await query(`ROLLBACK TO SAVEPOINT ${exports.ACCESS_NOTICE_SAVEPOINT}`);
368
+ }
369
+ catch {
370
+ // The database already ended the caller's transaction (e.g. a deadlock rolled it back,
371
+ // savepoint included): its later writes would autocommit, so the change must fail (SPEC v3 §5A).
372
+ throw new AccessNoticeTransactionLostError(err);
373
+ }
374
+ this.warnDropped(changes.length, errorClassOf(err));
375
+ return { created: 0, dropped: changes.length };
376
+ }
377
+ }
378
+ /** One parameterized catalog read; a missing, blank or raw-key label ⇒ the generated label. */
379
+ async labelsOf(query, keys) {
380
+ const stored = new Map();
381
+ for (const slice of chunksOf(keys, IN_CHUNK)) {
382
+ const rows = await query(`SELECT perm_key, perm_label FROM ${this.authz}.\`permission\`
383
+ WHERE perm_key IN (${slice.map(() => '?').join(', ')})`, slice);
384
+ for (const r of this.rowsOf(rows)) {
385
+ if (typeof r.perm_key === 'string' && typeof r.perm_label === 'string') {
386
+ stored.set(r.perm_key, r.perm_label);
387
+ }
388
+ }
389
+ }
390
+ const out = new Map();
391
+ for (const key of keys) {
392
+ const label = (stored.get(key) ?? '').replace(CONTROL_CHARS, ' ').trim();
393
+ out.set(key, label !== '' && label !== key ? label : generatedPermissionLabel(this.registry, key));
394
+ }
395
+ return out;
396
+ }
397
+ isBootstrap(actor) {
398
+ return (0, engine_1.operationOf)(actor)?.operationKey === org_bootstrap_manifest_js_1.ORG_BOOTSTRAP_OPERATION;
399
+ }
400
+ warnDropped(pairs, errorClass) {
401
+ this.logger.warn(`[notifications] access-change notices dropped: pairs=${pairs} cause=${errorClass}`);
402
+ }
403
+ rowsOf(rows) {
404
+ if (!Array.isArray(rows))
405
+ throw new TypeError('unexpected result');
406
+ return rows;
407
+ }
408
+ pairsOf(rows) {
409
+ const out = new Map();
410
+ for (const r of this.rowsOf(rows)) {
411
+ const userId = (0, engine_1.canonicalUuid)(r.user_id);
412
+ const tenantId = (0, engine_1.canonicalUuid)(r.tenant_id);
413
+ if (userId === null || tenantId === null)
414
+ continue;
415
+ const pair = { userId, tenantId };
416
+ out.set(pairKey(pair), pair);
417
+ }
418
+ return [...out.values()];
419
+ }
420
+ }
421
+ exports.AccessChangeNotifier = AccessChangeNotifier;
@@ -4,6 +4,7 @@ import { type AuditPort } from '@astram/engine';
4
4
  import { PermissionVersionBumper } from './permission-version.bumper.js';
5
5
  import { AccessBundleStore } from './access-bundle-store.js';
6
6
  import { GrantCeiling } from './grant-ceiling.js';
7
+ import type { AccessChangeNotifier } from './access-change-notifier.js';
7
8
  import { type IdentitySchemaNames } from '../../schema.js';
8
9
  /**
9
10
  * The caller's reachable subtree, threaded from `req.principal` (Task 4.2). It scopes the
@@ -65,6 +66,12 @@ export declare class GrantAdminService {
65
66
  private readonly ceiling?;
66
67
  private readonly audit?;
67
68
  private readonly schemas;
69
+ /**
70
+ * G-027 step 3 (notifying-user SPEC §5B, §5C): personal access-change notices on the grant's
71
+ * own transaction. OPTIONAL (absent ⇒ no notices, no extra reads); the identity module
72
+ * always wires it.
73
+ */
74
+ private readonly accessNotifier?;
68
75
  private readonly bundles;
69
76
  /**
70
77
  * @param ceiling The ADR-0029 self-escalation ceiling ("only grant what you hold") plus the
@@ -76,7 +83,13 @@ export declare class GrantAdminService {
76
83
  * connection, fail-closed. The identity module ALWAYS wires the engine `MariaDbAuditSink`.
77
84
  * @param schemas Resolved framework names (absent ⇒ resolved from the namespace, once, here).
78
85
  */
79
- constructor(pool: Pool, bumper: PermissionVersionBumper, bundleStore?: AccessBundleStore, ceiling?: GrantCeiling | undefined, audit?: AuditPort | undefined, schemas?: IdentitySchemaNames);
86
+ constructor(pool: Pool, bumper: PermissionVersionBumper, bundleStore?: AccessBundleStore, ceiling?: GrantCeiling | undefined, audit?: AuditPort | undefined, schemas?: IdentitySchemaNames,
87
+ /**
88
+ * G-027 step 3 (notifying-user SPEC §5B, §5C): personal access-change notices on the grant's
89
+ * own transaction. OPTIONAL (absent ⇒ no notices, no extra reads); the identity module
90
+ * always wires it.
91
+ */
92
+ accessNotifier?: AccessChangeNotifier | undefined);
80
93
  /** All grantable exact keys (from the catalog) + the computed wildcard set. */
81
94
  listCatalog(): Promise<{
82
95
  catalog: CatalogRow[];
@@ -108,6 +121,19 @@ export declare class GrantAdminService {
108
121
  * to AuthorizationUnavailableError (503).
109
122
  */
110
123
  private writeGrants;
124
+ /**
125
+ * G-027 step 3 (SPEC §5B, §5C): read the role's holders and their effective keys on the grant's
126
+ * own transaction `conn`, BEFORE the `role_permission` write. `null` (no reads at all) when no
127
+ * notifier is wired or it is disabled for this actor (guard `auth: false`, the org bootstrap).
128
+ * Never throws: a failed read is carried as an `AccessReadFailure`.
129
+ */
130
+ private beginAccessNotice;
131
+ /**
132
+ * After the write and the bump: the after-snapshot and the notices, on the same connection (a
133
+ * rollback removes them with the grant). Returns the audit detail addition: `noticesDropped: n`
134
+ * when n > 0, `'unknown'` when a holder or snapshot read failed, else nothing (byte-identical).
135
+ */
136
+ private finishAccessNotice;
111
137
  /**
112
138
  * Write the grant-administration audit row(s) on the transaction connection (D6). One row per
113
139
  * distinct tenant among the actor's tenant and every tenant the change bumped, so each affected
@@ -1 +1 @@
1
- {"version":3,"file":"grant-admin.service.d.ts","sourceRoot":"","sources":["../../../src/features/authz/grant-admin.service.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAc,SAAS,EAAE,MAAM,cAAc,CAAC;AAE1D,OAAO,EAAe,KAAK,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAC7D,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AACzE,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAE7D,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAKlD,OAAO,EAA2B,KAAK,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAiCpF;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;CAC1B;AAED;6EAC6E;AAC7E,qBAAa,WAAY,SAAQ,KAAK;gBACxB,GAAG,EAAE,MAAM;CAIxB;AAED;;;;GAIG;AACH,KAAK,UAAU,GAAG;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC,CAAC;AAEF,sFAAsF;AACtF,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,qBAAa,iBAAiB;IAc1B,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAEvB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC;IACzB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAlB1B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAoB;IAE5C;;;;;;;;;OASG;gBAEgB,IAAI,EAAE,IAAI,EACV,MAAM,EAAE,uBAAuB,EAChD,WAAW,CAAC,EAAE,iBAAiB,EACd,OAAO,CAAC,EAAE,YAAY,YAAA,EACtB,KAAK,CAAC,EAAE,SAAS,YAAA,EACjB,OAAO,GAAE,mBAA+C;IAK3E,+EAA+E;IACzE,WAAW,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,UAAU,EAAE,CAAC;QAAC,SAAS,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IA6C5E;;;;;OAKG;IACG,cAAc,CAClB,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,YAAY,GACzB,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IAiBhC,0FAA0F;YAC5E,cAAc;IAM5B;;;;;;OAMG;YACW,qBAAqB;IAUnC;;;;;OAKG;YACW,WAAW;IA2DzB;;;;;;OAMG;YACW,gBAAgB;IAiE9B;;;;;OAKG;IACG,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAmB7E;;;;;;OAMG;IACG,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAW5E;;;;;;;;;;;;;;;;;;;;;OAqBG;IACG,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAqDtE;;;;;;OAMG;IACH;;;;;;OAMG;YACW,cAAc;IAS5B,OAAO,CAAC,uBAAuB;IAgB/B,0EAA0E;IACpE,WAAW,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,UAAU,EAAE,CAAA;KAAE,CAAC;IASvD;;;;;;OAMG;IACG,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IA4BzF;;;;;;OAMG;IACG,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAmBxF,uFAAuF;YACzE,wBAAwB;IAWtC;;;;;;;;;;;;OAYG;IACG,iBAAiB,CACrB,MAAM,EACF;QACE,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,SAAS,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;KAC5B,GACD,SAAS,EACb,YAAY,EAAE,YAAY,GACzB,OAAO,CAAC;QAAE,IAAI,EAAE,gBAAgB,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAgIrF"}
1
+ {"version":3,"file":"grant-admin.service.d.ts","sourceRoot":"","sources":["../../../src/features/authz/grant-admin.service.ts"],"names":[],"mappings":"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"}
@@ -44,6 +44,7 @@ class GrantAdminService {
44
44
  ceiling;
45
45
  audit;
46
46
  schemas;
47
+ accessNotifier;
47
48
  bundles;
48
49
  /**
49
50
  * @param ceiling The ADR-0029 self-escalation ceiling ("only grant what you hold") plus the
@@ -55,12 +56,19 @@ class GrantAdminService {
55
56
  * connection, fail-closed. The identity module ALWAYS wires the engine `MariaDbAuditSink`.
56
57
  * @param schemas Resolved framework names (absent ⇒ resolved from the namespace, once, here).
57
58
  */
58
- constructor(pool, bumper, bundleStore, ceiling, audit, schemas = (0, schema_js_1.frameworkSchemasFromEnv)()) {
59
+ constructor(pool, bumper, bundleStore, ceiling, audit, schemas = (0, schema_js_1.frameworkSchemasFromEnv)(),
60
+ /**
61
+ * G-027 step 3 (notifying-user SPEC §5B, §5C): personal access-change notices on the grant's
62
+ * own transaction. OPTIONAL (absent ⇒ no notices, no extra reads); the identity module
63
+ * always wires it.
64
+ */
65
+ accessNotifier) {
59
66
  this.pool = pool;
60
67
  this.bumper = bumper;
61
68
  this.ceiling = ceiling;
62
69
  this.audit = audit;
63
70
  this.schemas = schemas;
71
+ this.accessNotifier = accessNotifier;
64
72
  this.bundles = bundleStore ?? new access_bundle_store_js_1.AccessBundleStore(pool, schemas);
65
73
  }
66
74
  /** All grantable exact keys (from the catalog) + the computed wildcard set. */
@@ -167,10 +175,17 @@ class GrantAdminService {
167
175
  await this.ceiling.assertMayStripRoleFromServiceAccounts(actor, roleUuid, accounts.map((a) => a.sa_tenant_id), 'revoke');
168
176
  }
169
177
  }
178
+ // G-027 step 3 (SPEC §5B): the holders' effective keys before the write, on this
179
+ // connection, after the lock and every refusal (a refused change never reaches a notice).
180
+ const notice = await this.beginAccessNotice(conn, roleUuid, actor);
170
181
  for (const key of keys)
171
182
  await statement(conn, key);
172
183
  const bumped = await this.bumper.bumpForRole(roleUuid, conn);
173
- await this.auditGrantChange(conn, action, roleUuid, keys, actor, bumped, extra);
184
+ const noticeDetail = await this.finishAccessNotice(notice, actor);
185
+ await this.auditGrantChange(conn, action, roleUuid, keys, actor, bumped, {
186
+ ...extra,
187
+ ...noticeDetail,
188
+ });
174
189
  await conn.commit();
175
190
  // ADR-0035: the committed versions reach Redis now (gate 5 reads them there).
176
191
  await this.bumper.publishCommitted(conn);
@@ -189,6 +204,36 @@ class GrantAdminService {
189
204
  await conn.release();
190
205
  }
191
206
  }
207
+ /**
208
+ * G-027 step 3 (SPEC §5B, §5C): read the role's holders and their effective keys on the grant's
209
+ * own transaction `conn`, BEFORE the `role_permission` write. `null` (no reads at all) when no
210
+ * notifier is wired or it is disabled for this actor (guard `auth: false`, the org bootstrap).
211
+ * Never throws: a failed read is carried as an `AccessReadFailure`.
212
+ */
213
+ async beginAccessNotice(conn, roleUuid, actor) {
214
+ const notifier = this.accessNotifier;
215
+ if (!notifier || !notifier.enabledFor(actor))
216
+ return null;
217
+ const query = (sql, params) => conn.query(sql, params);
218
+ const holders = await notifier.holdersOfRole(query, roleUuid);
219
+ const before = await notifier.snapshot(query, holders);
220
+ return { notifier, query, holders, before };
221
+ }
222
+ /**
223
+ * After the write and the bump: the after-snapshot and the notices, on the same connection (a
224
+ * rollback removes them with the grant). Returns the audit detail addition: `noticesDropped: n`
225
+ * when n > 0, `'unknown'` when a holder or snapshot read failed, else nothing (byte-identical).
226
+ */
227
+ async finishAccessNotice(run, actor) {
228
+ if (run === null)
229
+ return {};
230
+ const after = await run.notifier.snapshot(run.query, run.holders);
231
+ const { dropped } = await run.notifier.notify(run.query, actor, run.before, after);
232
+ const readFailed = !Array.isArray(run.holders) || !run.before.ok || !after.ok;
233
+ if (readFailed)
234
+ return { noticesDropped: 'unknown' };
235
+ return dropped > 0 ? { noticesDropped: dropped } : {};
236
+ }
192
237
  /**
193
238
  * Write the grant-administration audit row(s) on the transaction connection (D6). One row per
194
239
  * distinct tenant among the actor's tenant and every tenant the change bumped, so each affected
@@ -312,6 +357,9 @@ class GrantAdminService {
312
357
  if (this.ceiling && actor.isRoot !== true) {
313
358
  await this.ceiling.assertMayStripRoleFromServiceAccounts(actor, roleUuid, accounts.map((a) => a.sa_tenant_id), 'delete');
314
359
  }
360
+ // G-027 step 3 (SPEC §5C): holders + their keys before the grants go. Holders come from
361
+ // committed postings (the caller's uncommitted role delete is not visible here).
362
+ const notice = await this.beginAccessNotice(conn, roleUuid, actor);
315
363
  const keys = (await conn.query(`SELECT rp_perm_key FROM \`${this.schemas.authz}\`.\`role_permission\` WHERE rp_role_uuid = ? ORDER BY rp_perm_key`, [roleUuid])).map((r) => r.rp_perm_key);
316
364
  const bumped = await this.bumper.bumpForRole(roleUuid, conn);
317
365
  await conn.query(`DELETE FROM \`${this.schemas.authz}\`.\`role_permission\` WHERE rp_role_uuid = ?`, [roleUuid]);
@@ -321,9 +369,11 @@ class GrantAdminService {
321
369
  const previous = a.sa_updated_at_ms === null ? 0 : Number(a.sa_updated_at_ms);
322
370
  await conn.query(`UPDATE \`${this.schemas.app}\`.\`service_account\` SET sa_role_ids = ?, sa_updated_at_ms = ? WHERE sa_id = ?`, [JSON.stringify(remaining), Math.max(nowMs, previous + 1), a.sa_id]);
323
371
  }
372
+ const noticeDetail = await this.finishAccessNotice(notice, actor);
324
373
  await this.auditGrantChange(conn, 'role.permission_revoked', roleUuid, keys, actor, bumped, {
325
374
  roleDeleted: true,
326
375
  serviceAccountsUpdated: accounts.length,
376
+ ...noticeDetail,
327
377
  });
328
378
  await conn.commit();
329
379
  await this.bumper.publishCommitted(conn);
@@ -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,EAGL,KAAK,gBAAgB,EAEtB,MAAM,cAAc,CAAC;AACtB,OAAO,EAaL,KAAK,cAAc,EAGnB,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AA0DxB,OAAO,EAAoB,KAAK,aAAa,EAAE,MAAM,yBAAyB,CAAC;AA0C/E,OAAO,EAAiB,KAAK,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AAkElF;;;;;;GAMG;AACH,wBAAgB,0CAA0C,CAAC,SAAS,EAAE,OAAO,GAAG,IAAI,CAUnF;AAKD;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,gBAAgB,EAC1B,KAAK,EAAE,gBAAgB,GAAG,IAAI,GAAG,SAAS,GACzC,IAAI,CAwBN;AAKD;;;;;;GAMG;AACH,wBAAgB,6BAA6B,CAC3C,gBAAgB,EAAE,MAAM,EACxB,WAAW,EAAE;IAAE,QAAQ,CAAC,EAAE,CAAC,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,GAC/E,IAAI,CAQN;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,SAAS,eAAe,EAAE,EAClC,aAAa,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,SAAS,MAAM,EAAE,GAAG,SAAS,GAChE,gBAAgB,EAAE,CAapB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,gBAAgB,EACzB,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,cAAc,CAyBhB;AA2BD,qBACa,qBAAqB;IAChC;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,MAAM,CAAC,OAAO,CAAC,EACb,OAAO,EACP,YAAiB,EACjB,OAAO,EAAE,YAAY,EACrB,MAAM,GACP,GAAE;QACD,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;QAC5B,YAAY,CAAC,EAAE,SAAS,eAAe,EAAE,CAAC;QAC1C,OAAO,CAAC,EAAE,gBAAgB,CAAC;QAC3B,MAAM,CAAC,EAAE,aAAa,CAAC;KACnB,GAAG,aAAa;CAgqBvB"}
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,EAGL,KAAK,gBAAgB,EAEtB,MAAM,cAAc,CAAC;AACtB,OAAO,EAaL,KAAK,cAAc,EAGnB,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AA4DxB,OAAO,EAAoB,KAAK,aAAa,EAAE,MAAM,yBAAyB,CAAC;AA2C/E,OAAO,EAAiB,KAAK,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AAkElF;;;;;;GAMG;AACH,wBAAgB,0CAA0C,CAAC,SAAS,EAAE,OAAO,GAAG,IAAI,CAUnF;AAKD;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,gBAAgB,EAC1B,KAAK,EAAE,gBAAgB,GAAG,IAAI,GAAG,SAAS,GACzC,IAAI,CAwBN;AAKD;;;;;;GAMG;AACH,wBAAgB,6BAA6B,CAC3C,gBAAgB,EAAE,MAAM,EACxB,WAAW,EAAE;IAAE,QAAQ,CAAC,EAAE,CAAC,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,GAC/E,IAAI,CAQN;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,SAAS,eAAe,EAAE,EAClC,aAAa,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,SAAS,MAAM,EAAE,GAAG,SAAS,GAChE,gBAAgB,EAAE,CAapB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,gBAAgB,EACzB,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,cAAc,CAyBhB;AA2BD,qBACa,qBAAqB;IAChC;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,MAAM,CAAC,OAAO,CAAC,EACb,OAAO,EACP,YAAiB,EACjB,OAAO,EAAE,YAAY,EACrB,MAAM,GACP,GAAE;QACD,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;QAC5B,YAAY,CAAC,EAAE,SAAS,eAAe,EAAE,CAAC;QAC1C,OAAO,CAAC,EAAE,gBAAgB,CAAC;QAC3B,MAAM,CAAC,EAAE,aAAa,CAAC;KACnB,GAAG,aAAa;CA+qBvB"}
@@ -54,6 +54,7 @@ const access_bundle_store_js_1 = require("./authz/access-bundle-store.js");
54
54
  const grant_admin_service_js_1 = require("./authz/grant-admin.service.js");
55
55
  const grant_admin_controller_js_1 = require("./authz/grant-admin.controller.js");
56
56
  const grant_ceiling_js_1 = require("./authz/grant-ceiling.js");
57
+ const access_change_notifier_js_1 = require("./authz/access-change-notifier.js");
57
58
  const policy_service_js_1 = require("./policy/policy.service.js");
58
59
  const security_policy_service_js_1 = require("./security-policy/security-policy.service.js");
59
60
  const security_policy_controller_js_1 = require("./security-policy/security-policy.controller.js");
@@ -301,6 +302,10 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
301
302
  // registered for the cross-tenant writer and the policy author too.
302
303
  const recordAuthorization = (0, record_authorization_js_1.buildRecordAuthorizationInputs)(authzPool, extraObjects, schemas);
303
304
  const ceilingRegistry = recordAuthorization.registry;
305
+ // G-027 step 3: personal access-change notices, keys expanded against the SAME registry the
306
+ // request path grants from. Attached to guard's NotificationService by the provider below
307
+ // (absent with `auth: false` ⇒ a no-op notifier).
308
+ const accessChangeNotifier = new access_change_notifier_js_1.AccessChangeNotifier(ceilingRegistry, schemas);
304
309
  const bumper = new permission_version_bumper_js_1.PermissionVersionBumper(undefined, schemas);
305
310
  const postingVersionBump = {
306
311
  bumpTenant: (tenantUuid) => bumper.bumpForTenant(tenantUuid, authzPool),
@@ -331,7 +336,7 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
331
336
  const auditSinkConfig = { schema: schemas.audit };
332
337
  // Grant administration is audited on the grant's own transaction (D6, fail-closed).
333
338
  const grantAdminAuditSink = new engine_1.MariaDbAuditSink(auditSinkConfig);
334
- const grantAdminService = new grant_admin_service_js_1.GrantAdminService(authzPool, bumper, accessBundleStore, grantCeiling, grantAdminAuditSink, schemas);
339
+ const grantAdminService = new grant_admin_service_js_1.GrantAdminService(authzPool, bumper, accessBundleStore, grantCeiling, grantAdminAuditSink, schemas, accessChangeNotifier);
335
340
  // Scope closure (Task 1.2 of the scoped-hierarchy program): the reconciler + node
336
341
  // move/delete maintenance, over the SAME shared authzPool (it reaches BOTH shield_app —
337
342
  // companies — and shield_authz — scope_closure). Reuses the SAME `bumper` instance so a
@@ -671,6 +676,16 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
671
676
  org_catalog_service_js_1.OrgCatalogService,
672
677
  record_resolver_js_1.RecordResolverService,
673
678
  { provide: posting_version_bump_js_1.POSTING_VERSION_BUMP, useValue: postingVersionBump },
679
+ // G-027 step 3: the one access-change notifier (also held by GrantAdminService), attached
680
+ // here to guard's NotificationService when guard provides it (`auth: true`), else none.
681
+ {
682
+ provide: access_change_notifier_js_1.ACCESS_CHANGE_NOTIFIER,
683
+ useFactory: (service) => {
684
+ accessChangeNotifier.attach(service ?? null);
685
+ return accessChangeNotifier;
686
+ },
687
+ inject: [{ token: guard_1.SHIELD_NOTIFICATIONS, optional: true }],
688
+ },
674
689
  // Mandatory-2FA policies (R2.6, R2.9): the service, and its permission-version bump over
675
690
  // the ONE shared bumper (bumps on the write's transaction, published after the commit).
676
691
  security_policy_service_js_1.SecurityPolicyService,
@@ -20,11 +20,22 @@ import { RecordsService, type RecordId, type RecordView } from '@astram/engine';
20
20
  import type { Principal } from '@astram/core';
21
21
  import { type PostingVersionBump } from './posting-version-bump.js';
22
22
  import { GrantCeiling } from '../authz/grant-ceiling.js';
23
+ import { type AccessChangeNotifier } from '../authz/access-change-notifier.js';
23
24
  export declare class MembershipService {
24
25
  private readonly records;
25
26
  private readonly ceiling;
26
27
  private readonly versionBump?;
27
- constructor(records: RecordsService, ceiling: GrantCeiling, versionBump?: (PostingVersionBump | null) | undefined);
28
+ private readonly accessNotifier?;
29
+ constructor(records: RecordsService, ceiling: GrantCeiling, versionBump?: (PostingVersionBump | null) | undefined, accessNotifier?: (AccessChangeNotifier | null) | undefined);
30
+ /**
31
+ * G-027 step 3 (SPEC §5E): the before-snapshot of `pairs` on the engine transaction's own
32
+ * connection (`transactionQueryOf`), so the after-snapshot sees the uncommitted membership
33
+ * writes and the notice commits or rolls back with them. `null` (no reads) when no notifier is
34
+ * wired or it is disabled for this actor (guard `auth: false`, the org bootstrap). Never throws.
35
+ */
36
+ private beginAccessNotice;
37
+ /** After the last write: after-snapshot + notify (a drop is logged by the notifier only). */
38
+ private finishAccessNotice;
28
39
  /**
29
40
  * Read a membership through the subtree wall (404 when out of reach) and, unless the actor is
30
41
  * acting on their OWN membership, require the actor to dominate its user (security review): a
@@ -48,7 +59,8 @@ export declare class MembershipService {
48
59
  */
49
60
  addMembership(actor: Principal, userUuid: string, companyUuid: string): Promise<RecordId>;
50
61
  /**
51
- * Add an ACTIVE membership for `(userUuid, companyUuid)` in one create (G-009 R4): a brand-new
62
+ * Add an ACTIVE membership for `(userUuid, companyUuid)` in one create, inside one engine
63
+ * transaction with the user's access notice (G-009 R4; G-027 step 3): a brand-new
52
64
  * membership has no other active card to displace, so no create-then-activate pair. A
53
65
  * membership for that pair already existing is an `IdentityConflictError`, from the pre-check
54
66
  * and from the `(cm_user_uuid, cm_company_uuid)` unique index (the pre-check read is SQL-scoped
@@ -88,6 +100,11 @@ export declare class MembershipService {
88
100
  /**
89
101
  * Remove a membership card (its postings cascade at the DB via FK ON DELETE CASCADE). A peer's
90
102
  * membership needs dominance over the peer (security review); your own does not.
103
+ *
104
+ * Intentionally sends NO access-change notice (notifying-user SPEC §11): the user can no longer
105
+ * open that company's inbox, and the notice store's recipient filter would skip them anyway.
106
+ * `updateMembership` (status only, no key change), user status changes and `deleteUser` are
107
+ * likewise not hooked.
91
108
  */
92
109
  removeMembership(actor: Principal, membershipUuid: string): Promise<void>;
93
110
  }
@@ -1 +1 @@
1
- {"version":3,"file":"membership.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/membership.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAChF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAE1F,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAYzD,qBACa,iBAAiB;IAE1B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAGxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAQxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;gBAXZ,OAAO,EAAE,cAAc,EAGvB,OAAO,EAAE,YAAY,EAQrB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA;IAG1D;;;;OAIG;YACW,WAAW;IAQzB;;;;;;OAMG;YACW,WAAW;IAYzB;;;;;;OAMG;IACG,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAkB/F;;;;;;;OAOG;IACG,mBAAmB,CACvB,KAAK,EAAE,SAAS,EAChB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,QAAQ,CAAC;IAwBpB,kEAAkE;IAC5D,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,UAAU,EAAE,CAAC;IAKvE;;;;;;;;;;;;;;;;;OAiBG;IACG,SAAS,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA0D1F;;;;OAIG;IACG,gBAAgB,CACpB,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,KAAK,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GACzB,OAAO,CAAC,IAAI,CAAC;IA4BhB;;;OAGG;IACG,gBAAgB,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAYhF"}
1
+ {"version":3,"file":"membership.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/membership.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEhF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAE1F,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,EAEL,KAAK,oBAAoB,EAI1B,MAAM,oCAAoC,CAAC;AAoB5C,qBACa,iBAAiB;IAE1B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAGxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAQxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAK7B,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC;gBAhBf,OAAO,EAAE,cAAc,EAGvB,OAAO,EAAE,YAAY,EAQrB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA,EAKvC,cAAc,CAAC,GAAE,oBAAoB,GAAG,IAAI,aAAA;IAG/D;;;;;OAKG;YACW,iBAAiB;IAY/B,6FAA6F;YAC/E,kBAAkB;IAShC;;;;OAIG;YACW,WAAW;IAQzB;;;;;;OAMG;YACW,WAAW;IAYzB;;;;;;OAMG;IACG,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAkB/F;;;;;;;;OAQG;IACG,mBAAmB,CACvB,KAAK,EAAE,SAAS,EAChB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,QAAQ,CAAC;IAgCpB,kEAAkE;IAC5D,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,UAAU,EAAE,CAAC;IAKvE;;;;;;;;;;;;;;;;;OAiBG;IACG,SAAS,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAyE1F;;;;OAIG;IACG,gBAAgB,CACpB,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,KAAK,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GACzB,OAAO,CAAC,IAAI,CAAC;IA4BhB;;;;;;;;OAQG;IACG,gBAAgB,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAYhF"}
@@ -33,9 +33,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
33
33
  exports.MembershipService = void 0;
34
34
  const common_1 = require("@nestjs/common");
35
35
  const engine_1 = require("@astram/engine");
36
+ const internal_1 = require("@astram/engine/internal");
36
37
  const posting_version_bump_js_1 = require("./posting-version-bump.js");
37
38
  const identity_errors_js_1 = require("../shared/identity-errors.js");
38
39
  const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
40
+ const access_change_notifier_js_1 = require("../authz/access-change-notifier.js");
39
41
  /** The only status the active membership may have (case-sensitive, as the readers' predicate). */
40
42
  const ACTIVE_STATUS = 'active';
41
43
  /** A driver duplicate-key error (errno 1062), recognised by errno/code only, never its message. */
@@ -49,13 +51,36 @@ let MembershipService = class MembershipService {
49
51
  records;
50
52
  ceiling;
51
53
  versionBump;
54
+ accessNotifier;
52
55
  constructor(records,
53
56
  // Dominance over the member for destructive ops (security review): REQUIRED, so a missing
54
57
  // ceiling fails DI at boot instead of silently skipping the check.
55
- ceiling, versionBump) {
58
+ ceiling, versionBump, accessNotifier) {
56
59
  this.records = records;
57
60
  this.ceiling = ceiling;
58
61
  this.versionBump = versionBump;
62
+ this.accessNotifier = accessNotifier;
63
+ }
64
+ /**
65
+ * G-027 step 3 (SPEC §5E): the before-snapshot of `pairs` on the engine transaction's own
66
+ * connection (`transactionQueryOf`), so the after-snapshot sees the uncommitted membership
67
+ * writes and the notice commits or rolls back with them. `null` (no reads) when no notifier is
68
+ * wired or it is disabled for this actor (guard `auth: false`, the org bootstrap). Never throws.
69
+ */
70
+ async beginAccessNotice(tx, actor, pairs) {
71
+ const notifier = this.accessNotifier;
72
+ if (!notifier || !notifier.enabledFor(actor))
73
+ return null;
74
+ const query = (0, internal_1.transactionQueryOf)(tx);
75
+ const before = await notifier.snapshot(query, pairs);
76
+ return { notifier, query, pairs, before };
77
+ }
78
+ /** After the last write: after-snapshot + notify (a drop is logged by the notifier only). */
79
+ async finishAccessNotice(run, actor) {
80
+ if (run === null)
81
+ return;
82
+ const after = await run.notifier.snapshot(run.query, run.pairs);
83
+ await run.notifier.notify(run.query, actor, run.before, after);
59
84
  }
60
85
  /**
61
86
  * Read a membership through the subtree wall (404 when out of reach) and, unless the actor is
@@ -117,7 +142,8 @@ let MembershipService = class MembershipService {
117
142
  });
118
143
  }
119
144
  /**
120
- * Add an ACTIVE membership for `(userUuid, companyUuid)` in one create (G-009 R4): a brand-new
145
+ * Add an ACTIVE membership for `(userUuid, companyUuid)` in one create, inside one engine
146
+ * transaction with the user's access notice (G-009 R4; G-027 step 3): a brand-new
121
147
  * membership has no other active card to displace, so no create-then-activate pair. A
122
148
  * membership for that pair already existing is an `IdentityConflictError`, from the pre-check
123
149
  * and from the `(cm_user_uuid, cm_company_uuid)` unique index (the pre-check read is SQL-scoped
@@ -136,8 +162,16 @@ let MembershipService = class MembershipService {
136
162
  }
137
163
  let id;
138
164
  try {
139
- id = await this.records.create('company_membership', {
140
- fields: { userUuid, companyUuid, isActive: true, status: 'active' },
165
+ // G-027 step 3 (SPEC §5E): the create and the user's access notice in one transaction.
166
+ id = await this.records.transaction(async (tx) => {
167
+ const notice = await this.beginAccessNotice(tx, actor, [
168
+ { userId: userUuid, tenantId: companyUuid },
169
+ ]);
170
+ const created = await tx.create('company_membership', {
171
+ fields: { userUuid, companyUuid, isActive: true, status: 'active' },
172
+ });
173
+ await this.finishAccessNotice(notice, actor);
174
+ return created;
141
175
  });
142
176
  }
143
177
  catch (err) {
@@ -203,6 +237,16 @@ let MembershipService = class MembershipService {
203
237
  if (current.fields?.status !== ACTIVE_STATUS) {
204
238
  throw new identity_errors_js_1.IdentityConflictError('Only a membership with status active can be made the active membership.');
205
239
  }
240
+ // G-027 step 3 (SPEC §5E): the user in every company of their memberships (the target's
241
+ // and each one this flip deactivates), before the first write. One notice per company
242
+ // whose key set changed.
243
+ const companies = new Set();
244
+ for (const m of rows) {
245
+ const c = m.fields?.companyUuid;
246
+ if (typeof c === 'string' && c.length > 0)
247
+ companies.add(c);
248
+ }
249
+ const notice = await this.beginAccessNotice(tx, actor, [...companies].map((tenantId) => ({ userId: userUuid, tenantId })));
206
250
  // 1) TARGET FIRST (defence in depth, see the method comment).
207
251
  await tx.update('company_membership', target.id, { fields: { isActive: true } });
208
252
  // 2) Deactivate every OTHER membership (skip the target we just activated).
@@ -212,6 +256,7 @@ let MembershipService = class MembershipService {
212
256
  await tx.update('company_membership', m.id, { fields: { isActive: false } });
213
257
  deactivated.push(m);
214
258
  }
259
+ await this.finishAccessNotice(notice, actor);
215
260
  });
216
261
  // ADR-0011 STALENESS FIX: activation ADDS the target company's role grants and each
217
262
  // deactivation REMOVES that company's grants — a coarse-grant change in BOTH directions.
@@ -262,6 +307,11 @@ let MembershipService = class MembershipService {
262
307
  /**
263
308
  * Remove a membership card (its postings cascade at the DB via FK ON DELETE CASCADE). A peer's
264
309
  * membership needs dominance over the peer (security review); your own does not.
310
+ *
311
+ * Intentionally sends NO access-change notice (notifying-user SPEC §11): the user can no longer
312
+ * open that company's inbox, and the notice store's recipient filter would skip them anyway.
313
+ * `updateMembership` (status only, no key change), user status changes and `deleteUser` are
314
+ * likewise not hooked.
265
315
  */
266
316
  async removeMembership(actor, membershipUuid) {
267
317
  // ADR-0011 STALENESS FIX: resolve the membership's tenant BEFORE the delete (it's
@@ -281,6 +331,8 @@ exports.MembershipService = MembershipService = __decorate([
281
331
  (0, common_1.Injectable)(),
282
332
  __param(2, (0, common_1.Optional)()),
283
333
  __param(2, (0, common_1.Inject)(posting_version_bump_js_1.POSTING_VERSION_BUMP)),
334
+ __param(3, (0, common_1.Optional)()),
335
+ __param(3, (0, common_1.Inject)(access_change_notifier_js_1.ACCESS_CHANGE_NOTIFIER)),
284
336
  __metadata("design:paramtypes", [engine_1.RecordsService,
285
- grant_ceiling_js_1.GrantCeiling, Object])
337
+ grant_ceiling_js_1.GrantCeiling, Object, Object])
286
338
  ], MembershipService);
@@ -27,6 +27,7 @@ import { RecordsService, type RecordView } from '@astram/engine';
27
27
  import type { Principal } from '@astram/core';
28
28
  import { type PostingVersionBump } from './posting-version-bump.js';
29
29
  import { GrantCeiling } from '../authz/grant-ceiling.js';
30
+ import { type AccessChangeNotifier } from '../authz/access-change-notifier.js';
30
31
  /**
31
32
  * The shape of one desired posting row submitted by the editor. The effective-window
32
33
  * bounds arrive from the client as a bare `YYYY-MM-DD` date string (native `<input
@@ -44,7 +45,8 @@ export declare class PostingService {
44
45
  private readonly records;
45
46
  private readonly ceiling;
46
47
  private readonly versionBump?;
47
- constructor(records: RecordsService, ceiling: GrantCeiling, versionBump?: (PostingVersionBump | null) | undefined);
48
+ private readonly accessNotifier?;
49
+ constructor(records: RecordsService, ceiling: GrantCeiling, versionBump?: (PostingVersionBump | null) | undefined, accessNotifier?: (AccessChangeNotifier | null) | undefined);
48
50
  /** List a membership's postings (tenant-scoped by the pipe). */
49
51
  listPostings(membershipUuid: string): Promise<readonly RecordView[]>;
50
52
  /**
@@ -64,6 +66,15 @@ export declare class PostingService {
64
66
  addPostings(actor: Principal, membershipUuid: string, toAdd: readonly PostingInput[], opts?: {
65
67
  operationRoleIds?: ReadonlySet<string> | readonly string[];
66
68
  }): Promise<void>;
69
+ /**
70
+ * G-027 step 3 (SPEC §5D): the before-snapshot of (member, company) on the engine transaction's
71
+ * own connection (`transactionQueryOf`), so the after-snapshot sees the uncommitted posting
72
+ * writes and the notice commits or rolls back with them. `null` (no reads) when no notifier is
73
+ * wired or it is disabled for this actor (guard `auth: false`, the org bootstrap). Never throws.
74
+ */
75
+ private beginAccessNotice;
76
+ /** After the last write: after-snapshot + notify (a drop is logged by the notifier only). */
77
+ private finishAccessNotice;
67
78
  private assertEveryRole;
68
79
  /** The membership through the subtree read wall (404 when out of reach) and its company. */
69
80
  private reachMembership;
@@ -1 +1 @@
1
- {"version":3,"file":"posting.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/posting.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAC1F,OAAO,EAAE,YAAY,EAA6B,MAAM,2BAA2B,CAAC;AAOpF;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACvC,cAAc,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CACzC;AA0ED,qBACa,cAAc;IAEvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAGxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAKxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;gBARZ,OAAO,EAAE,cAAc,EAGvB,OAAO,EAAE,YAAY,EAKrB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA;IAG1D,gEAAgE;IAC1D,YAAY,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,UAAU,EAAE,CAAC;IAK1E;;;;;OAKG;IACG,eAAe,CACnB,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,SAAS,YAAY,EAAE,GAC/B,OAAO,CAAC,IAAI,CAAC;IA0EhB;;;;;;OAMG;IACG,WAAW,CACf,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,KAAK,EAAE,SAAS,YAAY,EAAE,EAC9B,IAAI,CAAC,EAAE;QAAE,gBAAgB,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,SAAS,MAAM,EAAE,CAAA;KAAE,GACpE,OAAO,CAAC,IAAI,CAAC;IA4ChB,OAAO,CAAC,eAAe;IAQvB,4FAA4F;YAC9E,eAAe;IAY7B,iGAAiG;YACnF,YAAY;IAuB1B;;;;OAIG;YACW,cAAc;IAa5B;;;;;;;;;;;;OAYG;YACW,YAAY;YA+BZ,UAAU;CAkBzB"}
1
+ {"version":3,"file":"posting.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/posting.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAC1F,OAAO,EAAE,YAAY,EAA6B,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAEL,KAAK,oBAAoB,EAI1B,MAAM,oCAAoC,CAAC;AAO5C;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACvC,cAAc,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CACzC;AAkFD,qBACa,cAAc;IAEvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAGxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAKxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAK7B,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC;gBAbf,OAAO,EAAE,cAAc,EAGvB,OAAO,EAAE,YAAY,EAKrB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA,EAKvC,cAAc,CAAC,GAAE,oBAAoB,GAAG,IAAI,aAAA;IAG/D,gEAAgE;IAC1D,YAAY,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,UAAU,EAAE,CAAC;IAK1E;;;;;OAKG;IACG,eAAe,CACnB,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,SAAS,YAAY,EAAE,GAC/B,OAAO,CAAC,IAAI,CAAC;IA6EhB;;;;;;OAMG;IACG,WAAW,CACf,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,KAAK,EAAE,SAAS,YAAY,EAAE,EAC9B,IAAI,CAAC,EAAE;QAAE,gBAAgB,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,SAAS,MAAM,EAAE,CAAA;KAAE,GACpE,OAAO,CAAC,IAAI,CAAC;IAiDhB;;;;;OAKG;YACW,iBAAiB;IAiB/B,6FAA6F;YAC/E,kBAAkB;IAShC,OAAO,CAAC,eAAe;IAQvB,4FAA4F;YAC9E,eAAe;IAY7B,iGAAiG;YACnF,YAAY;IAuB1B;;;;OAIG;YACW,cAAc;IAa5B;;;;;;;;;;;;OAYG;YACW,YAAY;YA+BZ,UAAU;CAkBzB"}
@@ -40,8 +40,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
40
40
  exports.PostingService = void 0;
41
41
  const common_1 = require("@nestjs/common");
42
42
  const engine_1 = require("@astram/engine");
43
+ const internal_1 = require("@astram/engine/internal");
43
44
  const posting_version_bump_js_1 = require("./posting-version-bump.js");
44
45
  const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
46
+ const access_change_notifier_js_1 = require("../authz/access-change-notifier.js");
45
47
  const identity_errors_js_1 = require("../shared/identity-errors.js");
46
48
  /** The field keys whose values are effective-window epoch-ms bounds (normalized on write). */
47
49
  const WINDOW_KEYS = new Set(['effectiveFrom', 'effectiveUntil']);
@@ -115,13 +117,15 @@ let PostingService = class PostingService {
115
117
  records;
116
118
  ceiling;
117
119
  versionBump;
120
+ accessNotifier;
118
121
  constructor(records,
119
122
  // The role-assignment ceiling + binding rule (security hardening #11). REQUIRED: a missing
120
123
  // ceiling must fail DI at boot, never silently skip the check.
121
- ceiling, versionBump) {
124
+ ceiling, versionBump, accessNotifier) {
122
125
  this.records = records;
123
126
  this.ceiling = ceiling;
124
127
  this.versionBump = versionBump;
128
+ this.accessNotifier = accessNotifier;
125
129
  }
126
130
  /** List a membership's postings (tenant-scoped by the pipe). */
127
131
  async listPostings(membershipUuid) {
@@ -170,12 +174,15 @@ let PostingService = class PostingService {
170
174
  // delete, while we waited is refused before the INSERT (rolled back).
171
175
  await this.lockAndRecheck(tx, actor, tenantUuid, addedRoles, undefined);
172
176
  await this.lockOrgUnits(tx, tenantUuid, added);
177
+ // G-027 step 3 (SPEC §5D): the member's keys in this company before the first write.
178
+ const notice = await this.beginAccessNotice(tx, actor, memberUuid, tenantUuid);
173
179
  for (const d of added) {
174
180
  await tx.create('membership_posting', { fields: { ...stripEmpty(d), membershipUuid } });
175
181
  }
176
182
  for (const r of removed) {
177
183
  await tx.delete('membership_posting', r.id);
178
184
  }
185
+ await this.finishAccessNotice(notice, actor);
179
186
  });
180
187
  await this.bumpTenant(tenantUuid);
181
188
  }
@@ -209,15 +216,43 @@ let PostingService = class PostingService {
209
216
  if (added.length === 0)
210
217
  return; // nothing missing: no write, no bump
211
218
  const addedRoles = await this.assertMayAdd(actor, tenantUuid, added, ceilingOpts);
219
+ const memberUuid = typeof membership.fields?.userUuid === 'string' ? membership.fields.userUuid : '';
212
220
  await this.records.transaction(async (tx) => {
213
221
  await this.lockAndRecheck(tx, actor, tenantUuid, addedRoles, ceilingOpts);
214
222
  await this.lockOrgUnits(tx, tenantUuid, added);
223
+ // G-027 step 3 (SPEC §5D): the member's keys in this company before the first write.
224
+ const notice = await this.beginAccessNotice(tx, actor, memberUuid, tenantUuid);
215
225
  for (const d of added) {
216
226
  await tx.create('membership_posting', { fields: { ...stripEmpty(d), membershipUuid } });
217
227
  }
228
+ await this.finishAccessNotice(notice, actor);
218
229
  });
219
230
  await this.bumpTenant(tenantUuid);
220
231
  }
232
+ /**
233
+ * G-027 step 3 (SPEC §5D): the before-snapshot of (member, company) on the engine transaction's
234
+ * own connection (`transactionQueryOf`), so the after-snapshot sees the uncommitted posting
235
+ * writes and the notice commits or rolls back with them. `null` (no reads) when no notifier is
236
+ * wired or it is disabled for this actor (guard `auth: false`, the org bootstrap). Never throws.
237
+ */
238
+ async beginAccessNotice(tx, actor, memberUuid, tenantUuid) {
239
+ const notifier = this.accessNotifier;
240
+ if (!notifier || !notifier.enabledFor(actor))
241
+ return null;
242
+ const query = (0, internal_1.transactionQueryOf)(tx);
243
+ const pairs = memberUuid !== '' && tenantUuid !== null
244
+ ? [{ userId: memberUuid, tenantId: tenantUuid }]
245
+ : [];
246
+ const before = await notifier.snapshot(query, pairs);
247
+ return { notifier, query, pairs, before };
248
+ }
249
+ /** After the last write: after-snapshot + notify (a drop is logged by the notifier only). */
250
+ async finishAccessNotice(run, actor) {
251
+ if (run === null)
252
+ return;
253
+ const after = await run.notifier.snapshot(run.query, run.pairs);
254
+ await run.notifier.notify(run.query, actor, run.before, after);
255
+ }
221
256
  assertEveryRole(postings) {
222
257
  for (const d of postings) {
223
258
  if (typeof d?.roleUuid !== 'string' || d.roleUuid.length === 0) {
@@ -327,6 +362,8 @@ exports.PostingService = PostingService = __decorate([
327
362
  (0, common_1.Injectable)(),
328
363
  __param(2, (0, common_1.Optional)()),
329
364
  __param(2, (0, common_1.Inject)(posting_version_bump_js_1.POSTING_VERSION_BUMP)),
365
+ __param(3, (0, common_1.Optional)()),
366
+ __param(3, (0, common_1.Inject)(access_change_notifier_js_1.ACCESS_CHANGE_NOTIFIER)),
330
367
  __metadata("design:paramtypes", [engine_1.RecordsService,
331
- grant_ceiling_js_1.GrantCeiling, Object])
368
+ grant_ceiling_js_1.GrantCeiling, Object, Object])
332
369
  ], PostingService);
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Framework identity migration — the per-user notification store (G-027 step 2, NAES G-034 R5;
3
+ * spec `docs/features/notification-store/SPEC.md` §4A).
4
+ *
5
+ * `<identity>.user_notification` — one row per notice to one user in one company. Written and read
6
+ * only by guard (`MariaDbNotificationStore`, through `NotificationService.post` and the
7
+ * `/account/notifications` routes); identity owns the DDL only, like `email_change_token`.
8
+ * - un_tenant_id: the company the notice belongs to, stamped server-side; a user reads only the
9
+ * notices of the session's active company. A deleted company takes its notices with it.
10
+ * - un_user_uuid: the recipient; a deleted user takes their notices with it.
11
+ * - un_category: 'announcement' | 'personal' (from the kind's registration, never the caller).
12
+ * - un_title / un_body / un_link: plain text; the link is an app-relative path.
13
+ * - un_dedupe_key: the poster's optional idempotency key; unique per user and company (NULL keys
14
+ * never collide), so a retried post inserts nothing. Binary collation: keys compare exactly
15
+ * (`A1` and `a1` are different keys).
16
+ * - un_created_at_ms / un_read_at_ms: epoch milliseconds (BIGINT, no driver time-zone shift);
17
+ * NULL read time = unread.
18
+ * Indexes serve the inbox list and counts, the dedupe key, the retention purge and the tenant FK.
19
+ *
20
+ * Idempotent (CREATE TABLE IF NOT EXISTS); `down` drops the table with IF EXISTS. No rows are
21
+ * written, no ids are hard-coded. Database names come from `ctx.schemas` (namespace or single
22
+ * layout, ADR-0037/ADR-0041); the table is listed in core `FRAMEWORK_TABLES`.
23
+ */
24
+
25
+ const { frameworkSchemasForMigration } = require('@astram/core');
26
+
27
+ async function up(ctx) {
28
+ const { query } = ctx;
29
+ const { app: APP, identity: IDENTITY } = frameworkSchemasForMigration(ctx, process.env);
30
+
31
+ await query(
32
+ `CREATE TABLE IF NOT EXISTS \`${IDENTITY}\`.\`user_notification\` (
33
+ un_uuid VARCHAR(36) NOT NULL,
34
+ un_tenant_id VARCHAR(36) NOT NULL,
35
+ un_user_uuid VARCHAR(36) NOT NULL,
36
+ un_category VARCHAR(16) NOT NULL,
37
+ un_kind VARCHAR(64) NOT NULL,
38
+ un_title VARCHAR(160) NOT NULL,
39
+ un_body VARCHAR(1000) NULL,
40
+ un_link VARCHAR(512) NULL,
41
+ un_dedupe_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
42
+ un_created_at_ms BIGINT NOT NULL,
43
+ un_read_at_ms BIGINT NULL,
44
+ PRIMARY KEY (un_uuid),
45
+ INDEX idx_un_inbox (un_user_uuid, un_tenant_id, un_category, un_read_at_ms, un_created_at_ms),
46
+ UNIQUE KEY uq_un_dedupe (un_user_uuid, un_tenant_id, un_dedupe_key),
47
+ INDEX idx_un_created (un_created_at_ms),
48
+ INDEX idx_un_tenant (un_tenant_id),
49
+ CONSTRAINT ck_un_category CHECK (un_category IN ('announcement', 'personal')),
50
+ CONSTRAINT FK_user_notification_tenant
51
+ FOREIGN KEY (un_tenant_id) REFERENCES \`${APP}\`.\`companies\` (c_uuid)
52
+ ON DELETE CASCADE,
53
+ CONSTRAINT FK_user_notification_user
54
+ FOREIGN KEY (un_user_uuid) REFERENCES \`${APP}\`.\`users\` (u_uuid)
55
+ ON DELETE CASCADE
56
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
57
+ );
58
+ }
59
+
60
+ async function down(ctx) {
61
+ const { query } = ctx;
62
+ const { identity: IDENTITY } = frameworkSchemasForMigration(ctx, process.env);
63
+ await query(`DROP TABLE IF EXISTS \`${IDENTITY}\`.\`user_notification\``);
64
+ }
65
+
66
+ module.exports = { up, down };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astram/identity",
3
- "version": "0.19.0",
3
+ "version": "0.21.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.12.0",
40
- "@astram/guard": "0.23.0",
41
- "@astram/engine": "0.16.0"
39
+ "@astram/core": "1.13.0",
40
+ "@astram/guard": "0.25.0",
41
+ "@astram/engine": "0.16.1"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@nestjs/common": "^11",