@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.
Files changed (120) hide show
  1. package/dist/auth/account.controller.d.ts +245 -0
  2. package/dist/auth/account.controller.d.ts.map +1 -0
  3. package/dist/auth/account.controller.js +673 -0
  4. package/dist/auth/auth.controller.d.ts +219 -5
  5. package/dist/auth/auth.controller.d.ts.map +1 -1
  6. package/dist/auth/auth.controller.js +790 -12
  7. package/dist/auth/auth.module.d.ts +101 -3
  8. package/dist/auth/auth.module.d.ts.map +1 -1
  9. package/dist/auth/auth.module.js +300 -8
  10. package/dist/auth/dto.d.ts +16 -6
  11. package/dist/auth/dto.d.ts.map +1 -1
  12. package/dist/auth/ports/audit-sink.d.ts +47 -0
  13. package/dist/auth/ports/audit-sink.d.ts.map +1 -0
  14. package/dist/auth/ports/audit-sink.js +20 -0
  15. package/dist/auth/ports/credential-writer.d.ts +23 -0
  16. package/dist/auth/ports/credential-writer.d.ts.map +1 -0
  17. package/dist/auth/ports/credential-writer.js +2 -0
  18. package/dist/auth/ports/email-sender.d.ts +14 -0
  19. package/dist/auth/ports/email-sender.d.ts.map +1 -0
  20. package/dist/auth/ports/email-sender.js +2 -0
  21. package/dist/auth/ports/mfa-device-store.d.ts +45 -0
  22. package/dist/auth/ports/mfa-device-store.d.ts.map +1 -0
  23. package/dist/auth/ports/mfa-device-store.js +13 -0
  24. package/dist/auth/ports/profile-writer.d.ts +12 -0
  25. package/dist/auth/ports/profile-writer.d.ts.map +1 -0
  26. package/dist/auth/ports/profile-writer.js +2 -0
  27. package/dist/auth/ports/recovery-code-store.d.ts +10 -0
  28. package/dist/auth/ports/recovery-code-store.d.ts.map +1 -0
  29. package/dist/auth/ports/recovery-code-store.js +2 -0
  30. package/dist/auth/ports/reset-token-store.d.ts +12 -0
  31. package/dist/auth/ports/reset-token-store.d.ts.map +1 -0
  32. package/dist/auth/ports/reset-token-store.js +2 -0
  33. package/dist/auth/ports/session-facts-projector.d.ts +44 -0
  34. package/dist/auth/ports/session-facts-projector.d.ts.map +1 -0
  35. package/dist/auth/ports/session-facts-projector.js +30 -0
  36. package/dist/auth/ports/session-sweeper.d.ts +12 -0
  37. package/dist/auth/ports/session-sweeper.d.ts.map +1 -0
  38. package/dist/auth/ports/session-sweeper.js +2 -0
  39. package/dist/auth/profile-resolver.d.ts +73 -0
  40. package/dist/auth/profile-resolver.d.ts.map +1 -0
  41. package/dist/auth/profile-resolver.js +28 -0
  42. package/dist/auth/rate-limit/attempt-limiter.d.ts +91 -0
  43. package/dist/auth/rate-limit/attempt-limiter.d.ts.map +1 -0
  44. package/dist/auth/rate-limit/attempt-limiter.js +133 -0
  45. package/dist/auth/tokens.d.ts +174 -0
  46. package/dist/auth/tokens.d.ts.map +1 -1
  47. package/dist/auth/tokens.js +190 -1
  48. package/dist/auth/totp-enrollment.d.ts +42 -0
  49. package/dist/auth/totp-enrollment.d.ts.map +1 -0
  50. package/dist/auth/totp-enrollment.js +96 -0
  51. package/dist/authentication/mariadb-credential-resolver.d.ts +38 -24
  52. package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
  53. package/dist/authentication/mariadb-credential-resolver.js +42 -28
  54. package/dist/authz/cross-tenant-grant-writer.d.ts +259 -0
  55. package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -0
  56. package/dist/authz/cross-tenant-grant-writer.js +397 -0
  57. package/dist/authz/manifest-operation-authority-adapter.d.ts +65 -0
  58. package/dist/authz/manifest-operation-authority-adapter.d.ts.map +1 -0
  59. package/dist/authz/manifest-operation-authority-adapter.js +108 -0
  60. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts +90 -0
  61. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -0
  62. package/dist/authz/mariadb-cross-tenant-grant-adapter.js +211 -0
  63. package/dist/authz/mariadb-record-rule-adapter.d.ts +60 -0
  64. package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -0
  65. package/dist/authz/mariadb-record-rule-adapter.js +154 -0
  66. package/dist/authz/mariadb-record-share-adapter.d.ts +75 -0
  67. package/dist/authz/mariadb-record-share-adapter.d.ts.map +1 -0
  68. package/dist/authz/mariadb-record-share-adapter.js +106 -0
  69. package/dist/authz/record-share-cascade.d.ts +52 -0
  70. package/dist/authz/record-share-cascade.d.ts.map +1 -0
  71. package/dist/authz/record-share-cascade.js +49 -0
  72. package/dist/authz/record-share-writer.d.ts +175 -0
  73. package/dist/authz/record-share-writer.d.ts.map +1 -0
  74. package/dist/authz/record-share-writer.js +225 -0
  75. package/dist/authz/wire-record-authorization.d.ts +66 -0
  76. package/dist/authz/wire-record-authorization.d.ts.map +1 -0
  77. package/dist/authz/wire-record-authorization.js +34 -0
  78. package/dist/completeness/catalog-reconciler.d.ts +21 -0
  79. package/dist/completeness/catalog-reconciler.d.ts.map +1 -0
  80. package/dist/completeness/catalog-reconciler.js +74 -0
  81. package/dist/completeness/operation-inventory-scanner.d.ts +17 -0
  82. package/dist/completeness/operation-inventory-scanner.d.ts.map +1 -0
  83. package/dist/completeness/operation-inventory-scanner.js +79 -0
  84. package/dist/completeness/ports/catalog-store.d.ts +33 -0
  85. package/dist/completeness/ports/catalog-store.d.ts.map +1 -0
  86. package/dist/completeness/ports/catalog-store.js +2 -0
  87. package/dist/config/app-config.d.ts +18 -16
  88. package/dist/config/app-config.d.ts.map +1 -1
  89. package/dist/config/app-config.js +68 -35
  90. package/dist/enforcement/classification.decorator.d.ts +33 -14
  91. package/dist/enforcement/classification.decorator.d.ts.map +1 -1
  92. package/dist/enforcement/classification.decorator.js +34 -14
  93. package/dist/enforcement/global-enforcement.guard.d.ts +29 -19
  94. package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
  95. package/dist/enforcement/global-enforcement.guard.js +108 -25
  96. package/dist/enforcement/reenroll-allowlist.d.ts +49 -0
  97. package/dist/enforcement/reenroll-allowlist.d.ts.map +1 -0
  98. package/dist/enforcement/reenroll-allowlist.js +77 -0
  99. package/dist/index.d.ts +35 -3
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +159 -7
  102. package/dist/module/shield-tokens.d.ts +15 -0
  103. package/dist/module/shield-tokens.d.ts.map +1 -0
  104. package/dist/module/shield-tokens.js +17 -0
  105. package/dist/module/shield.module.d.ts +154 -10
  106. package/dist/module/shield.module.d.ts.map +1 -1
  107. package/dist/module/shield.module.js +181 -24
  108. package/dist/request-auth/request-principal.d.ts +42 -0
  109. package/dist/request-auth/request-principal.d.ts.map +1 -0
  110. package/dist/request-auth/request-principal.js +87 -0
  111. package/dist/request-auth/session-facts-als.d.ts +65 -0
  112. package/dist/request-auth/session-facts-als.d.ts.map +1 -0
  113. package/dist/request-auth/session-facts-als.js +57 -0
  114. package/dist/request-auth/session-token-binder.d.ts +130 -0
  115. package/dist/request-auth/session-token-binder.d.ts.map +1 -0
  116. package/dist/request-auth/session-token-binder.js +277 -0
  117. package/dist/request-auth/tokens.d.ts +14 -0
  118. package/dist/request-auth/tokens.d.ts.map +1 -0
  119. package/dist/request-auth/tokens.js +16 -0
  120. 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;