@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,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 };
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
import { Permission } from "./permission.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The serializable shape of a role: a name plus its permissions kept as their
|
|
10
|
+
* primitive `"resource:action"` strings (so the role round-trips through JSON
|
|
11
|
+
* and stays deep-frozen). The {@link Role.permissions} getter rehydrates them
|
|
12
|
+
* back into {@link Permission} objects.
|
|
13
|
+
*/
|
|
14
|
+
type RoleProps = {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly permissions: readonly string[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const ROLE_FIELD = ValidationField.of("role");
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A named bundle of {@link Permission}s — `"cashier"`, `"manager"`, `"admin"`.
|
|
23
|
+
* There is **no role hierarchy** in v1: a role grants exactly the permissions
|
|
24
|
+
* it lists (wildcards included). Duplicate permissions are collapsed on
|
|
25
|
+
* construction, preserving first-seen order.
|
|
26
|
+
*
|
|
27
|
+
* A zod-free value object. Build through {@link of}; reconstruct a serialized
|
|
28
|
+
* one through {@link fromProps}.
|
|
29
|
+
*/
|
|
30
|
+
class Role extends ValueObject<RoleProps, RoleProps> {
|
|
31
|
+
// Parsed once, at construction, and cached: the decisor calls `grants` (and
|
|
32
|
+
// `PermissionSet` reads `permissions`) repeatedly, so re-parsing the stored
|
|
33
|
+
// strings on every access would be wasted work.
|
|
34
|
+
private readonly _permissions: readonly Permission[];
|
|
35
|
+
|
|
36
|
+
private constructor(props: RoleProps) {
|
|
37
|
+
super(props);
|
|
38
|
+
this._permissions = this.value.permissions.map(Permission.of);
|
|
39
|
+
this.finalize();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Builds a role from a name and its permissions. Throws
|
|
44
|
+
* {@link InvalidValueException} when the name is blank. Duplicate
|
|
45
|
+
* permissions are deduplicated.
|
|
46
|
+
*/
|
|
47
|
+
static of(name: string, permissions: readonly Permission[]): Role {
|
|
48
|
+
if (typeof name !== "string" || name.trim().length === 0) {
|
|
49
|
+
throw new InvalidValueException(
|
|
50
|
+
ROLE_FIELD,
|
|
51
|
+
ValidationCode.BLANK,
|
|
52
|
+
`role name must be a non-empty string, got "${name}"`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const seen = new Set<string>();
|
|
57
|
+
const deduped: string[] = [];
|
|
58
|
+
for (const permission of permissions) {
|
|
59
|
+
const primitive = permission.toPrimitive();
|
|
60
|
+
if (seen.has(primitive)) continue;
|
|
61
|
+
seen.add(primitive);
|
|
62
|
+
deduped.push(primitive);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return new Role({ name, permissions: deduped });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Rebuilds a role from its serialized {@link RoleProps} — the inverse of
|
|
70
|
+
* {@link toPrimitive}. Each stored string is re-parsed through
|
|
71
|
+
* {@link Permission.of}, so a malformed payload still fails loudly.
|
|
72
|
+
*/
|
|
73
|
+
static fromProps(props: RoleProps): Role {
|
|
74
|
+
return Role.of(props.name, props.permissions.map(Permission.of));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
get name(): string {
|
|
78
|
+
return this.value.name;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The role's permissions, rehydrated from their stored primitive form. */
|
|
82
|
+
get permissions(): readonly Permission[] {
|
|
83
|
+
return this._permissions;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Whether any permission in this role {@link Permission.implies | implies} `required`. */
|
|
87
|
+
grants(required: Permission): boolean {
|
|
88
|
+
return this._permissions.some((permission) =>
|
|
89
|
+
permission.implies(required),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
toPrimitive(): RoleProps {
|
|
94
|
+
return {
|
|
95
|
+
name: this.value.name,
|
|
96
|
+
permissions: [...this.value.permissions],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export { Role, type RoleProps };
|
|
@@ -0,0 +1,84 @@
|
|
|
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 wrapped, already-validated scope string (`"type:id"` or `"*"`). */
|
|
7
|
+
type ScopeProps = { readonly raw: string };
|
|
8
|
+
|
|
9
|
+
const SCOPE_FIELD = ValidationField.of("scope");
|
|
10
|
+
|
|
11
|
+
// "type:id": a lowercase type segment, a colon, then a non-empty id. The id
|
|
12
|
+
// charset is permissive enough for UUIDs and numeric keys (`school:42`,
|
|
13
|
+
// `tenant:550e8400-e29b-41d4-a716-446655440000`).
|
|
14
|
+
const SCOPE_PATTERN = /^[a-z][a-z0-9_-]*:[A-Za-z0-9._-]+$/u;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Where a grant (or a target resource) lives, aligned with
|
|
18
|
+
* `AuthorizationRequirement.scope` and the policy `PolicyScope`
|
|
19
|
+
* (`tenantId`/`schoolId`): `"tenant:{id}"`, `"school:{id}"`, or the global
|
|
20
|
+
* `"*"`.
|
|
21
|
+
*
|
|
22
|
+
* v1 keeps containment ({@link includes}) flat — the global scope contains
|
|
23
|
+
* everything, and any other scope contains only itself. A real hierarchy
|
|
24
|
+
* (`tenant` ⊇ `school`) is a deliberately deferred extension point.
|
|
25
|
+
*/
|
|
26
|
+
class Scope extends ValueObject<ScopeProps, string> {
|
|
27
|
+
private constructor(props: ScopeProps) {
|
|
28
|
+
super(props);
|
|
29
|
+
this.finalize();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Validates `"type:id"` or the global `"*"`, throwing on anything else. */
|
|
33
|
+
static of(raw: string): Scope {
|
|
34
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
35
|
+
throw new InvalidValueException(
|
|
36
|
+
SCOPE_FIELD,
|
|
37
|
+
ValidationCode.BLANK,
|
|
38
|
+
`scope must be a non-empty "type:id" string or "*", got "${raw}"`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (raw !== "*" && !SCOPE_PATTERN.test(raw)) {
|
|
43
|
+
throw new InvalidValueException(
|
|
44
|
+
SCOPE_FIELD,
|
|
45
|
+
ValidationCode.INVALID_FORMAT,
|
|
46
|
+
`scope must be "type:id" (e.g. "school:42") or the global "*", got "${raw}"`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return new Scope({ raw });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The global scope `"*"`, which {@link includes} every other scope. */
|
|
54
|
+
static global(): Scope {
|
|
55
|
+
return new Scope({ raw: "*" });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The type segment, or `"*"` for the global scope. */
|
|
59
|
+
get type(): string {
|
|
60
|
+
if (this.value.raw === "*") return "*";
|
|
61
|
+
return this.value.raw.slice(0, this.value.raw.indexOf(":"));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The id segment, or `undefined` for the global scope. */
|
|
65
|
+
get id(): string | undefined {
|
|
66
|
+
if (this.value.raw === "*") return undefined;
|
|
67
|
+
return this.value.raw.slice(this.value.raw.indexOf(":") + 1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether this scope contains `target`. The global scope contains
|
|
72
|
+
* everything; any other scope contains only an exact match of itself.
|
|
73
|
+
*/
|
|
74
|
+
includes(target: Scope): boolean {
|
|
75
|
+
if (this.value.raw === "*") return true;
|
|
76
|
+
return this.value.raw === target.value.raw;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
toPrimitive(): string {
|
|
80
|
+
return this.value.raw;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export { Scope, type ScopeProps };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Barrel for the zod-free RBAC module: the pure domain primitives, the pure
|
|
2
|
+
// decisor, the optional policy bridge, and the AuthorizerPort seam (which
|
|
3
|
+
// physically lives under application/ports/ but is surfaced here as the RBAC
|
|
4
|
+
// public home).
|
|
5
|
+
|
|
6
|
+
export type { AuthorizerPort } from "../application/ports/authorizer.port.js";
|
|
7
|
+
|
|
8
|
+
export type { AccessRequest } from "./access-request.js";
|
|
9
|
+
export { RbacAuthorizer } from "./authorizer.js";
|
|
10
|
+
export { Grant, type GrantProps } from "./domain/grant.js";
|
|
11
|
+
export { Permission, type PermissionProps } from "./domain/permission.js";
|
|
12
|
+
export { PermissionSet } from "./domain/permission-set.js";
|
|
13
|
+
export { Role, type RoleProps } from "./domain/role.js";
|
|
14
|
+
export { Scope, type ScopeProps } from "./domain/scope.js";
|
|
15
|
+
export { rbacContextFields } from "./policy-bridge.js";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { RequestedBy } from "../application/commands/requested-by.js";
|
|
2
|
+
|
|
3
|
+
import type { Grant } from "./domain/grant.js";
|
|
4
|
+
import { PermissionSet } from "./domain/permission-set.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Projects an actor's RBAC facts into flat {@link PolicyContext}-style fields so
|
|
8
|
+
* a declarative gate policy can reason over them (ABAC) — without importing the
|
|
9
|
+
* policy engine or `zod`. It only builds a plain object; the consumer merges it
|
|
10
|
+
* into `seed.fields` of `PolicyService.evaluate(...)`.
|
|
11
|
+
*
|
|
12
|
+
* The grants are filtered to `actor` first, so passing an actor's full grant
|
|
13
|
+
* list (or a mixed one) is safe.
|
|
14
|
+
*
|
|
15
|
+
* **Wildcards are projected literally — they are NOT expanded.** `actor.roles`
|
|
16
|
+
* and `actor.permissions` are the exact stored strings, so a permission such as
|
|
17
|
+
* `"orders:*"` or `"*:*"` appears verbatim. A gate condition like
|
|
18
|
+
* `{ field: "actor.permissions", op: "in", value: "orders:cancel" }` does a
|
|
19
|
+
* *literal* membership test and will therefore NOT match a holder of
|
|
20
|
+
* `"orders:*"`. Open-ended wildcards cannot be enumerated into a finite list, so
|
|
21
|
+
* for wildcard-aware checks either match on the finite `actor.roles` instead, or
|
|
22
|
+
* make the wildcard-aware decision with `RbacAuthorizer` (which knows
|
|
23
|
+
* {@link Permission.implies}) and use the gate only for the extra ABAC
|
|
24
|
+
* conditions.
|
|
25
|
+
*
|
|
26
|
+
* @returns `{ "actor.id", "actor.roles", "actor.permissions" }`, where roles and
|
|
27
|
+
* permissions are plain string arrays (wildcards left literal).
|
|
28
|
+
*/
|
|
29
|
+
export function rbacContextFields(
|
|
30
|
+
actor: RequestedBy,
|
|
31
|
+
grants: readonly Grant[],
|
|
32
|
+
): Record<string, unknown> {
|
|
33
|
+
const set = PermissionSet.fromGrants(
|
|
34
|
+
grants.filter((grant) => grant.appliesTo(actor)),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
"actor.id": actor.raw,
|
|
39
|
+
"actor.roles": [...set.roleNames],
|
|
40
|
+
"actor.permissions": set
|
|
41
|
+
.toArray()
|
|
42
|
+
.map((permission) => permission.toPrimitive()),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end reference for the ABAC seam: a concrete {@link Command} that refines
|
|
3
|
+
* a mutation through an {@link AbacAuthorizerPort} — deciding on the *attributes*
|
|
4
|
+
* of the request (the order's live status, the environment) rather than a static
|
|
5
|
+
* capability.
|
|
6
|
+
*
|
|
7
|
+
* Unlike the RBAC example, the aggregate is loaded *before* authorizing: ABAC
|
|
8
|
+
* reasons over resource state, so that state has to exist first. A denial comes
|
|
9
|
+
* back as `Result.err(AuthorizationError)` (translated to a 403 at the edge); a
|
|
10
|
+
* missing aggregate or a repository failure stays in band as `Result.err` too —
|
|
11
|
+
* the "errors as values" contract, never a thrown exception.
|
|
12
|
+
*
|
|
13
|
+
* Consumers import these symbols from `@cullet/erp-core` and
|
|
14
|
+
* `@cullet/erp-core/abac`; inside the kit we use relative paths so the example
|
|
15
|
+
* stays compilable and test-covered, guarding the README/KIT_CONTEXT snippet
|
|
16
|
+
* against rot.
|
|
17
|
+
*/
|
|
18
|
+
import type { AbacAuthorizerPort } from "../../core/abac/index.js";
|
|
19
|
+
import { Command, type CommandInput } from "../../core/application/index.js";
|
|
20
|
+
import type { ResultRepository } from "../../core/application/index.js";
|
|
21
|
+
import { AuthorizationError } from "../../core/errors/authorization-error.js";
|
|
22
|
+
import { NotFoundError } from "../../core/errors/not-found-error.js";
|
|
23
|
+
import { Result } from "../../core/result/result.js";
|
|
24
|
+
|
|
25
|
+
// ─── Minimal domain ─────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
type OrderStatus = "OPEN" | "CANCELLED";
|
|
28
|
+
|
|
29
|
+
interface Order {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly status: OrderStatus;
|
|
32
|
+
readonly locked: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Command contract ────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
interface CancelOrderInput extends CommandInput {
|
|
38
|
+
readonly orderId: string;
|
|
39
|
+
/** An environment attribute the composition root resolves from its clock. */
|
|
40
|
+
readonly businessHours: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type CancelOrderError = NotFoundError | AuthorizationError;
|
|
44
|
+
|
|
45
|
+
// ─── The use case ─────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
class CancelOrder extends Command<
|
|
48
|
+
CancelOrderInput,
|
|
49
|
+
Result<Order, CancelOrderError>
|
|
50
|
+
> {
|
|
51
|
+
constructor(
|
|
52
|
+
private readonly orders: ResultRepository<
|
|
53
|
+
Order,
|
|
54
|
+
string,
|
|
55
|
+
CancelOrderError
|
|
56
|
+
>,
|
|
57
|
+
private readonly authorizer: AbacAuthorizerPort,
|
|
58
|
+
) {
|
|
59
|
+
super();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
protected async execute(
|
|
63
|
+
input: CancelOrderInput,
|
|
64
|
+
): Promise<Result<Order, CancelOrderError>> {
|
|
65
|
+
// 1. Load the aggregate — its live state feeds the ABAC decision.
|
|
66
|
+
const found = await this.orders.findById(input.orderId);
|
|
67
|
+
if (found.isErr()) {
|
|
68
|
+
return found;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const order = found.getOrThrow();
|
|
72
|
+
if (!order) {
|
|
73
|
+
return Result.err(
|
|
74
|
+
new NotFoundError("Order", { id: input.orderId }),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2. Gate on attributes: the order's status/lock plus the environment.
|
|
79
|
+
// `CommandInput.requestedBy` is the ABAC subject.
|
|
80
|
+
const allowed = await this.authorizer.authorize({
|
|
81
|
+
actor: input.requestedBy,
|
|
82
|
+
action: "order.cancel",
|
|
83
|
+
resource: { type: "Order", id: order.id },
|
|
84
|
+
attributes: {
|
|
85
|
+
resource: { status: order.status, locked: order.locked },
|
|
86
|
+
environment: { businessHours: input.businessHours },
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
if (allowed.isErr()) {
|
|
90
|
+
// AuthorizationError → 403 at the boundary.
|
|
91
|
+
return Result.err(allowed.error);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 3. Mutate and persist. A version conflict on save also surfaces as
|
|
95
|
+
// Result.err — never an exception crossing the boundary.
|
|
96
|
+
const cancelled: Order = { ...order, status: "CANCELLED" };
|
|
97
|
+
const saved = await this.orders.save(cancelled);
|
|
98
|
+
if (saved.isErr()) {
|
|
99
|
+
return saved;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return Result.ok(cancelled);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { CancelOrder };
|
|
107
|
+
export type { CancelOrderError, CancelOrderInput, Order, OrderStatus };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end reference for the RBAC seam: a concrete {@link Command} that gates
|
|
3
|
+
* a mutation through an {@link AuthorizerPort} before touching the aggregate.
|
|
4
|
+
*
|
|
5
|
+
* Flow: ask the injected authorizer whether the actor may cancel the order →
|
|
6
|
+
* load the aggregate → mutate and persist. An authorization denial comes back
|
|
7
|
+
* as `Result.err(AuthorizationError)` (translated to a 403 at the edge), and a
|
|
8
|
+
* missing aggregate or a repository failure stays in band as `Result.err`
|
|
9
|
+
* too — the "errors as values" contract, never a thrown exception.
|
|
10
|
+
*
|
|
11
|
+
* Consumers import these symbols from `@cullet/erp-core` and
|
|
12
|
+
* `@cullet/erp-core/rbac`; inside the kit we use relative paths so the example
|
|
13
|
+
* stays compilable and test-covered, guarding the README/KIT_CONTEXT snippet
|
|
14
|
+
* against rot.
|
|
15
|
+
*/
|
|
16
|
+
import { Command, type CommandInput } from "../../core/application/index.js";
|
|
17
|
+
import type { ResultRepository } from "../../core/application/index.js";
|
|
18
|
+
import type { AuthorizerPort } from "../../core/application/ports/authorizer.port.js";
|
|
19
|
+
import { AuthorizationError } from "../../core/errors/authorization-error.js";
|
|
20
|
+
import { NotFoundError } from "../../core/errors/not-found-error.js";
|
|
21
|
+
import { Permission, Scope } from "../../core/rbac/index.js";
|
|
22
|
+
import { Result } from "../../core/result/result.js";
|
|
23
|
+
|
|
24
|
+
// ─── Minimal domain ─────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
type OrderStatus = "OPEN" | "CANCELLED";
|
|
27
|
+
|
|
28
|
+
interface Order {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly status: OrderStatus;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Command contract ────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
interface CancelOrderInput extends CommandInput {
|
|
36
|
+
readonly orderId: string;
|
|
37
|
+
readonly schoolId: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type CancelOrderError = NotFoundError | AuthorizationError;
|
|
41
|
+
|
|
42
|
+
// ─── The use case ─────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
class CancelOrder extends Command<
|
|
45
|
+
CancelOrderInput,
|
|
46
|
+
Result<Order, CancelOrderError>
|
|
47
|
+
> {
|
|
48
|
+
constructor(
|
|
49
|
+
private readonly orders: ResultRepository<
|
|
50
|
+
Order,
|
|
51
|
+
string,
|
|
52
|
+
CancelOrderError
|
|
53
|
+
>,
|
|
54
|
+
private readonly authorizer: AuthorizerPort,
|
|
55
|
+
) {
|
|
56
|
+
super();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
protected async execute(
|
|
60
|
+
input: CancelOrderInput,
|
|
61
|
+
): Promise<Result<Order, CancelOrderError>> {
|
|
62
|
+
// 1. Gate the action. `CommandInput.requestedBy` is the RBAC actor.
|
|
63
|
+
const allowed = await this.authorizer.authorize({
|
|
64
|
+
actor: input.requestedBy,
|
|
65
|
+
action: "order.cancel",
|
|
66
|
+
required: Permission.of("orders:cancel"),
|
|
67
|
+
resource: { type: "Order", id: input.orderId },
|
|
68
|
+
scope: Scope.of(`school:${input.schoolId}`),
|
|
69
|
+
});
|
|
70
|
+
if (allowed.isErr()) {
|
|
71
|
+
// AuthorizationError → 403 at the boundary.
|
|
72
|
+
return Result.err(allowed.error);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 2. Load the aggregate. Infra failures stay in band as Result.err.
|
|
76
|
+
const found = await this.orders.findById(input.orderId);
|
|
77
|
+
if (found.isErr()) {
|
|
78
|
+
return found;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const order = found.getOrThrow();
|
|
82
|
+
if (!order) {
|
|
83
|
+
return Result.err(
|
|
84
|
+
new NotFoundError("Order", { id: input.orderId }),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 3. Mutate and persist. A version conflict on save also surfaces as
|
|
89
|
+
// Result.err — never an exception crossing the boundary.
|
|
90
|
+
const cancelled: Order = { ...order, status: "CANCELLED" };
|
|
91
|
+
const saved = await this.orders.save(cancelled);
|
|
92
|
+
if (saved.isErr()) {
|
|
93
|
+
return saved;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return Result.ok(cancelled);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export { CancelOrder };
|
|
101
|
+
export type { CancelOrderError, CancelOrderInput, Order, OrderStatus };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../core/rbac/index.js";
|
package/src/version.ts
CHANGED
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
// Mantemos a versao aqui, dentro de src/, para que a copia full-control seja
|
|
5
5
|
// auto-contida: ao copiar so o conteudo de src/, o entry nao depende de um
|
|
6
6
|
// `../package.json` que deixaria de existir no projeto consumidor.
|
|
7
|
-
export const version = "1.
|
|
7
|
+
export const version = "1.4.0";
|