@cullet/erp-core 1.3.0 → 1.5.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 +11 -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 +201 -0
- package/dist/app-error.cjs.map +1 -0
- package/dist/app-error.js +178 -0
- package/dist/app-error.js.map +1 -0
- package/dist/application/index.cjs +2 -1
- package/dist/application/index.d.cts +2 -1
- 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 +579 -0
- package/dist/condition-evaluator.cjs.map +1 -0
- package/dist/condition-evaluator.js +550 -0
- package/dist/condition-evaluator.js.map +1 -0
- package/dist/{gate-types.d.ts → core-config.d.cts} +78 -77
- package/dist/{gate-types.d.cts → core-config.d.ts} +78 -77
- package/dist/domain/index.cjs +2 -1
- package/dist/domain/index.d.cts +2 -1
- package/dist/domain/index.d.ts +2 -1
- package/dist/domain/index.js +2 -1
- package/dist/domain-event-contracts.cjs +3 -2
- package/dist/domain-event-contracts.cjs.map +1 -1
- package/dist/domain-event-contracts.js +2 -1
- package/dist/domain-event-contracts.js.map +1 -1
- package/dist/entity.cjs +122 -0
- package/dist/entity.cjs.map +1 -0
- package/dist/entity.js +111 -0
- package/dist/entity.js.map +1 -0
- package/dist/errors/index.cjs +10 -7
- package/dist/errors/index.d.cts +2 -1
- package/dist/errors/index.d.ts +2 -1
- package/dist/errors/index.js +6 -3
- package/dist/gate-engine-registry.cjs +5 -4
- package/dist/gate-engine-registry.cjs.map +1 -1
- package/dist/gate-engine-registry.d.cts +3 -2
- 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 +0 -562
- package/dist/gate-v1-payload.schema.cjs.map +1 -1
- package/dist/gate-v1-payload.schema.js +1 -533
- package/dist/gate-v1-payload.schema.js.map +1 -1
- package/dist/hashing.cjs +2 -44
- package/dist/hashing.cjs.map +1 -1
- package/dist/hashing.js +2 -38
- package/dist/hashing.js.map +1 -1
- package/dist/index.cjs +36 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +19 -12
- package/dist/index.d.ts +19 -12
- package/dist/index.js +17 -10
- package/dist/index.js.map +1 -1
- package/dist/not-found-error.cjs +5 -5
- package/dist/not-found-error.cjs.map +1 -1
- package/dist/not-found-error.js +1 -1
- package/dist/outcome.d.cts +1 -83
- package/dist/outcome.d.ts +1 -83
- package/dist/parse-gate-payload.d.cts +2 -2
- package/dist/parse-gate-payload.d.ts +2 -2
- package/dist/path.d.cts +2 -2
- package/dist/path.d.ts +2 -2
- package/dist/policies/engines/index.d.cts +1 -1
- package/dist/policies/engines/index.d.ts +1 -1
- package/dist/policies/engines/v1/gate/index.cjs +5 -4
- package/dist/policies/engines/v1/gate/index.cjs.map +1 -1
- package/dist/policies/engines/v1/gate/index.d.cts +4 -2
- package/dist/policies/engines/v1/gate/index.d.ts +4 -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 +5 -5
- package/dist/policies/index.d.cts +3 -2
- 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 +241 -240
- package/dist/policy-service.cjs.map +1 -1
- package/dist/policy-service.d.cts +2 -2
- package/dist/policy-service.d.ts +2 -2
- package/dist/policy-service.js +240 -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.d.cts +2 -1
- package/dist/result/index.d.ts +2 -1
- 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/stable-stringify.cjs +47 -0
- package/dist/stable-stringify.cjs.map +1 -0
- package/dist/stable-stringify.js +42 -0
- package/dist/stable-stringify.js.map +1 -0
- package/dist/temporal-use-case.cjs +6 -53
- package/dist/temporal-use-case.cjs.map +1 -1
- package/dist/temporal-use-case.d.cts +11 -28
- package/dist/temporal-use-case.d.ts +11 -28
- package/dist/temporal-use-case.js +7 -48
- package/dist/temporal-use-case.js.map +1 -1
- package/dist/unexpected-error.cjs +3 -192
- package/dist/unexpected-error.cjs.map +1 -1
- package/dist/unexpected-error.js +2 -167
- package/dist/unexpected-error.js.map +1 -1
- package/dist/use-case.cjs +2 -6
- package/dist/use-case.cjs.map +1 -1
- package/dist/use-case.js +2 -6
- package/dist/use-case.js.map +1 -1
- package/dist/uuid-identifier.cjs +2 -7
- package/dist/uuid-identifier.cjs.map +1 -1
- package/dist/uuid-identifier.d.cts +2 -85
- package/dist/uuid-identifier.d.ts +2 -85
- package/dist/uuid-identifier.js +1 -6
- package/dist/uuid-identifier.js.map +1 -1
- package/dist/uuid.cjs +18 -0
- package/dist/uuid.cjs.map +1 -0
- package/dist/uuid.js +13 -0
- package/dist/uuid.js.map +1 -0
- package/dist/validation-error.cjs +33 -166
- package/dist/validation-error.cjs.map +1 -1
- package/dist/validation-error.d.cts +12 -103
- package/dist/validation-error.d.ts +12 -103
- package/dist/validation-error.js +7 -134
- package/dist/validation-error.js.map +1 -1
- package/dist/value-object.cjs +12 -126
- package/dist/value-object.cjs.map +1 -1
- package/dist/value-object.d.cts +97 -0
- package/dist/value-object.d.ts +97 -0
- package/dist/value-object.js +13 -115
- package/dist/value-object.js.map +1 -1
- package/meta.json +20 -4
- package/package.json +27 -1
- 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/commands/requested-by.ts +3 -4
- package/src/core/application/ports/abac-authorizer.port.ts +20 -0
- package/src/core/application/ports/authorizer.port.ts +22 -0
- package/src/core/application/queries/query.ts +6 -1
- package/src/core/application/use-case.ts +1 -1
- package/src/core/domain/entity.ts +1 -1
- package/src/core/domain/uuid-identifier.ts +1 -8
- package/src/core/domain/value-object.ts +12 -3
- package/src/core/errors/authorization-error.ts +26 -0
- package/src/core/errors/error-codes.ts +1 -0
- package/src/core/errors/utils/json-safe.ts +20 -11
- package/src/core/index.ts +7 -0
- package/src/core/policies/engines/v1/condition-evaluator.ts +56 -1
- 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/core/shared/hashing.ts +1 -55
- package/src/core/shared/stable-stringify.ts +57 -0
- package/src/core/shared/uuid.ts +11 -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
|
@@ -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
|
|
@@ -30,18 +30,23 @@ function sanitizeValue(value: unknown, seen: WeakSet<object>): JsonSafeValue {
|
|
|
30
30
|
|
|
31
31
|
const type = typeof value;
|
|
32
32
|
|
|
33
|
-
// Primitives
|
|
34
|
-
if (type === "
|
|
33
|
+
// Primitives (non-finite numbers are not JSON-representable — placeholder)
|
|
34
|
+
if (type === "number") {
|
|
35
|
+
return Number.isFinite(value)
|
|
36
|
+
? (value as number)
|
|
37
|
+
: NON_SERIALIZABLE_PLACEHOLDER;
|
|
38
|
+
}
|
|
39
|
+
if (type === "string" || type === "boolean") {
|
|
35
40
|
return value as JsonSafeValue;
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
// `undefined` only reaches here as an array item: object properties with
|
|
44
|
+
// undefined values are omitted by the plain-object branch below. Both
|
|
45
|
+
// mirror `JSON.stringify` semantics.
|
|
46
|
+
if (value === undefined) return null;
|
|
47
|
+
|
|
38
48
|
// Non-serializable primitives
|
|
39
|
-
if (
|
|
40
|
-
type === "bigint" ||
|
|
41
|
-
type === "function" ||
|
|
42
|
-
type === "symbol" ||
|
|
43
|
-
value === undefined
|
|
44
|
-
) {
|
|
49
|
+
if (type === "bigint" || type === "function" || type === "symbol") {
|
|
45
50
|
return NON_SERIALIZABLE_PLACEHOLDER;
|
|
46
51
|
}
|
|
47
52
|
|
|
@@ -69,6 +74,7 @@ function sanitizeValue(value: unknown, seen: WeakSet<object>): JsonSafeValue {
|
|
|
69
74
|
seen.add(value);
|
|
70
75
|
const result: Record<string, JsonSafeValue> = {};
|
|
71
76
|
for (const [key, val] of Object.entries(value)) {
|
|
77
|
+
if (val === undefined) continue;
|
|
72
78
|
result[key] = sanitizeValue(val, seen);
|
|
73
79
|
}
|
|
74
80
|
seen.delete(value);
|
|
@@ -82,11 +88,14 @@ function sanitizeValue(value: unknown, seen: WeakSet<object>): JsonSafeValue {
|
|
|
82
88
|
/**
|
|
83
89
|
* Ensures metadata is JSON-serializable.
|
|
84
90
|
*
|
|
85
|
-
* **Allowed:**
|
|
91
|
+
* **Allowed:** finite number, string, boolean, null, arrays, and plain objects.
|
|
92
|
+
*
|
|
93
|
+
* **Dropped (mirroring `JSON.stringify`):** object properties whose value is
|
|
94
|
+
* `undefined` (the key is omitted); an `undefined` array item becomes `null`.
|
|
86
95
|
*
|
|
87
96
|
* **Converted to placeholder:** Date, BigInt, class instances, functions,
|
|
88
|
-
* symbols,
|
|
89
|
-
* `JSON.stringify` throw).
|
|
97
|
+
* symbols, non-finite numbers (`NaN`/`Infinity`), and circular references
|
|
98
|
+
* (which would otherwise make `JSON.stringify` throw).
|
|
90
99
|
*
|
|
91
100
|
* @throws {TypeError} If `input` is not a plain object at the root level.
|
|
92
101
|
*/
|
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
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { PolicyContextPath } from "../../context/index.js";
|
|
2
|
-
|
|
2
|
+
// Import the date util directly (not the utils barrel): the barrel re-exports
|
|
3
|
+
// PolicyHashing, whose node:crypto import would needlessly drag a Node-only
|
|
4
|
+
// API into the zod-free ./abac subpath.
|
|
5
|
+
import { PolicyDateUtils } from "../../utils/date.js";
|
|
3
6
|
import { Result } from "../../../result/result.js";
|
|
4
7
|
|
|
5
8
|
import type {
|
|
@@ -213,6 +216,32 @@ export class ConditionEvaluatorV1 {
|
|
|
213
216
|
return Result.err(message);
|
|
214
217
|
}
|
|
215
218
|
|
|
219
|
+
private static containsInvalidDate(value: unknown): boolean {
|
|
220
|
+
if (value instanceof Date) {
|
|
221
|
+
return !PolicyDateUtils.isValid(value);
|
|
222
|
+
}
|
|
223
|
+
if (Array.isArray(value)) {
|
|
224
|
+
return value.some((item) =>
|
|
225
|
+
ConditionEvaluatorV1.containsInvalidDate(item),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Valid Dates become their ISO string so equality/set operators compare
|
|
232
|
+
// instants instead of references; every other value passes through.
|
|
233
|
+
private static normalizeDateOperand(value: unknown): unknown {
|
|
234
|
+
if (value instanceof Date) {
|
|
235
|
+
return value.toISOString();
|
|
236
|
+
}
|
|
237
|
+
if (Array.isArray(value)) {
|
|
238
|
+
return value.map((item) =>
|
|
239
|
+
ConditionEvaluatorV1.normalizeDateOperand(item),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return value;
|
|
243
|
+
}
|
|
244
|
+
|
|
216
245
|
private reportDateOperandError(
|
|
217
246
|
node: ConditionLeafNode,
|
|
218
247
|
actual: unknown,
|
|
@@ -527,6 +556,32 @@ export class ConditionEvaluatorV1 {
|
|
|
527
556
|
return this.reportInvalidSetOperand(node, actual);
|
|
528
557
|
}
|
|
529
558
|
|
|
559
|
+
if (
|
|
560
|
+
node.op === "eq" ||
|
|
561
|
+
node.op === "neq" ||
|
|
562
|
+
node.op === "in" ||
|
|
563
|
+
node.op === "notIn"
|
|
564
|
+
) {
|
|
565
|
+
// A Date operand would otherwise compare by reference and
|
|
566
|
+
// silently never match. Normalize valid Dates (either side,
|
|
567
|
+
// including set items) to their ISO string; an invalid Date is
|
|
568
|
+
// a context/configuration error, like the relational path.
|
|
569
|
+
if (
|
|
570
|
+
ConditionEvaluatorV1.containsInvalidDate(actual) ||
|
|
571
|
+
ConditionEvaluatorV1.containsInvalidDate(node.value)
|
|
572
|
+
) {
|
|
573
|
+
return this.reportDateOperandError(node, actual);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return Result.ok(
|
|
577
|
+
this.evaluateOperator(
|
|
578
|
+
ConditionEvaluatorV1.normalizeDateOperand(actual),
|
|
579
|
+
node.op,
|
|
580
|
+
ConditionEvaluatorV1.normalizeDateOperand(node.value),
|
|
581
|
+
),
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
530
585
|
return Result.ok(
|
|
531
586
|
this.evaluateOperator(actual, node.op, node.value),
|
|
532
587
|
);
|
|
@@ -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 };
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
|
|
6
|
+
/** The two halves of a permission: what is acted on, and what is done to it. */
|
|
7
|
+
type PermissionProps = {
|
|
8
|
+
readonly resource: string;
|
|
9
|
+
readonly action: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const PERMISSION_FIELD = ValidationField.of("permission");
|
|
13
|
+
|
|
14
|
+
// A segment is either a whole-segment wildcard (`*`) or a run of lowercase
|
|
15
|
+
// alphanumerics, hyphens and underscores. Partial wildcards (`ord*`) are
|
|
16
|
+
// rejected on purpose: a `*` only ever stands for an entire segment.
|
|
17
|
+
const SEGMENT_PATTERN = /^(?:\*|[a-z0-9_-]+)$/u;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A `"<resource>:<action>"` capability such as `"orders:cancel"`, with a
|
|
21
|
+
* single-level wildcard. Either segment may be the whole-segment wildcard `*`
|
|
22
|
+
* (`"orders:*"`, `"*:cancel"`, `"*:*"`), which is what lets a broad grant cover
|
|
23
|
+
* a narrower requirement — see {@link implies}.
|
|
24
|
+
*
|
|
25
|
+
* A zod-free value object: it validates its own format on construction
|
|
26
|
+
* (throwing {@link InvalidValueException}) and is frozen thereafter. Build one
|
|
27
|
+
* through {@link of} (from a raw `"resource:action"` string) or {@link for}
|
|
28
|
+
* (from already-separated parts); the constructor is private.
|
|
29
|
+
*/
|
|
30
|
+
class Permission extends ValueObject<PermissionProps, string> {
|
|
31
|
+
private constructor(props: PermissionProps) {
|
|
32
|
+
super(props);
|
|
33
|
+
this.finalize();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parses a `"resource:action"` string. Throws {@link InvalidValueException}
|
|
38
|
+
* when the string is blank, lacks exactly one `:`, or carries an invalid
|
|
39
|
+
* segment.
|
|
40
|
+
*/
|
|
41
|
+
static of(raw: string): Permission {
|
|
42
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
43
|
+
throw new InvalidValueException(
|
|
44
|
+
PERMISSION_FIELD,
|
|
45
|
+
ValidationCode.BLANK,
|
|
46
|
+
`permission must be a non-empty "resource:action" string, got "${raw}"`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const parts = raw.split(":");
|
|
51
|
+
if (parts.length !== 2) {
|
|
52
|
+
throw new InvalidValueException(
|
|
53
|
+
PERMISSION_FIELD,
|
|
54
|
+
ValidationCode.INVALID_FORMAT,
|
|
55
|
+
`permission must have exactly one ":" separating resource and action, got "${raw}"`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return Permission.for(parts[0], parts[1]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Builds from already-separated `resource` and `action` parts. Each must be
|
|
64
|
+
* a valid segment (`[a-z0-9_-]+` or the whole-segment wildcard `*`).
|
|
65
|
+
*/
|
|
66
|
+
static for(resource: string, action: string): Permission {
|
|
67
|
+
Permission.assertSegment(resource, "resource");
|
|
68
|
+
Permission.assertSegment(action, "action");
|
|
69
|
+
return new Permission({ resource, action });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private static assertSegment(
|
|
73
|
+
value: string,
|
|
74
|
+
label: "resource" | "action",
|
|
75
|
+
): void {
|
|
76
|
+
if (typeof value !== "string" || !SEGMENT_PATTERN.test(value)) {
|
|
77
|
+
throw new InvalidValueException(
|
|
78
|
+
PERMISSION_FIELD.nested(label),
|
|
79
|
+
ValidationCode.INVALID_FORMAT,
|
|
80
|
+
`permission ${label} must match [a-z0-9_-]+ or be the whole-segment wildcard "*" (no partial wildcards), got "${value}"`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get resource(): string {
|
|
86
|
+
return this.value.resource;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get action(): string {
|
|
90
|
+
return this.value.action;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether holding this permission grants `other`. A `*` segment matches any
|
|
95
|
+
* value in the same position, so `"orders:*"` implies `"orders:cancel"` and
|
|
96
|
+
* `"*:*"` implies everything. Concrete segments must match exactly.
|
|
97
|
+
*/
|
|
98
|
+
implies(other: Permission): boolean {
|
|
99
|
+
return (
|
|
100
|
+
(this.resource === "*" || this.resource === other.resource) &&
|
|
101
|
+
(this.action === "*" || this.action === other.action)
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether either segment is the whole-segment wildcard `*`. A *granted*
|
|
107
|
+
* permission may wildcard (see {@link implies}); a *required* one may not —
|
|
108
|
+
* `RbacAuthorizer` rejects a wildcard requirement as caller misuse.
|
|
109
|
+
*/
|
|
110
|
+
hasWildcard(): boolean {
|
|
111
|
+
return this.value.resource === "*" || this.value.action === "*";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
toPrimitive(): string {
|
|
115
|
+
return `${this.value.resource}:${this.value.action}`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export { Permission, type PermissionProps };
|