@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.
- package/KIT_CONTEXT.md +5 -3
- package/README.md +160 -0
- package/dist/abac/index.cjs +7 -0
- package/dist/abac/index.d.cts +2 -0
- package/dist/abac/index.d.ts +2 -0
- package/dist/abac/index.js +2 -0
- package/dist/app-error.cjs +193 -0
- package/dist/app-error.cjs.map +1 -0
- package/dist/app-error.d.cts +108 -0
- package/dist/app-error.js +170 -0
- package/dist/app-error.js.map +1 -0
- package/dist/application/index.cjs +27 -0
- package/dist/application/index.d.cts +3 -0
- package/dist/application/index.d.ts +2 -1
- package/dist/application/index.js +2 -1
- package/dist/authorization-error.cjs +157 -0
- package/dist/authorization-error.cjs.map +1 -0
- package/dist/authorization-error.d.cts +113 -0
- package/dist/authorization-error.d.ts +113 -0
- package/dist/authorization-error.js +152 -0
- package/dist/authorization-error.js.map +1 -0
- package/dist/authorizer.port.d.cts +130 -0
- package/dist/authorizer.port.d.ts +130 -0
- package/dist/composite-authorizer.d.cts +216 -0
- package/dist/composite-authorizer.d.ts +216 -0
- package/dist/condition-evaluator.cjs +565 -0
- package/dist/condition-evaluator.cjs.map +1 -0
- package/dist/condition-evaluator.js +536 -0
- package/dist/condition-evaluator.js.map +1 -0
- package/dist/core-config.d.cts +172 -0
- package/dist/{gate-types.d.ts → core-config.d.ts} +78 -77
- package/dist/domain/index.cjs +17 -0
- package/dist/domain/index.d.cts +4 -0
- package/dist/domain/index.d.ts +2 -1
- package/dist/domain/index.js +2 -1
- package/dist/domain-event-contracts.cjs +34 -0
- package/dist/domain-event-contracts.cjs.map +1 -0
- package/dist/domain-event-contracts.d.cts +27 -0
- package/dist/domain-event-contracts.js +2 -1
- package/dist/domain-event-contracts.js.map +1 -1
- package/dist/domain-exception.cjs +17 -0
- package/dist/domain-exception.cjs.map +1 -0
- package/dist/domain-exception.d.cts +7 -0
- package/dist/entity.cjs +126 -0
- package/dist/entity.cjs.map +1 -0
- package/dist/entity.js +115 -0
- package/dist/entity.js.map +1 -0
- package/dist/errors/index.cjs +43 -0
- package/dist/errors/index.d.cts +4 -0
- package/dist/errors/index.d.ts +2 -1
- package/dist/errors/index.js +4 -2
- package/dist/exceptions/index.cjs +17 -0
- package/dist/exceptions/index.d.cts +5 -0
- package/dist/exceptions/validation-field.cjs +3 -0
- package/dist/exceptions/validation-field.d.cts +2 -0
- package/dist/gate-engine-registry.cjs +309 -0
- package/dist/gate-engine-registry.cjs.map +1 -0
- package/dist/gate-engine-registry.d.cts +82 -0
- package/dist/gate-engine-registry.d.ts +3 -2
- package/dist/gate-engine-registry.js +2 -1
- package/dist/gate-engine-registry.js.map +1 -1
- package/dist/gate-v1-payload.schema.cjs +76 -0
- package/dist/gate-v1-payload.schema.cjs.map +1 -0
- package/dist/gate-v1-payload.schema.js +1 -533
- package/dist/gate-v1-payload.schema.js.map +1 -1
- package/dist/hashing.cjs +66 -0
- package/dist/hashing.cjs.map +1 -0
- package/dist/immutable.cjs +77 -0
- package/dist/immutable.cjs.map +1 -0
- package/dist/immutable.d.cts +6 -0
- package/dist/index.cjs +181 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +37 -0
- package/dist/index.d.ts +19 -12
- package/dist/index.js +15 -9
- package/dist/index.js.map +1 -1
- package/dist/invalid-state-transition-exception.cjs +47 -0
- package/dist/invalid-state-transition-exception.cjs.map +1 -0
- package/dist/invariant-violation-exception.cjs +16 -0
- package/dist/invariant-violation-exception.cjs.map +1 -0
- package/dist/not-found-error.cjs +65 -0
- package/dist/not-found-error.cjs.map +1 -0
- package/dist/not-found-error.js +1 -1
- package/dist/outcome.cjs +97 -0
- package/dist/outcome.cjs.map +1 -0
- package/dist/outcome.d.cts +58 -0
- package/dist/outcome.d.ts +1 -83
- package/dist/parse-gate-payload.d.cts +62 -0
- package/dist/parse-gate-payload.d.ts +2 -2
- package/dist/path.d.cts +90 -0
- package/dist/path.d.ts +2 -2
- package/dist/plugin.cjs +79 -0
- package/dist/plugin.cjs.map +1 -0
- package/dist/plugin.d.cts +85 -0
- package/dist/plugins/index.cjs +3 -0
- package/dist/plugins/index.d.cts +2 -0
- package/dist/policies/engines/index.cjs +10 -0
- package/dist/policies/engines/index.d.cts +4 -0
- package/dist/policies/engines/index.d.ts +1 -1
- package/dist/policies/engines/v1/gate/index.cjs +92 -0
- package/dist/policies/engines/v1/gate/index.cjs.map +1 -0
- package/dist/policies/engines/v1/gate/index.d.cts +121 -0
- package/dist/policies/engines/v1/gate/index.d.ts +2 -2
- package/dist/policies/engines/v1/gate/index.js +2 -1
- package/dist/policies/engines/v1/gate/index.js.map +1 -1
- package/dist/policies/index.cjs +41 -0
- package/dist/policies/index.d.cts +8 -0
- package/dist/policies/index.d.ts +3 -2
- package/dist/policies/index.js +2 -2
- package/dist/policy-bridge.cjs +437 -0
- package/dist/policy-bridge.cjs.map +1 -0
- package/dist/policy-bridge.d.cts +185 -0
- package/dist/policy-bridge.d.ts +185 -0
- package/dist/policy-bridge.js +396 -0
- package/dist/policy-bridge.js.map +1 -0
- package/dist/policy-service.cjs +1190 -0
- package/dist/policy-service.cjs.map +1 -0
- package/dist/policy-service.d.cts +559 -0
- package/dist/policy-service.d.ts +2 -2
- package/dist/policy-service.js +239 -239
- package/dist/policy-service.js.map +1 -1
- package/dist/rbac/index.cjs +9 -0
- package/dist/rbac/index.d.cts +3 -0
- package/dist/rbac/index.d.ts +3 -0
- package/dist/rbac/index.js +2 -0
- package/dist/requested-by.cjs +54 -0
- package/dist/requested-by.cjs.map +1 -0
- package/dist/requested-by.d.cts +25 -0
- package/dist/requested-by.d.ts +25 -0
- package/dist/requested-by.js +49 -0
- package/dist/requested-by.js.map +1 -0
- package/dist/result/index.cjs +7 -0
- package/dist/result/index.d.cts +3 -0
- package/dist/result/index.d.ts +2 -1
- package/dist/result.cjs +135 -0
- package/dist/result.cjs.map +1 -0
- package/dist/result.d.cts +84 -0
- package/dist/result.d.ts +84 -0
- package/dist/rule.cjs +327 -0
- package/dist/rule.cjs.map +1 -0
- package/dist/rule.js +298 -0
- package/dist/rule.js.map +1 -0
- package/dist/ruleset-registry.cjs +47 -0
- package/dist/ruleset-registry.cjs.map +1 -0
- package/dist/rulesets/index.cjs +3 -0
- package/dist/rulesets/index.d.cts +2 -0
- package/dist/temporal-guards.cjs +32 -0
- package/dist/temporal-guards.cjs.map +1 -0
- package/dist/temporal-use-case.cjs +139 -0
- package/dist/temporal-use-case.cjs.map +1 -0
- package/dist/temporal-use-case.d.cts +282 -0
- package/dist/temporal-use-case.d.ts +5 -27
- package/dist/temporal-use-case.js +2 -48
- package/dist/temporal-use-case.js.map +1 -1
- package/dist/unexpected-error.cjs +19 -0
- package/dist/unexpected-error.cjs.map +1 -0
- package/dist/unexpected-error.js +2 -167
- package/dist/unexpected-error.js.map +1 -1
- package/dist/use-case.cjs +96 -0
- package/dist/use-case.cjs.map +1 -0
- package/dist/uuid-identifier.cjs +65 -0
- package/dist/uuid-identifier.cjs.map +1 -0
- package/dist/uuid-identifier.d.cts +147 -0
- package/dist/uuid-identifier.d.ts +2 -85
- package/dist/validation-code.cjs +60 -0
- package/dist/validation-code.cjs.map +1 -0
- package/dist/validation-code.d.cts +23 -0
- package/dist/validation-error.cjs +887 -0
- package/dist/validation-error.cjs.map +1 -0
- package/dist/validation-error.d.cts +681 -0
- package/dist/validation-error.d.ts +2 -98
- package/dist/validation-error.js +7 -134
- package/dist/validation-error.js.map +1 -1
- package/dist/validation-exception.cjs +41 -0
- package/dist/validation-exception.cjs.map +1 -0
- package/dist/validation-exception.d.cts +50 -0
- package/dist/validation-field.cjs +28 -0
- package/dist/validation-field.cjs.map +1 -0
- package/dist/validation-field.d.cts +12 -0
- package/dist/value-object-ruleset.contracts.d.cts +36 -0
- package/dist/value-object.cjs +85 -0
- package/dist/value-object.cjs.map +1 -0
- package/dist/value-object.d.cts +89 -0
- package/dist/value-object.d.ts +89 -0
- package/dist/value-object.js +1 -112
- package/dist/value-object.js.map +1 -1
- package/dist/version.d.cts +10 -0
- package/dist/versioning/index.cjs +7 -0
- package/dist/versioning/index.d.cts +3 -0
- package/meta.json +19 -4
- package/package.json +173 -28
- package/src/abac/index.ts +1 -0
- package/src/core/abac/abac-request.ts +29 -0
- package/src/core/abac/attributes.ts +39 -0
- package/src/core/abac/authorizer.ts +108 -0
- package/src/core/abac/combining.ts +63 -0
- package/src/core/abac/composite-authorizer.ts +45 -0
- package/src/core/abac/domain/policy-set.ts +52 -0
- package/src/core/abac/domain/rule.ts +197 -0
- package/src/core/abac/index.ts +21 -0
- package/src/core/application/ports/abac-authorizer.port.ts +20 -0
- package/src/core/application/ports/authorizer.port.ts +22 -0
- package/src/core/errors/authorization-error.ts +26 -0
- package/src/core/errors/error-codes.ts +1 -0
- package/src/core/index.ts +7 -0
- package/src/core/rbac/access-request.ts +32 -0
- package/src/core/rbac/authorizer.ts +91 -0
- package/src/core/rbac/domain/grant.ts +96 -0
- package/src/core/rbac/domain/permission-set.ts +67 -0
- package/src/core/rbac/domain/permission.ts +119 -0
- package/src/core/rbac/domain/role.ts +101 -0
- package/src/core/rbac/domain/scope.ts +84 -0
- package/src/core/rbac/index.ts +15 -0
- package/src/core/rbac/policy-bridge.ts +44 -0
- package/src/examples/application/authorize-cancel-order-abac.example.ts +107 -0
- package/src/examples/application/authorize-cancel-order.example.ts +101 -0
- package/src/rbac/index.ts +1 -0
- 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
|
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 };
|