@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,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `MariaDbRecordShareAdapter` — the concrete {@link RecordSharePort} for `@astram/guard`,
|
|
4
|
+
* backed by the `record_share` table (design §6.1). It answers `hasShare`: does the
|
|
5
|
+
* principal (by id or any of its roles) hold an active per-record share on this
|
|
6
|
+
* record+action?
|
|
7
|
+
*
|
|
8
|
+
* ── WHAT A SHARE IS: an INSTANCE-level grant (one row per record×grantee×action) that
|
|
9
|
+
* WIDENS gate 6 (design §6.3). It is a permit SOURCE unioned with the owner baseline +
|
|
10
|
+
* admin rules under deny-wins — a share can grant access an absent rule wouldn't, but it
|
|
11
|
+
* can NEVER override an explicit deny (core enforces that; this adapter only reports the
|
|
12
|
+
* share's presence via a strict boolean).
|
|
13
|
+
*
|
|
14
|
+
* ── WHY HERE, NOT CORE (ADR-0003): the `mariadb` driver is a real runtime dependency;
|
|
15
|
+
* core defines only the PORT. This adapter is INJECTED into core's wired decision
|
|
16
|
+
* authority. It uses `@astram/core` TYPES only (erased).
|
|
17
|
+
*
|
|
18
|
+
* ── THE MATCH (design §6.1): a row matches when ALL hold —
|
|
19
|
+
* • `rs_type_key` = the object type AND `rs_record_uuid` = the record;
|
|
20
|
+
* • the grantee is EITHER this user (`rs_grantee_kind='user'` AND
|
|
21
|
+
* `rs_grantee_uuid = principalId`) OR one of the principal's roles
|
|
22
|
+
* (`rs_grantee_kind='role'` AND `rs_grantee_uuid IN (roleIds)`);
|
|
23
|
+
* • the action matches exactly (`rs_action = action`) OR the row is all-actions
|
|
24
|
+
* (`rs_action = '*'`).
|
|
25
|
+
* ≥1 matching row ⇒ `true`. No matching row ⇒ `false` (a NORMAL data-absence — no
|
|
26
|
+
* share — NOT an error, NOT a lockout: the owner baseline / admin rules still govern).
|
|
27
|
+
*
|
|
28
|
+
* ── TENANT ISOLATION (design §6 "cross-tenant share impossible"): the {@link RecordSharePort}
|
|
29
|
+
* contract does NOT carry a tenant id (core evaluates same-tenant shares only — a
|
|
30
|
+
* cross-tenant reach is governed by the SEPARATE crossTenantGrant port, not a share).
|
|
31
|
+
* Isolation is therefore enforced at BOTH ends structurally:
|
|
32
|
+
* 1. WRITE-TIME (the authoritative bound, {@link RecordShareWriter}): a share row can
|
|
33
|
+
* ONLY be created for a record the sharer OWNS in the sharer's OWN tenant, and the
|
|
34
|
+
* row is stamped with that tenant (`rs_tenant_uuid`). A share for a foreign tenant's
|
|
35
|
+
* record is impossible to create.
|
|
36
|
+
* 2. READ-TIME (here): `rs_record_uuid` is a GLOBALLY-UNIQUE char36 record uuid — it
|
|
37
|
+
* identifies exactly one record in exactly one tenant. Matching on
|
|
38
|
+
* `(rs_type_key, rs_record_uuid)` therefore selects that one tenant's row by
|
|
39
|
+
* construction; a tenant-A share row can never match a tenant-B record because the
|
|
40
|
+
* two records have DIFFERENT uuids. The record uuid IS the tenant discriminator.
|
|
41
|
+
* (A future core-contract change that threads the resource tenant into `hasShare` would
|
|
42
|
+
* let this add an explicit `rs_tenant_uuid = ?` bound too — flagged for the architect;
|
|
43
|
+
* it is a defense-in-depth ADD, not a correctness fix, given uuid uniqueness.)
|
|
44
|
+
*
|
|
45
|
+
* ── EMPTY roleIds: guarded — an empty role list must NOT emit `IN ()` (a MariaDB syntax
|
|
46
|
+
* error). We omit the role branch entirely when there are no roles, so a role-less
|
|
47
|
+
* principal is matched by its user-share rows alone.
|
|
48
|
+
*
|
|
49
|
+
* ── ADR-0016 (transient-vs-deny): a genuine store outage THROWS
|
|
50
|
+
* {@link AuthorizationUnavailableError} (→ 503 retryable) — NEVER a silent `false` that
|
|
51
|
+
* would mask an outage as "no share".
|
|
52
|
+
*
|
|
53
|
+
* ── SAFETY: every value (typeKey, recordId, principalId, each roleId, action) is BOUND as
|
|
54
|
+
* a `?` param — NEVER interpolated. A SQL payload in typeKey/recordId is data, not SQL.
|
|
55
|
+
* Mirrors {@link MariaDbRecordPermissionRuleAdapter}.
|
|
56
|
+
*/
|
|
57
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
58
|
+
exports.MariaDbRecordShareAdapter = exports.SHIELD_RECORD_SHARE_PORT = void 0;
|
|
59
|
+
const core_1 = require("@astram/core");
|
|
60
|
+
/** The DI token the adapter is bound under (parity with the rule-port token). */
|
|
61
|
+
exports.SHIELD_RECORD_SHARE_PORT = Symbol('SHIELD_RECORD_SHARE_PORT');
|
|
62
|
+
/** Schema the `record_share` table lives in (astram3 ships the migration). */
|
|
63
|
+
const DEFAULT_SCHEMA = 'shield_authz';
|
|
64
|
+
/**
|
|
65
|
+
* The concrete per-record share adapter. Answers `hasShare` from `record_share`, matching
|
|
66
|
+
* user- and role-granted shares with exact-or-wildcard action.
|
|
67
|
+
*/
|
|
68
|
+
class MariaDbRecordShareAdapter {
|
|
69
|
+
db;
|
|
70
|
+
schema;
|
|
71
|
+
constructor(db, opts = {}) {
|
|
72
|
+
this.db = db;
|
|
73
|
+
this.schema = opts.schema ?? DEFAULT_SCHEMA;
|
|
74
|
+
}
|
|
75
|
+
async hasShare(principalId, roleIds, typeKey, recordId, action) {
|
|
76
|
+
// Grantee predicate: the user share OR (only when roles exist) a role share. The role
|
|
77
|
+
// branch is OMITTED for an empty role list so we NEVER emit `IN ()` (a syntax error).
|
|
78
|
+
// The IN placeholder list is built from the COUNT of roleIds — never their values.
|
|
79
|
+
let granteeSql = `(rs_grantee_kind = 'user' AND rs_grantee_uuid = ?)`;
|
|
80
|
+
const granteeParams = [principalId];
|
|
81
|
+
if (roleIds.length > 0) {
|
|
82
|
+
const placeholders = roleIds.map(() => '?').join(', ');
|
|
83
|
+
granteeSql += ` OR (rs_grantee_kind = 'role' AND rs_grantee_uuid IN (${placeholders}))`;
|
|
84
|
+
granteeParams.push(...roleIds);
|
|
85
|
+
}
|
|
86
|
+
// Param order matches the SQL below exactly: type_key, record_uuid, <grantee params>, action.
|
|
87
|
+
const params = [typeKey, recordId, ...granteeParams, action];
|
|
88
|
+
let rows;
|
|
89
|
+
try {
|
|
90
|
+
rows = (await this.db.query(`SELECT COUNT(*) AS n FROM \`${this.schema}\`.\`record_share\`` +
|
|
91
|
+
` WHERE rs_type_key = ?` +
|
|
92
|
+
` AND rs_record_uuid = ?` +
|
|
93
|
+
` AND (${granteeSql})` +
|
|
94
|
+
` AND (rs_action = ? OR rs_action = '*')`, params));
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
// Genuine store outage — fail closed as retryable (never mask as "no share").
|
|
98
|
+
throw new core_1.AuthorizationUnavailableError(`record_share store unavailable for ${typeKey}:${recordId}:${action}: ` +
|
|
99
|
+
`${err.message}`);
|
|
100
|
+
}
|
|
101
|
+
// ≥1 matching row ⇒ a share exists ⇒ true. The engine additionally gates the returned
|
|
102
|
+
// boolean on strict `=== true` (defense-in-depth); we return a genuine boolean.
|
|
103
|
+
return Number(rows[0]?.n ?? 0) > 0;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
exports.MariaDbRecordShareAdapter = MariaDbRecordShareAdapter;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `makeShareDeleteCascade` — the share-side of record delete (design §6.2: "on record
|
|
3
|
+
* delete, its `record_share` rows are removed — no orphan grants").
|
|
4
|
+
*
|
|
5
|
+
* ⛔ LAYERING (ADR-0003): the ENGINE owns record delete, but it must NOT depend on guard.
|
|
6
|
+
* So the engine exposes an OPTIONAL `onRecordDeleted(query, typeKey, recordId, tenantId)`
|
|
7
|
+
* hook ({@link RecordEngineDeps}) that it calls AFTER a successful delete of an
|
|
8
|
+
* `owned`-type record. This factory builds a guard-owned function of that exact shape
|
|
9
|
+
* which the CONSUMER wires into the engine as that hook. The dependency direction is
|
|
10
|
+
* consumer → (engine hook) + consumer → (this guard function); the engine never imports
|
|
11
|
+
* guard.
|
|
12
|
+
*
|
|
13
|
+
* ⛔ SAME-TRANSACTION (mirrors the engine's `AuditPort.record(event, query)` precedent): the
|
|
14
|
+
* hook is handed the engine's TX-BOUND query fn per-call and runs the DELETE through THAT
|
|
15
|
+
* — NOT a pool/connection it closed over. So the cascade commits/rolls back ATOMICALLY
|
|
16
|
+
* with the record delete: a rolled-back delete never leaves the shares destroyed for a
|
|
17
|
+
* still-existing record, and there is no separate-connection self-deadlock on the delete's
|
|
18
|
+
* row lock. Because the query fn comes from the engine per-call, this factory needs NO
|
|
19
|
+
* pool/db of its own.
|
|
20
|
+
*
|
|
21
|
+
* ── WHAT IT DOES: DELETE every `record_share` row for `(tenant, type, record)`, tenant-
|
|
22
|
+
* scoped, all values BOUND `?` (no interpolation). The engine calls it only for `owned`
|
|
23
|
+
* types (the only shareable ones), inside the delete's transaction. Idempotent — a second
|
|
24
|
+
* run deletes zero rows.
|
|
25
|
+
*
|
|
26
|
+
* ── FAIL-CLOSED: a cascade failure PROPAGATES (throws) — because the engine runs it inside
|
|
27
|
+
* the delete's transaction, the throw aborts + rolls back the whole delete. An orphaned
|
|
28
|
+
* share pointing at a deleted record is a security-relevant leftover, not something to
|
|
29
|
+
* swallow; propagating keeps the record + its shares consistent (both present, or both
|
|
30
|
+
* gone).
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* The transaction-bound query fn the engine hands the cascade per-call — the SAME connection
|
|
34
|
+
* the delete stages used. Structurally matches the engine's `QueryFn` (guard does not import
|
|
35
|
+
* `@astram/engine`, so it declares the compatible shape here). Params are bound `?` values.
|
|
36
|
+
*/
|
|
37
|
+
export type ShareQueryFn = (sql: string, params?: readonly unknown[]) => Promise<unknown>;
|
|
38
|
+
/**
|
|
39
|
+
* The hook shape the engine's `onRecordDeleted` expects: it receives the TX-bound query fn
|
|
40
|
+
* FIRST, then the deleted record's coordinates.
|
|
41
|
+
*/
|
|
42
|
+
export type RecordDeletedHook = (query: ShareQueryFn, typeKey: string, recordId: string, tenantId: string) => Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Build the delete-cascade function (a {@link RecordDeletedHook}) the consumer wires into
|
|
45
|
+
* the engine as `onRecordDeleted`. Removes a deleted record's `record_share` rows,
|
|
46
|
+
* tenant-scoped, THROUGH the engine-supplied transaction query fn (same-tx, atomic). Takes
|
|
47
|
+
* no pool — the query fn is supplied by the engine on every call.
|
|
48
|
+
*/
|
|
49
|
+
export declare function makeShareDeleteCascade(opts?: {
|
|
50
|
+
schema?: string;
|
|
51
|
+
}): RecordDeletedHook;
|
|
52
|
+
//# sourceMappingURL=record-share-cascade.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"record-share-cascade.d.ts","sourceRoot":"","sources":["../../src/authz/record-share-cascade.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAKH;;;;GAIG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAE1F;;;GAGG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAC9B,KAAK,EAAE,YAAY,EACnB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,KACb,OAAO,CAAC,IAAI,CAAC,CAAC;AAEnB;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,iBAAiB,CAcxF"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `makeShareDeleteCascade` — the share-side of record delete (design §6.2: "on record
|
|
4
|
+
* delete, its `record_share` rows are removed — no orphan grants").
|
|
5
|
+
*
|
|
6
|
+
* ⛔ LAYERING (ADR-0003): the ENGINE owns record delete, but it must NOT depend on guard.
|
|
7
|
+
* So the engine exposes an OPTIONAL `onRecordDeleted(query, typeKey, recordId, tenantId)`
|
|
8
|
+
* hook ({@link RecordEngineDeps}) that it calls AFTER a successful delete of an
|
|
9
|
+
* `owned`-type record. This factory builds a guard-owned function of that exact shape
|
|
10
|
+
* which the CONSUMER wires into the engine as that hook. The dependency direction is
|
|
11
|
+
* consumer → (engine hook) + consumer → (this guard function); the engine never imports
|
|
12
|
+
* guard.
|
|
13
|
+
*
|
|
14
|
+
* ⛔ SAME-TRANSACTION (mirrors the engine's `AuditPort.record(event, query)` precedent): the
|
|
15
|
+
* hook is handed the engine's TX-BOUND query fn per-call and runs the DELETE through THAT
|
|
16
|
+
* — NOT a pool/connection it closed over. So the cascade commits/rolls back ATOMICALLY
|
|
17
|
+
* with the record delete: a rolled-back delete never leaves the shares destroyed for a
|
|
18
|
+
* still-existing record, and there is no separate-connection self-deadlock on the delete's
|
|
19
|
+
* row lock. Because the query fn comes from the engine per-call, this factory needs NO
|
|
20
|
+
* pool/db of its own.
|
|
21
|
+
*
|
|
22
|
+
* ── WHAT IT DOES: DELETE every `record_share` row for `(tenant, type, record)`, tenant-
|
|
23
|
+
* scoped, all values BOUND `?` (no interpolation). The engine calls it only for `owned`
|
|
24
|
+
* types (the only shareable ones), inside the delete's transaction. Idempotent — a second
|
|
25
|
+
* run deletes zero rows.
|
|
26
|
+
*
|
|
27
|
+
* ── FAIL-CLOSED: a cascade failure PROPAGATES (throws) — because the engine runs it inside
|
|
28
|
+
* the delete's transaction, the throw aborts + rolls back the whole delete. An orphaned
|
|
29
|
+
* share pointing at a deleted record is a security-relevant leftover, not something to
|
|
30
|
+
* swallow; propagating keeps the record + its shares consistent (both present, or both
|
|
31
|
+
* gone).
|
|
32
|
+
*/
|
|
33
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
+
exports.makeShareDeleteCascade = makeShareDeleteCascade;
|
|
35
|
+
/** Schema the `record_share` table lives in. */
|
|
36
|
+
const DEFAULT_SCHEMA = 'shield_authz';
|
|
37
|
+
/**
|
|
38
|
+
* Build the delete-cascade function (a {@link RecordDeletedHook}) the consumer wires into
|
|
39
|
+
* the engine as `onRecordDeleted`. Removes a deleted record's `record_share` rows,
|
|
40
|
+
* tenant-scoped, THROUGH the engine-supplied transaction query fn (same-tx, atomic). Takes
|
|
41
|
+
* no pool — the query fn is supplied by the engine on every call.
|
|
42
|
+
*/
|
|
43
|
+
function makeShareDeleteCascade(opts = {}) {
|
|
44
|
+
const schema = opts.schema ?? DEFAULT_SCHEMA;
|
|
45
|
+
return async (query, typeKey, recordId, tenantId) => {
|
|
46
|
+
await query(`DELETE FROM \`${schema}\`.\`record_share\`` +
|
|
47
|
+
` WHERE rs_tenant_uuid = ? AND rs_type_key = ? AND rs_record_uuid = ?`, [tenantId, typeKey, recordId]);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RecordShareWriter` — the framework-owned WHO-MAY-SHARE write path (design §6.2, the
|
|
3
|
+
* KEY NEW INVARIANT of Phase 3). Share CREATION — not just evaluation — is framework-
|
|
4
|
+
* governed here so a share can NEVER expose data the sharer does not own.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ THIS IS NOT A CORE PORT. Share evaluation is a core port ({@link RecordSharePort});
|
|
7
|
+
* share CREATION is a guard WRITE path (it needs the `mariadb` driver + the type's
|
|
8
|
+
* physical schema, neither of which core may touch — ADR-0003). It lives in guard and
|
|
9
|
+
* is exposed as a provider the consumer app calls from its share/unshare surface.
|
|
10
|
+
*
|
|
11
|
+
* ── THE BOUND (enforced fail-closed, IN ORDER, BEFORE any INSERT):
|
|
12
|
+
* 1. The type MUST be `owned`. A non-owned type (system / tenant-scoped-only) has no
|
|
13
|
+
* single owner, so there is no "sharer owns it" notion — sharing it is meaningless
|
|
14
|
+
* and REJECTED. The writer resolves the type's access posture via the injected
|
|
15
|
+
* {@link ShareTypeResolver}; an unknown type / non-owned type ⇒ reject.
|
|
16
|
+
* 2. The record's OWNER is FETCHED from the DB, TENANT-SCOPED
|
|
17
|
+
* (`SELECT owner_col FROM table WHERE pk = ? AND tenant_col = ?`). A row that does
|
|
18
|
+
* not exist in the SHARER'S tenant ⇒ reject (no cross-tenant reach, and no existence
|
|
19
|
+
* oracle beyond "you can't share it"). ⛔ THE OWNER IS ALWAYS FETCHED — never taken
|
|
20
|
+
* from caller input (fetch-then-gate discipline, mirrors the engine's fetch-owner
|
|
21
|
+
* stage).
|
|
22
|
+
* 3. The sharer MUST BE THE OWNER: `fetchedOwner === sharerId` (STRICT, non-empty). A
|
|
23
|
+
* non-owner — INCLUDING a grantee who merely received a share — can NEVER share.
|
|
24
|
+
* Because only the LITERAL owner passes, re-share by grantees is forbidden BY
|
|
25
|
+
* CONSTRUCTION (a grantee's id is not the owner id).
|
|
26
|
+
* ⛔ The `sharerId` is the AUTHENTICATED PRINCIPAL'S id — the caller MUST supply it from
|
|
27
|
+
* the request principal, NOT from a user-controlled body field. The writer never
|
|
28
|
+
* trusts an owner id in `input`; it fetches the owner and compares against `sharerId`.
|
|
29
|
+
*
|
|
30
|
+
* ── ON PASS: an IDEMPOTENT INSERT into `record_share` (the UNIQUE index on
|
|
31
|
+
* (tenant, type, record, grantee_kind, grantee_uuid, action) makes a repeat share of the
|
|
32
|
+
* same tuple a no-op via `INSERT ... ON DUPLICATE KEY UPDATE`). `unshare` DELETEs the
|
|
33
|
+
* matching row(s). Every share/unshare emits an audit event via the injected
|
|
34
|
+
* {@link ShareAuditSink} (best-effort; a sink error never fails the operation). NO raw
|
|
35
|
+
* uuid appears in a human-readable audit MESSAGE — ids ride in structured fields only.
|
|
36
|
+
*
|
|
37
|
+
* ── TENANT: every write is stamped with, and scoped to, the SHARER'S tenant. A share for
|
|
38
|
+
* a foreign tenant's record is impossible — the owner fetch is tenant-scoped, so a
|
|
39
|
+
* record outside the sharer's tenant is "not found" ⇒ rejected at step 2.
|
|
40
|
+
*/
|
|
41
|
+
import type { ShareQueryable } from './mariadb-record-share-adapter.js';
|
|
42
|
+
/** The DI token the writer is bound under. */
|
|
43
|
+
export declare const SHIELD_RECORD_SHARE_WRITER: unique symbol;
|
|
44
|
+
/** Who a share is granted to. Mirrors `rs_grantee_kind`. */
|
|
45
|
+
export type ShareGranteeKind = 'user' | 'role';
|
|
46
|
+
/**
|
|
47
|
+
* The physical shape of an `owned` object type the writer needs to fetch the record's
|
|
48
|
+
* owner tenant-scoped. The consumer resolves this from the engine's TableRegistry (so guard
|
|
49
|
+
* never imports `@astram/engine` — ADR-0003 layering). `owned` is the access posture flag
|
|
50
|
+
* the bound's step 1 checks.
|
|
51
|
+
*/
|
|
52
|
+
export interface ShareTypeInfo {
|
|
53
|
+
/** Physical table name. */
|
|
54
|
+
readonly table: string;
|
|
55
|
+
/** Schema the table lives in (undefined → the engine's default app schema). */
|
|
56
|
+
readonly schema?: string;
|
|
57
|
+
/** The single uuid primary-key column (owned types use a uuid PK). */
|
|
58
|
+
readonly pkColumn: string;
|
|
59
|
+
/** The `trusted:'actorId'` owner column. */
|
|
60
|
+
readonly ownerColumn: string;
|
|
61
|
+
/** The `trusted:'tenantId'` tenant column, if the type has one (owned types normally do). */
|
|
62
|
+
readonly tenantColumn?: string;
|
|
63
|
+
/** Whether the type declares `access.owned === true` (only owned types are shareable). */
|
|
64
|
+
readonly owned: boolean;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolves a type key → its physical share info, or `undefined` if the type is unknown.
|
|
68
|
+
* The consumer implements this over the engine registry (`isOwned`/`ownerColumn`/
|
|
69
|
+
* `tenantColumnOf`/`uuidPkColumn`) — keeping guard decoupled from the engine.
|
|
70
|
+
*/
|
|
71
|
+
export interface ShareTypeResolver {
|
|
72
|
+
resolve(typeKey: string): ShareTypeInfo | undefined;
|
|
73
|
+
}
|
|
74
|
+
/** A share/unshare audit event (structured — ids in fields, NEVER in a rendered message). */
|
|
75
|
+
export interface ShareAuditEvent {
|
|
76
|
+
/** Always `'record_share'`. */
|
|
77
|
+
readonly type: 'record_share';
|
|
78
|
+
/** The specific event key. */
|
|
79
|
+
readonly action: 'share.created' | 'share.removed';
|
|
80
|
+
/** Epoch ms the event occurred. */
|
|
81
|
+
readonly occurredAtMs: number;
|
|
82
|
+
/** The sharer (owner) principal id — the actor. */
|
|
83
|
+
readonly actorId: string;
|
|
84
|
+
/** The tenant the shared record belongs to. */
|
|
85
|
+
readonly tenantId: string;
|
|
86
|
+
/** Structured, UUID-free-message detail (the ids live HERE, not in a message string). */
|
|
87
|
+
readonly attributes: {
|
|
88
|
+
readonly typeKey: string;
|
|
89
|
+
readonly recordId: string;
|
|
90
|
+
readonly granteeKind: ShareGranteeKind;
|
|
91
|
+
readonly granteeUuid: string;
|
|
92
|
+
readonly shareAction: string;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** The audit seam the writer emits through. Best-effort — a throw never fails the write. */
|
|
96
|
+
export interface ShareAuditSink {
|
|
97
|
+
record(event: ShareAuditEvent): Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
/** Input to {@link RecordShareWriter.share} / `.unshare`. */
|
|
100
|
+
export interface ShareInput {
|
|
101
|
+
/** The SHARER'S tenant — the record must belong to it (owner fetch is scoped to it). */
|
|
102
|
+
readonly tenantId: string;
|
|
103
|
+
/** The object type being shared (MUST be `owned`). */
|
|
104
|
+
readonly typeKey: string;
|
|
105
|
+
/** The record uuid being shared. */
|
|
106
|
+
readonly recordId: string;
|
|
107
|
+
/** Who the share is granted to. */
|
|
108
|
+
readonly granteeKind: ShareGranteeKind;
|
|
109
|
+
/** The grantee's user/role uuid. */
|
|
110
|
+
readonly granteeUuid: string;
|
|
111
|
+
/** The action granted (a single action, or `'*'` for all actions). */
|
|
112
|
+
readonly action: string;
|
|
113
|
+
/**
|
|
114
|
+
* ⛔ The AUTHENTICATED principal id — supplied by the caller FROM THE REQUEST PRINCIPAL,
|
|
115
|
+
* never a user-controlled body field. The writer proves this id owns the record before
|
|
116
|
+
* writing; it never trusts an owner id in the input.
|
|
117
|
+
*/
|
|
118
|
+
readonly sharerId: string;
|
|
119
|
+
}
|
|
120
|
+
/** Thrown when the who-may-share bound rejects a share/unshare (fail-closed). */
|
|
121
|
+
export declare class ShareNotPermittedError extends Error {
|
|
122
|
+
constructor(message: string);
|
|
123
|
+
}
|
|
124
|
+
/** Thrown when the share store genuinely cannot answer/write (transient). */
|
|
125
|
+
export declare class ShareStoreUnavailableError extends Error {
|
|
126
|
+
constructor(message: string);
|
|
127
|
+
}
|
|
128
|
+
export declare class RecordShareWriter {
|
|
129
|
+
private readonly db;
|
|
130
|
+
private readonly types;
|
|
131
|
+
private readonly audit;
|
|
132
|
+
private readonly schema;
|
|
133
|
+
private readonly now;
|
|
134
|
+
constructor(deps: {
|
|
135
|
+
db: ShareQueryable;
|
|
136
|
+
types: ShareTypeResolver;
|
|
137
|
+
/** Optional — absent ⇒ shares are written un-audited (never blocks the write). */
|
|
138
|
+
audit?: ShareAuditSink;
|
|
139
|
+
schema?: string;
|
|
140
|
+
/** Test seam: a deterministic clock (defaults to Date.now). */
|
|
141
|
+
nowMs?: () => number;
|
|
142
|
+
});
|
|
143
|
+
/**
|
|
144
|
+
* Create a share, enforcing the §6.2 who-may-share bound. Idempotent: re-sharing the
|
|
145
|
+
* same (tenant, type, record, grantee, action) tuple is a no-op (UNIQUE index +
|
|
146
|
+
* ON DUPLICATE KEY UPDATE). Emits a `share.created` audit event on success.
|
|
147
|
+
*
|
|
148
|
+
* @throws {@link ShareNotPermittedError} if the bound rejects (non-owned type, record
|
|
149
|
+
* not in the sharer's tenant, or the sharer is not the owner).
|
|
150
|
+
* @throws {@link ShareStoreUnavailableError} if the store cannot be reached (transient).
|
|
151
|
+
*/
|
|
152
|
+
share(input: ShareInput): Promise<void>;
|
|
153
|
+
/**
|
|
154
|
+
* Remove a share. The SAME owner bound applies — only the owner of the record may revoke
|
|
155
|
+
* a share on it (a grantee cannot unshare a record they don't own). DELETEs the matching
|
|
156
|
+
* row(s); a no-match delete is a benign no-op. Emits `share.removed` on success.
|
|
157
|
+
*/
|
|
158
|
+
unshare(input: ShareInput): Promise<void>;
|
|
159
|
+
/**
|
|
160
|
+
* Enforce the §6.2 who-may-share bound (steps 1–3) and return the resolved type info.
|
|
161
|
+
* Fetches the record owner tenant-scoped and proves `sharerId` owns it. Throws
|
|
162
|
+
* {@link ShareNotPermittedError} on any failure (fail-closed).
|
|
163
|
+
*/
|
|
164
|
+
private assertMayShare;
|
|
165
|
+
/**
|
|
166
|
+
* SELECT the owner column for `recordId`, tenant-scoped by `tenantId`. Returns the owner
|
|
167
|
+
* id string, or `undefined` when the row does not exist in that tenant / the owner is not
|
|
168
|
+
* a non-empty string (fail-closed — a null/non-string owner never matches a sharerId).
|
|
169
|
+
*/
|
|
170
|
+
private fetchOwnerTenantScoped;
|
|
171
|
+
private qualifiedTable;
|
|
172
|
+
/** Best-effort audit emission — a sink error is swallowed (never fails the write). */
|
|
173
|
+
private emit;
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=record-share-writer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"record-share-writer.d.ts","sourceRoot":"","sources":["../../src/authz/record-share-writer.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `RecordShareWriter` — the framework-owned WHO-MAY-SHARE write path (design §6.2, the
|
|
4
|
+
* KEY NEW INVARIANT of Phase 3). Share CREATION — not just evaluation — is framework-
|
|
5
|
+
* governed here so a share can NEVER expose data the sharer does not own.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ THIS IS NOT A CORE PORT. Share evaluation is a core port ({@link RecordSharePort});
|
|
8
|
+
* share CREATION is a guard WRITE path (it needs the `mariadb` driver + the type's
|
|
9
|
+
* physical schema, neither of which core may touch — ADR-0003). It lives in guard and
|
|
10
|
+
* is exposed as a provider the consumer app calls from its share/unshare surface.
|
|
11
|
+
*
|
|
12
|
+
* ── THE BOUND (enforced fail-closed, IN ORDER, BEFORE any INSERT):
|
|
13
|
+
* 1. The type MUST be `owned`. A non-owned type (system / tenant-scoped-only) has no
|
|
14
|
+
* single owner, so there is no "sharer owns it" notion — sharing it is meaningless
|
|
15
|
+
* and REJECTED. The writer resolves the type's access posture via the injected
|
|
16
|
+
* {@link ShareTypeResolver}; an unknown type / non-owned type ⇒ reject.
|
|
17
|
+
* 2. The record's OWNER is FETCHED from the DB, TENANT-SCOPED
|
|
18
|
+
* (`SELECT owner_col FROM table WHERE pk = ? AND tenant_col = ?`). A row that does
|
|
19
|
+
* not exist in the SHARER'S tenant ⇒ reject (no cross-tenant reach, and no existence
|
|
20
|
+
* oracle beyond "you can't share it"). ⛔ THE OWNER IS ALWAYS FETCHED — never taken
|
|
21
|
+
* from caller input (fetch-then-gate discipline, mirrors the engine's fetch-owner
|
|
22
|
+
* stage).
|
|
23
|
+
* 3. The sharer MUST BE THE OWNER: `fetchedOwner === sharerId` (STRICT, non-empty). A
|
|
24
|
+
* non-owner — INCLUDING a grantee who merely received a share — can NEVER share.
|
|
25
|
+
* Because only the LITERAL owner passes, re-share by grantees is forbidden BY
|
|
26
|
+
* CONSTRUCTION (a grantee's id is not the owner id).
|
|
27
|
+
* ⛔ The `sharerId` is the AUTHENTICATED PRINCIPAL'S id — the caller MUST supply it from
|
|
28
|
+
* the request principal, NOT from a user-controlled body field. The writer never
|
|
29
|
+
* trusts an owner id in `input`; it fetches the owner and compares against `sharerId`.
|
|
30
|
+
*
|
|
31
|
+
* ── ON PASS: an IDEMPOTENT INSERT into `record_share` (the UNIQUE index on
|
|
32
|
+
* (tenant, type, record, grantee_kind, grantee_uuid, action) makes a repeat share of the
|
|
33
|
+
* same tuple a no-op via `INSERT ... ON DUPLICATE KEY UPDATE`). `unshare` DELETEs the
|
|
34
|
+
* matching row(s). Every share/unshare emits an audit event via the injected
|
|
35
|
+
* {@link ShareAuditSink} (best-effort; a sink error never fails the operation). NO raw
|
|
36
|
+
* uuid appears in a human-readable audit MESSAGE — ids ride in structured fields only.
|
|
37
|
+
*
|
|
38
|
+
* ── TENANT: every write is stamped with, and scoped to, the SHARER'S tenant. A share for
|
|
39
|
+
* a foreign tenant's record is impossible — the owner fetch is tenant-scoped, so a
|
|
40
|
+
* record outside the sharer's tenant is "not found" ⇒ rejected at step 2.
|
|
41
|
+
*/
|
|
42
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
|
+
exports.RecordShareWriter = exports.ShareStoreUnavailableError = exports.ShareNotPermittedError = exports.SHIELD_RECORD_SHARE_WRITER = void 0;
|
|
44
|
+
const node_crypto_1 = require("node:crypto");
|
|
45
|
+
/** Schema the `record_share` table lives in (astram3 ships the migration). */
|
|
46
|
+
const DEFAULT_SCHEMA = 'shield_authz';
|
|
47
|
+
/** The DI token the writer is bound under. */
|
|
48
|
+
exports.SHIELD_RECORD_SHARE_WRITER = Symbol('SHIELD_RECORD_SHARE_WRITER');
|
|
49
|
+
/** Thrown when the who-may-share bound rejects a share/unshare (fail-closed). */
|
|
50
|
+
class ShareNotPermittedError extends Error {
|
|
51
|
+
constructor(message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = 'ShareNotPermittedError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
exports.ShareNotPermittedError = ShareNotPermittedError;
|
|
57
|
+
/** Thrown when the share store genuinely cannot answer/write (transient). */
|
|
58
|
+
class ShareStoreUnavailableError extends Error {
|
|
59
|
+
constructor(message) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = 'ShareStoreUnavailableError';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
exports.ShareStoreUnavailableError = ShareStoreUnavailableError;
|
|
65
|
+
class RecordShareWriter {
|
|
66
|
+
db;
|
|
67
|
+
types;
|
|
68
|
+
audit;
|
|
69
|
+
schema;
|
|
70
|
+
now;
|
|
71
|
+
constructor(deps) {
|
|
72
|
+
this.db = deps.db;
|
|
73
|
+
this.types = deps.types;
|
|
74
|
+
this.audit = deps.audit;
|
|
75
|
+
this.schema = deps.schema ?? DEFAULT_SCHEMA;
|
|
76
|
+
this.now = deps.nowMs ?? (() => Date.now());
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Create a share, enforcing the §6.2 who-may-share bound. Idempotent: re-sharing the
|
|
80
|
+
* same (tenant, type, record, grantee, action) tuple is a no-op (UNIQUE index +
|
|
81
|
+
* ON DUPLICATE KEY UPDATE). Emits a `share.created` audit event on success.
|
|
82
|
+
*
|
|
83
|
+
* @throws {@link ShareNotPermittedError} if the bound rejects (non-owned type, record
|
|
84
|
+
* not in the sharer's tenant, or the sharer is not the owner).
|
|
85
|
+
* @throws {@link ShareStoreUnavailableError} if the store cannot be reached (transient).
|
|
86
|
+
*/
|
|
87
|
+
async share(input) {
|
|
88
|
+
await this.assertMayShare(input);
|
|
89
|
+
try {
|
|
90
|
+
// Idempotent insert — the UNIQUE index makes a repeat tuple a no-op. rs_granted_by
|
|
91
|
+
// records the sharer (for the who-may-share audit trail).
|
|
92
|
+
await this.db.query(`INSERT INTO \`${this.schema}\`.\`record_share\`` +
|
|
93
|
+
` (rs_uuid, rs_tenant_uuid, rs_type_key, rs_record_uuid,` +
|
|
94
|
+
` rs_grantee_kind, rs_grantee_uuid, rs_action, rs_granted_by)` +
|
|
95
|
+
` VALUES (?, ?, ?, ?, ?, ?, ?, ?)` +
|
|
96
|
+
` ON DUPLICATE KEY UPDATE rs_granted_by = VALUES(rs_granted_by)`, [
|
|
97
|
+
(0, node_crypto_1.randomUUID)(),
|
|
98
|
+
input.tenantId,
|
|
99
|
+
input.typeKey,
|
|
100
|
+
input.recordId,
|
|
101
|
+
input.granteeKind,
|
|
102
|
+
input.granteeUuid,
|
|
103
|
+
input.action,
|
|
104
|
+
input.sharerId,
|
|
105
|
+
]);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
throw new ShareStoreUnavailableError(`record_share write failed for ${input.typeKey}: ${err.message}`);
|
|
109
|
+
}
|
|
110
|
+
await this.emit('share.created', input);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Remove a share. The SAME owner bound applies — only the owner of the record may revoke
|
|
114
|
+
* a share on it (a grantee cannot unshare a record they don't own). DELETEs the matching
|
|
115
|
+
* row(s); a no-match delete is a benign no-op. Emits `share.removed` on success.
|
|
116
|
+
*/
|
|
117
|
+
async unshare(input) {
|
|
118
|
+
await this.assertMayShare(input);
|
|
119
|
+
try {
|
|
120
|
+
await this.db.query(`DELETE FROM \`${this.schema}\`.\`record_share\`` +
|
|
121
|
+
` WHERE rs_tenant_uuid = ?` +
|
|
122
|
+
` AND rs_type_key = ?` +
|
|
123
|
+
` AND rs_record_uuid = ?` +
|
|
124
|
+
` AND rs_grantee_kind = ?` +
|
|
125
|
+
` AND rs_grantee_uuid = ?` +
|
|
126
|
+
` AND rs_action = ?`, [
|
|
127
|
+
input.tenantId,
|
|
128
|
+
input.typeKey,
|
|
129
|
+
input.recordId,
|
|
130
|
+
input.granteeKind,
|
|
131
|
+
input.granteeUuid,
|
|
132
|
+
input.action,
|
|
133
|
+
]);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
throw new ShareStoreUnavailableError(`record_share delete failed for ${input.typeKey}: ${err.message}`);
|
|
137
|
+
}
|
|
138
|
+
await this.emit('share.removed', input);
|
|
139
|
+
}
|
|
140
|
+
// --- the bound -------------------------------------------------------------
|
|
141
|
+
/**
|
|
142
|
+
* Enforce the §6.2 who-may-share bound (steps 1–3) and return the resolved type info.
|
|
143
|
+
* Fetches the record owner tenant-scoped and proves `sharerId` owns it. Throws
|
|
144
|
+
* {@link ShareNotPermittedError} on any failure (fail-closed).
|
|
145
|
+
*/
|
|
146
|
+
async assertMayShare(input) {
|
|
147
|
+
// Step 1 — the type must be `owned` (and known).
|
|
148
|
+
const info = this.types.resolve(input.typeKey);
|
|
149
|
+
if (!info) {
|
|
150
|
+
throw new ShareNotPermittedError(`type '${input.typeKey}' is not registered — cannot share.`);
|
|
151
|
+
}
|
|
152
|
+
if (info.owned !== true) {
|
|
153
|
+
throw new ShareNotPermittedError(`type '${input.typeKey}' is not an owned type — only owned records are shareable.`);
|
|
154
|
+
}
|
|
155
|
+
// A sharerId is mandatory + non-empty (the authenticated principal).
|
|
156
|
+
if (typeof input.sharerId !== 'string' || input.sharerId.length === 0) {
|
|
157
|
+
throw new ShareNotPermittedError(`a non-empty sharerId (the authenticated principal) is required.`);
|
|
158
|
+
}
|
|
159
|
+
// Step 2 — FETCH the owner tenant-scoped (never trust caller input). A record absent
|
|
160
|
+
// from the sharer's tenant ⇒ reject (no cross-tenant, no existence oracle).
|
|
161
|
+
const owner = await this.fetchOwnerTenantScoped(info, input.recordId, input.tenantId);
|
|
162
|
+
if (owner === undefined) {
|
|
163
|
+
throw new ShareNotPermittedError(`record not found in tenant — cannot share.`);
|
|
164
|
+
}
|
|
165
|
+
// Step 3 — the sharer MUST BE the owner (strict, non-empty). Grantees never pass.
|
|
166
|
+
if (owner !== input.sharerId) {
|
|
167
|
+
throw new ShareNotPermittedError(`only the record owner may share it.`);
|
|
168
|
+
}
|
|
169
|
+
return info;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* SELECT the owner column for `recordId`, tenant-scoped by `tenantId`. Returns the owner
|
|
173
|
+
* id string, or `undefined` when the row does not exist in that tenant / the owner is not
|
|
174
|
+
* a non-empty string (fail-closed — a null/non-string owner never matches a sharerId).
|
|
175
|
+
*/
|
|
176
|
+
async fetchOwnerTenantScoped(info, recordId, tenantId) {
|
|
177
|
+
const qualified = this.qualifiedTable(info);
|
|
178
|
+
let where = `\`${info.pkColumn}\` = ?`;
|
|
179
|
+
const params = [recordId];
|
|
180
|
+
if (info.tenantColumn) {
|
|
181
|
+
where += ` AND \`${info.tenantColumn}\` = ?`;
|
|
182
|
+
params.push(tenantId);
|
|
183
|
+
}
|
|
184
|
+
let rows;
|
|
185
|
+
try {
|
|
186
|
+
rows = (await this.db.query(`SELECT \`${info.ownerColumn}\` AS owner_id FROM ${qualified} WHERE ${where} LIMIT 1`, params));
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
throw new ShareStoreUnavailableError(`owner fetch failed for ${info.table}: ${err.message}`);
|
|
190
|
+
}
|
|
191
|
+
const first = rows[0];
|
|
192
|
+
return first && typeof first.owner_id === 'string' && first.owner_id.length > 0
|
|
193
|
+
? first.owner_id
|
|
194
|
+
: undefined;
|
|
195
|
+
}
|
|
196
|
+
qualifiedTable(info) {
|
|
197
|
+
return info.schema ? `\`${info.schema}\`.\`${info.table}\`` : `\`${info.table}\``;
|
|
198
|
+
}
|
|
199
|
+
/** Best-effort audit emission — a sink error is swallowed (never fails the write). */
|
|
200
|
+
async emit(action, input) {
|
|
201
|
+
if (!this.audit)
|
|
202
|
+
return;
|
|
203
|
+
const event = {
|
|
204
|
+
type: 'record_share',
|
|
205
|
+
action,
|
|
206
|
+
occurredAtMs: this.now(),
|
|
207
|
+
actorId: input.sharerId,
|
|
208
|
+
tenantId: input.tenantId,
|
|
209
|
+
attributes: {
|
|
210
|
+
typeKey: input.typeKey,
|
|
211
|
+
recordId: input.recordId,
|
|
212
|
+
granteeKind: input.granteeKind,
|
|
213
|
+
granteeUuid: input.granteeUuid,
|
|
214
|
+
shareAction: input.action,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
try {
|
|
218
|
+
await this.audit.record(event);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// Best-effort: an audit failure must not fail a legitimate share/unshare.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
exports.RecordShareWriter = RecordShareWriter;
|