@astram/guard 0.2.0 → 0.4.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.
Files changed (120) hide show
  1. package/dist/auth/account.controller.d.ts +245 -0
  2. package/dist/auth/account.controller.d.ts.map +1 -0
  3. package/dist/auth/account.controller.js +673 -0
  4. package/dist/auth/auth.controller.d.ts +219 -5
  5. package/dist/auth/auth.controller.d.ts.map +1 -1
  6. package/dist/auth/auth.controller.js +790 -12
  7. package/dist/auth/auth.module.d.ts +101 -3
  8. package/dist/auth/auth.module.d.ts.map +1 -1
  9. package/dist/auth/auth.module.js +300 -8
  10. package/dist/auth/dto.d.ts +16 -6
  11. package/dist/auth/dto.d.ts.map +1 -1
  12. package/dist/auth/ports/audit-sink.d.ts +47 -0
  13. package/dist/auth/ports/audit-sink.d.ts.map +1 -0
  14. package/dist/auth/ports/audit-sink.js +20 -0
  15. package/dist/auth/ports/credential-writer.d.ts +23 -0
  16. package/dist/auth/ports/credential-writer.d.ts.map +1 -0
  17. package/dist/auth/ports/credential-writer.js +2 -0
  18. package/dist/auth/ports/email-sender.d.ts +14 -0
  19. package/dist/auth/ports/email-sender.d.ts.map +1 -0
  20. package/dist/auth/ports/email-sender.js +2 -0
  21. package/dist/auth/ports/mfa-device-store.d.ts +45 -0
  22. package/dist/auth/ports/mfa-device-store.d.ts.map +1 -0
  23. package/dist/auth/ports/mfa-device-store.js +13 -0
  24. package/dist/auth/ports/profile-writer.d.ts +12 -0
  25. package/dist/auth/ports/profile-writer.d.ts.map +1 -0
  26. package/dist/auth/ports/profile-writer.js +2 -0
  27. package/dist/auth/ports/recovery-code-store.d.ts +10 -0
  28. package/dist/auth/ports/recovery-code-store.d.ts.map +1 -0
  29. package/dist/auth/ports/recovery-code-store.js +2 -0
  30. package/dist/auth/ports/reset-token-store.d.ts +12 -0
  31. package/dist/auth/ports/reset-token-store.d.ts.map +1 -0
  32. package/dist/auth/ports/reset-token-store.js +2 -0
  33. package/dist/auth/ports/session-facts-projector.d.ts +44 -0
  34. package/dist/auth/ports/session-facts-projector.d.ts.map +1 -0
  35. package/dist/auth/ports/session-facts-projector.js +30 -0
  36. package/dist/auth/ports/session-sweeper.d.ts +12 -0
  37. package/dist/auth/ports/session-sweeper.d.ts.map +1 -0
  38. package/dist/auth/ports/session-sweeper.js +2 -0
  39. package/dist/auth/profile-resolver.d.ts +73 -0
  40. package/dist/auth/profile-resolver.d.ts.map +1 -0
  41. package/dist/auth/profile-resolver.js +28 -0
  42. package/dist/auth/rate-limit/attempt-limiter.d.ts +91 -0
  43. package/dist/auth/rate-limit/attempt-limiter.d.ts.map +1 -0
  44. package/dist/auth/rate-limit/attempt-limiter.js +133 -0
  45. package/dist/auth/tokens.d.ts +174 -0
  46. package/dist/auth/tokens.d.ts.map +1 -1
  47. package/dist/auth/tokens.js +190 -1
  48. package/dist/auth/totp-enrollment.d.ts +42 -0
  49. package/dist/auth/totp-enrollment.d.ts.map +1 -0
  50. package/dist/auth/totp-enrollment.js +96 -0
  51. package/dist/authentication/mariadb-credential-resolver.d.ts +38 -24
  52. package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
  53. package/dist/authentication/mariadb-credential-resolver.js +42 -28
  54. package/dist/authz/cross-tenant-grant-writer.d.ts +259 -0
  55. package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -0
  56. package/dist/authz/cross-tenant-grant-writer.js +397 -0
  57. package/dist/authz/manifest-operation-authority-adapter.d.ts +65 -0
  58. package/dist/authz/manifest-operation-authority-adapter.d.ts.map +1 -0
  59. package/dist/authz/manifest-operation-authority-adapter.js +108 -0
  60. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts +90 -0
  61. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -0
  62. package/dist/authz/mariadb-cross-tenant-grant-adapter.js +211 -0
  63. package/dist/authz/mariadb-record-rule-adapter.d.ts +60 -0
  64. package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -0
  65. package/dist/authz/mariadb-record-rule-adapter.js +154 -0
  66. package/dist/authz/mariadb-record-share-adapter.d.ts +75 -0
  67. package/dist/authz/mariadb-record-share-adapter.d.ts.map +1 -0
  68. package/dist/authz/mariadb-record-share-adapter.js +106 -0
  69. package/dist/authz/record-share-cascade.d.ts +52 -0
  70. package/dist/authz/record-share-cascade.d.ts.map +1 -0
  71. package/dist/authz/record-share-cascade.js +49 -0
  72. package/dist/authz/record-share-writer.d.ts +175 -0
  73. package/dist/authz/record-share-writer.d.ts.map +1 -0
  74. package/dist/authz/record-share-writer.js +225 -0
  75. package/dist/authz/wire-record-authorization.d.ts +66 -0
  76. package/dist/authz/wire-record-authorization.d.ts.map +1 -0
  77. package/dist/authz/wire-record-authorization.js +34 -0
  78. package/dist/completeness/catalog-reconciler.d.ts +21 -0
  79. package/dist/completeness/catalog-reconciler.d.ts.map +1 -0
  80. package/dist/completeness/catalog-reconciler.js +74 -0
  81. package/dist/completeness/operation-inventory-scanner.d.ts +17 -0
  82. package/dist/completeness/operation-inventory-scanner.d.ts.map +1 -0
  83. package/dist/completeness/operation-inventory-scanner.js +79 -0
  84. package/dist/completeness/ports/catalog-store.d.ts +33 -0
  85. package/dist/completeness/ports/catalog-store.d.ts.map +1 -0
  86. package/dist/completeness/ports/catalog-store.js +2 -0
  87. package/dist/config/app-config.d.ts +18 -16
  88. package/dist/config/app-config.d.ts.map +1 -1
  89. package/dist/config/app-config.js +68 -35
  90. package/dist/enforcement/classification.decorator.d.ts +33 -14
  91. package/dist/enforcement/classification.decorator.d.ts.map +1 -1
  92. package/dist/enforcement/classification.decorator.js +34 -14
  93. package/dist/enforcement/global-enforcement.guard.d.ts +29 -19
  94. package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
  95. package/dist/enforcement/global-enforcement.guard.js +108 -25
  96. package/dist/enforcement/reenroll-allowlist.d.ts +49 -0
  97. package/dist/enforcement/reenroll-allowlist.d.ts.map +1 -0
  98. package/dist/enforcement/reenroll-allowlist.js +77 -0
  99. package/dist/index.d.ts +35 -3
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +159 -7
  102. package/dist/module/shield-tokens.d.ts +15 -0
  103. package/dist/module/shield-tokens.d.ts.map +1 -0
  104. package/dist/module/shield-tokens.js +17 -0
  105. package/dist/module/shield.module.d.ts +154 -10
  106. package/dist/module/shield.module.d.ts.map +1 -1
  107. package/dist/module/shield.module.js +181 -24
  108. package/dist/request-auth/request-principal.d.ts +42 -0
  109. package/dist/request-auth/request-principal.d.ts.map +1 -0
  110. package/dist/request-auth/request-principal.js +87 -0
  111. package/dist/request-auth/session-facts-als.d.ts +65 -0
  112. package/dist/request-auth/session-facts-als.d.ts.map +1 -0
  113. package/dist/request-auth/session-facts-als.js +57 -0
  114. package/dist/request-auth/session-token-binder.d.ts +130 -0
  115. package/dist/request-auth/session-token-binder.d.ts.map +1 -0
  116. package/dist/request-auth/session-token-binder.js +277 -0
  117. package/dist/request-auth/tokens.d.ts +14 -0
  118. package/dist/request-auth/tokens.d.ts.map +1 -0
  119. package/dist/request-auth/tokens.js +16 -0
  120. package/package.json +2 -2
