@astram/identity 0.20.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.
- package/dist/features/authz/access-change-notifier.d.ts +109 -0
- package/dist/features/authz/access-change-notifier.d.ts.map +1 -0
- package/dist/features/authz/access-change-notifier.js +421 -0
- package/dist/features/authz/grant-admin.service.d.ts +27 -1
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.service.js +52 -2
- package/dist/features/identity-feature.module.d.ts.map +1 -1
- package/dist/features/identity-feature.module.js +16 -1
- package/dist/features/membership/membership.service.d.ts +19 -2
- package/dist/features/membership/membership.service.d.ts.map +1 -1
- package/dist/features/membership/membership.service.js +57 -5
- package/dist/features/membership/posting.service.d.ts +12 -1
- package/dist/features/membership/posting.service.d.ts.map +1 -1
- package/dist/features/membership/posting.service.js +39 -2
- package/package.json +2 -2
|
@@ -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;
|
|
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.
|
|
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;
|
|
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
|
-
|
|
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
|
|
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;
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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;
|
|
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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astram/identity",
|
|
3
|
-
"version": "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",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"mariadb": "^3.5.4",
|
|
39
39
|
"@astram/core": "1.13.0",
|
|
40
|
-
"@astram/guard": "0.
|
|
40
|
+
"@astram/guard": "0.25.0",
|
|
41
41
|
"@astram/engine": "0.16.1"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|