@cullet/erp-core 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/KIT_CONTEXT.md +5 -3
  2. package/README.md +160 -0
  3. package/dist/abac/index.cjs +7 -0
  4. package/dist/abac/index.d.cts +2 -0
  5. package/dist/abac/index.d.ts +2 -0
  6. package/dist/abac/index.js +2 -0
  7. package/dist/app-error.cjs +193 -0
  8. package/dist/app-error.cjs.map +1 -0
  9. package/dist/app-error.js +170 -0
  10. package/dist/app-error.js.map +1 -0
  11. package/dist/application/index.cjs +2 -1
  12. package/dist/application/index.d.cts +2 -1
  13. package/dist/application/index.d.ts +2 -1
  14. package/dist/application/index.js +2 -1
  15. package/dist/authorization-error.cjs +157 -0
  16. package/dist/authorization-error.cjs.map +1 -0
  17. package/dist/authorization-error.d.cts +113 -0
  18. package/dist/authorization-error.d.ts +113 -0
  19. package/dist/authorization-error.js +152 -0
  20. package/dist/authorization-error.js.map +1 -0
  21. package/dist/authorizer.port.d.cts +130 -0
  22. package/dist/authorizer.port.d.ts +130 -0
  23. package/dist/composite-authorizer.d.cts +216 -0
  24. package/dist/composite-authorizer.d.ts +216 -0
  25. package/dist/condition-evaluator.cjs +565 -0
  26. package/dist/condition-evaluator.cjs.map +1 -0
  27. package/dist/condition-evaluator.js +536 -0
  28. package/dist/condition-evaluator.js.map +1 -0
  29. package/dist/{gate-types.d.ts → core-config.d.cts} +78 -77
  30. package/dist/{gate-types.d.cts → core-config.d.ts} +78 -77
  31. package/dist/domain/index.cjs +2 -1
  32. package/dist/domain/index.d.cts +2 -1
  33. package/dist/domain/index.d.ts +2 -1
  34. package/dist/domain/index.js +2 -1
  35. package/dist/domain-event-contracts.cjs +3 -2
  36. package/dist/domain-event-contracts.cjs.map +1 -1
  37. package/dist/domain-event-contracts.js +2 -1
  38. package/dist/domain-event-contracts.js.map +1 -1
  39. package/dist/entity.cjs +126 -0
  40. package/dist/entity.cjs.map +1 -0
  41. package/dist/entity.js +115 -0
  42. package/dist/entity.js.map +1 -0
  43. package/dist/errors/index.cjs +8 -6
  44. package/dist/errors/index.d.cts +2 -1
  45. package/dist/errors/index.d.ts +2 -1
  46. package/dist/errors/index.js +4 -2
  47. package/dist/gate-engine-registry.cjs +5 -4
  48. package/dist/gate-engine-registry.cjs.map +1 -1
  49. package/dist/gate-engine-registry.d.cts +3 -2
  50. package/dist/gate-engine-registry.d.ts +3 -2
  51. package/dist/gate-engine-registry.js +2 -1
  52. package/dist/gate-engine-registry.js.map +1 -1
  53. package/dist/gate-v1-payload.schema.cjs +0 -562
  54. package/dist/gate-v1-payload.schema.cjs.map +1 -1
  55. package/dist/gate-v1-payload.schema.js +1 -533
  56. package/dist/gate-v1-payload.schema.js.map +1 -1
  57. package/dist/index.cjs +34 -16
  58. package/dist/index.cjs.map +1 -1
  59. package/dist/index.d.cts +19 -12
  60. package/dist/index.d.ts +19 -12
  61. package/dist/index.js +15 -9
  62. package/dist/index.js.map +1 -1
  63. package/dist/not-found-error.cjs +5 -5
  64. package/dist/not-found-error.cjs.map +1 -1
  65. package/dist/not-found-error.js +1 -1
  66. package/dist/outcome.d.cts +1 -83
  67. package/dist/outcome.d.ts +1 -83
  68. package/dist/parse-gate-payload.d.cts +2 -2
  69. package/dist/parse-gate-payload.d.ts +2 -2
  70. package/dist/path.d.cts +2 -2
  71. package/dist/path.d.ts +2 -2
  72. package/dist/policies/engines/index.d.cts +1 -1
  73. package/dist/policies/engines/index.d.ts +1 -1
  74. package/dist/policies/engines/v1/gate/index.cjs +5 -4
  75. package/dist/policies/engines/v1/gate/index.cjs.map +1 -1
  76. package/dist/policies/engines/v1/gate/index.d.cts +2 -2
  77. package/dist/policies/engines/v1/gate/index.d.ts +2 -2
  78. package/dist/policies/engines/v1/gate/index.js +2 -1
  79. package/dist/policies/engines/v1/gate/index.js.map +1 -1
  80. package/dist/policies/index.cjs +5 -5
  81. package/dist/policies/index.d.cts +3 -2
  82. package/dist/policies/index.d.ts +3 -2
  83. package/dist/policies/index.js +2 -2
  84. package/dist/policy-bridge.cjs +437 -0
  85. package/dist/policy-bridge.cjs.map +1 -0
  86. package/dist/policy-bridge.d.cts +185 -0
  87. package/dist/policy-bridge.d.ts +185 -0
  88. package/dist/policy-bridge.js +396 -0
  89. package/dist/policy-bridge.js.map +1 -0
  90. package/dist/policy-service.cjs +239 -239
  91. package/dist/policy-service.cjs.map +1 -1
  92. package/dist/policy-service.d.cts +2 -2
  93. package/dist/policy-service.d.ts +2 -2
  94. package/dist/policy-service.js +239 -239
  95. package/dist/policy-service.js.map +1 -1
  96. package/dist/rbac/index.cjs +9 -0
  97. package/dist/rbac/index.d.cts +3 -0
  98. package/dist/rbac/index.d.ts +3 -0
  99. package/dist/rbac/index.js +2 -0
  100. package/dist/requested-by.cjs +54 -0
  101. package/dist/requested-by.cjs.map +1 -0
  102. package/dist/requested-by.d.cts +25 -0
  103. package/dist/requested-by.d.ts +25 -0
  104. package/dist/requested-by.js +49 -0
  105. package/dist/requested-by.js.map +1 -0
  106. package/dist/result/index.d.cts +2 -1
  107. package/dist/result/index.d.ts +2 -1
  108. package/dist/result.d.cts +84 -0
  109. package/dist/result.d.ts +84 -0
  110. package/dist/rule.cjs +327 -0
  111. package/dist/rule.cjs.map +1 -0
  112. package/dist/rule.js +298 -0
  113. package/dist/rule.js.map +1 -0
  114. package/dist/temporal-use-case.cjs +1 -53
  115. package/dist/temporal-use-case.cjs.map +1 -1
  116. package/dist/temporal-use-case.d.cts +5 -27
  117. package/dist/temporal-use-case.d.ts +5 -27
  118. package/dist/temporal-use-case.js +2 -48
  119. package/dist/temporal-use-case.js.map +1 -1
  120. package/dist/unexpected-error.cjs +3 -192
  121. package/dist/unexpected-error.cjs.map +1 -1
  122. package/dist/unexpected-error.js +2 -167
  123. package/dist/unexpected-error.js.map +1 -1
  124. package/dist/uuid-identifier.d.cts +2 -85
  125. package/dist/uuid-identifier.d.ts +2 -85
  126. package/dist/validation-error.cjs +33 -166
  127. package/dist/validation-error.cjs.map +1 -1
  128. package/dist/validation-error.d.cts +2 -98
  129. package/dist/validation-error.d.ts +2 -98
  130. package/dist/validation-error.js +7 -134
  131. package/dist/validation-error.js.map +1 -1
  132. package/dist/value-object.cjs +0 -123
  133. package/dist/value-object.cjs.map +1 -1
  134. package/dist/value-object.d.cts +89 -0
  135. package/dist/value-object.d.ts +89 -0
  136. package/dist/value-object.js +1 -112
  137. package/dist/value-object.js.map +1 -1
  138. package/meta.json +18 -4
  139. package/package.json +27 -1
  140. package/src/abac/index.ts +1 -0
  141. package/src/core/abac/abac-request.ts +29 -0
  142. package/src/core/abac/attributes.ts +39 -0
  143. package/src/core/abac/authorizer.ts +108 -0
  144. package/src/core/abac/combining.ts +63 -0
  145. package/src/core/abac/composite-authorizer.ts +45 -0
  146. package/src/core/abac/domain/policy-set.ts +52 -0
  147. package/src/core/abac/domain/rule.ts +197 -0
  148. package/src/core/abac/index.ts +21 -0
  149. package/src/core/application/ports/abac-authorizer.port.ts +20 -0
  150. package/src/core/application/ports/authorizer.port.ts +22 -0
  151. package/src/core/errors/authorization-error.ts +26 -0
  152. package/src/core/errors/error-codes.ts +1 -0
  153. package/src/core/index.ts +7 -0
  154. package/src/core/rbac/access-request.ts +32 -0
  155. package/src/core/rbac/authorizer.ts +91 -0
  156. package/src/core/rbac/domain/grant.ts +96 -0
  157. package/src/core/rbac/domain/permission-set.ts +67 -0
  158. package/src/core/rbac/domain/permission.ts +119 -0
  159. package/src/core/rbac/domain/role.ts +101 -0
  160. package/src/core/rbac/domain/scope.ts +84 -0
  161. package/src/core/rbac/index.ts +15 -0
  162. package/src/core/rbac/policy-bridge.ts +44 -0
  163. package/src/examples/application/authorize-cancel-order-abac.example.ts +107 -0
  164. package/src/examples/application/authorize-cancel-order.example.ts +101 -0
  165. package/src/rbac/index.ts +1 -0
  166. package/src/version.ts +1 -1
@@ -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 };
@@ -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
+ }