@cullet/erp-core 1.2.0 → 1.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 (218) hide show
  1. package/KIT_CONTEXT.md +5 -3
  2. package/README.md +160 -0
  3. package/dist/abac/index.cjs +7 -0
  4. package/dist/abac/index.d.cts +2 -0
  5. package/dist/abac/index.d.ts +2 -0
  6. package/dist/abac/index.js +2 -0
  7. package/dist/app-error.cjs +193 -0
  8. package/dist/app-error.cjs.map +1 -0
  9. package/dist/app-error.d.cts +108 -0
  10. package/dist/app-error.js +170 -0
  11. package/dist/app-error.js.map +1 -0
  12. package/dist/application/index.cjs +27 -0
  13. package/dist/application/index.d.cts +3 -0
  14. package/dist/application/index.d.ts +2 -1
  15. package/dist/application/index.js +2 -1
  16. package/dist/authorization-error.cjs +157 -0
  17. package/dist/authorization-error.cjs.map +1 -0
  18. package/dist/authorization-error.d.cts +113 -0
  19. package/dist/authorization-error.d.ts +113 -0
  20. package/dist/authorization-error.js +152 -0
  21. package/dist/authorization-error.js.map +1 -0
  22. package/dist/authorizer.port.d.cts +130 -0
  23. package/dist/authorizer.port.d.ts +130 -0
  24. package/dist/composite-authorizer.d.cts +216 -0
  25. package/dist/composite-authorizer.d.ts +216 -0
  26. package/dist/condition-evaluator.cjs +565 -0
  27. package/dist/condition-evaluator.cjs.map +1 -0
  28. package/dist/condition-evaluator.js +536 -0
  29. package/dist/condition-evaluator.js.map +1 -0
  30. package/dist/core-config.d.cts +172 -0
  31. package/dist/{gate-types.d.ts → core-config.d.ts} +78 -77
  32. package/dist/domain/index.cjs +17 -0
  33. package/dist/domain/index.d.cts +4 -0
  34. package/dist/domain/index.d.ts +2 -1
  35. package/dist/domain/index.js +2 -1
  36. package/dist/domain-event-contracts.cjs +34 -0
  37. package/dist/domain-event-contracts.cjs.map +1 -0
  38. package/dist/domain-event-contracts.d.cts +27 -0
  39. package/dist/domain-event-contracts.js +2 -1
  40. package/dist/domain-event-contracts.js.map +1 -1
  41. package/dist/domain-exception.cjs +17 -0
  42. package/dist/domain-exception.cjs.map +1 -0
  43. package/dist/domain-exception.d.cts +7 -0
  44. package/dist/entity.cjs +126 -0
  45. package/dist/entity.cjs.map +1 -0
  46. package/dist/entity.js +115 -0
  47. package/dist/entity.js.map +1 -0
  48. package/dist/errors/index.cjs +43 -0
  49. package/dist/errors/index.d.cts +4 -0
  50. package/dist/errors/index.d.ts +2 -1
  51. package/dist/errors/index.js +4 -2
  52. package/dist/exceptions/index.cjs +17 -0
  53. package/dist/exceptions/index.d.cts +5 -0
  54. package/dist/exceptions/validation-field.cjs +3 -0
  55. package/dist/exceptions/validation-field.d.cts +2 -0
  56. package/dist/gate-engine-registry.cjs +309 -0
  57. package/dist/gate-engine-registry.cjs.map +1 -0
  58. package/dist/gate-engine-registry.d.cts +82 -0
  59. package/dist/gate-engine-registry.d.ts +3 -2
  60. package/dist/gate-engine-registry.js +2 -1
  61. package/dist/gate-engine-registry.js.map +1 -1
  62. package/dist/gate-v1-payload.schema.cjs +76 -0
  63. package/dist/gate-v1-payload.schema.cjs.map +1 -0
  64. package/dist/gate-v1-payload.schema.js +1 -533
  65. package/dist/gate-v1-payload.schema.js.map +1 -1
  66. package/dist/hashing.cjs +66 -0
  67. package/dist/hashing.cjs.map +1 -0
  68. package/dist/immutable.cjs +77 -0
  69. package/dist/immutable.cjs.map +1 -0
  70. package/dist/immutable.d.cts +6 -0
  71. package/dist/index.cjs +181 -0
  72. package/dist/index.cjs.map +1 -0
  73. package/dist/index.d.cts +37 -0
  74. package/dist/index.d.ts +19 -12
  75. package/dist/index.js +15 -9
  76. package/dist/index.js.map +1 -1
  77. package/dist/invalid-state-transition-exception.cjs +47 -0
  78. package/dist/invalid-state-transition-exception.cjs.map +1 -0
  79. package/dist/invariant-violation-exception.cjs +16 -0
  80. package/dist/invariant-violation-exception.cjs.map +1 -0
  81. package/dist/not-found-error.cjs +65 -0
  82. package/dist/not-found-error.cjs.map +1 -0
  83. package/dist/not-found-error.js +1 -1
  84. package/dist/outcome.cjs +97 -0
  85. package/dist/outcome.cjs.map +1 -0
  86. package/dist/outcome.d.cts +58 -0
  87. package/dist/outcome.d.ts +1 -83
  88. package/dist/parse-gate-payload.d.cts +62 -0
  89. package/dist/parse-gate-payload.d.ts +2 -2
  90. package/dist/path.d.cts +90 -0
  91. package/dist/path.d.ts +2 -2
  92. package/dist/plugin.cjs +79 -0
  93. package/dist/plugin.cjs.map +1 -0
  94. package/dist/plugin.d.cts +85 -0
  95. package/dist/plugins/index.cjs +3 -0
  96. package/dist/plugins/index.d.cts +2 -0
  97. package/dist/policies/engines/index.cjs +10 -0
  98. package/dist/policies/engines/index.d.cts +4 -0
  99. package/dist/policies/engines/index.d.ts +1 -1
  100. package/dist/policies/engines/v1/gate/index.cjs +92 -0
  101. package/dist/policies/engines/v1/gate/index.cjs.map +1 -0
  102. package/dist/policies/engines/v1/gate/index.d.cts +121 -0
  103. package/dist/policies/engines/v1/gate/index.d.ts +2 -2
  104. package/dist/policies/engines/v1/gate/index.js +2 -1
  105. package/dist/policies/engines/v1/gate/index.js.map +1 -1
  106. package/dist/policies/index.cjs +41 -0
  107. package/dist/policies/index.d.cts +8 -0
  108. package/dist/policies/index.d.ts +3 -2
  109. package/dist/policies/index.js +2 -2
  110. package/dist/policy-bridge.cjs +437 -0
  111. package/dist/policy-bridge.cjs.map +1 -0
  112. package/dist/policy-bridge.d.cts +185 -0
  113. package/dist/policy-bridge.d.ts +185 -0
  114. package/dist/policy-bridge.js +396 -0
  115. package/dist/policy-bridge.js.map +1 -0
  116. package/dist/policy-service.cjs +1190 -0
  117. package/dist/policy-service.cjs.map +1 -0
  118. package/dist/policy-service.d.cts +559 -0
  119. package/dist/policy-service.d.ts +2 -2
  120. package/dist/policy-service.js +239 -239
  121. package/dist/policy-service.js.map +1 -1
  122. package/dist/rbac/index.cjs +9 -0
  123. package/dist/rbac/index.d.cts +3 -0
  124. package/dist/rbac/index.d.ts +3 -0
  125. package/dist/rbac/index.js +2 -0
  126. package/dist/requested-by.cjs +54 -0
  127. package/dist/requested-by.cjs.map +1 -0
  128. package/dist/requested-by.d.cts +25 -0
  129. package/dist/requested-by.d.ts +25 -0
  130. package/dist/requested-by.js +49 -0
  131. package/dist/requested-by.js.map +1 -0
  132. package/dist/result/index.cjs +7 -0
  133. package/dist/result/index.d.cts +3 -0
  134. package/dist/result/index.d.ts +2 -1
  135. package/dist/result.cjs +135 -0
  136. package/dist/result.cjs.map +1 -0
  137. package/dist/result.d.cts +84 -0
  138. package/dist/result.d.ts +84 -0
  139. package/dist/rule.cjs +327 -0
  140. package/dist/rule.cjs.map +1 -0
  141. package/dist/rule.js +298 -0
  142. package/dist/rule.js.map +1 -0
  143. package/dist/ruleset-registry.cjs +47 -0
  144. package/dist/ruleset-registry.cjs.map +1 -0
  145. package/dist/rulesets/index.cjs +3 -0
  146. package/dist/rulesets/index.d.cts +2 -0
  147. package/dist/temporal-guards.cjs +32 -0
  148. package/dist/temporal-guards.cjs.map +1 -0
  149. package/dist/temporal-use-case.cjs +139 -0
  150. package/dist/temporal-use-case.cjs.map +1 -0
  151. package/dist/temporal-use-case.d.cts +282 -0
  152. package/dist/temporal-use-case.d.ts +5 -27
  153. package/dist/temporal-use-case.js +2 -48
  154. package/dist/temporal-use-case.js.map +1 -1
  155. package/dist/unexpected-error.cjs +19 -0
  156. package/dist/unexpected-error.cjs.map +1 -0
  157. package/dist/unexpected-error.js +2 -167
  158. package/dist/unexpected-error.js.map +1 -1
  159. package/dist/use-case.cjs +96 -0
  160. package/dist/use-case.cjs.map +1 -0
  161. package/dist/uuid-identifier.cjs +65 -0
  162. package/dist/uuid-identifier.cjs.map +1 -0
  163. package/dist/uuid-identifier.d.cts +147 -0
  164. package/dist/uuid-identifier.d.ts +2 -85
  165. package/dist/validation-code.cjs +60 -0
  166. package/dist/validation-code.cjs.map +1 -0
  167. package/dist/validation-code.d.cts +23 -0
  168. package/dist/validation-error.cjs +887 -0
  169. package/dist/validation-error.cjs.map +1 -0
  170. package/dist/validation-error.d.cts +681 -0
  171. package/dist/validation-error.d.ts +2 -98
  172. package/dist/validation-error.js +7 -134
  173. package/dist/validation-error.js.map +1 -1
  174. package/dist/validation-exception.cjs +41 -0
  175. package/dist/validation-exception.cjs.map +1 -0
  176. package/dist/validation-exception.d.cts +50 -0
  177. package/dist/validation-field.cjs +28 -0
  178. package/dist/validation-field.cjs.map +1 -0
  179. package/dist/validation-field.d.cts +12 -0
  180. package/dist/value-object-ruleset.contracts.d.cts +36 -0
  181. package/dist/value-object.cjs +85 -0
  182. package/dist/value-object.cjs.map +1 -0
  183. package/dist/value-object.d.cts +89 -0
  184. package/dist/value-object.d.ts +89 -0
  185. package/dist/value-object.js +1 -112
  186. package/dist/value-object.js.map +1 -1
  187. package/dist/version.d.cts +10 -0
  188. package/dist/versioning/index.cjs +7 -0
  189. package/dist/versioning/index.d.cts +3 -0
  190. package/meta.json +19 -4
  191. package/package.json +173 -28
  192. package/src/abac/index.ts +1 -0
  193. package/src/core/abac/abac-request.ts +29 -0
  194. package/src/core/abac/attributes.ts +39 -0
  195. package/src/core/abac/authorizer.ts +108 -0
  196. package/src/core/abac/combining.ts +63 -0
  197. package/src/core/abac/composite-authorizer.ts +45 -0
  198. package/src/core/abac/domain/policy-set.ts +52 -0
  199. package/src/core/abac/domain/rule.ts +197 -0
  200. package/src/core/abac/index.ts +21 -0
  201. package/src/core/application/ports/abac-authorizer.port.ts +20 -0
  202. package/src/core/application/ports/authorizer.port.ts +22 -0
  203. package/src/core/errors/authorization-error.ts +26 -0
  204. package/src/core/errors/error-codes.ts +1 -0
  205. package/src/core/index.ts +7 -0
  206. package/src/core/rbac/access-request.ts +32 -0
  207. package/src/core/rbac/authorizer.ts +91 -0
  208. package/src/core/rbac/domain/grant.ts +96 -0
  209. package/src/core/rbac/domain/permission-set.ts +67 -0
  210. package/src/core/rbac/domain/permission.ts +119 -0
  211. package/src/core/rbac/domain/role.ts +101 -0
  212. package/src/core/rbac/domain/scope.ts +84 -0
  213. package/src/core/rbac/index.ts +15 -0
  214. package/src/core/rbac/policy-bridge.ts +44 -0
  215. package/src/examples/application/authorize-cancel-order-abac.example.ts +107 -0
  216. package/src/examples/application/authorize-cancel-order.example.ts +101 -0
  217. package/src/rbac/index.ts +1 -0
  218. package/src/version.ts +1 -1
