@astram/guard 0.2.0 → 0.3.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/auth/account.controller.d.ts +245 -0
- package/dist/auth/account.controller.d.ts.map +1 -0
- package/dist/auth/account.controller.js +673 -0
- package/dist/auth/account.controller.js.map +1 -0
- package/dist/auth/auth.controller.d.ts +219 -5
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +790 -12
- package/dist/auth/auth.controller.js.map +1 -0
- package/dist/auth/auth.module.d.ts +100 -2
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +299 -7
- package/dist/auth/auth.module.js.map +1 -0
- package/dist/auth/connections.js.map +1 -0
- package/dist/auth/dto.d.ts +16 -6
- package/dist/auth/dto.d.ts.map +1 -1
- package/dist/auth/dto.js.map +1 -0
- package/dist/auth/pending-mfa-store.js.map +1 -0
- package/dist/auth/persistence.module.js.map +1 -0
- package/dist/auth/ports/audit-sink.d.ts +47 -0
- package/dist/auth/ports/audit-sink.d.ts.map +1 -0
- package/dist/auth/ports/audit-sink.js +20 -0
- package/dist/auth/ports/audit-sink.js.map +1 -0
- package/dist/auth/ports/credential-writer.d.ts +23 -0
- package/dist/auth/ports/credential-writer.d.ts.map +1 -0
- package/dist/auth/ports/credential-writer.js +2 -0
- package/dist/auth/ports/credential-writer.js.map +1 -0
- package/dist/auth/ports/email-sender.d.ts +14 -0
- package/dist/auth/ports/email-sender.d.ts.map +1 -0
- package/dist/auth/ports/email-sender.js +2 -0
- package/dist/auth/ports/email-sender.js.map +1 -0
- package/dist/auth/ports/mfa-device-store.d.ts +45 -0
- package/dist/auth/ports/mfa-device-store.d.ts.map +1 -0
- package/dist/auth/ports/mfa-device-store.js +13 -0
- package/dist/auth/ports/mfa-device-store.js.map +1 -0
- package/dist/auth/ports/profile-writer.d.ts +12 -0
- package/dist/auth/ports/profile-writer.d.ts.map +1 -0
- package/dist/auth/ports/profile-writer.js +2 -0
- package/dist/auth/ports/profile-writer.js.map +1 -0
- package/dist/auth/ports/recovery-code-store.d.ts +10 -0
- package/dist/auth/ports/recovery-code-store.d.ts.map +1 -0
- package/dist/auth/ports/recovery-code-store.js +2 -0
- package/dist/auth/ports/recovery-code-store.js.map +1 -0
- package/dist/auth/ports/reset-token-store.d.ts +12 -0
- package/dist/auth/ports/reset-token-store.d.ts.map +1 -0
- package/dist/auth/ports/reset-token-store.js +2 -0
- package/dist/auth/ports/reset-token-store.js.map +1 -0
- package/dist/auth/ports/session-facts-projector.d.ts +44 -0
- package/dist/auth/ports/session-facts-projector.d.ts.map +1 -0
- package/dist/auth/ports/session-facts-projector.js +30 -0
- package/dist/auth/ports/session-facts-projector.js.map +1 -0
- package/dist/auth/ports/session-sweeper.d.ts +12 -0
- package/dist/auth/ports/session-sweeper.d.ts.map +1 -0
- package/dist/auth/ports/session-sweeper.js +2 -0
- package/dist/auth/ports/session-sweeper.js.map +1 -0
- package/dist/auth/profile-resolver.d.ts +73 -0
- package/dist/auth/profile-resolver.d.ts.map +1 -0
- package/dist/auth/profile-resolver.js +28 -0
- package/dist/auth/profile-resolver.js.map +1 -0
- package/dist/auth/rate-limit/attempt-limiter.d.ts +91 -0
- package/dist/auth/rate-limit/attempt-limiter.d.ts.map +1 -0
- package/dist/auth/rate-limit/attempt-limiter.js +133 -0
- package/dist/auth/rate-limit/attempt-limiter.js.map +1 -0
- package/dist/auth/tokens.d.ts +174 -0
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +190 -1
- package/dist/auth/tokens.js.map +1 -0
- package/dist/auth/totp-enrollment.d.ts +42 -0
- package/dist/auth/totp-enrollment.d.ts.map +1 -0
- package/dist/auth/totp-enrollment.js +96 -0
- package/dist/auth/totp-enrollment.js.map +1 -0
- package/dist/authentication/argon2id-password-hasher.js.map +1 -0
- package/dist/authentication/mariadb-credential-resolver.d.ts +38 -24
- package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-resolver.js +42 -28
- package/dist/authentication/mariadb-credential-resolver.js.map +1 -0
- package/dist/authz/cross-tenant-grant-writer.d.ts +259 -0
- package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -0
- package/dist/authz/cross-tenant-grant-writer.js +397 -0
- package/dist/authz/cross-tenant-grant-writer.js.map +1 -0
- package/dist/authz/manifest-operation-authority-adapter.d.ts +65 -0
- package/dist/authz/manifest-operation-authority-adapter.d.ts.map +1 -0
- package/dist/authz/manifest-operation-authority-adapter.js +108 -0
- package/dist/authz/manifest-operation-authority-adapter.js.map +1 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts +90 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js +211 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js.map +1 -0
- package/dist/authz/mariadb-record-rule-adapter.d.ts +60 -0
- package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -0
- package/dist/authz/mariadb-record-rule-adapter.js +154 -0
- package/dist/authz/mariadb-record-rule-adapter.js.map +1 -0
- package/dist/authz/mariadb-record-share-adapter.d.ts +75 -0
- package/dist/authz/mariadb-record-share-adapter.d.ts.map +1 -0
- package/dist/authz/mariadb-record-share-adapter.js +106 -0
- package/dist/authz/mariadb-record-share-adapter.js.map +1 -0
- package/dist/authz/record-share-cascade.d.ts +52 -0
- package/dist/authz/record-share-cascade.d.ts.map +1 -0
- package/dist/authz/record-share-cascade.js +49 -0
- package/dist/authz/record-share-cascade.js.map +1 -0
- package/dist/authz/record-share-writer.d.ts +175 -0
- package/dist/authz/record-share-writer.d.ts.map +1 -0
- package/dist/authz/record-share-writer.js +225 -0
- package/dist/authz/record-share-writer.js.map +1 -0
- package/dist/authz/wire-record-authorization.d.ts +66 -0
- package/dist/authz/wire-record-authorization.d.ts.map +1 -0
- package/dist/authz/wire-record-authorization.js +34 -0
- package/dist/authz/wire-record-authorization.js.map +1 -0
- package/dist/boot/boot-sequence.js.map +1 -0
- package/dist/boot/boot.module.js.map +1 -0
- package/dist/completeness/catalog-reconciler.d.ts +21 -0
- package/dist/completeness/catalog-reconciler.d.ts.map +1 -0
- package/dist/completeness/catalog-reconciler.js +74 -0
- package/dist/completeness/catalog-reconciler.js.map +1 -0
- package/dist/completeness/operation-inventory-scanner.d.ts +17 -0
- package/dist/completeness/operation-inventory-scanner.d.ts.map +1 -0
- package/dist/completeness/operation-inventory-scanner.js +79 -0
- package/dist/completeness/operation-inventory-scanner.js.map +1 -0
- package/dist/completeness/ports/catalog-store.d.ts +33 -0
- package/dist/completeness/ports/catalog-store.d.ts.map +1 -0
- package/dist/completeness/ports/catalog-store.js +2 -0
- package/dist/completeness/ports/catalog-store.js.map +1 -0
- package/dist/config/app-config.js.map +1 -0
- package/dist/config/config.module.js.map +1 -0
- package/dist/enforcement/classification.decorator.d.ts +33 -14
- package/dist/enforcement/classification.decorator.d.ts.map +1 -1
- package/dist/enforcement/classification.decorator.js +34 -14
- package/dist/enforcement/classification.decorator.js.map +1 -0
- package/dist/enforcement/core-seam.js.map +1 -0
- package/dist/enforcement/enforcement.module.js.map +1 -0
- package/dist/enforcement/global-enforcement.guard.d.ts +29 -19
- package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
- package/dist/enforcement/global-enforcement.guard.js +108 -25
- package/dist/enforcement/global-enforcement.guard.js.map +1 -0
- package/dist/enforcement/reenroll-allowlist.d.ts +49 -0
- package/dist/enforcement/reenroll-allowlist.d.ts.map +1 -0
- package/dist/enforcement/reenroll-allowlist.js +77 -0
- package/dist/enforcement/reenroll-allowlist.js.map +1 -0
- package/dist/index.d.ts +35 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +159 -7
- package/dist/index.js.map +1 -0
- package/dist/module/shield-tokens.d.ts +15 -0
- package/dist/module/shield-tokens.d.ts.map +1 -0
- package/dist/module/shield-tokens.js +17 -0
- package/dist/module/shield-tokens.js.map +1 -0
- package/dist/module/shield.module.d.ts +153 -9
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +181 -24
- package/dist/module/shield.module.js.map +1 -0
- package/dist/request-auth/request-principal.d.ts +42 -0
- package/dist/request-auth/request-principal.d.ts.map +1 -0
- package/dist/request-auth/request-principal.js +87 -0
- package/dist/request-auth/request-principal.js.map +1 -0
- package/dist/request-auth/session-facts-als.d.ts +65 -0
- package/dist/request-auth/session-facts-als.d.ts.map +1 -0
- package/dist/request-auth/session-facts-als.js +57 -0
- package/dist/request-auth/session-facts-als.js.map +1 -0
- package/dist/request-auth/session-token-binder.d.ts +130 -0
- package/dist/request-auth/session-token-binder.d.ts.map +1 -0
- package/dist/request-auth/session-token-binder.js +277 -0
- package/dist/request-auth/session-token-binder.js.map +1 -0
- package/dist/request-auth/tokens.d.ts +14 -0
- package/dist/request-auth/tokens.d.ts.map +1 -0
- package/dist/request-auth/tokens.js +16 -0
- package/dist/request-auth/tokens.js.map +1 -0
- package/dist/session/crypto-adapters.js.map +1 -0
- package/dist/session/index.js.map +1 -0
- package/dist/session/mariadb-session-record-store.js.map +1 -0
- package/dist/session/redis-hot-session-store.js.map +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CrossTenantGrantWriter` — the framework-owned WHO-MAY-GRANT write path (ADR-0009, the KEY
|
|
3
|
+
* NEW INVARIANT of Phase 4). Cross-tenant grant LIFECYCLE — offer / accept / revoke — is
|
|
4
|
+
* framework-governed here so a grant can NEVER be minted by the party that BENEFITS from it.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ THIS IS NOT A CORE PORT. Grant EVALUATION is a core port ({@link CrossTenantGrantPort});
|
|
7
|
+
* grant LIFECYCLE is a guard WRITE path (it needs the `mariadb` driver + the consumer's
|
|
8
|
+
* identity schema to answer "is X an admin of tenant T", neither of which core may touch —
|
|
9
|
+
* ADR-0003). It lives in guard and is exposed as a provider the consumer app's grant surface
|
|
10
|
+
* calls (the invitation-email / inbox UX is DEFERRED — ADR-0009's Admin-Console follow-up).
|
|
11
|
+
*
|
|
12
|
+
* ── THE CONSENT MODEL (ADR-0009). A cross-tenant grant is TENANT B (the OWNING tenant, whose
|
|
13
|
+
* data is reached into) consenting to let TENANT A's principal in. The handshake:
|
|
14
|
+
* offer → an owner/ADMIN in the OWNING tenant (or root) declares the grant
|
|
15
|
+
* (object type, scope, action ceiling, expiry) → row created in 'offered' status.
|
|
16
|
+
* accept → the GRANTEE tenant must ACCEPT → row moves to 'accepted' → the grant ACTIVATES
|
|
17
|
+
* (only now does {@link CrossTenantGrantPort.findActiveGrant} return it).
|
|
18
|
+
* revoke → EITHER side (owning tenant admin OR grantee tenant member) may revoke → row
|
|
19
|
+
* moves to 'revoked' + `ctg_revoked_at` stamped → immediately inactive.
|
|
20
|
+
* ⛔ THE #1 SECURITY CRUX: the grantee's OWN tenant can NEVER unilaterally mint a grant. A
|
|
21
|
+
* grant is consent FROM the owning tenant; only an owning-tenant admin may OFFER.
|
|
22
|
+
*
|
|
23
|
+
* ── THE BOUNDS (enforced fail-closed, IN ORDER, BEFORE any write):
|
|
24
|
+
* • offer:
|
|
25
|
+
* 1. The offerer MUST hold `'share'` authority in the OWNING tenant (`ctg_owning_tenant_uuid`)
|
|
26
|
+
* — verified SERVER-SIDE via the injected {@link GrantAuthorizeFn} (the app's `decide()`);
|
|
27
|
+
* rejected otherwise. ⛔ `offeredBy` + `owningTenantId` come from the AUTHENTICATED
|
|
28
|
+
* principal context — the caller supplies them from the request principal, NEVER from
|
|
29
|
+
* a user-controlled body field for the authority decision.
|
|
30
|
+
* 2. `allowedActions` MUST be non-empty AND MUST NOT contain a wildcard (`'*'`) — the
|
|
31
|
+
* ceiling is a hard, explicit, enumerated set (ADR-0009: "non-wildcard, non-empty").
|
|
32
|
+
* 3. `expiresAtMs` MUST be a finite FUTURE timestamp — no perpetual / absent / past
|
|
33
|
+
* expiry (ADR-0009: "expiry mandatory").
|
|
34
|
+
* 4. scope='record' REQUIRES a non-empty `recordId`; scope='object_type' FORBIDS one.
|
|
35
|
+
* On pass ⇒ INSERT a fresh row in 'offered' status.
|
|
36
|
+
* • accept:
|
|
37
|
+
* Only a principal who holds `'accept'` authority in the GRANTEE tenant (`ctg_grantee_tenant_uuid`)
|
|
38
|
+
* may accept — verified via {@link GrantAuthorizeFn} (the app's `decide()`, which implies
|
|
39
|
+
* grantee-tenant membership); and only a grant
|
|
40
|
+
* currently in 'offered' status (not already accepted/revoked). On pass ⇒ UPDATE to
|
|
41
|
+
* 'accepted' + stamp `ctg_accepted_at`. Re-accepting an already-'accepted' grant is a
|
|
42
|
+
* benign no-op (idempotent). Accepting a 'revoked' grant is REJECTED (a revoked grant is
|
|
43
|
+
* dead — it must be re-offered).
|
|
44
|
+
* • revoke:
|
|
45
|
+
* EITHER an admin of the OWNING tenant OR a member of the GRANTEE tenant may revoke
|
|
46
|
+
* (ADR-0009: "either side may revoke immediately"). A stranger (neither) is REJECTED. On
|
|
47
|
+
* pass ⇒ UPDATE to 'revoked' + stamp `ctg_revoked_at`. Re-revoking an already-'revoked'
|
|
48
|
+
* grant is a benign no-op (idempotent).
|
|
49
|
+
*
|
|
50
|
+
* ── AUDIT ON BOTH TENANTS (ADR-0009: "audited on BOTH tenants as cross-tenant"). Every
|
|
51
|
+
* offer/accept/revoke emits ONE {@link GrantAuditEvent} through the injected
|
|
52
|
+
* {@link GrantAuditSink} carrying BOTH `owningTenantId` and `granteeTenantId` in STRUCTURED
|
|
53
|
+
* fields — so the consumer's sink can record it under each tenant's audit trail. NO raw uuid
|
|
54
|
+
* appears in any human-readable MESSAGE (ids ride in structured attributes only). Best-effort
|
|
55
|
+
* — a sink throw never fails the lifecycle operation.
|
|
56
|
+
*
|
|
57
|
+
* ── FETCH-THEN-GATE (mirrors {@link RecordShareWriter}). The authority decisions (is-admin /
|
|
58
|
+
* is-member) are resolved SERVER-SIDE via the injected resolver — NEVER trusted from caller
|
|
59
|
+
* input. accept/revoke FETCH the grant's owning+grantee tenants from the row (not from the
|
|
60
|
+
* caller) before deciding who may act.
|
|
61
|
+
*/
|
|
62
|
+
import type { GrantQueryable } from './mariadb-cross-tenant-grant-adapter.js';
|
|
63
|
+
/** The DI token the writer is bound under. */
|
|
64
|
+
export declare const SHIELD_CROSS_TENANT_GRANT_WRITER: unique symbol;
|
|
65
|
+
/** Who a grant is granted to (the grantee principal kind). Mirrors `ctg_grantee_kind`. */
|
|
66
|
+
export type GrantGranteeKind = 'user' | 'role';
|
|
67
|
+
/** The two scopes a grant may span (mirrors core's `CrossTenantGrantScope`). */
|
|
68
|
+
export type GrantScope = 'object_type' | 'record';
|
|
69
|
+
/**
|
|
70
|
+
* The ONE authority question the writer asks: "may this principal perform `action` within
|
|
71
|
+
* `tenantId`?" — answered by the CONSUMER over its OWN identity + authorization model, so guard
|
|
72
|
+
* never imports `@astram/engine` or the app (ADR-0003 layering).
|
|
73
|
+
*
|
|
74
|
+
* This REPLACES the former `GrantAuthorityResolver` port (the bespoke `isTenantAdmin` /
|
|
75
|
+
* `isTenantMember` pair). Cross-tenant sharing is NOT a special authority question — it is the
|
|
76
|
+
* SAME "may this person do this?" question every other action asks. So the app resolves it the
|
|
77
|
+
* SAME way it resolves everything else: through `decide()`. The app's closure builds the
|
|
78
|
+
* principal (id → roleIds for `tenantId`, via its in-effect membership→posting read) and calls
|
|
79
|
+
* its own `decide(principal, {key: action}, {level:'object_type', typeKey:'cross_tenant_grant',
|
|
80
|
+
* tenantId}, ...)`. Because a principal's permissions are resolved THROUGH their membership,
|
|
81
|
+
* one `decide()` answers BOTH "are you in this tenant" AND "do you hold the permission" — so no
|
|
82
|
+
* separate membership check is needed.
|
|
83
|
+
*
|
|
84
|
+
* • offer → `authorize(offeredBy, owningTenantId, 'share')` (act in your own tenant A)
|
|
85
|
+
* • accept → `authorize(acceptedBy, granteeTenantId, 'accept')` (act in your own tenant B)
|
|
86
|
+
* • revoke → `authorize(revokedBy, owningTenantId, 'revoke')` OR (…granteeTenantId, 'revoke')
|
|
87
|
+
*
|
|
88
|
+
* Returns a genuine boolean; the writer gates on strict `=== true`. A store/authority outage
|
|
89
|
+
* SHOULD throw (the writer surfaces it as {@link GrantStoreUnavailableError}, a retryable 503) —
|
|
90
|
+
* never a silent `false` that would mask an outage as "not authorized".
|
|
91
|
+
*/
|
|
92
|
+
export type GrantAuthorizeFn = (principalId: string, tenantId: string, action: 'share' | 'accept' | 'revoke') => Promise<boolean>;
|
|
93
|
+
/** A grant lifecycle audit event (structured — ids in fields, NEVER in a rendered message). */
|
|
94
|
+
export interface GrantAuditEvent {
|
|
95
|
+
/** Always `'cross_tenant_grant'`. */
|
|
96
|
+
readonly type: 'cross_tenant_grant';
|
|
97
|
+
/** The specific lifecycle event. */
|
|
98
|
+
readonly action: 'grant.offered' | 'grant.accepted' | 'grant.revoked';
|
|
99
|
+
/** Epoch ms the event occurred. */
|
|
100
|
+
readonly occurredAtMs: number;
|
|
101
|
+
/** The principal who performed the action (offerer / accepter / revoker). */
|
|
102
|
+
readonly actorId: string;
|
|
103
|
+
/**
|
|
104
|
+
* The grant's OWNING tenant (whose data is reached into / who consents). Present so the
|
|
105
|
+
* consumer's sink records the event under this tenant's audit trail too (audit-on-both).
|
|
106
|
+
*/
|
|
107
|
+
readonly owningTenantId: string;
|
|
108
|
+
/** The grant's GRANTEE tenant (reaching in). Present so the sink records it under BOTH. */
|
|
109
|
+
readonly granteeTenantId: string;
|
|
110
|
+
/** Structured, UUID-free-message detail (the ids live HERE, not in a message string). */
|
|
111
|
+
readonly attributes: {
|
|
112
|
+
readonly grantUuid: string;
|
|
113
|
+
readonly typeKey: string;
|
|
114
|
+
readonly scope: GrantScope;
|
|
115
|
+
readonly recordId?: string;
|
|
116
|
+
readonly granteeKind: GrantGranteeKind;
|
|
117
|
+
readonly granteeUuid: string;
|
|
118
|
+
readonly allowedActions: readonly string[];
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** The audit seam the writer emits through. Best-effort — a throw never fails the write. */
|
|
122
|
+
export interface GrantAuditSink {
|
|
123
|
+
record(event: GrantAuditEvent): Promise<void>;
|
|
124
|
+
}
|
|
125
|
+
/** Input to {@link CrossTenantGrantWriter.offer}. */
|
|
126
|
+
export interface OfferGrantInput {
|
|
127
|
+
/**
|
|
128
|
+
* ⛔ The OWNING tenant — whose data is reached into / who consents. Supplied from the
|
|
129
|
+
* AUTHENTICATED principal's context, NEVER a user-controlled body field for the authority
|
|
130
|
+
* decision. The offerer MUST be an admin of THIS tenant.
|
|
131
|
+
*/
|
|
132
|
+
readonly owningTenantId: string;
|
|
133
|
+
/** The GRANTEE tenant — the tenant reaching in. */
|
|
134
|
+
readonly granteeTenantId: string;
|
|
135
|
+
/** Who in the grantee tenant the grant is for (a specific user or role). */
|
|
136
|
+
readonly granteeKind: GrantGranteeKind;
|
|
137
|
+
/** The grantee user/role uuid (in the grantee tenant). */
|
|
138
|
+
readonly granteeUuid: string;
|
|
139
|
+
/** The object type the grant covers. */
|
|
140
|
+
readonly typeKey: string;
|
|
141
|
+
/** Whole-object-type, or a single record. */
|
|
142
|
+
readonly scope: GrantScope;
|
|
143
|
+
/** REQUIRED iff `scope='record'`; FORBIDDEN for `scope='object_type'`. */
|
|
144
|
+
readonly recordId?: string;
|
|
145
|
+
/** The hard action ceiling — non-empty, non-wildcard, enumerated action keys. */
|
|
146
|
+
readonly allowedActions: readonly string[];
|
|
147
|
+
/** Mandatory FUTURE expiry (UTC epoch ms). No perpetual/absent/past grants. */
|
|
148
|
+
readonly expiresAtMs: number;
|
|
149
|
+
/**
|
|
150
|
+
* ⛔ The AUTHENTICATED principal id who is offering — supplied from the request principal,
|
|
151
|
+
* never a user-controlled body field. Proven to be an OWNING-tenant admin before the write.
|
|
152
|
+
*/
|
|
153
|
+
readonly offeredBy: string;
|
|
154
|
+
}
|
|
155
|
+
/** Input to {@link CrossTenantGrantWriter.accept}. */
|
|
156
|
+
export interface AcceptGrantInput {
|
|
157
|
+
/** The grant to accept (its uuid). */
|
|
158
|
+
readonly grantUuid: string;
|
|
159
|
+
/**
|
|
160
|
+
* ⛔ The AUTHENTICATED principal accepting — supplied from the request principal. Proven to
|
|
161
|
+
* be a MEMBER of the grant's GRANTEE tenant before the write.
|
|
162
|
+
*/
|
|
163
|
+
readonly acceptedBy: string;
|
|
164
|
+
}
|
|
165
|
+
/** Input to {@link CrossTenantGrantWriter.revoke}. */
|
|
166
|
+
export interface RevokeGrantInput {
|
|
167
|
+
/** The grant to revoke (its uuid). */
|
|
168
|
+
readonly grantUuid: string;
|
|
169
|
+
/**
|
|
170
|
+
* ⛔ The AUTHENTICATED principal revoking — supplied from the request principal. Proven to be
|
|
171
|
+
* EITHER an admin of the OWNING tenant OR a member of the GRANTEE tenant before the write.
|
|
172
|
+
*/
|
|
173
|
+
readonly revokedBy: string;
|
|
174
|
+
}
|
|
175
|
+
/** Thrown when the who-may-grant bound rejects an offer/accept/revoke (fail-closed). */
|
|
176
|
+
export declare class GrantNotPermittedError extends Error {
|
|
177
|
+
constructor(message: string);
|
|
178
|
+
}
|
|
179
|
+
/** Thrown when the grant input is malformed (bad ceiling / expiry / scope). */
|
|
180
|
+
export declare class InvalidGrantError extends Error {
|
|
181
|
+
constructor(message: string);
|
|
182
|
+
}
|
|
183
|
+
/** Thrown when the grant store genuinely cannot answer/write (transient → 503-retryable). */
|
|
184
|
+
export declare class GrantStoreUnavailableError extends Error {
|
|
185
|
+
constructor(message: string);
|
|
186
|
+
}
|
|
187
|
+
export declare class CrossTenantGrantWriter {
|
|
188
|
+
private readonly db;
|
|
189
|
+
private readonly authorize;
|
|
190
|
+
private readonly audit;
|
|
191
|
+
private readonly schema;
|
|
192
|
+
private readonly now;
|
|
193
|
+
constructor(deps: {
|
|
194
|
+
db: GrantQueryable;
|
|
195
|
+
/**
|
|
196
|
+
* Answers "may `principalId` perform `action` in `tenantId`?" over the consumer's OWN
|
|
197
|
+
* authorization model (typically a thin closure that builds the principal and calls the
|
|
198
|
+
* app's `decide()`). Replaces the former `authority: GrantAuthorityResolver`.
|
|
199
|
+
*/
|
|
200
|
+
authorize: GrantAuthorizeFn;
|
|
201
|
+
/** Optional — absent ⇒ lifecycle events are un-audited (never blocks the write). */
|
|
202
|
+
audit?: GrantAuditSink;
|
|
203
|
+
schema?: string;
|
|
204
|
+
/** Test seam: a deterministic clock (defaults to Date.now). */
|
|
205
|
+
nowMs?: () => number;
|
|
206
|
+
});
|
|
207
|
+
/**
|
|
208
|
+
* OFFER a grant — the OWNING tenant consents to let a grantee tenant's principal in. Enforces
|
|
209
|
+
* the offer bound (owning-tenant admin, non-empty/non-wildcard ceiling, future expiry, scope
|
|
210
|
+
* ↔ recordId). Creates a fresh row in 'offered' status. Emits `grant.offered`.
|
|
211
|
+
*
|
|
212
|
+
* @returns the new grant's uuid.
|
|
213
|
+
* @throws {@link GrantNotPermittedError} if the offerer is not an owning-tenant admin.
|
|
214
|
+
* @throws {@link InvalidGrantError} on a bad ceiling / expiry / scope.
|
|
215
|
+
* @throws {@link GrantStoreUnavailableError} if the store cannot be reached (transient).
|
|
216
|
+
*/
|
|
217
|
+
offer(input: OfferGrantInput): Promise<string>;
|
|
218
|
+
/**
|
|
219
|
+
* ACCEPT a grant — the GRANTEE tenant activates it. Only a MEMBER of the grant's grantee
|
|
220
|
+
* tenant may accept, and only a grant currently 'offered'. Moves it to 'accepted' (the point
|
|
221
|
+
* at which it becomes ACTIVE for `findActiveGrant`). Re-accepting an 'accepted' grant is a
|
|
222
|
+
* benign no-op; accepting a 'revoked' grant is rejected. Emits `grant.accepted`.
|
|
223
|
+
*
|
|
224
|
+
* @throws {@link GrantNotPermittedError} if the accepter is not a grantee-tenant member, the
|
|
225
|
+
* grant does not exist, or it is not in 'offered' status.
|
|
226
|
+
* @throws {@link GrantStoreUnavailableError} if the store cannot be reached (transient).
|
|
227
|
+
*/
|
|
228
|
+
accept(input: AcceptGrantInput): Promise<void>;
|
|
229
|
+
/**
|
|
230
|
+
* REVOKE a grant — EITHER an admin of the owning tenant OR a member of the grantee tenant may
|
|
231
|
+
* revoke (either side, ADR-0009). Moves it to 'revoked' + stamps `ctg_revoked_at` (it becomes
|
|
232
|
+
* immediately inactive — `findActiveGrant` reads live). Re-revoking a 'revoked' grant is a
|
|
233
|
+
* benign no-op. Emits `grant.revoked`.
|
|
234
|
+
*
|
|
235
|
+
* @throws {@link GrantNotPermittedError} if the revoker is neither an owning-tenant admin nor
|
|
236
|
+
* a grantee-tenant member, or the grant does not exist.
|
|
237
|
+
* @throws {@link GrantStoreUnavailableError} if the store cannot be reached (transient).
|
|
238
|
+
*/
|
|
239
|
+
revoke(input: RevokeGrantInput): Promise<void>;
|
|
240
|
+
/**
|
|
241
|
+
* Enforce the offer INPUT bounds (ceiling non-empty + non-wildcard, future expiry, scope ↔
|
|
242
|
+
* recordId). Throws {@link InvalidGrantError} on any failure (fail-closed). The is-admin
|
|
243
|
+
* authority check is done separately (it needs I/O).
|
|
244
|
+
*/
|
|
245
|
+
private assertValidOffer;
|
|
246
|
+
/**
|
|
247
|
+
* Run an authority resolver call, translating a store outage into
|
|
248
|
+
* {@link GrantStoreUnavailableError} (retryable) — a resolver that THROWS is an outage, not a
|
|
249
|
+
* "not authorized". A resolver returning `false` is a legitimate deny (handled by the caller).
|
|
250
|
+
*/
|
|
251
|
+
private resolveAuthority;
|
|
252
|
+
/** Fetch a grant's lifecycle row (tenants + status) by uuid — never trusts caller-supplied tenants. */
|
|
253
|
+
private fetchGrant;
|
|
254
|
+
/** Emit an offer event (fields already assembled). Best-effort. */
|
|
255
|
+
private emit;
|
|
256
|
+
/** Emit an accept/revoke event, deriving structured attributes from the fetched row. */
|
|
257
|
+
private emitFromRow;
|
|
258
|
+
}
|
|
259
|
+
//# sourceMappingURL=cross-tenant-grant-writer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cross-tenant-grant-writer.d.ts","sourceRoot":"","sources":["../../src/authz/cross-tenant-grant-writer.ts"],"names":[],"mappings":"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"}
|
|
@@ -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;
|