@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.
- 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/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.module.d.ts +101 -3
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +300 -8
- package/dist/auth/dto.d.ts +16 -6
- package/dist/auth/dto.d.ts.map +1 -1
- 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/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/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/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/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/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/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/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-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/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/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/tokens.d.ts +174 -0
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +190 -1
- 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/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/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/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/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-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-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/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-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/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/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/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/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/config/app-config.d.ts +18 -16
- package/dist/config/app-config.d.ts.map +1 -1
- package/dist/config/app-config.js +68 -35
- 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/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/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/index.d.ts +35 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +159 -7
- 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.module.d.ts +154 -10
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +181 -24
- 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/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-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/tokens.d.ts +14 -0
- package/dist/request-auth/tokens.d.ts.map +1 -0
- package/dist/request-auth/tokens.js +16 -0
- package/package.json +2 -2
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MariaDbCrossTenantGrantAdapter` — the concrete {@link CrossTenantGrantPort} for
|
|
3
|
+
* `@astram/guard`, backed by the `cross_tenant_grant` table (ADR-0009). It answers
|
|
4
|
+
* `findActiveGrant`: is there an ACTIVE (accepted, unexpired, unrevoked), consented grant
|
|
5
|
+
* that lets THIS grantee reach THIS owning tenant's object type (+ record) for an action?
|
|
6
|
+
*
|
|
7
|
+
* ── WHAT A CROSS-TENANT GRANT IS (ADR-0009): the ONLY sanctioned way (short of root)
|
|
8
|
+
* a principal reaches a resource owned by a DIFFERENT tenant. It is an explicit,
|
|
9
|
+
* CONSENTED handshake — the OWNING tenant (whose data is reached into) offers it, the
|
|
10
|
+
* GRANTEE tenant accepts it, either side may revoke it, and it always expires. It is NEVER
|
|
11
|
+
* a root bypass: it carries a HARD, non-wildcard action ceiling and a scope
|
|
12
|
+
* (whole object type, or a single record). This adapter only REPORTS an active grant; core's
|
|
13
|
+
* `isCrossTenantGrantApplicable` re-checks the ceiling + scope + expiry (defense-in-depth).
|
|
14
|
+
*
|
|
15
|
+
* ── WHY HERE, NOT CORE (ADR-0003): the `mariadb` driver is a real runtime dependency; core
|
|
16
|
+
* defines only the PORT. This adapter is INJECTED into core's wired decision authority. It
|
|
17
|
+
* uses `@astram/core` TYPES only (erased at build).
|
|
18
|
+
*
|
|
19
|
+
* ── THE MATCH (ADR-0009 — an ACTIVE grant): a row matches when ALL hold —
|
|
20
|
+
* • `ctg_owning_tenant_uuid` = the resource's owning tenant AND `ctg_type_key` = the object
|
|
21
|
+
* type;
|
|
22
|
+
* • the grant is ACTIVE: `ctg_status = 'accepted'` (consent completed) AND
|
|
23
|
+
* `ctg_revoked_at IS NULL` (not revoked by either side) AND `ctg_expires_at_ms > ?` (not
|
|
24
|
+
* expired — bound epoch-ms, timezone-immune) — a revoked/offered/expired grant is NEVER
|
|
25
|
+
* returned, so it can never open access even for a heartbeat;
|
|
26
|
+
* • the grantee is EITHER this user (`ctg_grantee_kind='user'` AND
|
|
27
|
+
* `ctg_grantee_uuid = grantee`) OR one of the grantee's roles (`ctg_grantee_kind='role'`
|
|
28
|
+
* AND `ctg_grantee_uuid IN (roleIds)`);
|
|
29
|
+
* • the action is in the ceiling — the action-ceiling membership check is left to core's
|
|
30
|
+
* grant-evaluator (which re-checks `allowedActions`), but we DO gate scope: an
|
|
31
|
+
* `object_type` grant matches any record of the type; a `record` grant matches ONLY when
|
|
32
|
+
* the query names the exact `ctg_record_uuid` (a record-scoped row is EXCLUDED for an
|
|
33
|
+
* object-type / record-less query so a NULL-compare never spuriously matches).
|
|
34
|
+
* ≥1 matching row ⇒ mapped to a {@link CrossTenantGrant} (first match). No matching row ⇒
|
|
35
|
+
* `null` (a NORMAL data-absence — no active consent — NOT an error, NOT a lockout).
|
|
36
|
+
*
|
|
37
|
+
* ── SCOPE + RECORD-ID HANDLING: `object_type` rows are always eligible; a `record` row is
|
|
38
|
+
* eligible ONLY when `query.recordId` is present AND equals `ctg_record_uuid`. When the
|
|
39
|
+
* query has NO recordId (an object-type-level question), the SQL restricts to
|
|
40
|
+
* `ctg_scope = 'object_type'` so a record row's `ctg_record_uuid` is never compared to a
|
|
41
|
+
* bound NULL (which would never match — but we make the intent explicit and avoid the
|
|
42
|
+
* accidental all-records reach a `record_uuid = NULL` OR-branch could imply).
|
|
43
|
+
*
|
|
44
|
+
* ── ACTION CEILING PARSING: `ctg_allowed_actions` is a JSON column (a JSON array of action-key
|
|
45
|
+
* strings). ⚠️ The mariadb driver AUTO-PARSES a JSON column into a JS ARRAY on read, so the
|
|
46
|
+
* cell arrives already-parsed — `parseAllowedActions` accepts BOTH an already-parsed array
|
|
47
|
+
* (production) and a JSON string (test mocks). A malformed/empty value ⇒ `[]` (an empty
|
|
48
|
+
* ceiling permits nothing — fail-closed; core's ceiling check then denies).
|
|
49
|
+
*
|
|
50
|
+
* ── EXPIRY (MUST-FIX #1 — timezone immunity): `ctg_expires_at_ms` is a BIGINT epoch-ms column
|
|
51
|
+
* (NOT a TIMESTAMP). The active-grant filter compares it `> ?` against a BOUND integer (the
|
|
52
|
+
* adapter's `now()` epoch-ms), so it is immune to host-timezone / `NOW()` wall-clock skew (a
|
|
53
|
+
* TIMESTAMP + `NOW()` comparison silently kept expired grants alive on a non-UTC host). Mirrors
|
|
54
|
+
* the reset-token store's `prt_expires_at_ms` BIGINT precedent.
|
|
55
|
+
*
|
|
56
|
+
* ── EMPTY roleIds: guarded — an empty role list must NOT emit `IN ()` (a MariaDB syntax
|
|
57
|
+
* error). We omit the role branch entirely when there are no roles, so a role-less grantee
|
|
58
|
+
* is matched by its user-grant rows alone.
|
|
59
|
+
*
|
|
60
|
+
* ── ADR-0016 (transient-vs-deny): a genuine store outage THROWS
|
|
61
|
+
* {@link AuthorizationUnavailableError} (→ 503 retryable) — NEVER a silent `null` that
|
|
62
|
+
* would mask an outage as "no grant".
|
|
63
|
+
*
|
|
64
|
+
* ── SAFETY: every value (owningTenantId, typeKey, grantee, each roleId, recordId) is BOUND
|
|
65
|
+
* as a `?` param — NEVER interpolated. A SQL payload in typeKey/recordId is data, not SQL.
|
|
66
|
+
* Mirrors {@link MariaDbRecordShareAdapter}.
|
|
67
|
+
*/
|
|
68
|
+
import type { CrossTenantGrant, CrossTenantGrantPort, CrossTenantGrantQuery } from '@astram/core';
|
|
69
|
+
/** The DI token the adapter is bound under (parity with the share/rule-port tokens). */
|
|
70
|
+
export declare const SHIELD_CROSS_TENANT_GRANT_PORT: unique symbol;
|
|
71
|
+
/** The minimal query surface this adapter needs — a `mariadb` Pool satisfies it. */
|
|
72
|
+
export interface GrantQueryable {
|
|
73
|
+
query(sql: string, params?: readonly unknown[]): Promise<unknown>;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The concrete cross-tenant grant adapter. Answers `findActiveGrant` from `cross_tenant_grant`,
|
|
77
|
+
* matching user- and role-granted, ACCEPTED, unexpired, unrevoked grants for the owning tenant
|
|
78
|
+
* + object type (+ record for record-scoped).
|
|
79
|
+
*/
|
|
80
|
+
export declare class MariaDbCrossTenantGrantAdapter implements CrossTenantGrantPort {
|
|
81
|
+
private readonly db;
|
|
82
|
+
private readonly schema;
|
|
83
|
+
private readonly now;
|
|
84
|
+
constructor(db: GrantQueryable, opts?: {
|
|
85
|
+
schema?: string;
|
|
86
|
+
nowMs?: () => number;
|
|
87
|
+
});
|
|
88
|
+
findActiveGrant(query: CrossTenantGrantQuery): Promise<CrossTenantGrant | null>;
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=mariadb-cross-tenant-grant-adapter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mariadb-cross-tenant-grant-adapter.d.ts","sourceRoot":"","sources":["../../src/authz/mariadb-cross-tenant-grant-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkEG;AAEH,OAAO,KAAK,EACV,gBAAgB,EAChB,oBAAoB,EACpB,qBAAqB,EAEtB,MAAM,cAAc,CAAC;AAGtB,wFAAwF;AACxF,eAAO,MAAM,8BAA8B,eAA2C,CAAC;AAKvF,oFAAoF;AACpF,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAsDD;;;;GAIG;AACH,qBAAa,8BAA+B,YAAW,oBAAoB;IACzE,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAiB;IACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;gBAEvB,EAAE,EAAE,cAAc,EAAE,IAAI,GAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,MAAM,CAAA;KAAO;IAQ9E,eAAe,CAAC,KAAK,EAAE,qBAAqB,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC;CAuFtF"}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `MariaDbCrossTenantGrantAdapter` — the concrete {@link CrossTenantGrantPort} for
|
|
4
|
+
* `@astram/guard`, backed by the `cross_tenant_grant` table (ADR-0009). It answers
|
|
5
|
+
* `findActiveGrant`: is there an ACTIVE (accepted, unexpired, unrevoked), consented grant
|
|
6
|
+
* that lets THIS grantee reach THIS owning tenant's object type (+ record) for an action?
|
|
7
|
+
*
|
|
8
|
+
* ── WHAT A CROSS-TENANT GRANT IS (ADR-0009): the ONLY sanctioned way (short of root)
|
|
9
|
+
* a principal reaches a resource owned by a DIFFERENT tenant. It is an explicit,
|
|
10
|
+
* CONSENTED handshake — the OWNING tenant (whose data is reached into) offers it, the
|
|
11
|
+
* GRANTEE tenant accepts it, either side may revoke it, and it always expires. It is NEVER
|
|
12
|
+
* a root bypass: it carries a HARD, non-wildcard action ceiling and a scope
|
|
13
|
+
* (whole object type, or a single record). This adapter only REPORTS an active grant; core's
|
|
14
|
+
* `isCrossTenantGrantApplicable` re-checks the ceiling + scope + expiry (defense-in-depth).
|
|
15
|
+
*
|
|
16
|
+
* ── WHY HERE, NOT CORE (ADR-0003): the `mariadb` driver is a real runtime dependency; core
|
|
17
|
+
* defines only the PORT. This adapter is INJECTED into core's wired decision authority. It
|
|
18
|
+
* uses `@astram/core` TYPES only (erased at build).
|
|
19
|
+
*
|
|
20
|
+
* ── THE MATCH (ADR-0009 — an ACTIVE grant): a row matches when ALL hold —
|
|
21
|
+
* • `ctg_owning_tenant_uuid` = the resource's owning tenant AND `ctg_type_key` = the object
|
|
22
|
+
* type;
|
|
23
|
+
* • the grant is ACTIVE: `ctg_status = 'accepted'` (consent completed) AND
|
|
24
|
+
* `ctg_revoked_at IS NULL` (not revoked by either side) AND `ctg_expires_at_ms > ?` (not
|
|
25
|
+
* expired — bound epoch-ms, timezone-immune) — a revoked/offered/expired grant is NEVER
|
|
26
|
+
* returned, so it can never open access even for a heartbeat;
|
|
27
|
+
* • the grantee is EITHER this user (`ctg_grantee_kind='user'` AND
|
|
28
|
+
* `ctg_grantee_uuid = grantee`) OR one of the grantee's roles (`ctg_grantee_kind='role'`
|
|
29
|
+
* AND `ctg_grantee_uuid IN (roleIds)`);
|
|
30
|
+
* • the action is in the ceiling — the action-ceiling membership check is left to core's
|
|
31
|
+
* grant-evaluator (which re-checks `allowedActions`), but we DO gate scope: an
|
|
32
|
+
* `object_type` grant matches any record of the type; a `record` grant matches ONLY when
|
|
33
|
+
* the query names the exact `ctg_record_uuid` (a record-scoped row is EXCLUDED for an
|
|
34
|
+
* object-type / record-less query so a NULL-compare never spuriously matches).
|
|
35
|
+
* ≥1 matching row ⇒ mapped to a {@link CrossTenantGrant} (first match). No matching row ⇒
|
|
36
|
+
* `null` (a NORMAL data-absence — no active consent — NOT an error, NOT a lockout).
|
|
37
|
+
*
|
|
38
|
+
* ── SCOPE + RECORD-ID HANDLING: `object_type` rows are always eligible; a `record` row is
|
|
39
|
+
* eligible ONLY when `query.recordId` is present AND equals `ctg_record_uuid`. When the
|
|
40
|
+
* query has NO recordId (an object-type-level question), the SQL restricts to
|
|
41
|
+
* `ctg_scope = 'object_type'` so a record row's `ctg_record_uuid` is never compared to a
|
|
42
|
+
* bound NULL (which would never match — but we make the intent explicit and avoid the
|
|
43
|
+
* accidental all-records reach a `record_uuid = NULL` OR-branch could imply).
|
|
44
|
+
*
|
|
45
|
+
* ── ACTION CEILING PARSING: `ctg_allowed_actions` is a JSON column (a JSON array of action-key
|
|
46
|
+
* strings). ⚠️ The mariadb driver AUTO-PARSES a JSON column into a JS ARRAY on read, so the
|
|
47
|
+
* cell arrives already-parsed — `parseAllowedActions` accepts BOTH an already-parsed array
|
|
48
|
+
* (production) and a JSON string (test mocks). A malformed/empty value ⇒ `[]` (an empty
|
|
49
|
+
* ceiling permits nothing — fail-closed; core's ceiling check then denies).
|
|
50
|
+
*
|
|
51
|
+
* ── EXPIRY (MUST-FIX #1 — timezone immunity): `ctg_expires_at_ms` is a BIGINT epoch-ms column
|
|
52
|
+
* (NOT a TIMESTAMP). The active-grant filter compares it `> ?` against a BOUND integer (the
|
|
53
|
+
* adapter's `now()` epoch-ms), so it is immune to host-timezone / `NOW()` wall-clock skew (a
|
|
54
|
+
* TIMESTAMP + `NOW()` comparison silently kept expired grants alive on a non-UTC host). Mirrors
|
|
55
|
+
* the reset-token store's `prt_expires_at_ms` BIGINT precedent.
|
|
56
|
+
*
|
|
57
|
+
* ── EMPTY roleIds: guarded — an empty role list must NOT emit `IN ()` (a MariaDB syntax
|
|
58
|
+
* error). We omit the role branch entirely when there are no roles, so a role-less grantee
|
|
59
|
+
* is matched by its user-grant rows alone.
|
|
60
|
+
*
|
|
61
|
+
* ── ADR-0016 (transient-vs-deny): a genuine store outage THROWS
|
|
62
|
+
* {@link AuthorizationUnavailableError} (→ 503 retryable) — NEVER a silent `null` that
|
|
63
|
+
* would mask an outage as "no grant".
|
|
64
|
+
*
|
|
65
|
+
* ── SAFETY: every value (owningTenantId, typeKey, grantee, each roleId, recordId) is BOUND
|
|
66
|
+
* as a `?` param — NEVER interpolated. A SQL payload in typeKey/recordId is data, not SQL.
|
|
67
|
+
* Mirrors {@link MariaDbRecordShareAdapter}.
|
|
68
|
+
*/
|
|
69
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
70
|
+
exports.MariaDbCrossTenantGrantAdapter = exports.SHIELD_CROSS_TENANT_GRANT_PORT = void 0;
|
|
71
|
+
const core_1 = require("@astram/core");
|
|
72
|
+
/** The DI token the adapter is bound under (parity with the share/rule-port tokens). */
|
|
73
|
+
exports.SHIELD_CROSS_TENANT_GRANT_PORT = Symbol('SHIELD_CROSS_TENANT_GRANT_PORT');
|
|
74
|
+
/** Schema the `cross_tenant_grant` table lives in (astram3 ships the migration). */
|
|
75
|
+
const DEFAULT_SCHEMA = 'shield_authz';
|
|
76
|
+
/**
|
|
77
|
+
* Parse `ctg_allowed_actions` to a `string[]`. ⚠️ The `ctg_allowed_actions` column is a JSON
|
|
78
|
+
* column, and the mariadb driver AUTO-PARSES it into a JS ARRAY on read — so `raw` is ALREADY an
|
|
79
|
+
* array in production. A test mock, however, may hand back a JSON STRING. This helper accepts
|
|
80
|
+
* BOTH:
|
|
81
|
+
* • already-parsed array ⇒ filter to strings, return directly (NO `JSON.parse` — parsing an
|
|
82
|
+
* array throws, which is exactly the bug that collapsed every ceiling to `[]`);
|
|
83
|
+
* • non-empty string ⇒ `JSON.parse`, then filter to strings;
|
|
84
|
+
* • anything else (null / empty / non-array-non-string / malformed) ⇒ `[]`.
|
|
85
|
+
* Non-string members are dropped (fail-closed — a hostile array can never widen the ceiling); an
|
|
86
|
+
* empty ceiling permits nothing.
|
|
87
|
+
*/
|
|
88
|
+
function parseAllowedActions(raw) {
|
|
89
|
+
// Production path: the driver already parsed the JSON column into an array.
|
|
90
|
+
if (Array.isArray(raw)) {
|
|
91
|
+
return raw.filter((a) => typeof a === 'string');
|
|
92
|
+
}
|
|
93
|
+
// Mock / legacy path: a JSON string. Empty/null ⇒ empty ceiling.
|
|
94
|
+
if (typeof raw === 'string' && raw.length > 0) {
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(raw);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
if (!Array.isArray(parsed))
|
|
103
|
+
return [];
|
|
104
|
+
return parsed.filter((a) => typeof a === 'string');
|
|
105
|
+
}
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
/** Coerce a BIGINT epoch-ms cell (number | bigint | numeric string | null) to a JS number. */
|
|
109
|
+
function toMs(value) {
|
|
110
|
+
if (value === null)
|
|
111
|
+
return 0;
|
|
112
|
+
const ms = typeof value === 'number' ? value : Number(value);
|
|
113
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The concrete cross-tenant grant adapter. Answers `findActiveGrant` from `cross_tenant_grant`,
|
|
117
|
+
* matching user- and role-granted, ACCEPTED, unexpired, unrevoked grants for the owning tenant
|
|
118
|
+
* + object type (+ record for record-scoped).
|
|
119
|
+
*/
|
|
120
|
+
class MariaDbCrossTenantGrantAdapter {
|
|
121
|
+
db;
|
|
122
|
+
schema;
|
|
123
|
+
now;
|
|
124
|
+
constructor(db, opts = {}) {
|
|
125
|
+
this.db = db;
|
|
126
|
+
this.schema = opts.schema ?? DEFAULT_SCHEMA;
|
|
127
|
+
// The expiry filter binds THIS clock's epoch-ms against the BIGINT `ctg_expires_at_ms`
|
|
128
|
+
// column — timezone-immune (no Date/`NOW()` skew). Injectable for deterministic tests.
|
|
129
|
+
this.now = opts.nowMs ?? (() => Date.now());
|
|
130
|
+
}
|
|
131
|
+
async findActiveGrant(query) {
|
|
132
|
+
// Grantee predicate: the user grant OR (only when roles exist) a role grant. The role
|
|
133
|
+
// branch is OMITTED for an empty role list so we NEVER emit `IN ()` (a syntax error).
|
|
134
|
+
let granteeSql = `(ctg_grantee_kind = 'user' AND ctg_grantee_uuid = ?)`;
|
|
135
|
+
const granteeParams = [query.grantee];
|
|
136
|
+
if (query.roleIds.length > 0) {
|
|
137
|
+
const placeholders = query.roleIds.map(() => '?').join(', ');
|
|
138
|
+
granteeSql += ` OR (ctg_grantee_kind = 'role' AND ctg_grantee_uuid IN (${placeholders}))`;
|
|
139
|
+
granteeParams.push(...query.roleIds);
|
|
140
|
+
}
|
|
141
|
+
// Scope predicate. An `object_type` grant always matches this type; a `record` grant
|
|
142
|
+
// matches ONLY when the query names the exact record. When the query has NO recordId, we
|
|
143
|
+
// restrict to object_type grants (a record row's record_uuid must never be compared to a
|
|
144
|
+
// bound NULL — it would not match, but we make the intent explicit + never risk a NULL OR).
|
|
145
|
+
let scopeSql;
|
|
146
|
+
const scopeParams = [];
|
|
147
|
+
if (typeof query.recordId === 'string' && query.recordId.length > 0) {
|
|
148
|
+
scopeSql = `(ctg_scope = 'object_type' OR (ctg_scope = 'record' AND ctg_record_uuid = ?))`;
|
|
149
|
+
scopeParams.push(query.recordId);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
scopeSql = `ctg_scope = 'object_type'`;
|
|
153
|
+
}
|
|
154
|
+
// The expiry bound is a BIGINT epoch-ms comparison against a BOUND integer (this clock's
|
|
155
|
+
// now) — timezone-immune (no `NOW()`/Date wall-clock skew, MUST-FIX #1). Param order matches
|
|
156
|
+
// the SQL below exactly: owning_tenant, type_key, expiry_now, <grantee params>, <scope params>.
|
|
157
|
+
const nowMs = this.now();
|
|
158
|
+
const params = [
|
|
159
|
+
query.owningTenantId,
|
|
160
|
+
query.typeKey,
|
|
161
|
+
nowMs,
|
|
162
|
+
...granteeParams,
|
|
163
|
+
...scopeParams,
|
|
164
|
+
];
|
|
165
|
+
let rows;
|
|
166
|
+
try {
|
|
167
|
+
rows = (await this.db.query(`SELECT ctg_scope, ctg_type_key, ctg_record_uuid, ctg_allowed_actions, ctg_expires_at_ms` +
|
|
168
|
+
` FROM \`${this.schema}\`.\`cross_tenant_grant\`` +
|
|
169
|
+
` WHERE ctg_owning_tenant_uuid = ?` +
|
|
170
|
+
` AND ctg_type_key = ?` +
|
|
171
|
+
` AND ctg_status = 'accepted'` +
|
|
172
|
+
` AND ctg_revoked_at IS NULL` +
|
|
173
|
+
` AND ctg_expires_at_ms > ?` +
|
|
174
|
+
` AND (${granteeSql})` +
|
|
175
|
+
` AND (${scopeSql})` +
|
|
176
|
+
// Prefer a record-scoped row over an object_type row when both match, and the
|
|
177
|
+
// latest-expiring first — deterministic single-match selection.
|
|
178
|
+
` ORDER BY (ctg_scope = 'record') DESC, ctg_expires_at_ms DESC` +
|
|
179
|
+
` LIMIT 1`, params));
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
// Genuine store outage — fail closed as retryable (never mask as "no grant").
|
|
183
|
+
throw new core_1.AuthorizationUnavailableError(`cross_tenant_grant store unavailable for ` +
|
|
184
|
+
`${query.owningTenantId}:${query.typeKey}:${query.action}: ${err.message}`);
|
|
185
|
+
}
|
|
186
|
+
const row = rows[0];
|
|
187
|
+
if (!row) {
|
|
188
|
+
// No active consented grant — a NORMAL data-absence (NOT an error). Core reads this as
|
|
189
|
+
// "no cross-tenant permit" — deny-by-default for the reach.
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
// Map the row → CrossTenantGrant. Core's grant-evaluator re-checks allowedActions + scope
|
|
193
|
+
// + expiry; we still filtered status/expiry/revoke/scope in SQL so a revoked/expired/
|
|
194
|
+
// offered grant is never even returned.
|
|
195
|
+
const scope = row.ctg_scope === 'record' ? 'record' : 'object_type';
|
|
196
|
+
const grant = {
|
|
197
|
+
scope,
|
|
198
|
+
typeKey: row.ctg_type_key,
|
|
199
|
+
allowedActions: parseAllowedActions(row.ctg_allowed_actions),
|
|
200
|
+
// Already epoch-ms (BIGINT column) — just coerce the driver's number/string to a JS number.
|
|
201
|
+
expiresAtMs: toMs(row.ctg_expires_at_ms),
|
|
202
|
+
// `recordId` is only present for a record-scoped grant (exactOptionalPropertyTypes:
|
|
203
|
+
// never write an explicit `undefined` onto the optional field).
|
|
204
|
+
...(scope === 'record' && row.ctg_record_uuid !== null
|
|
205
|
+
? { recordId: row.ctg_record_uuid }
|
|
206
|
+
: {}),
|
|
207
|
+
};
|
|
208
|
+
return grant;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
exports.MariaDbCrossTenantGrantAdapter = MariaDbCrossTenantGrantAdapter;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MariaDbRecordPermissionRuleAdapter` — the concrete {@link RecordPermissionRulePort}
|
|
3
|
+
* for `@astram/guard`, backed by the `record_permission_rule` table (design §5.1). It
|
|
4
|
+
* reads ADMIN rules ONLY and evaluates them for a single record+action, returning the
|
|
5
|
+
* `{ allow, explicitDeny }` verdict core's record-permit tier composes with the
|
|
6
|
+
* framework-owned owner BASELINE (design §5.2).
|
|
7
|
+
*
|
|
8
|
+
* ── ADMIN-ONLY BY CONSTRUCTION (not by a WHERE filter): the owner/system baselines are
|
|
9
|
+
* COMPUTED IN CORE (no rows), so this table is only ever WRITTEN admin-authored rules —
|
|
10
|
+
* there is no `provenance` column and every row here is an admin rule. ⚠️ If a future
|
|
11
|
+
* change EVER stores non-admin (e.g. `system`) rows in this table, this SELECT MUST add
|
|
12
|
+
* a `WHERE rpr_provenance = 'admin'` filter — otherwise a system row would be read as an
|
|
13
|
+
* admin rule and could conflict with core's immutable baseline.
|
|
14
|
+
*
|
|
15
|
+
* ── WHY HERE, NOT CORE (ADR-0003): the `mariadb` driver is a real runtime dependency;
|
|
16
|
+
* core defines only the PORT + the immutable owner baseline. This adapter is INJECTED
|
|
17
|
+
* into core's wired decision authority. It uses `@astram/core` TYPES only (erased).
|
|
18
|
+
*
|
|
19
|
+
* ── THE MODEL (design §5):
|
|
20
|
+
* • The OWNER baseline is CORE's job — it is NOT a row here. This table holds ONLY
|
|
21
|
+
* admin-authored rules that WIDEN the baseline (e.g. "Company Admins read all
|
|
22
|
+
* inspection_reports") or add explicit DENIES (deny-wins). There is no
|
|
23
|
+
* `owner`/`system` subject_kind row.
|
|
24
|
+
* • NO applicable admin rule ⇒ `{ allow: false, explicitDeny: false }` — a NORMAL
|
|
25
|
+
* data-absence (not a lockout): core's owner baseline still permits the owner. An
|
|
26
|
+
* empty table ⇒ owner-only access, exactly the safe default.
|
|
27
|
+
* • A matching `allow` rule ⇒ `allow: true`. A matching `deny` rule ⇒
|
|
28
|
+
* `explicitDeny: true` (deny-wins; core short-circuits the share on it).
|
|
29
|
+
*
|
|
30
|
+
* ── ADR-0016 (transient-vs-deny): a genuine store outage THROWS
|
|
31
|
+
* {@link AuthorizationUnavailableError} (→ 503 retryable) — NEVER a silent
|
|
32
|
+
* `{allow:false,explicitDeny:false}` that would mask an outage as a deny.
|
|
33
|
+
*
|
|
34
|
+
* ── SAFETY: every rule row is matched on `(tenant, type_key, action)` with all values
|
|
35
|
+
* BOUND as `?` params (no interpolation). The action match is exact OR the rule's
|
|
36
|
+
* wildcard `*`. Field conditions are evaluated in JS against the record attributes
|
|
37
|
+
* core supplies (design §11.1 builder shape) — the operator set is a fixed
|
|
38
|
+
* allow-list; an unknown operator makes the rule NOT match (fail-closed, never a
|
|
39
|
+
* spurious allow).
|
|
40
|
+
*/
|
|
41
|
+
import type { RecordPermissionRulePort, RulePermit, RulePermitInput } from '@astram/core';
|
|
42
|
+
/** The DI token the adapter is bound under (parity with the session store token). */
|
|
43
|
+
export declare const SHIELD_RECORD_RULE_PORT: unique symbol;
|
|
44
|
+
/** The minimal query surface this adapter needs — a `mariadb` Pool satisfies it. */
|
|
45
|
+
export interface RuleQueryable {
|
|
46
|
+
query(sql: string, params?: readonly unknown[]): Promise<unknown>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The concrete admin-rule adapter. Reads ADMIN rules for `(tenant, type_key, action|*)`
|
|
50
|
+
* and folds them into a `{ allow, explicitDeny }` verdict with deny-wins.
|
|
51
|
+
*/
|
|
52
|
+
export declare class MariaDbRecordPermissionRuleAdapter implements RecordPermissionRulePort {
|
|
53
|
+
private readonly db;
|
|
54
|
+
private readonly schema;
|
|
55
|
+
constructor(db: RuleQueryable, opts?: {
|
|
56
|
+
schema?: string;
|
|
57
|
+
});
|
|
58
|
+
evaluate(input: RulePermitInput): Promise<RulePermit>;
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=mariadb-record-rule-adapter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mariadb-record-rule-adapter.d.ts","sourceRoot":"","sources":["../../src/authz/mariadb-record-rule-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAEH,OAAO,KAAK,EACV,wBAAwB,EACxB,UAAU,EACV,eAAe,EAChB,MAAM,cAAc,CAAC;AAGtB,qFAAqF;AACrF,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAKzE,oFAAoF;AACpF,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAsED;;;GAGG;AACH,qBAAa,kCAAmC,YAAW,wBAAwB;IACjF,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAgB;IACnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;gBAEpB,EAAE,EAAE,aAAa,EAAE,IAAI,GAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAO;IAKvD,QAAQ,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,UAAU,CAAC;CAwC5D"}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `MariaDbRecordPermissionRuleAdapter` — the concrete {@link RecordPermissionRulePort}
|
|
4
|
+
* for `@astram/guard`, backed by the `record_permission_rule` table (design §5.1). It
|
|
5
|
+
* reads ADMIN rules ONLY and evaluates them for a single record+action, returning the
|
|
6
|
+
* `{ allow, explicitDeny }` verdict core's record-permit tier composes with the
|
|
7
|
+
* framework-owned owner BASELINE (design §5.2).
|
|
8
|
+
*
|
|
9
|
+
* ── ADMIN-ONLY BY CONSTRUCTION (not by a WHERE filter): the owner/system baselines are
|
|
10
|
+
* COMPUTED IN CORE (no rows), so this table is only ever WRITTEN admin-authored rules —
|
|
11
|
+
* there is no `provenance` column and every row here is an admin rule. ⚠️ If a future
|
|
12
|
+
* change EVER stores non-admin (e.g. `system`) rows in this table, this SELECT MUST add
|
|
13
|
+
* a `WHERE rpr_provenance = 'admin'` filter — otherwise a system row would be read as an
|
|
14
|
+
* admin rule and could conflict with core's immutable baseline.
|
|
15
|
+
*
|
|
16
|
+
* ── WHY HERE, NOT CORE (ADR-0003): the `mariadb` driver is a real runtime dependency;
|
|
17
|
+
* core defines only the PORT + the immutable owner baseline. This adapter is INJECTED
|
|
18
|
+
* into core's wired decision authority. It uses `@astram/core` TYPES only (erased).
|
|
19
|
+
*
|
|
20
|
+
* ── THE MODEL (design §5):
|
|
21
|
+
* • The OWNER baseline is CORE's job — it is NOT a row here. This table holds ONLY
|
|
22
|
+
* admin-authored rules that WIDEN the baseline (e.g. "Company Admins read all
|
|
23
|
+
* inspection_reports") or add explicit DENIES (deny-wins). There is no
|
|
24
|
+
* `owner`/`system` subject_kind row.
|
|
25
|
+
* • NO applicable admin rule ⇒ `{ allow: false, explicitDeny: false }` — a NORMAL
|
|
26
|
+
* data-absence (not a lockout): core's owner baseline still permits the owner. An
|
|
27
|
+
* empty table ⇒ owner-only access, exactly the safe default.
|
|
28
|
+
* • A matching `allow` rule ⇒ `allow: true`. A matching `deny` rule ⇒
|
|
29
|
+
* `explicitDeny: true` (deny-wins; core short-circuits the share on it).
|
|
30
|
+
*
|
|
31
|
+
* ── ADR-0016 (transient-vs-deny): a genuine store outage THROWS
|
|
32
|
+
* {@link AuthorizationUnavailableError} (→ 503 retryable) — NEVER a silent
|
|
33
|
+
* `{allow:false,explicitDeny:false}` that would mask an outage as a deny.
|
|
34
|
+
*
|
|
35
|
+
* ── SAFETY: every rule row is matched on `(tenant, type_key, action)` with all values
|
|
36
|
+
* BOUND as `?` params (no interpolation). The action match is exact OR the rule's
|
|
37
|
+
* wildcard `*`. Field conditions are evaluated in JS against the record attributes
|
|
38
|
+
* core supplies (design §11.1 builder shape) — the operator set is a fixed
|
|
39
|
+
* allow-list; an unknown operator makes the rule NOT match (fail-closed, never a
|
|
40
|
+
* spurious allow).
|
|
41
|
+
*/
|
|
42
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
|
+
exports.MariaDbRecordPermissionRuleAdapter = exports.SHIELD_RECORD_RULE_PORT = void 0;
|
|
44
|
+
const core_1 = require("@astram/core");
|
|
45
|
+
/** The DI token the adapter is bound under (parity with the session store token). */
|
|
46
|
+
exports.SHIELD_RECORD_RULE_PORT = Symbol('SHIELD_RECORD_RULE_PORT');
|
|
47
|
+
/** Schema the `record_permission_rule` table lives in (astram3 ships the migration). */
|
|
48
|
+
const DEFAULT_SCHEMA = 'shield_authz';
|
|
49
|
+
/** Whether the rule's subject matches this principal (role membership / everyone). */
|
|
50
|
+
function subjectMatches(row, input) {
|
|
51
|
+
const kind = row.rpr_subject_kind;
|
|
52
|
+
if (kind === 'everyone')
|
|
53
|
+
return true;
|
|
54
|
+
if (kind === 'role') {
|
|
55
|
+
return (row.rpr_subject_value !== null && input.principal.roleIds.includes(row.rpr_subject_value));
|
|
56
|
+
}
|
|
57
|
+
// Unknown subject kind → does NOT match (fail-closed).
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
/** Resolve a rule value token against the request (e.g. `$actor.id`) or use the literal. */
|
|
61
|
+
function resolveValue(raw, input) {
|
|
62
|
+
if (raw === '$actor.id')
|
|
63
|
+
return input.principal.id;
|
|
64
|
+
if (raw === '$actor.tenant')
|
|
65
|
+
return input.principal.tenantId;
|
|
66
|
+
return raw;
|
|
67
|
+
}
|
|
68
|
+
/** The value the rule's field condition tests, read from the record attributes core supplies. */
|
|
69
|
+
function fieldValue(field, input) {
|
|
70
|
+
return input.attributes?.[field];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Evaluate a rule's field condition. A rule with NO field (`rpr_field` null) is
|
|
74
|
+
* UNCONDITIONAL (applies whenever subject+action match). The operator set is a fixed
|
|
75
|
+
* allow-list; an unknown operator → the condition does NOT hold (fail-closed).
|
|
76
|
+
*/
|
|
77
|
+
function conditionHolds(row, input) {
|
|
78
|
+
if (row.rpr_field === null)
|
|
79
|
+
return true; // unconditional
|
|
80
|
+
const actual = fieldValue(row.rpr_field, input);
|
|
81
|
+
const expectedRaw = row.rpr_value ?? '';
|
|
82
|
+
const expected = resolveValue(expectedRaw, input);
|
|
83
|
+
switch (row.rpr_operator) {
|
|
84
|
+
case 'equals':
|
|
85
|
+
return actual === expected;
|
|
86
|
+
case 'not_equals':
|
|
87
|
+
return actual !== expected;
|
|
88
|
+
case 'in': {
|
|
89
|
+
// comma-separated token list; membership by string comparison.
|
|
90
|
+
const set = String(expectedRaw)
|
|
91
|
+
.split(',')
|
|
92
|
+
.map((s) => s.trim());
|
|
93
|
+
return set.includes(String(actual));
|
|
94
|
+
}
|
|
95
|
+
case 'not_in': {
|
|
96
|
+
const set = String(expectedRaw)
|
|
97
|
+
.split(',')
|
|
98
|
+
.map((s) => s.trim());
|
|
99
|
+
return !set.includes(String(actual));
|
|
100
|
+
}
|
|
101
|
+
default:
|
|
102
|
+
return false; // unknown operator → fail-closed (rule does not apply)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The concrete admin-rule adapter. Reads ADMIN rules for `(tenant, type_key, action|*)`
|
|
107
|
+
* and folds them into a `{ allow, explicitDeny }` verdict with deny-wins.
|
|
108
|
+
*/
|
|
109
|
+
class MariaDbRecordPermissionRuleAdapter {
|
|
110
|
+
db;
|
|
111
|
+
schema;
|
|
112
|
+
constructor(db, opts = {}) {
|
|
113
|
+
this.db = db;
|
|
114
|
+
this.schema = opts.schema ?? DEFAULT_SCHEMA;
|
|
115
|
+
}
|
|
116
|
+
async evaluate(input) {
|
|
117
|
+
// tenant scoping: match rows for the resource's tenant OR the global/system baseline
|
|
118
|
+
// (rpr_tenant_uuid IS NULL). Action match: the exact action OR the rule's `*` wildcard.
|
|
119
|
+
const tenantId = input.resource.tenantId ?? input.principal.tenantId;
|
|
120
|
+
let rows;
|
|
121
|
+
try {
|
|
122
|
+
rows = (await this.db.query(`SELECT rpr_effect, rpr_subject_kind, rpr_subject_value, rpr_field, rpr_operator, rpr_value
|
|
123
|
+
FROM \`${this.schema}\`.\`record_permission_rule\`
|
|
124
|
+
WHERE rpr_type_key = ?
|
|
125
|
+
AND (rpr_tenant_uuid = ? OR rpr_tenant_uuid IS NULL)
|
|
126
|
+
AND (rpr_action = ? OR rpr_action = '*')`, [input.resource.typeKey, tenantId, input.action.key]));
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
// Genuine store outage — fail closed as retryable (never mask as a deny).
|
|
130
|
+
throw new core_1.AuthorizationUnavailableError(`record_permission_rule store unavailable for ` +
|
|
131
|
+
`${input.resource.typeKey}:${input.action.key}: ${err.message}`);
|
|
132
|
+
}
|
|
133
|
+
let allow = false;
|
|
134
|
+
let explicitDeny = false;
|
|
135
|
+
for (const row of rows) {
|
|
136
|
+
if (!subjectMatches(row, input))
|
|
137
|
+
continue;
|
|
138
|
+
if (!conditionHolds(row, input))
|
|
139
|
+
continue;
|
|
140
|
+
if (row.rpr_effect === 'deny') {
|
|
141
|
+
// Deny-wins — one matching deny is final for this tier.
|
|
142
|
+
explicitDeny = true;
|
|
143
|
+
}
|
|
144
|
+
else if (row.rpr_effect === 'allow') {
|
|
145
|
+
allow = true;
|
|
146
|
+
}
|
|
147
|
+
// any other effect string is ignored (fail-closed — no permit inferred).
|
|
148
|
+
}
|
|
149
|
+
// NO applicable admin rule ⇒ {false,false}: a normal data-absence. The CORE owner
|
|
150
|
+
// baseline still provides the owner permit — this is NOT a lockout.
|
|
151
|
+
return { allow, explicitDeny };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
exports.MariaDbRecordPermissionRuleAdapter = MariaDbRecordPermissionRuleAdapter;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MariaDbRecordShareAdapter` — the concrete {@link RecordSharePort} for `@astram/guard`,
|
|
3
|
+
* backed by the `record_share` table (design §6.1). It answers `hasShare`: does the
|
|
4
|
+
* principal (by id or any of its roles) hold an active per-record share on this
|
|
5
|
+
* record+action?
|
|
6
|
+
*
|
|
7
|
+
* ── WHAT A SHARE IS: an INSTANCE-level grant (one row per record×grantee×action) that
|
|
8
|
+
* WIDENS gate 6 (design §6.3). It is a permit SOURCE unioned with the owner baseline +
|
|
9
|
+
* admin rules under deny-wins — a share can grant access an absent rule wouldn't, but it
|
|
10
|
+
* can NEVER override an explicit deny (core enforces that; this adapter only reports the
|
|
11
|
+
* share's presence via a strict boolean).
|
|
12
|
+
*
|
|
13
|
+
* ── WHY HERE, NOT CORE (ADR-0003): the `mariadb` driver is a real runtime dependency;
|
|
14
|
+
* core defines only the PORT. This adapter is INJECTED into core's wired decision
|
|
15
|
+
* authority. It uses `@astram/core` TYPES only (erased).
|
|
16
|
+
*
|
|
17
|
+
* ── THE MATCH (design §6.1): a row matches when ALL hold —
|
|
18
|
+
* • `rs_type_key` = the object type AND `rs_record_uuid` = the record;
|
|
19
|
+
* • the grantee is EITHER this user (`rs_grantee_kind='user'` AND
|
|
20
|
+
* `rs_grantee_uuid = principalId`) OR one of the principal's roles
|
|
21
|
+
* (`rs_grantee_kind='role'` AND `rs_grantee_uuid IN (roleIds)`);
|
|
22
|
+
* • the action matches exactly (`rs_action = action`) OR the row is all-actions
|
|
23
|
+
* (`rs_action = '*'`).
|
|
24
|
+
* ≥1 matching row ⇒ `true`. No matching row ⇒ `false` (a NORMAL data-absence — no
|
|
25
|
+
* share — NOT an error, NOT a lockout: the owner baseline / admin rules still govern).
|
|
26
|
+
*
|
|
27
|
+
* ── TENANT ISOLATION (design §6 "cross-tenant share impossible"): the {@link RecordSharePort}
|
|
28
|
+
* contract does NOT carry a tenant id (core evaluates same-tenant shares only — a
|
|
29
|
+
* cross-tenant reach is governed by the SEPARATE crossTenantGrant port, not a share).
|
|
30
|
+
* Isolation is therefore enforced at BOTH ends structurally:
|
|
31
|
+
* 1. WRITE-TIME (the authoritative bound, {@link RecordShareWriter}): a share row can
|
|
32
|
+
* ONLY be created for a record the sharer OWNS in the sharer's OWN tenant, and the
|
|
33
|
+
* row is stamped with that tenant (`rs_tenant_uuid`). A share for a foreign tenant's
|
|
34
|
+
* record is impossible to create.
|
|
35
|
+
* 2. READ-TIME (here): `rs_record_uuid` is a GLOBALLY-UNIQUE char36 record uuid — it
|
|
36
|
+
* identifies exactly one record in exactly one tenant. Matching on
|
|
37
|
+
* `(rs_type_key, rs_record_uuid)` therefore selects that one tenant's row by
|
|
38
|
+
* construction; a tenant-A share row can never match a tenant-B record because the
|
|
39
|
+
* two records have DIFFERENT uuids. The record uuid IS the tenant discriminator.
|
|
40
|
+
* (A future core-contract change that threads the resource tenant into `hasShare` would
|
|
41
|
+
* let this add an explicit `rs_tenant_uuid = ?` bound too — flagged for the architect;
|
|
42
|
+
* it is a defense-in-depth ADD, not a correctness fix, given uuid uniqueness.)
|
|
43
|
+
*
|
|
44
|
+
* ── EMPTY roleIds: guarded — an empty role list must NOT emit `IN ()` (a MariaDB syntax
|
|
45
|
+
* error). We omit the role branch entirely when there are no roles, so a role-less
|
|
46
|
+
* principal is matched by its user-share rows alone.
|
|
47
|
+
*
|
|
48
|
+
* ── ADR-0016 (transient-vs-deny): a genuine store outage THROWS
|
|
49
|
+
* {@link AuthorizationUnavailableError} (→ 503 retryable) — NEVER a silent `false` that
|
|
50
|
+
* would mask an outage as "no share".
|
|
51
|
+
*
|
|
52
|
+
* ── SAFETY: every value (typeKey, recordId, principalId, each roleId, action) is BOUND as
|
|
53
|
+
* a `?` param — NEVER interpolated. A SQL payload in typeKey/recordId is data, not SQL.
|
|
54
|
+
* Mirrors {@link MariaDbRecordPermissionRuleAdapter}.
|
|
55
|
+
*/
|
|
56
|
+
import type { RecordSharePort } from '@astram/core';
|
|
57
|
+
/** The DI token the adapter is bound under (parity with the rule-port token). */
|
|
58
|
+
export declare const SHIELD_RECORD_SHARE_PORT: unique symbol;
|
|
59
|
+
/** The minimal query surface this adapter needs — a `mariadb` Pool satisfies it. */
|
|
60
|
+
export interface ShareQueryable {
|
|
61
|
+
query(sql: string, params?: readonly unknown[]): Promise<unknown>;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The concrete per-record share adapter. Answers `hasShare` from `record_share`, matching
|
|
65
|
+
* user- and role-granted shares with exact-or-wildcard action.
|
|
66
|
+
*/
|
|
67
|
+
export declare class MariaDbRecordShareAdapter implements RecordSharePort {
|
|
68
|
+
private readonly db;
|
|
69
|
+
private readonly schema;
|
|
70
|
+
constructor(db: ShareQueryable, opts?: {
|
|
71
|
+
schema?: string;
|
|
72
|
+
});
|
|
73
|
+
hasShare(principalId: string, roleIds: readonly string[], typeKey: string, recordId: string, action: string): Promise<boolean>;
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=mariadb-record-share-adapter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mariadb-record-share-adapter.d.ts","sourceRoot":"","sources":["../../src/authz/mariadb-record-share-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAGpD,iFAAiF;AACjF,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAK3E,oFAAoF;AACpF,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAOD;;;GAGG;AACH,qBAAa,yBAA0B,YAAW,eAAe;IAC/D,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAiB;IACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;gBAEpB,EAAE,EAAE,cAAc,EAAE,IAAI,GAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAO;IAKxD,QAAQ,CACZ,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,OAAO,CAAC;CAqCpB"}
|