@@ -0,0 +1,197 @@
1
+ import { ValidationCode } from "../../exceptions/validation-code.js";
2
+ import { InvalidValueException } from "../../exceptions/validation-exception.js";
3
+ import { ValidationField } from "../../exceptions/validation-field.js";
4
+ import { ValueObject } from "../../domain/value-object.js";
5
+ import type {
6
+ ConditionNode,
7
+ ConditionOp,
8
+ } from "../../policies/engines/v1/condition-types.js";
9
+
10
+ /** Whether a matching {@link AbacRule} permits or denies the action. */
11
+ type RuleEffect = "PERMIT" | "DENY";
12
+
13
+ /**
14
+ * The serializable shape of a rule: a stable `id`/`version` (surfaced on a
15
+ * denial as `policyId`/`policyVersion`), the {@link RuleEffect}, and the
16
+ * {@link ConditionNode} that decides whether the rule applies to a request's
17
+ * flattened attributes.
18
+ */
19
+ type AbacRuleProps = {
20
+ readonly id: string;
21
+ readonly version: number;
22
+ readonly effect: RuleEffect;
23
+ readonly condition: ConditionNode;
24
+ readonly description?: string;
25
+ };
26
+
27
+ const RULE_FIELD = ValidationField.of("abacRule");
28
+
29
+ const CONDITION_OPS = new Set<ConditionOp>([
30
+ "eq",
31
+ "neq",
32
+ "gt",
33
+ "gte",
34
+ "lt",
35
+ "lte",
36
+ "in",
37
+ "notIn",
38
+ "isNull",
39
+ "isNotNull",
40
+ ]);
41
+
42
+ const RULE_EFFECTS = new Set<RuleEffect>(["PERMIT", "DENY"]);
43
+
44
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
45
+ return typeof value === "object" && value !== null && !Array.isArray(value);
46
+ }
47
+
48
+ /**
49
+ * A single attribute-based rule: an {@link RuleEffect} that takes hold when its
50
+ * {@link ConditionNode} matches the request's flattened attributes. The
51
+ * condition reuses the gate/compute condition DSL (`{ field, op, value }` leaves
52
+ * combined with `and`/`or`/`not`), so a consumer learns one condition language
53
+ * across the whole kit.
54
+ *
55
+ * A zod-free value object: it validates its own shape on construction (throwing
56
+ * {@link InvalidValueException}) and is deep-frozen thereafter. Because rules are
57
+ * authored in TypeScript — trusted data, not untrusted JSON — the structural
58
+ * check is a hand-rolled walk of the condition tree rather than a schema parse.
59
+ * Build one through {@link of}; the constructor is private.
60
+ */
61
+ class AbacRule extends ValueObject<AbacRuleProps, AbacRuleProps> {
62
+ private constructor(props: AbacRuleProps) {
63
+ super(props);
64
+ this.finalize();
65
+ }
66
+
67
+ /**
68
+ * Builds a rule, validating that `id` is non-blank, `version` is an integer
69
+ * ≥ 1, `effect` is `"PERMIT"`/`"DENY"`, and `condition` is a well-formed
70
+ * condition node. Throws {@link InvalidValueException} otherwise.
71
+ */
72
+ static of(props: AbacRuleProps): AbacRule {
73
+ if (typeof props.id !== "string" || props.id.trim().length === 0) {
74
+ throw new InvalidValueException(
75
+ RULE_FIELD.nested("id"),
76
+ ValidationCode.BLANK,
77
+ `abac rule id must be a non-empty string, got "${props.id}"`,
78
+ );
79
+ }
80
+
81
+ if (!Number.isInteger(props.version) || props.version < 1) {
82
+ throw new InvalidValueException(
83
+ RULE_FIELD.nested("version"),
84
+ ValidationCode.OUT_OF_RANGE,
85
+ `abac rule version must be an integer >= 1, got "${props.version}"`,
86
+ );
87
+ }
88
+
89
+ if (!RULE_EFFECTS.has(props.effect)) {
90
+ throw new InvalidValueException(
91
+ RULE_FIELD.nested("effect"),
92
+ ValidationCode.INVALID_FORMAT,
93
+ `abac rule effect must be "PERMIT" or "DENY", got "${String(props.effect)}"`,
94
+ );
95
+ }
96
+
97
+ AbacRule.assertConditionShape(props.condition);
98
+
99
+ return new AbacRule(props);
100
+ }
101
+
102
+ // Mirrors what the shared condition evaluator would reject at eval time
103
+ // (empty and/or nodes, malformed leaves) but fails fast at construction.
104
+ private static assertConditionShape(node: unknown): void {
105
+ if (!isPlainObject(node)) {
106
+ AbacRule.rejectCondition(
107
+ "abac rule condition must be a leaf { field, op } or an { and | or | not } node",
108
+ );
109
+ }
110
+
111
+ if ("and" in node || "or" in node) {
112
+ const key = "and" in node ? "and" : "or";
113
+ const children = node[key];
114
+ if (!Array.isArray(children) || children.length === 0) {
115
+ AbacRule.rejectCondition(
116
+ `abac rule "${key}" node must hold a non-empty array of child conditions`,
117
+ );
118
+ }
119
+ for (const child of children) {
120
+ AbacRule.assertConditionShape(child);
121
+ }
122
+ return;
123
+ }
124
+
125
+ if ("not" in node) {
126
+ AbacRule.assertConditionShape(node.not);
127
+ return;
128
+ }
129
+
130
+ if ("field" in node && "op" in node) {
131
+ if (
132
+ typeof node.field !== "string" ||
133
+ node.field.trim().length === 0
134
+ ) {
135
+ AbacRule.rejectCondition(
136
+ `abac rule condition leaf "field" must be a non-empty string, got "${String(node.field)}"`,
137
+ );
138
+ }
139
+ if (
140
+ typeof node.op !== "string" ||
141
+ !CONDITION_OPS.has(node.op as ConditionOp)
142
+ ) {
143
+ AbacRule.rejectCondition(
144
+ `abac rule condition leaf "op" is not a supported operator, got "${String(node.op)}"`,
145
+ );
146
+ }
147
+ return;
148
+ }
149
+
150
+ AbacRule.rejectCondition(
151
+ "abac rule condition must be a leaf { field, op } or an { and | or | not } node",
152
+ );
153
+ }
154
+
155
+ private static rejectCondition(message: string): never {
156
+ throw new InvalidValueException(
157
+ RULE_FIELD.nested("condition"),
158
+ ValidationCode.INVALID_FORMAT,
159
+ message,
160
+ );
161
+ }
162
+
163
+ get id(): string {
164
+ return this.value.id;
165
+ }
166
+
167
+ get version(): number {
168
+ return this.value.version;
169
+ }
170
+
171
+ get effect(): RuleEffect {
172
+ return this.value.effect;
173
+ }
174
+
175
+ /** The condition tree evaluated against a request's flattened attributes. */
176
+ get condition(): ConditionNode {
177
+ return this.value.condition as ConditionNode;
178
+ }
179
+
180
+ get description(): string | undefined {
181
+ return this.value.description;
182
+ }
183
+
184
+ toPrimitive(): AbacRuleProps {
185
+ return {
186
+ id: this.value.id,
187
+ version: this.value.version,
188
+ effect: this.value.effect,
189
+ condition: this.value.condition as ConditionNode,
190
+ ...(this.value.description !== undefined
191
+ ? { description: this.value.description }
192
+ : {}),
193
+ };
194
+ }
195
+ }
196
+
197
+ export { AbacRule, type AbacRuleProps, type RuleEffect };
@@ -0,0 +1,21 @@
1
+ // Barrel for the zod-free ABAC module: the pure rule/policy-set primitives, the
2
+ // combining algorithms, the attribute→context flattening, the pure decisor, the
3
+ // optional RBAC+ABAC composite, and the AbacAuthorizerPort seam (which physically
4
+ // lives under application/ports/ but is surfaced here as the ABAC public home).
5
+
6
+ export type { AbacAuthorizerPort } from "../application/ports/abac-authorizer.port.js";
7
+
8
+ export type { AbacAttributes, AbacRequest } from "./abac-request.js";
9
+ export { abacContext } from "./attributes.js";
10
+ export { AbacAuthorizer, type AbacDecision } from "./authorizer.js";
11
+ export type { CombiningAlgorithm } from "./combining.js";
12
+ export {
13
+ type CompositeAccessRequest,
14
+ CompositeAuthorizer,
15
+ } from "./composite-authorizer.js";
16
+ export { AbacPolicySet } from "./domain/policy-set.js";
17
+ export {
18
+ AbacRule,
19
+ type AbacRuleProps,
20
+ type RuleEffect,
21
+ } from "./domain/rule.js";
@@ -0,0 +1,20 @@
1
+ import type { AbacRequest } from "../../abac/abac-request.js";
2
+ import type { AuthorizationError } from "../../errors/authorization-error.js";
3
+ import type { Result } from "../../result/result.js";
4
+
5
+ /**
6
+ * The seam an application layer injects to authorize an action by attributes —
7
+ * symmetric to {@link AuthorizerPort} (RBAC). The use case depends only on this
8
+ * contract; an adapter in the consumer's stack resolves the dynamic attributes
9
+ * (resource state, environment, RBAC facts) and delegates the decision to the
10
+ * pure `AbacAuthorizer`.
11
+ *
12
+ * Returns the decision as a {@link Result}: `ok(void)` when permitted, or
13
+ * `err(AuthorizationError)` carrying `reason: "policy_denied"` (or `"forbidden"`
14
+ * on a fail-closed evaluation error) that the HTTP boundary maps to a 403.
15
+ */
16
+ interface AbacAuthorizerPort {
17
+ authorize(request: AbacRequest): Promise<Result<void, AuthorizationError>>;
18
+ }
19
+
20
+ export type { AbacAuthorizerPort };
@@ -0,0 +1,22 @@
1
+ import type { AuthorizationError } from "../../errors/authorization-error.js";
2
+ import type { AccessRequest } from "../../rbac/access-request.js";
3
+ import type { Result } from "../../result/result.js";
4
+
5
+ /**
6
+ * The seam an application layer injects to authorize an action — symmetric to
7
+ * {@link PolicyPort}. The use case depends only on this contract; an adapter in
8
+ * the consumer's stack loads the actor's grants (from its own store) and
9
+ * delegates the decision to the pure `RbacAuthorizer`.
10
+ *
11
+ * Returns the decision as a {@link Result}: `ok(void)` when allowed, or
12
+ * `err(AuthorizationError)` carrying the discriminated `reason` the HTTP
13
+ * boundary maps to a 403. Promised here under `[extension-points]` in
14
+ * KIT_CONTEXT.md.
15
+ */
16
+ interface AuthorizerPort {
17
+ authorize(
18
+ request: AccessRequest,
19
+ ): Promise<Result<void, AuthorizationError>>;
20
+ }
21
+
22
+ export type { AuthorizerPort };
@@ -158,6 +158,32 @@ class AuthorizationError extends AppError {
158
158
  });