@@ -0,0 +1,397 @@
1
+ "use strict";
2
+ /**
3
+ * `CrossTenantGrantWriter` — the framework-owned WHO-MAY-GRANT write path (ADR-0009, the KEY
4
+ * NEW INVARIANT of Phase 4). Cross-tenant grant LIFECYCLE — offer / accept / revoke — is
5
+ * framework-governed here so a grant can NEVER be minted by the party that BENEFITS from it.
6
+ *
7
+ * ⛔ THIS IS NOT A CORE PORT. Grant EVALUATION is a core port ({@link CrossTenantGrantPort});
8
+ * grant LIFECYCLE is a guard WRITE path (it needs the `mariadb` driver + the consumer's
9
+ * identity schema to answer "is X an admin of tenant T", neither of which core may touch —
10
+ * ADR-0003). It lives in guard and is exposed as a provider the consumer app's grant surface
11
+ * calls (the invitation-email / inbox UX is DEFERRED — ADR-0009's Admin-Console follow-up).
12
+ *
13
+ * ── THE CONSENT MODEL (ADR-0009). A cross-tenant grant is TENANT B (the OWNING tenant, whose
14
+ * data is reached into) consenting to let TENANT A's principal in. The handshake:
15
+ * offer → an owner/ADMIN in the OWNING tenant (or root) declares the grant
16
+ * (object type, scope, action ceiling, expiry) → row created in 'offered' status.
17
+ * accept → the GRANTEE tenant must ACCEPT → row moves to 'accepted' → the grant ACTIVATES
18
+ * (only now does {@link CrossTenantGrantPort.findActiveGrant} return it).
19
+ * revoke → EITHER side (owning tenant admin OR grantee tenant member) may revoke → row
20
+ * moves to 'revoked' + `ctg_revoked_at` stamped → immediately inactive.
21
+ * ⛔ THE #1 SECURITY CRUX: the grantee's OWN tenant can NEVER unilaterally mint a grant. A
22
+ * grant is consent FROM the owning tenant; only an owning-tenant admin may OFFER.
23
+ *
24
+ * ── THE BOUNDS (enforced fail-closed, IN ORDER, BEFORE any write):
25
+ * • offer:
26
+ * 1. The offerer MUST hold `'share'` authority in the OWNING tenant (`ctg_owning_tenant_uuid`)
27
+ * — verified SERVER-SIDE via the injected {@link GrantAuthorizeFn} (the app's `decide()`);
28
+ * rejected otherwise. ⛔ `offeredBy` + `owningTenantId` come from the AUTHENTICATED
29
+ * principal context — the caller supplies them from the request principal, NEVER from
30
+ * a user-controlled body field for the authority decision.
31
+ * 2. `allowedActions` MUST be non-empty AND MUST NOT contain a wildcard (`'*'`) — the
32
+ * ceiling is a hard, explicit, enumerated set (ADR-0009: "non-wildcard, non-empty").
33
+ * 3. `expiresAtMs` MUST be a finite FUTURE timestamp — no perpetual / absent / past
34
+ * expiry (ADR-0009: "expiry mandatory").
35
+ * 4. scope='record' REQUIRES a non-empty `recordId`; scope='object_type' FORBIDS one.
36
+ * On pass ⇒ INSERT a fresh row in 'offered' status.
37
+ * • accept:
38
+ * Only a principal who holds `'accept'` authority in the GRANTEE tenant (`ctg_grantee_tenant_uuid`)
39
+ * may accept — verified via {@link GrantAuthorizeFn} (the app's `decide()`, which implies
40
+ * grantee-tenant membership); and only a grant
41
+ * currently in 'offered' status (not already accepted/revoked). On pass ⇒ UPDATE to
42
+ * 'accepted' + stamp `ctg_accepted_at`. Re-accepting an already-'accepted' grant is a
43
+ * benign no-op (idempotent). Accepting a 'revoked' grant is REJECTED (a revoked grant is
44
+ * dead — it must be re-offered).
45
+ * • revoke:
46
+ * EITHER an admin of the OWNING tenant OR a member of the GRANTEE tenant may revoke
47
+ * (ADR-0009: "either side may revoke immediately"). A stranger (neither) is REJECTED. On
48
+ * pass ⇒ UPDATE to 'revoked' + stamp `ctg_revoked_at`. Re-revoking an already-'revoked'
49
+ * grant is a benign no-op (idempotent).
50
+ *
51
+ * ── AUDIT ON BOTH TENANTS (ADR-0009: "audited on BOTH tenants as cross-tenant"). Every
52
+ * offer/accept/revoke emits ONE {@link GrantAuditEvent} through the injected
53
+ * {@link GrantAuditSink} carrying BOTH `owningTenantId` and `granteeTenantId` in STRUCTURED
54
+ * fields — so the consumer's sink can record it under each tenant's audit trail. NO raw uuid
55
+ * appears in any human-readable MESSAGE (ids ride in structured attributes only). Best-effort
56
+ * — a sink throw never fails the lifecycle operation.
57
+ *
58
+ * ── FETCH-THEN-GATE (mirrors {@link RecordShareWriter}). The authority decisions (is-admin /
59
+ * is-member) are resolved SERVER-SIDE via the injected resolver — NEVER trusted from caller
60
+ * input. accept/revoke FETCH the grant's owning+grantee tenants from the row (not from the
61
+ * caller) before deciding who may act.
62
+ */
63
+ Object.defineProperty(exports, "__esModule", { value: true });
64
+ exports.CrossTenantGrantWriter = exports.GrantStoreUnavailableError = exports.InvalidGrantError = exports.GrantNotPermittedError = exports.SHIELD_CROSS_TENANT_GRANT_WRITER = void 0;
65
+ const node_crypto_1 = require("node:crypto");
66
+ /** Schema the `cross_tenant_grant` table lives in (astram3 ships the migration). */
67
+ const DEFAULT_SCHEMA = 'shield_authz';
68
+ /** The DI token the writer is bound under. */
69
+ exports.SHIELD_CROSS_TENANT_GRANT_WRITER = Symbol('SHIELD_CROSS_TENANT_GRANT_WRITER');
70
+ /** Thrown when the who-may-grant bound rejects an offer/accept/revoke (fail-closed). */
71
+ class GrantNotPermittedError extends Error {
72
+ constructor(message) {
73
+ super(message);
74
+ this.name = 'GrantNotPermittedError';
75
+ }
76
+ }
77
+ exports.GrantNotPermittedError = GrantNotPermittedError;
78
+ /** Thrown when the grant input is malformed (bad ceiling / expiry / scope). */
79
+ class InvalidGrantError extends Error {
80
+ constructor(message) {
81
+ super(message);
82
+ this.name = 'InvalidGrantError';
83
+ }
84
+ }
85
+ exports.InvalidGrantError = InvalidGrantError;
86
+ /** Thrown when the grant store genuinely cannot answer/write (transient → 503-retryable). */
87
+ class GrantStoreUnavailableError extends Error {
88
+ constructor(message) {
89
+ super(message);
90
+ this.name = 'GrantStoreUnavailableError';
91
+ }
92
+ }
93
+ exports.GrantStoreUnavailableError = GrantStoreUnavailableError;
94
+ /**
95
+ * Parse a `ctg_allowed_actions` cell to a `string[]`, accepting BOTH an already-parsed array
96
+ * (production — the mariadb driver auto-parses the JSON column) and a JSON string (test mocks).
97
+ * Anything else / malformed ⇒ `[]` (fail-closed). Shared shape with the adapter's parser.
98
+ */
99
+ function parseCeiling(raw) {
100
+ if (Array.isArray(raw)) {
101
+ return raw.filter((a) => typeof a === 'string');
102
+ }
103
+ if (typeof raw === 'string' && raw.length > 0) {
104
+ try {
105
+ const parsed = JSON.parse(raw);
106
+ if (Array.isArray(parsed))
107
+ return parsed.filter((a) => typeof a === 'string');
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ }
113
+ return [];
114
+ }
115
+ class CrossTenantGrantWriter {
116
+ db;
117
+ authorize;
118
+ audit;
119
+ schema;
120
+ now;
121
+ constructor(deps) {
122
+ this.db = deps.db;
123
+ this.authorize = deps.authorize;
124
+ this.audit = deps.audit;
125
+ this.schema = deps.schema ?? DEFAULT_SCHEMA;
126
+ this.now = deps.nowMs ?? (() => Date.now());
127
+ }
128
+ /**
129
+ * OFFER a grant — the OWNING tenant consents to let a grantee tenant's principal in. Enforces
130
+ * the offer bound (owning-tenant admin, non-empty/non-wildcard ceiling, future expiry, scope
131
+ * ↔ recordId). Creates a fresh row in 'offered' status. Emits `grant.offered`.
132
+ *
133
+ * @returns the new grant's uuid.
134
+ * @throws {@link GrantNotPermittedError} if the offerer is not an owning-tenant admin.
135
+ * @throws {@link InvalidGrantError} on a bad ceiling / expiry / scope.
136
+ * @throws {@link GrantStoreUnavailableError} if the store cannot be reached (transient).
137
+ */
138
+ async offer(input) {
139
+ // (2)–(4) input validity BEFORE any authority I/O (cheap, deterministic, fail-closed).
140
+ this.assertValidOffer(input);
141
+ // (1) THE CRUX: the offerer MUST be an admin of the OWNING tenant — resolved SERVER-SIDE
142
+ // (never trusted from input). The grantee's own tenant can never mint a grant, because the
143
+ // offer authority is checked against the OWNING tenant, not the grantee tenant.
144
+ if (typeof input.offeredBy !== 'string' || input.offeredBy.length === 0) {
145
+ throw new GrantNotPermittedError(`a non-empty offeredBy (the authenticated principal) is required.`);
146
+ }
147
+ const mayShare = await this.resolveAuthority(() => this.authorize(input.offeredBy, input.owningTenantId, 'share'));
148
+ if (mayShare !== true) {
149
+ throw new GrantNotPermittedError(`principal lacks 'share' authority in the owning tenant — cannot offer a cross-tenant grant.`);
150
+ }
151
+ const grantUuid = (0, node_crypto_1.randomUUID)();
152
+ // Store the ceiling as a JSON array (text — the JSON column accepts a valid JSON string).
153
+ const allowedActionsJson = JSON.stringify([...input.allowedActions]);
154
+ try {
155
+ await this.db.query(`INSERT INTO \`${this.schema}\`.\`cross_tenant_grant\`` +
156
+ ` (ctg_uuid, ctg_owning_tenant_uuid, ctg_grantee_tenant_uuid,` +
157
+ ` ctg_grantee_kind, ctg_grantee_uuid, ctg_type_key, ctg_scope, ctg_record_uuid,` +
158
+ ` ctg_allowed_actions, ctg_status, ctg_offered_by, ctg_expires_at_ms)` +
159
+ ` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'offered', ?, ?)`, [
160
+ grantUuid,
161
+ input.owningTenantId,
162
+ input.granteeTenantId,
163
+ input.granteeKind,
164
+ input.granteeUuid,
165
+ input.typeKey,
166
+ input.scope,
167
+ input.scope === 'record' ? (input.recordId ?? null) : null,
168
+ allowedActionsJson,
169
+ input.offeredBy,
170
+ // Expiry stored as BIGINT epoch-ms (timezone-immune — MUST-FIX #1). Bind the integer
171
+ // directly; NEVER `new Date(...)` (which serializes to a TZ-skewed wall-clock TIMESTAMP).
172
+ input.expiresAtMs,
173
+ ]);
174
+ }
175
+ catch (err) {
176
+ throw new GrantStoreUnavailableError(`cross_tenant_grant offer write failed for ${input.typeKey}: ${err.message}`);
177
+ }
178
+ await this.emit('grant.offered', {
179
+ occurredAtMs: this.now(),
180
+ actorId: input.offeredBy,
181
+ owningTenantId: input.owningTenantId,
182
+ granteeTenantId: input.granteeTenantId,
183
+ attributes: {
184
+ grantUuid,
185
+ typeKey: input.typeKey,
186
+ scope: input.scope,
187
+ ...(input.scope === 'record' && input.recordId ? { recordId: input.recordId } : {}),
188
+ granteeKind: input.granteeKind,
189
+ granteeUuid: input.granteeUuid,
190
+ allowedActions: [...input.allowedActions],
191
+ },
192
+ });
193
+ return grantUuid;
194
+ }
195
+ /**
196
+ * ACCEPT a grant — the GRANTEE tenant activates it. Only a MEMBER of the grant's grantee
197
+ * tenant may accept, and only a grant currently 'offered'. Moves it to 'accepted' (the point
198
+ * at which it becomes ACTIVE for `findActiveGrant`). Re-accepting an 'accepted' grant is a
199
+ * benign no-op; accepting a 'revoked' grant is rejected. Emits `grant.accepted`.
200
+ *
201
+ * @throws {@link GrantNotPermittedError} if the accepter is not a grantee-tenant member, the
202
+ * grant does not exist, or it is not in 'offered' status.
203
+ * @throws {@link GrantStoreUnavailableError} if the store cannot be reached (transient).
204
+ */
205
+ async accept(input) {
206
+ const row = await this.fetchGrant(input.grantUuid);
207
+ if (!row) {
208
+ throw new GrantNotPermittedError(`grant not found — cannot accept.`); // no uuid in message
209
+ }
210
+ if (typeof input.acceptedBy !== 'string' || input.acceptedBy.length === 0) {
211
+ throw new GrantNotPermittedError(`a non-empty acceptedBy (the authenticated principal) is required.`);
212
+ }
213
+ // Idempotent no-op: already accepted ⇒ nothing to do (but still verify the actor has
214
+ // 'accept' authority in the grantee tenant, so a stranger cannot probe/replay). A principal
215
+ // only holds a grantee-tenant permission THROUGH membership there, so this one check covers
216
+ // both "is a member" and "may accept".
217
+ const mayAccept = await this.resolveAuthority(() => this.authorize(input.acceptedBy, row.ctg_grantee_tenant_uuid, 'accept'));
218
+ if (mayAccept !== true) {
219
+ throw new GrantNotPermittedError(`principal lacks 'accept' authority in the grantee tenant — cannot accept this grant.`);
220
+ }
221
+ if (row.ctg_status === 'accepted') {
222
+ return; // benign idempotent no-op
223
+ }
224
+ if (row.ctg_status !== 'offered') {
225
+ // 'revoked' (dead) — must be re-offered, not re-accepted.
226
+ throw new GrantNotPermittedError(`grant is not in an offerable state — cannot accept.`);
227
+ }
228
+ try {
229
+ await this.db.query(`UPDATE \`${this.schema}\`.\`cross_tenant_grant\`` +
230
+ ` SET ctg_status = 'accepted', ctg_accepted_at = ?` +
231
+ ` WHERE ctg_uuid = ? AND ctg_status = 'offered'`, [new Date(this.now()), input.grantUuid]);
232
+ }
233
+ catch (err) {
234
+ throw new GrantStoreUnavailableError(`cross_tenant_grant accept write failed: ${err.message}`);
235
+ }
236
+ await this.emitFromRow('grant.accepted', row, input.grantUuid, input.acceptedBy);
237
+ }
238
+ /**
239
+ * REVOKE a grant — EITHER an admin of the owning tenant OR a member of the grantee tenant may
240
+ * revoke (either side, ADR-0009). Moves it to 'revoked' + stamps `ctg_revoked_at` (it becomes
241
+ * immediately inactive — `findActiveGrant` reads live). Re-revoking a 'revoked' grant is a
242
+ * benign no-op. Emits `grant.revoked`.
243
+ *
244
+ * @throws {@link GrantNotPermittedError} if the revoker is neither an owning-tenant admin nor
245
+ * a grantee-tenant member, or the grant does not exist.
246
+ * @throws {@link GrantStoreUnavailableError} if the store cannot be reached (transient).
247
+ */
248
+ async revoke(input) {
249
+ const row = await this.fetchGrant(input.grantUuid);
250
+ if (!row) {
251
+ throw new GrantNotPermittedError(`grant not found — cannot revoke.`); // no uuid in message
252
+ }
253
+ if (typeof input.revokedBy !== 'string' || input.revokedBy.length === 0) {
254
+ throw new GrantNotPermittedError(`a non-empty revokedBy (the authenticated principal) is required.`);
255
+ }
256
+ // Either side may revoke (ADR-0009): 'revoke' authority in the OWNING tenant OR in the
257
+ // GRANTEE tenant. Check owning-side first, then grantee-side; a principal with neither is a
258
+ // stranger and is rejected. (Each check is a `decide()` in that tenant, so it implies
259
+ // membership there too.)
260
+ const mayRevokeOwning = await this.resolveAuthority(() => this.authorize(input.revokedBy, row.ctg_owning_tenant_uuid, 'revoke'));
261
+ let permitted = mayRevokeOwning === true;
262
+ if (!permitted) {
263
+ const mayRevokeGrantee = await this.resolveAuthority(() => this.authorize(input.revokedBy, row.ctg_grantee_tenant_uuid, 'revoke'));
264
+ permitted = mayRevokeGrantee === true;
265
+ }
266
+ if (!permitted) {
267
+ throw new GrantNotPermittedError(`principal lacks 'revoke' authority in either the owning or grantee tenant — cannot revoke this grant.`);
268
+ }
269
+ if (row.ctg_status === 'revoked') {
270
+ return; // benign idempotent no-op
271
+ }
272
+ try {
273
+ await this.db.query(`UPDATE \`${this.schema}\`.\`cross_tenant_grant\`` +
274
+ ` SET ctg_status = 'revoked', ctg_revoked_at = ?` +
275
+ ` WHERE ctg_uuid = ? AND ctg_status <> 'revoked'`, [new Date(this.now()), input.grantUuid]);
276
+ }
277
+ catch (err) {
278
+ throw new GrantStoreUnavailableError(`cross_tenant_grant revoke write failed: ${err.message}`);
279
+ }
280
+ await this.emitFromRow('grant.revoked', row, input.grantUuid, input.revokedBy);
281
+ }
282
+ // --- validity + authority helpers ------------------------------------------
283
+ /**
284
+ * Enforce the offer INPUT bounds (ceiling non-empty + non-wildcard, future expiry, scope ↔
285
+ * recordId). Throws {@link InvalidGrantError} on any failure (fail-closed). The is-admin
286
+ * authority check is done separately (it needs I/O).
287
+ */
288
+ assertValidOffer(input) {
289
+ // Ceiling: non-empty AND non-wildcard AND all-strings. A wildcard `'*'` is a hard reject —
290
+ // the ceiling must be an explicit enumerated set (ADR-0009).
291
+ const actions = input.allowedActions;
292
+ if (!Array.isArray(actions) || actions.length === 0) {
293
+ throw new InvalidGrantError(`allowedActions must be a non-empty set.`);
294
+ }
295
+ for (const a of actions) {
296
+ if (typeof a !== 'string' || a.length === 0) {
297
+ throw new InvalidGrantError(`allowedActions must contain only non-empty action keys.`);
298
+ }
299
+ if (a === '*') {
300
+ throw new InvalidGrantError(`allowedActions must not contain a wildcard '*' — the ceiling is explicit.`);
301
+ }
302
+ }
303
+ // Expiry: a finite FUTURE timestamp (mandatory, no perpetual/absent/past).
304
+ if (typeof input.expiresAtMs !== 'number' || !Number.isFinite(input.expiresAtMs)) {
305
+ throw new InvalidGrantError(`expiresAtMs must be a finite timestamp.`);
306
+ }
307
+ if (input.expiresAtMs <= this.now()) {
308
+ throw new InvalidGrantError(`expiresAtMs must be in the future — no perpetual/expired grants.`);
309
+ }
310
+ // Scope ↔ recordId: 'record' requires one; 'object_type' forbids one.
311
+ if (input.scope === 'record') {
312
+ if (typeof input.recordId !== 'string' || input.recordId.length === 0) {
313
+ throw new InvalidGrantError(`a record-scoped grant requires a non-empty recordId.`);
314
+ }
315
+ }
316
+ else if (input.scope === 'object_type') {
317
+ if (input.recordId !== undefined) {
318
+ throw new InvalidGrantError(`an object_type-scoped grant must not carry a recordId.`);
319
+ }
320
+ }
321
+ else {
322
+ throw new InvalidGrantError(`scope must be 'object_type' or 'record'.`);
323
+ }
324
+ // Tenant sanity: both tenants non-empty (a grant spans two DISTINCT tenants).
325
+ if (typeof input.owningTenantId !== 'string' || input.owningTenantId.length === 0) {
326
+ throw new InvalidGrantError(`a non-empty owningTenantId is required.`);
327
+ }
328
+ if (typeof input.granteeTenantId !== 'string' || input.granteeTenantId.length === 0) {
329
+ throw new InvalidGrantError(`a non-empty granteeTenantId is required.`);
330
+ }
331
+ }
332
+ /**
333
+ * Run an authority resolver call, translating a store outage into
334
+ * {@link GrantStoreUnavailableError} (retryable) — a resolver that THROWS is an outage, not a
335
+ * "not authorized". A resolver returning `false` is a legitimate deny (handled by the caller).
336
+ */
337
+ async resolveAuthority(fn) {
338
+ try {
339
+ return await fn();
340
+ }
341
+ catch (err) {
342
+ throw new GrantStoreUnavailableError(`grant authority resolver unavailable: ${err.message}`);
343
+ }
344
+ }
345
+ /** Fetch a grant's lifecycle row (tenants + status) by uuid — never trusts caller-supplied tenants. */
346
+ async fetchGrant(grantUuid) {
347
+ let rows;
348
+ try {
349
+ rows = (await this.db.query(`SELECT ctg_owning_tenant_uuid, ctg_grantee_tenant_uuid, ctg_grantee_kind,` +
350
+ ` ctg_grantee_uuid, ctg_type_key, ctg_scope, ctg_record_uuid,` +
351
+ ` ctg_allowed_actions, ctg_status` +
352
+ ` FROM \`${this.schema}\`.\`cross_tenant_grant\`` +
353
+ ` WHERE ctg_uuid = ? LIMIT 1`, [grantUuid]));
354
+ }
355
+ catch (err) {
356
+ throw new GrantStoreUnavailableError(`cross_tenant_grant read failed: ${err.message}`);
357
+ }
358
+ return rows[0];
359
+ }
360
+ // --- audit -----------------------------------------------------------------
361
+ /** Emit an offer event (fields already assembled). Best-effort. */
362
+ async emit(action, detail) {
363
+ if (!this.audit)
364
+ return;
365
+ const event = { type: 'cross_tenant_grant', action, ...detail };
366
+ try {
367
+ await this.audit.record(event);
368
+ }
369
+ catch {
370
+ // Best-effort: an audit failure must not fail a legitimate lifecycle operation.
371
+ }
372
+ }
373
+ /** Emit an accept/revoke event, deriving structured attributes from the fetched row. */
374
+ async emitFromRow(action, row, grantUuid, actorId) {
375
+ if (!this.audit)
376
+ return;
377
+ const scope = row.ctg_scope === 'record' ? 'record' : 'object_type';
378
+ // Array-aware (driver auto-parses the JSON column) — NEVER a bare JSON.parse on an array.
379
+ const allowedActions = parseCeiling(row.ctg_allowed_actions);
380
+ await this.emit(action, {
381
+ occurredAtMs: this.now(),
382
+ actorId,
383
+ owningTenantId: row.ctg_owning_tenant_uuid,
384
+ granteeTenantId: row.ctg_grantee_tenant_uuid,
385
+ attributes: {
386
+ grantUuid,
387
+ typeKey: row.ctg_type_key,
388
+ scope,
389
+ ...(scope === 'record' && row.ctg_record_uuid ? { recordId: row.ctg_record_uuid } : {}),
390
+ granteeKind: row.ctg_grantee_kind === 'role' ? 'role' : 'user',
391
+ granteeUuid: row.ctg_grantee_uuid,
392
+ allowedActions,
393
+ },
394
+ });
395
+ }
396
+ }
397
+ exports.CrossTenantGrantWriter = CrossTenantGrantWriter;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `ManifestOperationAuthorityAdapter` — the concrete {@link OperationAuthorityPort} for
3
+ * `@astram/guard`, backed by a DECLARED operation manifest (ADR-0008). It answers
4
+ * `resolveDelegatedAuthority(operationKey, stepKey)`: what scoped, non-wildcard authority does
5
+ * a trusted operation's step carry?
6
+ *
7
+ * ── WHAT DELEGATED AUTHORITY IS (ADR-0008 / gate 7): one server operation performs an internal
8
+ * side-effect on an object type the CALLER may not touch directly (e.g. "create user" also
9
+ * writes an onboarding record in a separate object type). That internal step must run under
10
+ * the OPERATION'S OWN declared, scoped, least-privilege authority — NOT the caller's grants,
11
+ * and NEVER a wildcard "skip all checks". This adapter resolves THAT declared authority.
12
+ *
13
+ * ── PURE, NO DB (ADR-0008). This is DECLARED metadata — the operation's own manifest, known at
14
+ * build/registration time. So the lookup is SYNCHRONOUS and in-memory (no store, no I/O). The
15
+ * port is contracted synchronous; a manifest is the natural backing.
16
+ *
17
+ * ── THE MANIFEST: `${operationKey}:${stepKey}` → {@link DelegatedAuthority}. The consumer
18
+ * DECLARES it by constructing the adapter with a map (or `[key, authority]` entries). An
19
+ * UNKNOWN key ⇒ `undefined` (pass-through: NO delegation — evaluate under the caller — the
20
+ * common case).
21
+ *
22
+ * ── FAIL-CLOSED ON A MALFORMED / WILDCARD ENTRY. Core's gate-7 overlay re-checks scope with
23
+ * strict membership, but this adapter NEVER hands back a wildcard or malformed authority: a
24
+ * declared entry whose `objectTypeKeys` / `allowedActions` is not a non-empty array of
25
+ * non-empty strings, OR contains a wildcard `'*'`, is treated as UNDEFINED (no delegation).
26
+ * An operation cannot accidentally (or maliciously) declare a `'*'` and gain a root
27
+ * reach — the malformed declaration simply grants no delegated authority.
28
+ *
29
+ * ── EMPTY MANIFEST = every lookup returns `undefined` = pass-through everywhere. A consumer with
30
+ * no trusted operations yet wires this with an empty manifest — identical runtime behavior to
31
+ * the interim `emptyOperationAuthority`, but now a REAL, declared surface ready for the first
32
+ * trusted operation.
33
+ *
34
+ * ── WHY HERE, NOT CORE (ADR-0003): core defines the PORT + the gate-7 overlay that consumes it;
35
+ * the manifest-backed concrete lives in guard and is injected into the wired decision
36
+ * authority. It uses `@astram/core` TYPES only (erased at build).
37
+ */
38
+ import type { DelegatedAuthority, OperationAuthorityPort } from '@astram/core';
39
+ /** The DI token the adapter is bound under (parity with the other authz-port tokens). */
40
+ export declare const SHIELD_OPERATION_AUTHORITY_PORT: unique symbol;
41
+ /**
42
+ * A single declared operation-step authority. Mirrors core's {@link DelegatedAuthority} (a
43
+ * scoped, NON-wildcard object-type + action ceiling) — declared by the consumer per trusted
44
+ * operation step.
45
+ */
46
+ export interface OperationManifestEntry {
47
+ /** The trusted operation's stable key (its manifest identity). */
48
+ readonly operationKey: string;
49
+ /** The specific internal step within the operation. */
50
+ readonly stepKey: string;
51
+ /** The declared authority the step carries (scoped, non-wildcard). */
52
+ readonly authority: DelegatedAuthority;
53
+ }
54
+ export declare class ManifestOperationAuthorityAdapter implements OperationAuthorityPort {
55
+ private readonly manifest;
56
+ /**
57
+ * @param entries the declared operation-step authorities. Each malformed / wildcard entry is
58
+ * DROPPED at construction (fail-closed) — it will resolve to `undefined` (no delegation)
59
+ * rather than ever hand back an unsafe authority. A duplicate `${op}:${step}` key keeps the
60
+ * LAST well-formed declaration.
61
+ */
62
+ constructor(entries?: readonly OperationManifestEntry[]);
63
+ resolveDelegatedAuthority(operationKey: string, stepKey: string): DelegatedAuthority | undefined;
64
+ }
65
+ //# sourceMappingURL=manifest-operation-authority-adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest-operation-authority-adapter.d.ts","sourceRoot":"","sources":["../../src/authz/manifest-operation-authority-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AAE/E,yFAAyF;AACzF,eAAO,MAAM,+BAA+B,eAA4C,CAAC;AAEzF;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACrC,kEAAkE;IAClE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,uDAAuD;IACvD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,sEAAsE;IACtE,QAAQ,CAAC,SAAS,EAAE,kBAAkB,CAAC;CACxC;AA6BD,qBAAa,iCAAkC,YAAW,sBAAsB;IAC9E,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAkC;IAE3D;;;;;OAKG;gBACS,OAAO,GAAE,SAAS,sBAAsB,EAAO;IAuB3D,yBAAyB,CACvB,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,MAAM,GACd,kBAAkB,GAAG,SAAS;CAalC"}
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ /**
3
+ * `ManifestOperationAuthorityAdapter` — the concrete {@link OperationAuthorityPort} for
4
+ * `@astram/guard`, backed by a DECLARED operation manifest (ADR-0008). It answers
5
+ * `resolveDelegatedAuthority(operationKey, stepKey)`: what scoped, non-wildcard authority does
6
+ * a trusted operation's step carry?
7
+ *
8
+ * ── WHAT DELEGATED AUTHORITY IS (ADR-0008 / gate 7): one server operation performs an internal
9
+ * side-effect on an object type the CALLER may not touch directly (e.g. "create user" also
10
+ * writes an onboarding record in a separate object type). That internal step must run under
11
+ * the OPERATION'S OWN declared, scoped, least-privilege authority — NOT the caller's grants,
12
+ * and NEVER a wildcard "skip all checks". This adapter resolves THAT declared authority.
13
+ *
14
+ * ── PURE, NO DB (ADR-0008). This is DECLARED metadata — the operation's own manifest, known at
15
+ * build/registration time. So the lookup is SYNCHRONOUS and in-memory (no store, no I/O). The
16
+ * port is contracted synchronous; a manifest is the natural backing.
17
+ *
18
+ * ── THE MANIFEST: `${operationKey}:${stepKey}` → {@link DelegatedAuthority}. The consumer
19
+ * DECLARES it by constructing the adapter with a map (or `[key, authority]` entries). An
20
+ * UNKNOWN key ⇒ `undefined` (pass-through: NO delegation — evaluate under the caller — the
21
+ * common case).
22
+ *
23
+ * ── FAIL-CLOSED ON A MALFORMED / WILDCARD ENTRY. Core's gate-7 overlay re-checks scope with
24
+ * strict membership, but this adapter NEVER hands back a wildcard or malformed authority: a
25
+ * declared entry whose `objectTypeKeys` / `allowedActions` is not a non-empty array of
26
+ * non-empty strings, OR contains a wildcard `'*'`, is treated as UNDEFINED (no delegation).
27
+ * An operation cannot accidentally (or maliciously) declare a `'*'` and gain a root
28
+ * reach — the malformed declaration simply grants no delegated authority.
29
+ *
30
+ * ── EMPTY MANIFEST = every lookup returns `undefined` = pass-through everywhere. A consumer with
31
+ * no trusted operations yet wires this with an empty manifest — identical runtime behavior to
32
+ * the interim `emptyOperationAuthority`, but now a REAL, declared surface ready for the first
33
+ * trusted operation.
34
+ *
35
+ * ── WHY HERE, NOT CORE (ADR-0003): core defines the PORT + the gate-7 overlay that consumes it;
36
+ * the manifest-backed concrete lives in guard and is injected into the wired decision
37
+ * authority. It uses `@astram/core` TYPES only (erased at build).
38
+ */
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.ManifestOperationAuthorityAdapter = exports.SHIELD_OPERATION_AUTHORITY_PORT = void 0;
41
+ /** The DI token the adapter is bound under (parity with the other authz-port tokens). */
42
+ exports.SHIELD_OPERATION_AUTHORITY_PORT = Symbol('SHIELD_OPERATION_AUTHORITY_PORT');
43
+ /** Compose the internal manifest map key from an operation + step. */
44
+ function manifestKey(operationKey, stepKey) {
45
+ return `${operationKey}:${stepKey}`;
46
+ }
47
+ /**
48
+ * Whether a declared authority is WELL-FORMED and non-wildcard: both `objectTypeKeys` and
49
+ * `allowedActions` must be non-empty arrays of non-empty strings, and NEITHER may contain a
50
+ * wildcard `'*'`. A malformed / wildcard authority is rejected (⇒ treated as no delegation).
51
+ */
52
+ function isWellFormedAuthority(authority) {
53
+ if (!authority || typeof authority !== 'object')
54
+ return false;
55
+ return (isNonEmptyExplicitStringArray(authority.objectTypeKeys) &&
56
+ isNonEmptyExplicitStringArray(authority.allowedActions));
57
+ }
58
+ /** A non-empty array of non-empty strings, NONE of which is a wildcard `'*'`. */
59
+ function isNonEmptyExplicitStringArray(value) {
60
+ if (!Array.isArray(value) || value.length === 0)
61
+ return false;
62
+ for (const v of value) {
63
+ if (typeof v !== 'string' || v.length === 0 || v === '*')
64
+ return false;
65
+ }
66
+ return true;
67
+ }
68
+ class ManifestOperationAuthorityAdapter {
69
+ manifest;
70
+ /**
71
+ * @param entries the declared operation-step authorities. Each malformed / wildcard entry is
72
+ * DROPPED at construction (fail-closed) — it will resolve to `undefined` (no delegation)
73
+ * rather than ever hand back an unsafe authority. A duplicate `${op}:${step}` key keeps the
74
+ * LAST well-formed declaration.
75
+ */
76
+ constructor(entries = []) {
77
+ this.manifest = new Map();
78
+ for (const entry of entries) {
79
+ if (typeof entry.operationKey !== 'string' ||
80
+ entry.operationKey.length === 0 ||
81
+ typeof entry.stepKey !== 'string' ||
82
+ entry.stepKey.length === 0) {
83
+ continue; // malformed key — skip
84
+ }
85
+ if (!isWellFormedAuthority(entry.authority)) {
86
+ continue; // malformed / wildcard authority — skip (fail-closed → undefined)
87
+ }
88
+ // Freeze a defensive copy so a later mutation of the caller's arrays cannot widen it.
89
+ const frozen = {
90
+ objectTypeKeys: Object.freeze([...entry.authority.objectTypeKeys]),
91
+ allowedActions: Object.freeze([...entry.authority.allowedActions]),
92
+ };
93
+ this.manifest.set(manifestKey(entry.operationKey, entry.stepKey), frozen);
94
+ }
95
+ }
96
+ resolveDelegatedAuthority(operationKey, stepKey) {
97
+ if (typeof operationKey !== 'string' ||
98
+ operationKey.length === 0 ||
99
+ typeof stepKey !== 'string' ||
100
+ stepKey.length === 0) {
101
+ return undefined; // malformed lookup key ⇒ pass-through
102
+ }
103
+ // A present entry is already validated + frozen at construction — return it as-is. An
104
+ // absent key ⇒ undefined (pass-through: no delegation, the common case).
105
+ return this.manifest.get(manifestKey(operationKey, stepKey));
106
+ }
107
+ }
108
+ exports.ManifestOperationAuthorityAdapter = ManifestOperationAuthorityAdapter;