@astram/guard 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/auth/account.controller.d.ts.map +1 -1
  2. package/dist/auth/account.controller.js +3 -0
  3. package/dist/auth/auth.controller.d.ts +90 -1
  4. package/dist/auth/auth.controller.d.ts.map +1 -1
  5. package/dist/auth/auth.controller.js +245 -7
  6. package/dist/auth/auth.module.d.ts +21 -0
  7. package/dist/auth/auth.module.d.ts.map +1 -1
  8. package/dist/auth/auth.module.js +28 -0
  9. package/dist/auth/dto.d.ts +32 -0
  10. package/dist/auth/dto.d.ts.map +1 -1
  11. package/dist/auth/ports/membership-checker.d.ts +41 -0
  12. package/dist/auth/ports/membership-checker.d.ts.map +1 -0
  13. package/dist/auth/ports/membership-checker.js +32 -0
  14. package/dist/auth/ports/scope-subtree-resolver.d.ts +48 -0
  15. package/dist/auth/ports/scope-subtree-resolver.d.ts.map +1 -0
  16. package/dist/auth/ports/scope-subtree-resolver.js +38 -0
  17. package/dist/auth/tokens.d.ts +25 -0
  18. package/dist/auth/tokens.d.ts.map +1 -1
  19. package/dist/auth/tokens.js +26 -1
  20. package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
  21. package/dist/authentication/mariadb-profile-writer.js +10 -1
  22. package/dist/authz/list-scope-sql.d.ts +125 -0
  23. package/dist/authz/list-scope-sql.d.ts.map +1 -0
  24. package/dist/authz/list-scope-sql.js +477 -0
  25. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts +28 -0
  26. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -1
  27. package/dist/authz/mariadb-cross-tenant-grant-adapter.js +49 -0
  28. package/dist/authz/mariadb-list-scope-adapter.d.ts +61 -0
  29. package/dist/authz/mariadb-list-scope-adapter.d.ts.map +1 -0
  30. package/dist/authz/mariadb-list-scope-adapter.js +157 -0
  31. package/dist/authz/mariadb-policy-store.d.ts +118 -0
  32. package/dist/authz/mariadb-policy-store.d.ts.map +1 -0
  33. package/dist/authz/mariadb-policy-store.js +225 -0
  34. package/dist/authz/mariadb-record-rule-adapter.d.ts +7 -3
  35. package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -1
  36. package/dist/authz/mariadb-record-rule-adapter.js +156 -13
  37. package/dist/authz/record-share-writer.d.ts +22 -0
  38. package/dist/authz/record-share-writer.d.ts.map +1 -1
  39. package/dist/authz/record-share-writer.js +41 -0
  40. package/dist/authz/wire-record-authorization.d.ts +35 -1
  41. package/dist/authz/wire-record-authorization.d.ts.map +1 -1
  42. package/dist/authz/wire-record-authorization.js +75 -0
  43. package/dist/identity/identity-actions.d.ts +7 -0
  44. package/dist/identity/identity-actions.d.ts.map +1 -1
  45. package/dist/identity/identity-actions.js +8 -1
  46. package/dist/index.d.ts +7 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +47 -3
  49. package/dist/module/shield.module.d.ts +33 -0
  50. package/dist/module/shield.module.d.ts.map +1 -1
  51. package/dist/module/shield.module.js +12 -0
  52. package/dist/request-auth/request-principal.d.ts.map +1 -1
  53. package/dist/request-auth/request-principal.js +12 -0
  54. package/dist/request-auth/session-token-binder.d.ts +35 -1
  55. package/dist/request-auth/session-token-binder.d.ts.map +1 -1
  56. package/dist/request-auth/session-token-binder.js +123 -8
  57. package/dist/scope/closure-builder.d.ts +75 -0
  58. package/dist/scope/closure-builder.d.ts.map +1 -0
  59. package/dist/scope/closure-builder.js +134 -0
  60. package/migrations/1710000000009-CreateRecordPermissionRule.cjs +56 -0
  61. package/migrations/1710000000014-CreateRecordShare.cjs +49 -0
  62. package/migrations/1710000000015-CreateScopeClosure.cjs +46 -0
  63. package/migrations/1710000000016-AlterCompanyParentFkRestrict.cjs +71 -0
  64. package/package.json +3 -3
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ /**
3
+ * `MariaDbListScopeAdapter` — the concrete {@link ListScopePort} for `@astram/guard`,
4
+ * backed by the SAME `record_permission_rule` table the record-permit rule adapter
5
+ * ({@link MariaDbRecordPermissionRuleAdapter}) enforces. This is the LIST-form sibling
6
+ * to that adapter's `evaluate`: where `evaluate` folds the rules into an allow/deny
7
+ * verdict for ONE record, this projects them into the abstract allow/deny
8
+ * {@link ScopeCondition} GROUPS that core's `computeListScope` hands the guard translator
9
+ * ({@link translateListScope}) to push into a collection's WHERE (Invariant #8).
10
+ *
11
+ * ── WHY HERE, NOT CORE (ADR-0003): the `mariadb` driver is a real runtime dependency;
12
+ * core defines only the {@link ListScopePort}. This adapter is INJECTED into the wired
13
+ * list-scope computation. It uses `@astram/core` TYPES only (erased).
14
+ *
15
+ * ── THE SELECTION (identical to the rule adapter's `evaluate`): rows for
16
+ * `(rpr_type_key = ? AND (rpr_tenant_uuid = ? OR rpr_tenant_uuid IS NULL)
17
+ * AND (rpr_action = ? OR rpr_action = '*'))` — the exact-tenant OR global-NULL rule
18
+ * and the exact-action OR `*` wildcard, all values BOUND as `?` (never interpolated).
19
+ * NO OR-IS-NULL escalation of a tenant-scoped rule beyond that global-baseline match.
20
+ *
21
+ * ── THE GROUPING (Phase-0 / Task-0.3, mirrors both the rule adapter's `evaluate` and the
22
+ * policy store's `listPolicies`): rows sharing a non-null `rpr_policy_uuid` form ONE
23
+ * AND-group (a guided policy's N AND-composed conditions); a NULL-uuid row is its OWN
24
+ * singleton group (legacy single-row rule — an OR term, never merged). Allow-effect
25
+ * groups → `allow: ScopeCondition[][]`; deny-effect groups → `deny: ScopeCondition[][]`.
26
+ * A NULL-field row contributes NO condition — an all-`TRUE` (unconditional) group,
27
+ * which the translator renders as `(TRUE)` (a valid "allow/deny all of this type" rule).
28
+ *
29
+ * ── VALUE FIDELITY: `rpr_value` is read back as its stored VARCHAR string — the SAME form
30
+ * the enforcement adapter's `conditionHolds` sees (it always coerces via `String(...)`).
31
+ * For `in`/`not_in` this string is the canonical comma form the translator splits on `,`,
32
+ * so the emitted `IN (...)` list equals the adapter's `String(rpr_value).split(',')` set
33
+ * (the Phase-1 review's serialization obligation — Decision B in the task report).
34
+ *
35
+ * ── FAIL-CLOSED (ADR-0016 / Invariant #8): a genuine store outage THROWS
36
+ * {@link AuthorizationUnavailableError} — NEVER an empty `{allow:[],deny:[]}` that would
37
+ * silently unbound/mis-scope the list (an unbounded list is a leak). An empty result is
38
+ * returned ONLY for a genuine data-absence (no applicable rule), NEVER to mask an outage.
39
+ *
40
+ * ── SAFETY: parameterized SQL only; schema `shield_authz`. Mirrors
41
+ * {@link MariaDbRecordPermissionRuleAdapter}.
42
+ */
43
+ Object.defineProperty(exports, "__esModule", { value: true });
44
+ exports.MariaDbListScopeAdapter = exports.SHIELD_LIST_SCOPE_PORT = void 0;
45
+ const core_1 = require("@astram/core");
46
+ /** The DI token the adapter is bound under (parity with the rule-port token). */
47
+ exports.SHIELD_LIST_SCOPE_PORT = Symbol('SHIELD_LIST_SCOPE_PORT');
48
+ /** Schema the `record_permission_rule` table lives in (framework migration ...009). */
49
+ const DEFAULT_SCHEMA = 'shield_authz';
50
+ /**
51
+ * Whether a rule group's SUBJECT matches the requesting principal — the EXACT mirror of the
52
+ * per-record rule adapter's `subjectMatches` ({@link MariaDbRecordPermissionRuleAdapter}):
53
+ * • `everyone` → always matches.
54
+ * • `role` → matches IFF the principal HOLDS the named role
55
+ * (`subject.roleIds.includes(rpr_subject_value)`, value non-null).
56
+ * • any other / unknown kind → does NOT match (fail-closed).
57
+ * A group's subject is consistent across its rows (the store writes one subject per policy),
58
+ * so it is read from the group's HEAD row (like the effect). A group that FAILS the subject
59
+ * match contributes to NEITHER allow NOR deny — it is simply not this principal's rule (a
60
+ * role-subjected DENY not aimed at the principal must NOT over-restrict them, the symmetric
61
+ * divergence — so BOTH sides are filtered identically).
62
+ */
63
+ function subjectMatches(head, subject) {
64
+ const kind = head.rpr_subject_kind;
65
+ if (kind === 'everyone')
66
+ return true;
67
+ if (kind === 'role') {
68
+ return head.rpr_subject_value !== null && subject.roleIds.includes(head.rpr_subject_value);
69
+ }
70
+ // Unknown subject kind → does NOT match (fail-closed).
71
+ return false;
72
+ }
73
+ /**
74
+ * The concrete list-scope adapter. Reads the ADMIN rules for `(tenant, type_key, action|*)`
75
+ * and projects them into allow/deny {@link ScopeCondition} AND-groups for the translator.
76
+ */
77
+ class MariaDbListScopeAdapter {
78
+ db;
79
+ schema;
80
+ constructor(db, opts = {}) {
81
+ this.db = db;
82
+ this.schema = opts.schema ?? DEFAULT_SCHEMA;
83
+ }
84
+ async listPolicyConditions(typeKey, tenantNodeIds, action, subject) {
85
+ // The caller's reachable subtree (active node + descendants, ADR-0027). A rule scoped to
86
+ // ANY node in the subtree applies — critically a DENY at a descendant node excludes that
87
+ // node's records for a parent admin (deny-wins inherits down). Plus global (NULL-tenant)
88
+ // rules. Empty set (shouldn't happen — always ≥ the active node) → only global rules.
89
+ const nodes = tenantNodeIds.length > 0 ? tenantNodeIds : [];
90
+ const tenantClause = nodes.length > 0
91
+ ? `(rpr_tenant_uuid IN (${nodes.map(() => '?').join(', ')}) OR rpr_tenant_uuid IS NULL)`
92
+ : `rpr_tenant_uuid IS NULL`;
93
+ let rows;
94
+ try {
95
+ rows = (await this.db.query(`SELECT rpr_policy_uuid, rpr_effect, rpr_subject_kind, rpr_subject_value, rpr_field, rpr_operator, rpr_value
96
+ FROM \`${this.schema}\`.\`record_permission_rule\`
97
+ WHERE rpr_type_key = ?
98
+ AND ${tenantClause}
99
+ AND (rpr_action = ? OR rpr_action = '*')`, [typeKey, ...nodes, action]));
100
+ }
101
+ catch (err) {
102
+ // Genuine store outage — fail closed as retryable (never mask as an empty scope,
103
+ // which would silently unbound the list — a leak). ADR-0016 / Invariant #8.
104
+ throw new core_1.AuthorizationUnavailableError(`record_permission_rule store unavailable for ` +
105
+ `${typeKey}:${action} (list scope): ${err.message}`);
106
+ }
107
+ // ── AND-GROUPING BY rpr_policy_uuid (mirrors evaluate / listPolicies) ─────────
108
+ // Rows sharing a non-null policy uuid form ONE group (its conditions AND-composed);
109
+ // every NULL-policy row is a distinct singleton group (keyed by a unique sentinel).
110
+ // Preserve first-seen order for stable, testable output.
111
+ const groups = new Map();
112
+ let nullGroupSeq = 0;
113
+ for (const row of rows) {
114
+ const key = row.rpr_policy_uuid != null // null OR undefined ⇒ legacy single-row rule
115
+ ? `p:${row.rpr_policy_uuid}`
116
+ : `n:${nullGroupSeq++}`; // each legacy row → its own singleton group
117
+ const bucket = groups.get(key);
118
+ if (bucket)
119
+ bucket.push(row);
120
+ else
121
+ groups.set(key, [row]);
122
+ }
123
+ const allow = [];
124
+ const deny = [];
125
+ for (const groupRows of groups.values()) {
126
+ const head = groupRows[0];
127
+ if (head === undefined)
128
+ continue; // a group always has ≥1 row; guard for the type
129
+ // SUBJECT FILTER (HIGH-1) — the whole group is gated by its subject BEFORE its effect,
130
+ // EXACTLY as decide()'s per-record `subjectMatches` gates the group before its effect.
131
+ // A group whose subject does NOT match this principal contributes to NEITHER allow NOR
132
+ // deny (it is not this principal's rule). Applied identically to allow AND deny so a
133
+ // role-subjected deny not aimed at the principal cannot over-restrict them.
134
+ if (!subjectMatches(head, subject))
135
+ continue;
136
+ // A NULL-field row contributes NO condition (unconditional). A group of only such
137
+ // rows → an EMPTY condition list, which the translator renders as `(TRUE)` — a
138
+ // valid "allow/deny all of this type" rule.
139
+ const conditions = groupRows
140
+ .filter((r) => r.rpr_field !== null)
141
+ .map((r) => ({
142
+ field: r.rpr_field,
143
+ operator: r.rpr_operator,
144
+ value: r.rpr_value, // the stored VARCHAR string (canonical form; comma-string for `in`)
145
+ }));
146
+ // The group's effect is consistent across its rows (the store writes one effect per
147
+ // policy); take it from the first row. A non-'allow'/'deny' effect contributes to
148
+ // NEITHER side (fail-closed — no scope inferred from a malformed effect).
149
+ if (head.rpr_effect === 'deny')
150
+ deny.push(conditions);
151
+ else if (head.rpr_effect === 'allow')
152
+ allow.push(conditions);
153
+ }
154
+ return { allow, deny };
155
+ }
156
+ }
157
+ exports.MariaDbListScopeAdapter = MariaDbListScopeAdapter;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `MariaDbPolicyStore` — the concrete {@link PolicyStore} (core admin port) for
3
+ * `@astram/guard`, backed by the SAME `record_permission_rule` table the read
4
+ * adapter ({@link MariaDbRecordPermissionRuleAdapter}) enforces. This is the
5
+ * AUTHOR half of **author-what-enforces**: a guided policy the admin builder
6
+ * produces is persisted here as EXACTLY the rows the enforcement adapter reads —
7
+ * no lossy round-trip, no divergent representation (List-Scoping Engine plan Task 0.3).
8
+ *
9
+ * ── THE SEMANTIC GAP THIS CLOSES (architect-identified):
10
+ * A {@link GuidedPolicy} has N conditions that are **AND-composed** (ALL must
11
+ * hold for the policy to apply). But `record_permission_rule` is
12
+ * ONE-CONDITION-PER-ROW. If a 2-condition policy were written as 2 INDEPENDENT
13
+ * rows, the read adapter's per-row fold would allow when EITHER holds — an OR,
14
+ * over-widening the policy (a security bug). The fix is a GROUP id:
15
+ * `rpr_policy_uuid`. All N condition-rows of one policy SHARE one generated
16
+ * `rpr_policy_uuid`, and the read adapter's fold (extended in the same task)
17
+ * AND-groups by it — a policy applies IFF ALL its rows hold. This store writes
18
+ * that group id; the adapter honors it. The two halves agree by construction.
19
+ *
20
+ * ── THE ROW MAPPING (one GuidedPolicy → N record_permission_rule rows):
21
+ * • `rpr_uuid` — a fresh per-row UUID (crypto.randomUUID; NEVER a SQL
22
+ * `UUID()` per row — that would give each row a different
23
+ * id and, for the GROUP id, break the sharing).
24
+ * • `rpr_policy_uuid` — ONE generated UUID shared by ALL of this policy's rows
25
+ * (the group id — the `Policy.policyId` returned).
26
+ * • `rpr_tenant_uuid` — the owning tenant (storage scope; server-stamped by the
27
+ * command engine before calling — never client input).
28
+ * • `rpr_type_key` — the policy's `typeKey` (same on every row).
29
+ * • `rpr_action` — the policy's `action` (same on every row).
30
+ * • `rpr_effect` — the policy's `effect` ('allow' | 'deny'; same every row).
31
+ * • `rpr_subject_kind` — always `'everyone'` (v1 mapping — a GuidedPolicy carries
32
+ * NO role subject; role-subjected policies are a future
33
+ * extension. See recon C gap #5 / Task 0.3).
34
+ * • `rpr_subject_value`— always NULL (no role subject in v1).
35
+ * • `rpr_field` — the condition's `field` (NULL for a 0-condition policy).
36
+ * • `rpr_operator` — the condition's operator, persisted with core's CANONICAL
37
+ * name (`eq`, `ne`, `is_owner`, …). The read adapter
38
+ * dual-accepts legacy names, but the store writes canonical.
39
+ * • `rpr_value` — the condition's operand serialized to its string form,
40
+ * OR NULL for an operand-less operator (`is_null`,
41
+ * `is_owner`, … — the adapter ignores the operand for those,
42
+ * so NULL round-trips faithfully as `value: null`).
43
+ *
44
+ * A ZERO-condition policy → exactly ONE row with `rpr_field`/`rpr_operator`/
45
+ * `rpr_value` all NULL. The read adapter treats a NULL-field row as
46
+ * UNCONDITIONAL (always-holds) — verified against `conditionHolds` (`row.rpr_field
47
+ * === null ⇒ return true`). So an unconditional policy enforces as "applies
48
+ * whenever subject+action match", exactly its authored intent.
49
+ *
50
+ * ── VALUE FIDELITY (documented): `rpr_value` is a VARCHAR column, so a condition's
51
+ * `value` is stored as its STRING form and read back as a string. This is the
52
+ * SAME form the enforcement adapter sees (`conditionHolds` always coerces via
53
+ * `String(...)` / `Number(...)`), so author == enforce holds. A condition
54
+ * authored with `value: 50` round-trips as `value: '50'`; both evaluate
55
+ * identically at enforcement (the adapter numeric-parses ordered compares). An
56
+ * operand-less operator's `value` round-trips as `null` (its operand is ignored
57
+ * everywhere). Round-trip fidelity is thus preserved AT THE ENFORCEMENT LAYER —
58
+ * the only fidelity that matters for author-what-enforces.
59
+ *
60
+ * ── SAFETY: every value (tenant, type_key, action, effect, field, operator, value,
61
+ * both uuids) is BOUND as a `?` param — NEVER interpolated. A SQL payload in any
62
+ * field is data, not SQL. No hard-coded UUIDs — ids are minted at runtime via
63
+ * `crypto.randomUUID()`. Mirrors {@link MariaDbRecordPermissionRuleAdapter}.
64
+ *
65
+ * ── ADR-0003: the `mariadb` driver is a real runtime dependency; core defines only
66
+ * the {@link PolicyStore} PORT. This adapter uses `@astram/core` TYPES only (erased).
67
+ */
68
+ import type { GuidedPolicy, Policy, PolicyStore } from '@astram/core';
69
+ /** The DI token the policy store is bound under (parity with the rule-port token). */
70
+ export declare const SHIELD_POLICY_STORE: unique symbol;
71
+ /** The minimal query surface this store needs — a `mariadb` Pool satisfies it. */
72
+ export interface RuleQueryable {
73
+ query(sql: string, params?: readonly unknown[]): Promise<unknown>;
74
+ }
75
+ /**
76
+ * The concrete guided-policy store. Persists a {@link GuidedPolicy} as N
77
+ * `record_permission_rule` rows sharing one `rpr_policy_uuid`, reads them back
78
+ * grouped by that id, and deletes a policy by it.
79
+ */
80
+ export declare class MariaDbPolicyStore implements PolicyStore {
81
+ private readonly db;
82
+ private readonly schema;
83
+ constructor(db: RuleQueryable, opts?: {
84
+ schema?: string;
85
+ });
86
+ /**
87
+ * Persist a guided policy as N `record_permission_rule` rows (ONE row for a
88
+ * 0-condition policy), all sharing a freshly-minted `rpr_policy_uuid` group id.
89
+ * Returns the {@link Policy} with `policyId = rpr_policy_uuid`. The engine has
90
+ * already VALIDATED the structure (AC 5) before this is called.
91
+ */
92
+ savePolicy(tenantId: string, policy: GuidedPolicy): Promise<Policy>;
93
+ /**
94
+ * List the policies governing `typeKey` in `tenantId`, GROUPED by
95
+ * `rpr_policy_uuid` — each group's rows reconstruct one {@link GuidedPolicy}
96
+ * (its conditions = its rows, in row order). Returns `[]` when none exist (NORMAL).
97
+ */
98
+ listPolicies(typeKey: string, tenantId: string): Promise<readonly Policy[]>;
99
+ /**
100
+ * Delete the policy identified by `policyId` — ALL its condition-rows sharing
101
+ * that `rpr_policy_uuid`. Idempotent: a second delete of an absent policy affects
102
+ * zero rows (a no-op, NOT an error).
103
+ */
104
+ deletePolicy(policyId: string): Promise<void>;
105
+ /**
106
+ * TENANT-SCOPED delete (the authoring-surface delete path). Removes the policy's rows ONLY
107
+ * when they belong to `tenantId` — so a tenant admin can NEVER delete another tenant's policy
108
+ * by guessing/replaying its id (the un-scoped {@link deletePolicy} would). Returns the number
109
+ * of rows removed (0 ⇒ absent OR not this tenant's — idempotent, and the caller can surface a
110
+ * 404 rather than silently "succeeding" on someone else's policy). Idempotent: a second call
111
+ * removes zero rows.
112
+ *
113
+ * ── SAFETY: both `rpr_policy_uuid` + `rpr_tenant_uuid` are `?`-bound. The tenant is the
114
+ * SERVER-resolved actor tenant (never client input) — see the PolicyService.
115
+ */
116
+ deletePolicyForTenant(policyId: string, tenantId: string): Promise<number>;
117
+ }
118
+ //# sourceMappingURL=mariadb-policy-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mariadb-policy-store.d.ts","sourceRoot":"","sources":["../../src/authz/mariadb-policy-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkEG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,EAAE,WAAW,EAAmB,MAAM,cAAc,CAAC;AAEvF,sFAAsF;AACtF,eAAO,MAAM,mBAAmB,eAAgC,CAAC;AAKjE,kFAAkF;AAClF,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAgCD;;;;GAIG;AACH,qBAAa,kBAAmB,YAAW,WAAW;IACpD,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;IAK7D;;;;;OAKG;IACG,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC;IA4CzE;;;;OAIG;IACG,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC;IAiDjF;;;;OAIG;IACG,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQnD;;;;;;;;;;OAUG;IACG,qBAAqB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CASjF"}
@@ -0,0 +1,225 @@
1
+ "use strict";
2
+ /**
3
+ * `MariaDbPolicyStore` — the concrete {@link PolicyStore} (core admin port) for
4
+ * `@astram/guard`, backed by the SAME `record_permission_rule` table the read
5
+ * adapter ({@link MariaDbRecordPermissionRuleAdapter}) enforces. This is the
6
+ * AUTHOR half of **author-what-enforces**: a guided policy the admin builder
7
+ * produces is persisted here as EXACTLY the rows the enforcement adapter reads —
8
+ * no lossy round-trip, no divergent representation (List-Scoping Engine plan Task 0.3).
9
+ *
10
+ * ── THE SEMANTIC GAP THIS CLOSES (architect-identified):
11
+ * A {@link GuidedPolicy} has N conditions that are **AND-composed** (ALL must
12
+ * hold for the policy to apply). But `record_permission_rule` is
13
+ * ONE-CONDITION-PER-ROW. If a 2-condition policy were written as 2 INDEPENDENT
14
+ * rows, the read adapter's per-row fold would allow when EITHER holds — an OR,
15
+ * over-widening the policy (a security bug). The fix is a GROUP id:
16
+ * `rpr_policy_uuid`. All N condition-rows of one policy SHARE one generated
17
+ * `rpr_policy_uuid`, and the read adapter's fold (extended in the same task)
18
+ * AND-groups by it — a policy applies IFF ALL its rows hold. This store writes
19
+ * that group id; the adapter honors it. The two halves agree by construction.
20
+ *
21
+ * ── THE ROW MAPPING (one GuidedPolicy → N record_permission_rule rows):
22
+ * • `rpr_uuid` — a fresh per-row UUID (crypto.randomUUID; NEVER a SQL
23
+ * `UUID()` per row — that would give each row a different
24
+ * id and, for the GROUP id, break the sharing).
25
+ * • `rpr_policy_uuid` — ONE generated UUID shared by ALL of this policy's rows
26
+ * (the group id — the `Policy.policyId` returned).
27
+ * • `rpr_tenant_uuid` — the owning tenant (storage scope; server-stamped by the
28
+ * command engine before calling — never client input).
29
+ * • `rpr_type_key` — the policy's `typeKey` (same on every row).
30
+ * • `rpr_action` — the policy's `action` (same on every row).
31
+ * • `rpr_effect` — the policy's `effect` ('allow' | 'deny'; same every row).
32
+ * • `rpr_subject_kind` — always `'everyone'` (v1 mapping — a GuidedPolicy carries
33
+ * NO role subject; role-subjected policies are a future
34
+ * extension. See recon C gap #5 / Task 0.3).
35
+ * • `rpr_subject_value`— always NULL (no role subject in v1).
36
+ * • `rpr_field` — the condition's `field` (NULL for a 0-condition policy).
37
+ * • `rpr_operator` — the condition's operator, persisted with core's CANONICAL
38
+ * name (`eq`, `ne`, `is_owner`, …). The read adapter
39
+ * dual-accepts legacy names, but the store writes canonical.
40
+ * • `rpr_value` — the condition's operand serialized to its string form,
41
+ * OR NULL for an operand-less operator (`is_null`,
42
+ * `is_owner`, … — the adapter ignores the operand for those,
43
+ * so NULL round-trips faithfully as `value: null`).
44
+ *
45
+ * A ZERO-condition policy → exactly ONE row with `rpr_field`/`rpr_operator`/
46
+ * `rpr_value` all NULL. The read adapter treats a NULL-field row as
47
+ * UNCONDITIONAL (always-holds) — verified against `conditionHolds` (`row.rpr_field
48
+ * === null ⇒ return true`). So an unconditional policy enforces as "applies
49
+ * whenever subject+action match", exactly its authored intent.
50
+ *
51
+ * ── VALUE FIDELITY (documented): `rpr_value` is a VARCHAR column, so a condition's
52
+ * `value` is stored as its STRING form and read back as a string. This is the
53
+ * SAME form the enforcement adapter sees (`conditionHolds` always coerces via
54
+ * `String(...)` / `Number(...)`), so author == enforce holds. A condition
55
+ * authored with `value: 50` round-trips as `value: '50'`; both evaluate
56
+ * identically at enforcement (the adapter numeric-parses ordered compares). An
57
+ * operand-less operator's `value` round-trips as `null` (its operand is ignored
58
+ * everywhere). Round-trip fidelity is thus preserved AT THE ENFORCEMENT LAYER —
59
+ * the only fidelity that matters for author-what-enforces.
60
+ *
61
+ * ── SAFETY: every value (tenant, type_key, action, effect, field, operator, value,
62
+ * both uuids) is BOUND as a `?` param — NEVER interpolated. A SQL payload in any
63
+ * field is data, not SQL. No hard-coded UUIDs — ids are minted at runtime via
64
+ * `crypto.randomUUID()`. Mirrors {@link MariaDbRecordPermissionRuleAdapter}.
65
+ *
66
+ * ── ADR-0003: the `mariadb` driver is a real runtime dependency; core defines only
67
+ * the {@link PolicyStore} PORT. This adapter uses `@astram/core` TYPES only (erased).
68
+ */
69
+ Object.defineProperty(exports, "__esModule", { value: true });
70
+ exports.MariaDbPolicyStore = exports.SHIELD_POLICY_STORE = void 0;
71
+ const node_crypto_1 = require("node:crypto");
72
+ /** The DI token the policy store is bound under (parity with the rule-port token). */
73
+ exports.SHIELD_POLICY_STORE = Symbol('SHIELD_POLICY_STORE');
74
+ /** Schema the `record_permission_rule` table lives in (framework migration ...009). */
75
+ const DEFAULT_SCHEMA = 'shield_authz';
76
+ /**
77
+ * The operators that take NO operand (the read adapter ignores `rpr_value` for
78
+ * these). Their conditions persist `rpr_value = NULL` so a round-trip yields the
79
+ * authored `value` back as `null` (never a spurious serialized operand).
80
+ */
81
+ const OPERAND_LESS_OPERATORS = new Set([
82
+ 'is_null',
83
+ 'is_not_null',
84
+ 'is_owner',
85
+ 'is_not_owner',
86
+ ]);
87
+ /** Serialize a condition operand to the VARCHAR form the enforcement adapter reads. */
88
+ function serializeValue(condition) {
89
+ if (OPERAND_LESS_OPERATORS.has(condition.operator))
90
+ return null;
91
+ const { value } = condition;
92
+ if (value === null || value === undefined)
93
+ return null;
94
+ return String(value);
95
+ }
96
+ /**
97
+ * The concrete guided-policy store. Persists a {@link GuidedPolicy} as N
98
+ * `record_permission_rule` rows sharing one `rpr_policy_uuid`, reads them back
99
+ * grouped by that id, and deletes a policy by it.
100
+ */
101
+ class MariaDbPolicyStore {
102
+ db;
103
+ schema;
104
+ constructor(db, opts = {}) {
105
+ this.db = db;
106
+ this.schema = opts.schema ?? DEFAULT_SCHEMA;
107
+ }
108
+ /**
109
+ * Persist a guided policy as N `record_permission_rule` rows (ONE row for a
110
+ * 0-condition policy), all sharing a freshly-minted `rpr_policy_uuid` group id.
111
+ * Returns the {@link Policy} with `policyId = rpr_policy_uuid`. The engine has
112
+ * already VALIDATED the structure (AC 5) before this is called.
113
+ */
114
+ async savePolicy(tenantId, policy) {
115
+ // ONE generated group id shared by every row of this policy (the AND-group key).
116
+ const policyUuid = (0, node_crypto_1.randomUUID)();
117
+ // A 0-condition policy → ONE unconditional row (field/operator/value NULL).
118
+ // Otherwise one row per AND-composed condition.
119
+ const rows = policy.conditions.length === 0
120
+ ? [{ field: null, operator: null, value: null }]
121
+ : policy.conditions.map((c) => ({
122
+ field: c.field,
123
+ operator: c.operator, // canonical core name (eq/ne/is_owner/…)
124
+ value: serializeValue(c),
125
+ }));
126
+ for (const row of rows) {
127
+ await this.db.query(`INSERT INTO \`${this.schema}\`.\`record_permission_rule\`
128
+ (rpr_uuid, rpr_policy_uuid, rpr_tenant_uuid, rpr_type_key, rpr_action,
129
+ rpr_effect, rpr_subject_kind, rpr_subject_value, rpr_field, rpr_operator, rpr_value)
130
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
131
+ (0, node_crypto_1.randomUUID)(), // rpr_uuid — a fresh per-row id
132
+ policyUuid, // rpr_policy_uuid — the SHARED group id
133
+ tenantId, // rpr_tenant_uuid
134
+ policy.typeKey, // rpr_type_key
135
+ policy.action, // rpr_action
136
+ policy.effect, // rpr_effect
137
+ 'everyone', // rpr_subject_kind — v1 mapping (no role subject)
138
+ null, // rpr_subject_value
139
+ row.field, // rpr_field
140
+ row.operator, // rpr_operator
141
+ row.value, // rpr_value
142
+ ]);
143
+ }
144
+ return { policyId: policyUuid, tenantId, policy };
145
+ }
146
+ /**
147
+ * List the policies governing `typeKey` in `tenantId`, GROUPED by
148
+ * `rpr_policy_uuid` — each group's rows reconstruct one {@link GuidedPolicy}
149
+ * (its conditions = its rows, in row order). Returns `[]` when none exist (NORMAL).
150
+ */
151
+ async listPolicies(typeKey, tenantId) {
152
+ const rows = (await this.db.query(`SELECT rpr_policy_uuid, rpr_action, rpr_effect, rpr_field, rpr_operator, rpr_value
153
+ FROM \`${this.schema}\`.\`record_permission_rule\`
154
+ WHERE rpr_type_key = ?
155
+ AND rpr_tenant_uuid = ?
156
+ ORDER BY rpr_policy_uuid, rpr_created_at, rpr_uuid`, [typeKey, tenantId]));
157
+ // Group by rpr_policy_uuid, preserving first-seen order.
158
+ const groups = new Map();
159
+ for (const row of rows) {
160
+ // A policy authored via this store always has a group id; a legacy
161
+ // single-row rule (rpr_policy_uuid NULL) is not a store-authored policy, so
162
+ // it is not surfaced as a GuidedPolicy here (the read adapter still enforces
163
+ // it independently).
164
+ if (row.rpr_policy_uuid === null)
165
+ continue;
166
+ const bucket = groups.get(row.rpr_policy_uuid);
167
+ if (bucket)
168
+ bucket.push(row);
169
+ else
170
+ groups.set(row.rpr_policy_uuid, [row]);
171
+ }
172
+ const policies = [];
173
+ for (const [policyId, groupRows] of groups) {
174
+ const first = groupRows[0];
175
+ if (first === undefined)
176
+ continue; // a group always has ≥1 row; guard for the type
177
+ const conditions = groupRows
178
+ // an unconditional (NULL-field) row contributes NO condition
179
+ .filter((r) => r.rpr_field !== null)
180
+ .map((r) => ({
181
+ field: r.rpr_field,
182
+ operator: r.rpr_operator,
183
+ value: r.rpr_value,
184
+ }));
185
+ policies.push({
186
+ policyId,
187
+ tenantId,
188
+ policy: {
189
+ typeKey,
190
+ action: first.rpr_action ?? '',
191
+ effect: (first.rpr_effect === 'deny' ? 'deny' : 'allow'),
192
+ conditions,
193
+ },
194
+ });
195
+ }
196
+ return policies;
197
+ }
198
+ /**
199
+ * Delete the policy identified by `policyId` — ALL its condition-rows sharing
200
+ * that `rpr_policy_uuid`. Idempotent: a second delete of an absent policy affects
201
+ * zero rows (a no-op, NOT an error).
202
+ */
203
+ async deletePolicy(policyId) {
204
+ await this.db.query(`DELETE FROM \`${this.schema}\`.\`record_permission_rule\`
205
+ WHERE rpr_policy_uuid = ?`, [policyId]);
206
+ }
207
+ /**
208
+ * TENANT-SCOPED delete (the authoring-surface delete path). Removes the policy's rows ONLY
209
+ * when they belong to `tenantId` — so a tenant admin can NEVER delete another tenant's policy
210
+ * by guessing/replaying its id (the un-scoped {@link deletePolicy} would). Returns the number
211
+ * of rows removed (0 ⇒ absent OR not this tenant's — idempotent, and the caller can surface a
212
+ * 404 rather than silently "succeeding" on someone else's policy). Idempotent: a second call
213
+ * removes zero rows.
214
+ *
215
+ * ── SAFETY: both `rpr_policy_uuid` + `rpr_tenant_uuid` are `?`-bound. The tenant is the
216
+ * SERVER-resolved actor tenant (never client input) — see the PolicyService.
217
+ */
218
+ async deletePolicyForTenant(policyId, tenantId) {
219
+ const result = (await this.db.query(`DELETE FROM \`${this.schema}\`.\`record_permission_rule\`
220
+ WHERE rpr_policy_uuid = ? AND rpr_tenant_uuid = ?`, [policyId, tenantId]));
221
+ const affected = result?.affectedRows;
222
+ return typeof affected === 'number' ? affected : 0;
223
+ }
224
+ }
225
+ exports.MariaDbPolicyStore = MariaDbPolicyStore;
@@ -34,9 +34,13 @@
34
34
  * ── SAFETY: every rule row is matched on `(tenant, type_key, action)` with all values
35
35
  * BOUND as `?` params (no interpolation). The action match is exact OR the rule's
36
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).
37
+ * core supplies (design §11.1 builder shape) — the operator set is the FULL
38
+ * 14-operator {@link import('@astram/core').PolicyOperator} model (unified with
39
+ * the guided policy builder, Task 0.2): `eq/ne` (dual-accept legacy
40
+ * `equals`/`not_equals`), `in/not_in`, `gt/lt/gte/lte`, `contains/not_contains`,
41
+ * `is_null/is_not_null`, `is_owner/is_not_owner`. An unknown operator makes the
42
+ * rule NOT match (fail-closed, never a spurious allow) — see `conditionHolds`
43
+ * below for the exact per-operator semantics and the documented edge-case choices.
40
44
  */
41
45
  import type { RecordPermissionRulePort, RulePermit, RulePermitInput } from '@astram/core';
42
46
  /** The DI token the adapter is bound under (parity with the session store token). */
@@ -1 +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"}
1
+ {"version":3,"file":"mariadb-record-rule-adapter.d.ts","sourceRoot":"","sources":["../../src/authz/mariadb-record-rule-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;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;AAsLD;;;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;CAuE5D"}