159
159
  }
160
160
 
161
+ /**
162
+ * The actor holds no role at all that bears on the action — the most basic
163
+ * denial, distinct from {@link AuthorizationError.missingCapability} (which
164
+ * means the actor *has* roles, just none granting the required capability).
165
+ * The expected {@link AuthorizationRequirement} is recorded so the boundary
166
+ * can tell the caller what grant is missing.
167
+ *
168
+ * @param input - Factory options plus the `required` role/capability/scope.
169
+ */
170
+ static missingRole(
171
+ input: AuthorizationErrorFactoryOptions & {
172
+ required: AuthorizationRequirement;
173
+ },
174
+ ): AuthorizationError {
175
+ return new AuthorizationError({
176
+ message: "Missing required role to perform the action",
177
+ code: ErrorCodes.authorization.missingRole,
178
+ reason: "missing_role",
179
+ metadata: {
180
+ reason: "missing_role",
181
+ ...extractMetadataOnly(input),
182
+ },
183
+ ...extractAppErrorOptions(input),
184
+ });
185
+ }
186
+
161
187
  /**
162
188
  * The actor lacks a required role or capability. The expected
163
189
  * {@link AuthorizationRequirement} is recorded so the boundary can tell the
@@ -8,6 +8,7 @@ const ErrorCodes = {
8
8
  authorization: {
9
9
  forbidden: "sec.authz.forbidden",
10
10
  policyDenied: "sec.authz.policy_denied",
11
+ missingRole: "sec.authz.missing_role",
11
12
  missingCapability: "sec.authz.missing_capability",
12
13
  outOfScope: "sec.authz.out_of_scope",
13
14
  },
package/src/core/index.ts CHANGED
@@ -9,6 +9,13 @@ export {
9
9
  type ValueObjectPluginContract,
10
10
  } from "./domain/value-object.js";
11
11
  export * from "./domain/rulesets/index.js";
12
+ // RBAC primitives (zod-free). Re-exported from the root for convenience;
13
+ // consumers that want to stay off `zod` should prefer the dedicated `./rbac`
14
+ // subpath, since the root barrel pulls in `./policies` (and therefore `zod`).
15
+ export * from "./rbac/index.js";
16
+ // ABAC primitives (zod-free), same rationale as RBAC re: the dedicated `./abac`
17
+ // subpath for staying off `zod`.
18
+ export * from "./abac/index.js";
12
19
  export * from "./plugins/index.js";
13
20
  export * from "./versioning/index.js";
14
21
  // NOTE: `./policies` re-exports Result/Ok/Err/Outcome/CommonOutcomeStatus and
@@ -0,0 +1,32 @@
1
+ import type { RequestedBy } from "../application/commands/requested-by.js";
2
+
3
+ import type { Permission } from "./domain/permission.js";
4
+ import type { Scope } from "./domain/scope.js";
5
+
6
+ /**
7
+ * The question put to the {@link RbacAuthorizer}: *may this actor perform this
8
+ * action on this resource, in this scope?* Carries the audit-friendly business
9
+ * `action` label alongside the concrete {@link Permission} it requires, and
10
+ * references the target by type/id only (never the full payload).
11
+ */
12
+ interface AccessRequest {
13
+ /** Who is acting — reused as the RBAC subject, no separate `Actor` type. */
14
+ readonly actor: RequestedBy;
15
+
16
+ /** Stable business action for auditing, e.g. `"order.cancel"`. */
17
+ readonly action: string;
18
+
19
+ /**
20
+ * The permission the action requires, e.g. `Permission.of("orders:cancel")`.
21
+ * Never a wildcard — only the *granted* permissions on roles may wildcard.
22
+ */
23
+ readonly required: Permission;
24
+
25
+ /** The target resource, identified without leaking its payload. */
26
+ readonly resource?: { readonly type: string; readonly id?: string };
27
+
28
+ /** The scope the resource lives in, e.g. `Scope.of("school:123")`. */
29
+ readonly scope: Scope;
30
+ }
31
+
32
+ export type { AccessRequest };
@@ -0,0 +1,91 @@
1
+ import {
2
+ AuthorizationError,
3
+ type AuthorizationRequirement,
4
+ } from "../errors/authorization-error.js";
5
+ import { ValidationCode } from "../exceptions/validation-code.js";
6
+ import { InvalidValueException } from "../exceptions/validation-exception.js";
7
+ import { ValidationField } from "../exceptions/validation-field.js";
8
+ import { Result } from "../result/result.js";
9
+
10
+ import type { AccessRequest } from "./access-request.js";
11
+ import type { Grant } from "./domain/grant.js";
12
+
13
+ const REQUIRED_FIELD = ValidationField.of("required");
14
+
15
+ /**
16
+ * The pure RBAC decisor. Given an {@link AccessRequest} and the {@link Grant}s
17
+ * already loaded for the actor, it answers "may this actor do this?" with no
18
+ * I/O — no storage, no network, no clock — returning a {@link Result}. An
19
+ * authorization *decision* is never thrown, always a value, mirroring the
20
+ * "errors as values" contract of `UseCase`/`PolicyService`. Loading the grants
21
+ * is the consumer's job (behind an `AuthorizerPort` adapter); this class only
22
+ * decides.
23
+ *
24
+ * The one thing it *does* throw is {@link InvalidValueException} when the caller
25
+ * misuses the API by passing a wildcard `required` permission — that is a
26
+ * programming error surfaced loudly, not an authorization outcome.
27
+ */
28
+ class RbacAuthorizer {
29
+ /**
30
+ * Decides the request against the supplied grants. The checks run
31
+ * role → capability → scope so the denial reason is the most informative
32
+ * one available:
33
+ *
34
+ * 1. no grant for the actor → `missing_role`;
35
+ * 2. has grants but none grants the permission → `missing_capability`;
36
+ * 3. grants the permission but not in scope → `out_of_scope`;
37
+ * 4. otherwise → ALLOW (`Result.ok`).
38
+ *
39
+ * @throws {@link InvalidValueException} when `request.required` is a wildcard
40
+ * permission (only *granted* permissions may wildcard, never the *required*
41
+ * one).
42
+ */
43
+ authorize(
44
+ request: AccessRequest,
45
+ grants: readonly Grant[],
46
+ ): Result<void, AuthorizationError> {
47
+ if (request.required.hasWildcard()) {
48
+ throw new InvalidValueException(
49
+ REQUIRED_FIELD,
50
+ ValidationCode.INVALID_FORMAT,
51
+ `AccessRequest.required must be a concrete permission, not a wildcard, got "${request.required.toPrimitive()}"`,
52
+ );
53
+ }
54
+
55
+ const required: AuthorizationRequirement = {
56
+ capability: request.required.toPrimitive(),
57
+ scope: request.scope.toPrimitive(),
58
+ };
59
+ const metadata = {
60
+ action: request.action,
61
+ resource: request.resource,
62
+ actor: { userId: request.actor.raw },
63
+ required,
64
+ };
65
+
66
+ const actorGrants = grants.filter((grant) =>
67
+ grant.appliesTo(request.actor),
68
+ );
69
+ if (actorGrants.length === 0) {
70
+ return Result.err(AuthorizationError.missingRole(metadata));
71
+ }
72
+
73
+ const withPermission = actorGrants.filter((grant) =>
74
+ grant.role.grants(request.required),
75
+ );
76
+ if (withPermission.length === 0) {
77
+ return Result.err(AuthorizationError.missingCapability(metadata));
78
+ }
79
+
80
+ const inScope = withPermission.filter((grant) =>
81
+ grant.scope.includes(request.scope),
82
+ );
83
+ if (inScope.length === 0) {
84
+ return Result.err(AuthorizationError.outOfScope(metadata));
85
+ }
86
+
87
+ return Result.ok(undefined);
88
+ }
89
+ }
90
+
91
+ export { RbacAuthorizer };
@@ -0,0 +1,96 @@
1
+ import { RequestedBy } from "../../application/commands/requested-by.js";
2
+ import { ValueObject } from "../../domain/value-object.js";
3
+
4
+ import { Role, type RoleProps } from "./role.js";
5
+ import { Scope } from "./scope.js";
6
+
7
+ /**
8
+ * The serializable shape a consumer persists and loads: the actor's identity
9
+ * (`RequestedBy.raw`), the role they hold (as {@link RoleProps}), and the scope
10
+ * the role applies in (the {@link Scope} string).
11
+ */
12
+ type GrantProps = {
13
+ readonly subject: string;
14
+ readonly role: RoleProps;
15
+ readonly scope: string;
16
+ };
17
+
18
+ /**
19
+ * A role binding — it ties one actor to one {@link Role} within one
20
+ * {@link Scope}. This is the unit the consumer stores (a `(subject, role,
21
+ * scope)` row) and rehydrates into the pure decisor; the kit itself persists
22
+ * nothing.
23
+ *
24
+ * A zod-free value object built through {@link of} from live
25
+ * `RequestedBy`/`Role`/`Scope` objects; the getters reconstruct those objects
26
+ * back from the frozen primitive form.
27
+ */
28
+ class Grant extends ValueObject<GrantProps, GrantProps> {
29
+ // The live objects are rehydrated once, at construction, and cached — the
30
+ // decisor reads `role`/`scope` at least once per grant, so parsing lazily on
31
+ // each getter access would re-parse the same frozen primitives repeatedly.
32
+ private readonly _subject: RequestedBy;
33
+ private readonly _role: Role;
34
+ private readonly _scope: Scope;
35
+
36
+ private constructor(props: GrantProps) {
37
+ super(props);
38
+ this._subject = RequestedBy.parse(this.value.subject);
39
+ this._role = Role.fromProps(this.value.role);
40
+ this._scope = Scope.of(this.value.scope);
41
+ this.finalize();
42
+ }
43
+
44
+ static of(params: {
45
+ subject: RequestedBy;
46
+ role: Role;
47
+ scope: Scope;
48
+ }): Grant {
49
+ return new Grant({
50
+ subject: params.subject.raw,
51
+ role: params.role.toPrimitive(),
52
+ scope: params.scope.toPrimitive(),
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Rebuilds a grant from its serialized {@link GrantProps} — the inverse of
58
+ * {@link toPrimitive}, symmetric to {@link Role.fromProps}. Each part is
59
+ * re-validated (subject through {@link RequestedBy.parse}, role through
60
+ * {@link Role.fromProps}, scope through {@link Scope.of}), so a malformed
61
+ * payload still fails loudly.
62
+ */
63
+ static fromProps(props: GrantProps): Grant {
64
+ return new Grant(props);
65
+ }
66
+
67
+ get subject(): RequestedBy {
68
+ return this._subject;
69
+ }
70
+
71
+ get role(): Role {
72
+ return this._role;
73
+ }
74
+
75
+ get scope(): Scope {
76
+ return this._scope;
77
+ }
78
+
79
+ /** Whether this grant belongs to `actor`. */
80
+ appliesTo(actor: RequestedBy): boolean {
81
+ return this.value.subject === actor.raw;
82
+ }
83
+
84
+ toPrimitive(): GrantProps {
85
+ return {
86
+ subject: this.value.subject,
87
+ role: {
88
+ name: this.value.role.name,
89
+ permissions: [...this.value.role.permissions],
90
+ },
91
+ scope: this.value.scope,
92
+ };
93
+ }
94
+ }
95
+
96
+ export { Grant, type GrantProps };
@@ -0,0 +1,67 @@
1
+ import type { Grant } from "./grant.js";
2
+ import type { Permission } from "./permission.js";
3
+
4
+ /**
5
+ * The flattened, deduplicated permissions an actor effectively holds across a
6
+ * set of {@link Grant}s, together with the role names that contributed them.
7
+ *
8
+ * Pure resolution, isolated from the decisor so the "roles → permissions"
9
+ * collapse can be tested on its own. Build through {@link fromGrants}; pre-filter
10
+ * the grants to a single actor first when that matters (the set does not know
11
+ * which actor it describes).
12
+ */
13
+ class PermissionSet {
14
+ private constructor(
15
+ private readonly permissions: readonly Permission[],
16
+ private readonly _roleNames: readonly string[],
17
+ ) {}
18
+
19
+ /**
20
+ * Collapses the roles across `grants` into one permission set. Permissions
21
+ * are deduplicated by their primitive form and role names by value, both
22
+ * preserving first-seen order.
23
+ */
24
+ static fromGrants(grants: readonly Grant[]): PermissionSet {
25
+ const permissions: Permission[] = [];
26
+ const seenPermissions = new Set<string>();
27
+ const roleNames: string[] = [];
28
+ const seenRoles = new Set<string>();
29
+
30
+ for (const grant of grants) {
31
+ const role = grant.role;
32
+
33
+ if (!seenRoles.has(role.name)) {
34
+ seenRoles.add(role.name);
35
+ roleNames.push(role.name);
36
+ }
37
+
38
+ for (const permission of role.permissions) {
39
+ const primitive = permission.toPrimitive();
40
+ if (seenPermissions.has(primitive)) continue;
41
+ seenPermissions.add(primitive);
42
+ permissions.push(permission);
43
+ }
44
+ }
45
+
46
+ return new PermissionSet(permissions, roleNames);
47
+ }
48
+
49
+ /** Whether any held permission {@link Permission.implies | implies} `required`. */
50
+ has(required: Permission): boolean {
51
+ return this.permissions.some((permission) =>
52
+ permission.implies(required),
53
+ );
54
+ }
55
+
56
+ /** The effective permissions, deduplicated. */
57
+ toArray(): readonly Permission[] {
58
+ return this.permissions;
59
+ }
60
+
61
+ /** The names of the roles that contributed to this set, deduplicated. */
62
+ get roleNames(): readonly string[] {
63
+ return this._roleNames;
64
+ }
65
+ }
66
+
67
+ export { PermissionSet };