@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.
- package/dist/auth/account.controller.d.ts.map +1 -1
- package/dist/auth/account.controller.js +3 -0
- package/dist/auth/auth.controller.d.ts +90 -1
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +245 -7
- package/dist/auth/auth.module.d.ts +21 -0
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +28 -0
- package/dist/auth/dto.d.ts +32 -0
- package/dist/auth/dto.d.ts.map +1 -1
- package/dist/auth/ports/membership-checker.d.ts +41 -0
- package/dist/auth/ports/membership-checker.d.ts.map +1 -0
- package/dist/auth/ports/membership-checker.js +32 -0
- package/dist/auth/ports/scope-subtree-resolver.d.ts +48 -0
- package/dist/auth/ports/scope-subtree-resolver.d.ts.map +1 -0
- package/dist/auth/ports/scope-subtree-resolver.js +38 -0
- package/dist/auth/tokens.d.ts +25 -0
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +26 -1
- package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
- package/dist/authentication/mariadb-profile-writer.js +10 -1
- package/dist/authz/list-scope-sql.d.ts +125 -0
- package/dist/authz/list-scope-sql.d.ts.map +1 -0
- package/dist/authz/list-scope-sql.js +477 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts +28 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js +49 -0
- package/dist/authz/mariadb-list-scope-adapter.d.ts +61 -0
- package/dist/authz/mariadb-list-scope-adapter.d.ts.map +1 -0
- package/dist/authz/mariadb-list-scope-adapter.js +157 -0
- package/dist/authz/mariadb-policy-store.d.ts +118 -0
- package/dist/authz/mariadb-policy-store.d.ts.map +1 -0
- package/dist/authz/mariadb-policy-store.js +225 -0
- package/dist/authz/mariadb-record-rule-adapter.d.ts +7 -3
- package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-record-rule-adapter.js +156 -13
- package/dist/authz/record-share-writer.d.ts +22 -0
- package/dist/authz/record-share-writer.d.ts.map +1 -1
- package/dist/authz/record-share-writer.js +41 -0
- package/dist/authz/wire-record-authorization.d.ts +35 -1
- package/dist/authz/wire-record-authorization.d.ts.map +1 -1
- package/dist/authz/wire-record-authorization.js +75 -0
- package/dist/identity/identity-actions.d.ts +7 -0
- package/dist/identity/identity-actions.d.ts.map +1 -1
- package/dist/identity/identity-actions.js +8 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +47 -3
- package/dist/module/shield.module.d.ts +33 -0
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +12 -0
- package/dist/request-auth/request-principal.d.ts.map +1 -1
- package/dist/request-auth/request-principal.js +12 -0
- package/dist/request-auth/session-token-binder.d.ts +35 -1
- package/dist/request-auth/session-token-binder.d.ts.map +1 -1
- package/dist/request-auth/session-token-binder.js +123 -8
- package/dist/scope/closure-builder.d.ts +75 -0
- package/dist/scope/closure-builder.d.ts.map +1 -0
- package/dist/scope/closure-builder.js +134 -0
- package/migrations/1710000000009-CreateRecordPermissionRule.cjs +56 -0
- package/migrations/1710000000014-CreateRecordShare.cjs +49 -0
- package/migrations/1710000000015-CreateScopeClosure.cjs +46 -0
- package/migrations/1710000000016-AlterCompanyParentFkRestrict.cjs +71 -0
- 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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
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"}
|