bazis 0.128.0 → 0.130.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 (75) hide show
  1. package/core/access/Access.module.ts +36 -0
  2. package/core/access/catalog/AccessCatalog.ts +73 -0
  3. package/core/access/catalog/IAccessCatalog.service.ts +17 -0
  4. package/core/access/catalog/moduleFields.ts +153 -0
  5. package/core/access/catalog/permissions.ts +127 -0
  6. package/core/access/check/Access.ts +191 -0
  7. package/core/access/check/IAccess.service.ts +20 -0
  8. package/core/access/check/accessConfig.ts +18 -0
  9. package/core/access/check/can.ts +21 -0
  10. package/core/access/check/conditions.ts +132 -0
  11. package/core/access/check/filter.ts +132 -0
  12. package/core/access/contracts/AccessPolicy.ts +29 -0
  13. package/core/access/contracts/AttributeLookup.ts +17 -0
  14. package/core/access/contracts/References.ts +33 -0
  15. package/core/access/contracts/errors.ts +13 -0
  16. package/core/access/contracts/types.ts +66 -0
  17. package/core/access/generated/deps.ts +14 -0
  18. package/core/access/http/AccessCatalog.controller.ts +51 -0
  19. package/core/access/index.ts +35 -0
  20. package/core/access/roles/RolesAccessPolicy.ts +64 -0
  21. package/core/access/roles/roles.ts +62 -0
  22. package/core/access/testing/index.ts +54 -0
  23. package/core/background/BackgroundService.ts +1 -1
  24. package/core/di/README.md +5 -5
  25. package/core/di/errors/ClassDependenciesMismatchError.ts +1 -1
  26. package/core/di/internal/GraphValidator.ts +1 -1
  27. package/core/di/module/autoDeps.ts +11 -1
  28. package/core/di/module/createContainer.ts +1 -1
  29. package/core/di/module/providerSelection.ts +1 -1
  30. package/core/grpc/{GrpcClient.ts → GrpcClientBase.ts} +1 -1
  31. package/core/grpc/grpcClients.ts +79 -0
  32. package/core/grpc/index.ts +2 -2
  33. package/core/http/options.ts +1 -1
  34. package/core/http-client/httpClients.ts +82 -0
  35. package/core/http-client/index.ts +11 -91
  36. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  37. package/core/scripts/di-generate-target.ts +9 -0
  38. package/core/scripts/di-generate.ts +177 -9
  39. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +15 -11
  40. package/core/workflow/definitions_module/access/Definitions.permissions.ts +23 -0
  41. package/core/workflow/definitions_module/access/Process.references.ts +27 -0
  42. package/core/workflow/definitions_module/code/CodeProcessCatalog.ts +4 -3
  43. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +2 -2
  44. package/core/workflow/definitions_module/http/Processes.controller.ts +23 -14
  45. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +9 -0
  46. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +4 -0
  47. package/core/workflow/generated/deps.ts +56 -0
  48. package/core/workflow/index.ts +1 -1
  49. package/core/workflow/internal/WorkflowTools.ts +2 -2
  50. package/core/workflow/internal/http.ts +18 -17
  51. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +17 -34
  52. package/core/workflow/runtime_module/access/Runtime.permissions.ts +28 -0
  53. package/core/workflow/runtime_module/background/WorkflowRetention.ts +1 -1
  54. package/core/workflow/runtime_module/background/WorkflowWorker.ts +1 -10
  55. package/core/workflow/runtime_module/config/Runtime.config.ts +11 -1
  56. package/core/workflow/runtime_module/http/Events.controller.ts +5 -3
  57. package/core/workflow/runtime_module/http/Instances.controller.ts +25 -15
  58. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +4 -3
  59. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +3 -0
  60. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +16 -5
  61. package/core/workflow/tasks_module/WorkflowTasks.module.ts +8 -10
  62. package/core/workflow/tasks_module/access/Tasks.permissions.ts +13 -0
  63. package/core/workflow/tasks_module/events/TaskEvents.ts +29 -8
  64. package/core/workflow/tasks_module/http/Tasks.controller.ts +21 -12
  65. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +8 -3
  66. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +41 -42
  67. package/core/workflow/testing/index.ts +1 -0
  68. package/index.ts +1 -1
  69. package/library/http-client/{HttpClient.ts → HttpClientBase.ts} +5 -5
  70. package/library/http-client/HttpClientFactory.ts +5 -5
  71. package/library/http-client/index.ts +3 -3
  72. package/library/terminal/messages.ts +2 -2
  73. package/package.json +3 -1
  74. package/core/grpc/grpcClientProvider.ts +0 -9
  75. package/core/workflow/access/WorkflowAccess.ts +0 -52
@@ -0,0 +1,36 @@
1
+ import { Module, singleton } from "../di";
2
+ import { AccessCatalog } from "./catalog/AccessCatalog";
3
+ import { IAccessCatalog } from "./catalog/IAccessCatalog.service";
4
+ import { definePermissions } from "./catalog/permissions";
5
+ import { Access } from "./check/Access";
6
+ import { accessConfig } from "./check/accessConfig";
7
+ import { IAccess } from "./check/IAccess.service";
8
+ import { AccessPolicy } from "./contracts/AccessPolicy";
9
+ import { AccessCatalogController } from "./http/AccessCatalog.controller";
10
+ import { RolesAccessPolicy } from "./roles/RolesAccessPolicy";
11
+
12
+ const accessPermissions = definePermissions({
13
+ "access.catalog.read": {
14
+ title: { en: "See the permissions catalog", ru: "Видеть каталог прав" },
15
+ },
16
+ });
17
+
18
+ /**
19
+ * Access as a layer of the framework (ACCESS-001, [passport](MODULE.md)):
20
+ * the catalog of the modules' permissions, checks and list filters by the
21
+ * grants of the application's `AccessPolicy`, the default rights system by
22
+ * `@Module({ roles })`, and the catalog over HTTP. An application replaces the
23
+ * rights system by registering its own `AccessPolicy`.
24
+ */
25
+ @Module({
26
+ config: [accessConfig],
27
+ permissions: accessPermissions,
28
+ providers: [
29
+ singleton(IAccessCatalog, AccessCatalog),
30
+ singleton(AccessPolicy, RolesAccessPolicy),
31
+ singleton(IAccess, Access),
32
+ ],
33
+ controllers: [AccessCatalogController],
34
+ exports: [IAccessCatalog, IAccess, AccessPolicy],
35
+ })
36
+ export class AccessModule {}
@@ -0,0 +1,73 @@
1
+ import type { Class } from "../../di";
2
+ import type { ServiceProvider } from "../../di/ServiceProvider";
3
+ import type { References } from "../contracts/References";
4
+ import type { RolePermission } from "../roles/roles";
5
+ import { IAccessCatalog } from "./IAccessCatalog.service";
6
+ import { declarationsOf } from "./moduleFields";
7
+ import type { PermissionSpec } from "./permissions";
8
+
9
+ /** A permission of `@Module({ permissions })` with the module that declares it. */
10
+ export interface OwnedPermission {
11
+ readonly key: string;
12
+ readonly spec: PermissionSpec;
13
+ readonly module: string;
14
+ }
15
+
16
+ /** A reference kind of `@Module({ references })`. */
17
+ export interface OwnedReferenceKind {
18
+ readonly kind: string;
19
+ readonly type: Class<References>;
20
+ readonly module: string;
21
+ }
22
+
23
+ /** A role of `@Module({ roles })`: `"all"` or its permissions as grants. */
24
+ export interface DeclaredRole {
25
+ readonly name: string;
26
+ readonly permissions: "all" | readonly RolePermission[];
27
+ readonly module: string;
28
+ }
29
+
30
+ /** The catalog read from the container's module contributions. */
31
+ export class AccessCatalog extends IAccessCatalog {
32
+ private readonly byKey: ReadonlyMap<string, OwnedPermission>;
33
+ private readonly byKind: ReadonlyMap<string, OwnedReferenceKind>;
34
+ private readonly byName: ReadonlyMap<string, DeclaredRole>;
35
+
36
+ constructor(services: ServiceProvider) {
37
+ super();
38
+ const { permissions, kinds, roles } = declarationsOf(services);
39
+ this.byKey = new Map([...permissions].sort((a, b) => a.key.localeCompare(b.key)).map((item) => [item.key, item]));
40
+ this.byKind = new Map([...kinds].sort((a, b) => a.kind.localeCompare(b.kind)).map((item) => [item.kind, item]));
41
+ this.byName = new Map(roles.map((item) => [item.name, item]));
42
+ }
43
+
44
+ permissions(): readonly OwnedPermission[] {
45
+ return [...this.byKey.values()];
46
+ }
47
+
48
+ permission(key: string): OwnedPermission | undefined {
49
+ return this.byKey.get(key);
50
+ }
51
+
52
+ require(key: string): OwnedPermission {
53
+ const permission = this.byKey.get(key);
54
+ if (permission === undefined) throw new TypeError(`Permission "${key}" is not declared: add it to definePermissions(...) of a module's permissions.`);
55
+ return permission;
56
+ }
57
+
58
+ referenceKinds(): readonly OwnedReferenceKind[] {
59
+ return [...this.byKind.values()];
60
+ }
61
+
62
+ referenceKind(kind: string): OwnedReferenceKind | undefined {
63
+ return this.byKind.get(kind);
64
+ }
65
+
66
+ roles(): readonly DeclaredRole[] {
67
+ return [...this.byName.values()];
68
+ }
69
+
70
+ role(name: string): DeclaredRole | undefined {
71
+ return this.byName.get(name);
72
+ }
73
+ }
@@ -0,0 +1,17 @@
1
+ import type { DeclaredRole, OwnedPermission, OwnedReferenceKind } from "./AccessCatalog";
2
+
3
+ /**
4
+ * What the application's modules declared about access: permissions, reference
5
+ * kinds and default roles (ACCESS-001 §3, §6, §8). Checked when the application
6
+ * is built.
7
+ */
8
+ export abstract class IAccessCatalog {
9
+ abstract permissions(): readonly OwnedPermission[];
10
+ abstract permission(key: string): OwnedPermission | undefined;
11
+ /** The permission or an error: asking for an undeclared one is a bug of the caller. */
12
+ abstract require(key: string): OwnedPermission;
13
+ abstract referenceKinds(): readonly OwnedReferenceKind[];
14
+ abstract referenceKind(kind: string): OwnedReferenceKind | undefined;
15
+ abstract roles(): readonly DeclaredRole[];
16
+ abstract role(name: string): DeclaredRole | undefined;
17
+ }
@@ -0,0 +1,153 @@
1
+ import {
2
+ createModuleOwnedMetadataChannel,
3
+ registerModuleOwnedContributionValidator,
4
+ registerModuleOwnedProviderContributor,
5
+ DiContainer,
6
+ type BazisModuleMetadata,
7
+ type Class,
8
+ type ServiceProvider,
9
+ } from "../../di";
10
+ import { grantProblem } from "../check/conditions";
11
+ import { AccessSetupError } from "../contracts/errors";
12
+ import type { References } from "../contracts/References";
13
+ import { isDeclaredRoles, rolePermission, type RoleDeclarations, type RolePermission } from "../roles/roles";
14
+ import { IAccess } from "../check/IAccess.service";
15
+ import type { DeclaredRole, OwnedPermission, OwnedReferenceKind } from "./AccessCatalog";
16
+ import { isDeclaredPermissions, isReferenceKind, lookupsOf, permissionProblems, type PermissionDeclarations, type PermissionSpec } from "./permissions";
17
+
18
+ interface PermissionPayload { readonly key: string; readonly spec: PermissionSpec }
19
+ interface ReferencePayload { readonly kind: string; readonly type: Class<References> }
20
+ interface RolePayload { readonly name: string; readonly permissions: "all" | readonly RolePermission[] }
21
+
22
+ const PERMISSION_CHANNEL = createModuleOwnedMetadataChannel<PermissionPayload>("access.permission/v1");
23
+ const REFERENCE_CHANNEL = createModuleOwnedMetadataChannel<ReferencePayload>("access.reference/v1");
24
+ const ROLE_CHANNEL = createModuleOwnedMetadataChannel<RolePayload>("access.role/v1");
25
+
26
+ /** The values of `in`/`notIn` a role may list; the run-time limit is `access.maxInList`. */
27
+ const STARTUP_MAX_IN_LIST = 1000;
28
+ const ROLE = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
29
+
30
+ // Internal imports must not depend on the public barrel having augmented DI's metadata interface.
31
+ interface AccessModuleFields {
32
+ readonly permissions?: unknown;
33
+ readonly references?: unknown;
34
+ readonly roles?: unknown;
35
+ }
36
+
37
+ registerModuleOwnedProviderContributor((metadata: BazisModuleMetadata, context) => {
38
+ const fields = metadata as BazisModuleMetadata & AccessModuleFields;
39
+ if (fields.permissions === undefined && fields.references === undefined && fields.roles === undefined) return;
40
+ const module = moduleName(metadata);
41
+ if (fields.permissions !== undefined) {
42
+ if (!isDeclaredPermissions(fields.permissions)) throw new AccessSetupError(`${module}: permissions must be a definePermissions(...) result.`);
43
+ const problems = Object.entries(fields.permissions).flatMap(([key, spec]) => permissionProblems(key, spec));
44
+ if (problems.length > 0) throw setupError(module, "permissions", problems);
45
+ for (const [key, spec] of Object.entries(fields.permissions)) context.addMetadata(PERMISSION_CHANNEL, { key, spec });
46
+ }
47
+ if (fields.references !== undefined) {
48
+ const references = fields.references;
49
+ if (typeof references !== "object" || references === null || Array.isArray(references)) throw new AccessSetupError(`${module}: references must map a reference kind to a References class.`);
50
+ const problems: string[] = [];
51
+ for (const [kind, type] of Object.entries(references)) {
52
+ if (!isReferenceKind(kind)) problems.push(`reference kind "${kind}" must look like "<module>.<kind>" in lower case`);
53
+ if (typeof type !== "function") problems.push(`reference kind "${kind}" must name a References class`);
54
+ }
55
+ if (problems.length > 0) throw setupError(module, "references", problems);
56
+ for (const [kind, type] of Object.entries(references)) context.addMetadata(REFERENCE_CHANNEL, { kind, type: type as Class<References> });
57
+ }
58
+ if (fields.roles !== undefined) {
59
+ if (!isDeclaredRoles(fields.roles)) throw new AccessSetupError(`${module}: roles must be a defineRoles(...) result.`);
60
+ const problems: string[] = [];
61
+ for (const [name, permissions] of Object.entries(fields.roles as RoleDeclarations)) {
62
+ if (!ROLE.test(name)) problems.push(`role "${name}" must be a name of letters, digits, _, . and -`);
63
+ if (permissions !== "all" && !Array.isArray(permissions)) problems.push(`role "${name}" must be "all" or a list of permissions`);
64
+ }
65
+ if (problems.length > 0) throw setupError(module, "roles", problems);
66
+ for (const [name, permissions] of Object.entries(fields.roles as RoleDeclarations)) {
67
+ context.addMetadata(ROLE_CHANNEL, { name, permissions: permissions === "all" ? "all" : permissions.map(rolePermission) });
68
+ }
69
+ }
70
+ });
71
+
72
+ registerModuleOwnedContributionValidator((snapshot) => {
73
+ const permissions = snapshot.getMetadataContributions(PERMISSION_CHANNEL);
74
+ const references = snapshot.getMetadataContributions(REFERENCE_CHANNEL);
75
+ const roles = snapshot.getMetadataContributions(ROLE_CHANNEL);
76
+ const first = permissions[0] ?? references[0] ?? roles[0];
77
+ if (first === undefined) return;
78
+ if (snapshot.countProviders(IAccess) === 0) {
79
+ throw new AccessSetupError(`${first.ownerName} declares access (permissions, references or roles), but the application does not import AccessModule (bazis/core/access).`);
80
+ }
81
+
82
+ const owners = new Map<string, string>();
83
+ const specs = new Map<string, PermissionSpec>();
84
+ for (const { payload, ownerName } of permissions) {
85
+ const other = owners.get(payload.key);
86
+ if (other !== undefined) throw new AccessSetupError(`Permission "${payload.key}" is declared twice: in ${other} and in ${ownerName}.`);
87
+ owners.set(payload.key, ownerName);
88
+ specs.set(payload.key, payload.spec);
89
+ }
90
+
91
+ const kinds = new Map<string, string>();
92
+ for (const { payload, ownerName } of references) {
93
+ const other = kinds.get(payload.kind);
94
+ if (other !== undefined) throw new AccessSetupError(`Reference kind "${payload.kind}" is declared twice: in ${other} and in ${ownerName}.`);
95
+ kinds.set(payload.kind, ownerName);
96
+ if (snapshot.countProviders(payload.type) === 0) throw new AccessSetupError(`${ownerName}: ${payload.type.name} of reference kind "${payload.kind}" is not registered: add it to the module's providers.`);
97
+ }
98
+
99
+ for (const { payload, ownerName } of permissions) {
100
+ for (const [name, attribute] of Object.entries(payload.spec.on ?? {})) {
101
+ if (attribute.ref !== undefined && !kinds.has(attribute.ref)) {
102
+ throw new AccessSetupError(`${ownerName}: permission "${payload.key}", attribute "${name}" refers to kind "${attribute.ref}", which no module declares in references.`);
103
+ }
104
+ }
105
+ for (const lookup of lookupsOf(payload.spec)) {
106
+ if (snapshot.countProviders(lookup) === 0) throw new AccessSetupError(`${ownerName}: ${lookup.name} of permission "${payload.key}" is not registered: add it to the module's providers.`);
107
+ }
108
+ }
109
+
110
+ const names = new Map<string, string>();
111
+ for (const { payload, ownerName } of roles) {
112
+ const other = names.get(payload.name);
113
+ if (other !== undefined) throw new AccessSetupError(`Role "${payload.name}" is declared twice: in ${other} and in ${ownerName}.`);
114
+ names.set(payload.name, ownerName);
115
+ if (payload.permissions === "all") continue;
116
+ for (const { permission, grant } of payload.permissions) {
117
+ const spec = specs.get(permission);
118
+ if (spec === undefined) throw new AccessSetupError(`${ownerName}: role "${payload.name}" names permission "${permission}", which no module declares.`);
119
+ const problem = grantProblem(spec, grant, STARTUP_MAX_IN_LIST);
120
+ if (problem !== undefined) throw new AccessSetupError(`${ownerName}: role "${payload.name}", permission "${permission}": ${problem}.`);
121
+ }
122
+ }
123
+ });
124
+
125
+ /** What the modules of the application's container declared. */
126
+ export function declarationsOf(services: ServiceProvider): { permissions: OwnedPermission[]; kinds: OwnedReferenceKind[]; roles: DeclaredRole[] } {
127
+ if (!(services instanceof DiContainer)) throw new AccessSetupError("Access declarations require the framework DiContainer.");
128
+ const permissions: OwnedPermission[] = services.getModuleOwnedMetadataContributions(PERMISSION_CHANNEL).map(({ payload, ownerName }) => ({ ...payload, module: ownerName }));
129
+ const kinds: OwnedReferenceKind[] = services.getModuleOwnedMetadataContributions(REFERENCE_CHANNEL).map(({ payload, ownerName }) => ({ ...payload, module: ownerName }));
130
+ const roles: DeclaredRole[] = services.getModuleOwnedMetadataContributions(ROLE_CHANNEL).map(({ payload, ownerName }) => ({ ...payload, module: ownerName }));
131
+ return { permissions, kinds, roles };
132
+ }
133
+
134
+ function setupError(module: string, field: string, problems: readonly string[]): AccessSetupError {
135
+ return new AccessSetupError(`${module}: ${field} ${problems.length === 1 ? "has an error" : "have errors"}:\n${problems.map((line) => ` - ${line}`).join("\n")}`);
136
+ }
137
+
138
+ function moduleName(metadata: BazisModuleMetadata): string {
139
+ return typeof metadata === "function" ? (metadata as { name: string }).name || "<anonymous module>" : "<module>";
140
+ }
141
+
142
+ // Augment the declaring file, not the "../../di" re-export: re-export targets
143
+ // depend on program file order and silently stop merging in some projects.
144
+ declare module "../../di/module/types/BazisModule" {
145
+ interface BazisModuleMetadata {
146
+ /** What the module lets the administrator allow, from `definePermissions` (ACCESS-001 §3). */
147
+ readonly permissions?: PermissionDeclarations;
148
+ /** Reference kinds the module owns: kind → its `References` class, registered in `providers` (ACCESS-001 §6). */
149
+ readonly references?: Readonly<Record<string, Class<References>>>;
150
+ /** Roles of the default rights system, from `defineRoles` (ACCESS-001 §8). */
151
+ readonly roles?: RoleDeclarations;
152
+ }
153
+ }
@@ -0,0 +1,127 @@
1
+ import type { Class } from "../../di";
2
+ import type { AttributeLookup } from "../contracts/AttributeLookup";
3
+ import type { LocalizedText } from "../contracts/types";
4
+
5
+ /** An attribute of the object of a permission: what a condition may test (ACCESS-001 §3). */
6
+ export interface AttributeSpec {
7
+ /** A reference kind (`"workflow.process"`): the administrator picks values from it. */
8
+ readonly ref?: string;
9
+ /** A plain value instead of a reference. */
10
+ readonly type?: "string" | "number" | "boolean";
11
+ /** The column of the listed rows; by default the attribute's name. */
12
+ readonly column?: string;
13
+ /** For an attribute that is not a column: turns its condition into a column condition. */
14
+ readonly lookup?: Class<AttributeLookup>;
15
+ }
16
+
17
+ /**
18
+ * Where the partition of an object comes from: a column of the row, a lookup
19
+ * (several partitions are possible), the request (the caller passes it), or
20
+ * nowhere — the permission has no partition.
21
+ */
22
+ export type ScopeSource =
23
+ | "none"
24
+ | "request"
25
+ | { readonly column: string }
26
+ | { readonly lookup: Class<AttributeLookup> };
27
+
28
+ export interface PermissionSpec {
29
+ readonly title: LocalizedText;
30
+ readonly description?: LocalizedText;
31
+ /** By default `"none"`. */
32
+ readonly scope?: ScopeSource;
33
+ readonly on?: Readonly<Record<string, AttributeSpec>>;
34
+ /** The owner column: makes `{ own: true }` possible. */
35
+ readonly owner?: string;
36
+ /** Grants may carry field lists. */
37
+ readonly fields?: boolean;
38
+ }
39
+
40
+ export type PermissionDeclarations = Readonly<Record<string, PermissionSpec>>;
41
+
42
+ const DECLARED = new WeakSet<object>();
43
+
44
+ /**
45
+ * Declares what a module lets the administrator allow or deny (ACCESS-001 §3);
46
+ * the result goes to `@Module({ permissions })`. Only describes: the checks run
47
+ * when the application is built.
48
+ *
49
+ * ```ts
50
+ * export const orderPermissions = definePermissions({
51
+ * "orders.read": { title: "See orders", scope: { column: "shopId" }, owner: "customerId" },
52
+ * });
53
+ * ```
54
+ */
55
+ export function definePermissions<const T extends PermissionDeclarations>(permissions: T): T {
56
+ const frozen = deepFreeze({ ...permissions });
57
+ DECLARED.add(frozen);
58
+ return frozen;
59
+ }
60
+
61
+ export function isDeclaredPermissions(value: unknown): value is PermissionDeclarations {
62
+ return typeof value === "object" && value !== null && DECLARED.has(value);
63
+ }
64
+
65
+ const KEY = /^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/;
66
+ const NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
67
+ /** Words of conditions; `scope` is the partition. */
68
+ const RESERVED = new Set(["all", "any", "not", "own", "scope"]);
69
+
70
+ /** The problems of one permission, before the reference kinds are known. */
71
+ export function permissionProblems(key: string, spec: PermissionSpec): string[] {
72
+ const problems: string[] = [];
73
+ const at = `permission "${key}"`;
74
+ if (!KEY.test(key) || key.length > 128) problems.push(`${at}: the key must look like "<module>.<object>.<action>" in lower case`);
75
+ if (!isText(spec.title, 200)) problems.push(`${at}: title must be a non-empty text up to 200 characters`);
76
+ if (spec.description !== undefined && !isText(spec.description, 2000)) problems.push(`${at}: description must be a non-empty text up to 2000 characters`);
77
+ const scope = spec.scope;
78
+ if (scope !== undefined && scope !== "none" && scope !== "request") {
79
+ const column = (scope as { column?: unknown }).column;
80
+ const lookup = (scope as { lookup?: unknown }).lookup;
81
+ if ((column === undefined) === (lookup === undefined)) problems.push(`${at}: scope must be "none", "request", { column } or { lookup }`);
82
+ else if (column !== undefined && !isName(column)) problems.push(`${at}: scope.column must be a column name`);
83
+ else if (lookup !== undefined && typeof lookup !== "function") problems.push(`${at}: scope.lookup must be an AttributeLookup class`);
84
+ }
85
+ for (const [name, attribute] of Object.entries(spec.on ?? {})) {
86
+ if (!NAME.test(name)) problems.push(`${at}: attribute "${name}" must be a name of letters, digits and _`);
87
+ else if (RESERVED.has(name)) problems.push(`${at}: attribute "${name}" is a reserved word of conditions`);
88
+ if ((attribute.ref === undefined) === (attribute.type === undefined)) problems.push(`${at}: attribute "${name}" needs exactly one of ref and type`);
89
+ if (attribute.ref !== undefined && !KEY.test(attribute.ref)) problems.push(`${at}: attribute "${name}": ref must be a reference kind like "<module>.<kind>"`);
90
+ if (attribute.type !== undefined && !["string", "number", "boolean"].includes(attribute.type)) problems.push(`${at}: attribute "${name}": type must be string, number or boolean`);
91
+ if (attribute.column !== undefined && attribute.lookup !== undefined) problems.push(`${at}: attribute "${name}" has both column and lookup`);
92
+ if (attribute.column !== undefined && !isName(attribute.column)) problems.push(`${at}: attribute "${name}": column must be a column name`);
93
+ if (attribute.lookup !== undefined && typeof attribute.lookup !== "function") problems.push(`${at}: attribute "${name}": lookup must be an AttributeLookup class`);
94
+ }
95
+ if (spec.owner !== undefined && !isName(spec.owner)) problems.push(`${at}: owner must be a column name`);
96
+ if (spec.fields !== undefined && typeof spec.fields !== "boolean") problems.push(`${at}: fields must be true or false`);
97
+ return problems;
98
+ }
99
+
100
+ /** The lookup classes a permission needs registered. */
101
+ export function lookupsOf(spec: PermissionSpec): Class<AttributeLookup>[] {
102
+ const lookups: Class<AttributeLookup>[] = [];
103
+ if (typeof spec.scope === "object" && "lookup" in spec.scope) lookups.push(spec.scope.lookup);
104
+ for (const attribute of Object.values(spec.on ?? {})) if (attribute.lookup !== undefined) lookups.push(attribute.lookup);
105
+ return lookups;
106
+ }
107
+
108
+ export function isReferenceKind(value: string): boolean {
109
+ return KEY.test(value) && value.length <= 128;
110
+ }
111
+
112
+ function isText(value: unknown, max: number): boolean {
113
+ if (typeof value === "string") return value.trim() !== "" && value.length <= max;
114
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
115
+ const entries = Object.entries(value);
116
+ return entries.length > 0 && entries.every(([language, text]) => /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/.test(language) && typeof text === "string" && text.trim() !== "" && text.length <= max);
117
+ }
118
+
119
+ function isName(value: unknown): value is string {
120
+ return typeof value === "string" && NAME.test(value);
121
+ }
122
+
123
+ function deepFreeze<T>(value: T): T {
124
+ if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
125
+ for (const item of Object.values(value)) if (typeof item === "object") deepFreeze(item);
126
+ return Object.freeze(value);
127
+ }
@@ -0,0 +1,191 @@
1
+ import type { Class, ServiceResolver } from "../../di";
2
+ import { UnauthorizedError, type AuthorizeContext } from "../../http";
3
+ import { LOGGER, type ConfigView } from "../../kernel";
4
+ import type { AccessConfig } from "./accessConfig";
5
+ import type { IAccessCatalog } from "../catalog/IAccessCatalog.service";
6
+ import type { PermissionSpec } from "../catalog/permissions";
7
+ import { AccessPolicy, type ScopePrincipals } from "../contracts/AccessPolicy";
8
+ import type { AttributeLookup } from "../contracts/AttributeLookup";
9
+ import type { AccessObject, AccessSubject, Condition, Grant, Grants, Scope } from "../contracts/types";
10
+ import { evaluate, grantProblem } from "./conditions";
11
+ import { buildFilter, type AccessFilter } from "./filter";
12
+ import { IAccess } from "./IAccess.service";
13
+
14
+ const SUBJECT_STATE = "access:subject";
15
+
16
+ /** The fields the subject may read and write on an object. */
17
+ export interface FieldAccess {
18
+ readonly read: "all" | readonly string[];
19
+ readonly write: "all" | readonly string[];
20
+ }
21
+
22
+ /**
23
+ * Asks the application's `AccessPolicy` for grants and applies them itself.
24
+ * The subject of a request is cached on it. The policy and the lookups are
25
+ * resolved in the given scope: an application may register them scoped.
26
+ */
27
+ export class Access extends IAccess {
28
+ constructor(private readonly catalog: IAccessCatalog, private readonly config: ConfigView<AccessConfig>) {
29
+ super();
30
+ }
31
+
32
+ policy(scope: ServiceResolver): AccessPolicy {
33
+ return scope.resolve(AccessPolicy);
34
+ }
35
+
36
+ async subject(ctx: AuthorizeContext): Promise<AccessSubject | undefined> {
37
+ return (await this.cached(ctx))?.subject;
38
+ }
39
+
40
+ async for(ctx: AuthorizeContext): Promise<SubjectAccess> {
41
+ const access = await this.cached(ctx);
42
+ if (access === undefined) throw new UnauthorizedError();
43
+ return access;
44
+ }
45
+
46
+ as(subject: AccessSubject, scope: ServiceResolver): SubjectAccess {
47
+ return new SubjectAccess(subject, this.policy(scope), this.catalog, this.config.get("maxInList"), scope);
48
+ }
49
+
50
+ private async cached(ctx: AuthorizeContext): Promise<SubjectAccess | undefined> {
51
+ if (ctx.state.has(SUBJECT_STATE)) return ctx.state.get(SUBJECT_STATE) as SubjectAccess | undefined;
52
+ const subject = await this.policy(ctx.services).subject(ctx);
53
+ const access = subject === undefined ? undefined : this.as(subject, ctx.services);
54
+ ctx.state.set(SUBJECT_STATE, access);
55
+ return access;
56
+ }
57
+ }
58
+
59
+ /** What one subject may do; grants are asked once per permission. */
60
+ export class SubjectAccess {
61
+ private readonly grantCache = new Map<string, Promise<Grants>>();
62
+ private owners: Promise<readonly string[]> | undefined;
63
+
64
+ constructor(
65
+ readonly subject: AccessSubject,
66
+ private readonly policy: AccessPolicy,
67
+ private readonly catalog: IAccessCatalog,
68
+ private readonly maxInList: number,
69
+ private readonly scope: ServiceResolver,
70
+ ) {}
71
+
72
+ /** The grants of the permission; a grant the permission cannot use is dropped with a warning. */
73
+ grants(permission: string): Promise<Grants> {
74
+ let grants = this.grantCache.get(permission);
75
+ if (grants === undefined) {
76
+ grants = this.load(permission);
77
+ this.grantCache.set(permission, grants);
78
+ }
79
+ return grants;
80
+ }
81
+
82
+ /**
83
+ * Whether the subject may do `permission` on `object`. Without an object —
84
+ * whether it has the permission anywhere (a route before the object is known).
85
+ */
86
+ async can(permission: string, object?: AccessObject): Promise<boolean> {
87
+ const spec = this.catalog.require(permission).spec;
88
+ const grants = await this.grants(permission);
89
+ if (grants === "all") return true;
90
+ if (object === undefined) return grants.length > 0;
91
+ return (await this.matching(permission, spec, grants, object)).length > 0;
92
+ }
93
+
94
+ /** Which rows of a list the subject may see; `scope` — the partition of the request for `scope: "request"`. */
95
+ async filter(permission: string, options: { readonly scope?: Scope } = {}): Promise<AccessFilter> {
96
+ const spec = this.catalog.require(permission).spec;
97
+ const grants = await this.grants(permission);
98
+ return await buildFilter({
99
+ permission,
100
+ spec,
101
+ grants,
102
+ ownerIds: grants === "all" || !usesOwn(grants) ? [] : await this.ownerIds(),
103
+ requestScope: options.scope,
104
+ maxInList: this.maxInList,
105
+ lookup: (type: Class<AttributeLookup>) => this.scope.resolve(type),
106
+ });
107
+ }
108
+
109
+ /** The fields of `object` the subject may read and write (ACCESS-001 §4.4). */
110
+ async fields(permission: string, object: AccessObject): Promise<FieldAccess> {
111
+ const spec = this.catalog.require(permission).spec;
112
+ const grants = await this.grants(permission);
113
+ if (grants === "all") return { read: "all", write: "all" };
114
+ const matching = await this.matching(permission, spec, grants, object);
115
+ return { read: union(matching, "read"), write: union(matching, "write") };
116
+ }
117
+
118
+ /** Whom the subject is in each partition, for tasks. */
119
+ principals(scopes: readonly Scope[]): Promise<readonly ScopePrincipals[]> {
120
+ return this.policy.principals(this.subject, scopes);
121
+ }
122
+
123
+ /** The owner column values that mean this subject. */
124
+ ownerIds(): Promise<readonly string[]> {
125
+ this.owners ??= Promise.resolve(this.policy.ownerIds(this.subject));
126
+ return this.owners;
127
+ }
128
+
129
+ private async load(permission: string): Promise<Grants> {
130
+ const spec = this.catalog.require(permission).spec;
131
+ const grants = await this.policy.grants(this.subject, permission);
132
+ if (grants === "all") return "all";
133
+ if (!Array.isArray(grants)) {
134
+ this.warn(permission, "the rights system must answer \"all\" or a list of grants");
135
+ return [];
136
+ }
137
+ return grants.filter((grant) => {
138
+ const problem = grantProblem(spec, grant, this.maxInList);
139
+ if (problem !== undefined) this.warn(permission, problem);
140
+ return problem === undefined;
141
+ });
142
+ }
143
+
144
+ private async matching(permission: string, spec: PermissionSpec, grants: readonly Grant[], object: AccessObject): Promise<Grant[]> {
145
+ const scopes = objectScopes(permission, spec, object);
146
+ const owners = usesOwn(grants) ? await this.ownerIds() : [];
147
+ return grants.filter((grant) => {
148
+ if (grant.scopes !== "all" && !scopes.some((scope) => (grant.scopes as readonly Scope[]).includes(scope))) return false;
149
+ return grant.where === undefined || evaluate(grant.where, object, spec, owners) === true;
150
+ });
151
+ }
152
+
153
+ private warn(permission: string, problem: string): void {
154
+ this.scope.tryResolve(LOGGER)?.warn("access grant dropped", { permission, subject: this.subject.sub, problem });
155
+ }
156
+ }
157
+
158
+ function objectScopes(permission: string, spec: PermissionSpec, object: AccessObject): readonly Scope[] {
159
+ if ((spec.scope ?? "none") === "none") return [null];
160
+ const scope = object.scope;
161
+ if (scope === undefined) throw new TypeError(`can("${permission}") needs the object's partition: pass { scope }.`);
162
+ return Array.isArray(scope) ? scope : [scope as Scope];
163
+ }
164
+
165
+ function usesOwn(grants: readonly Grant[]): boolean {
166
+ return grants.some((grant) => grant.where !== undefined && hasOwn(grant.where));
167
+ }
168
+
169
+ function hasOwn(condition: Condition): boolean {
170
+ if ("own" in condition) return condition.own === true;
171
+ if ("not" in condition && typeof condition.not === "object" && condition.not !== null) return hasOwn(condition.not as Condition);
172
+ const list = "all" in condition ? condition.all : "any" in condition ? condition.any : undefined;
173
+ return Array.isArray(list) && (list as readonly Condition[]).some(hasOwn);
174
+ }
175
+
176
+ function union(grants: readonly Grant[], side: "read" | "write"): "all" | readonly string[] {
177
+ const fields = new Set<string>();
178
+ for (const grant of grants) {
179
+ const list = grant.fields?.[side];
180
+ if (list === undefined || list === "all") return "all";
181
+ for (const field of list) fields.add(field);
182
+ }
183
+ return [...fields];
184
+ }
185
+
186
+ /** The record with only the fields of `read`. */
187
+ export function maskFields<T extends Record<string, unknown>>(record: T, read: "all" | readonly string[]): Partial<T> {
188
+ if (read === "all") return record;
189
+ const allowed = new Set(read);
190
+ return Object.fromEntries(Object.entries(record).filter(([field]) => allowed.has(field))) as Partial<T>;
191
+ }
@@ -0,0 +1,20 @@
1
+ import type { ServiceResolver } from "../../di";
2
+ import type { AuthorizeContext } from "../../http";
3
+ import type { AccessPolicy } from "../contracts/AccessPolicy";
4
+ import type { AccessSubject } from "../contracts/types";
5
+ import type { SubjectAccess } from "./Access";
6
+
7
+ /**
8
+ * Checks of access (ACCESS-001 §5): `for(ctx)` — the subject of a request,
9
+ * `as(subject, scope)` — without HTTP (services, agents, background work).
10
+ */
11
+ export abstract class IAccess {
12
+ /** The application's rights system in this scope. */
13
+ abstract policy(scope: ServiceResolver): AccessPolicy;
14
+ /** Who makes the request, or `undefined`. */
15
+ abstract subject(ctx: AuthorizeContext): Promise<AccessSubject | undefined>;
16
+ /** Access of the request's subject; without one — 401. */
17
+ abstract for(ctx: AuthorizeContext): Promise<SubjectAccess>;
18
+ /** Access of a known subject; `scope` — where to resolve the policy and the lookups. */
19
+ abstract as(subject: AccessSubject, scope: ServiceResolver): SubjectAccess;
20
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from "../../kernel";
2
+
3
+ /** Settings of access (ACCESS-001), `access.*` in the configuration. */
4
+ export interface AccessConfig {
5
+ /** The claim with the account kind: `sub` becomes `<kind>:<subject>`; empty — no kind. */
6
+ kindClaim: string;
7
+ /** The claim with the roles of the default rights system, separated by spaces or commas. */
8
+ roleClaim: string;
9
+ /** Values of one list condition and of a lookup result. */
10
+ maxInList: number;
11
+ }
12
+
13
+ export const accessConfig = defineConfig<AccessConfig>("access", {
14
+ default: { kindClaim: "", roleClaim: "role", maxInList: 1000 },
15
+ validate: {
16
+ maxInList: (value) => Number.isSafeInteger(value) && value >= 1 && value <= 100_000 ? undefined : "must be an integer 1–100000",
17
+ },
18
+ });
@@ -0,0 +1,21 @@
1
+ import type { AuthorizeCheck, AuthorizeContext } from "../../http";
2
+ import type { AccessObject } from "../contracts/types";
3
+ import { IAccess } from "./IAccess.service";
4
+
5
+ /**
6
+ * A route check by a permission (ACCESS-001 §5): 401 without a subject, 403
7
+ * without the right. `objectOf` gives the object from the request; without it
8
+ * the route needs the permission anywhere, and the handler checks the object
9
+ * once it has loaded it.
10
+ *
11
+ * ```ts
12
+ * @Authorize(can("orders.create", (ctx) => ({ scope: ctx.header("x-shop") ?? null })))
13
+ * @Post() create(ctx: HttpContext) { … }
14
+ * ```
15
+ */
16
+ export function can(permission: string, objectOf?: (ctx: AuthorizeContext) => AccessObject | Promise<AccessObject>): AuthorizeCheck {
17
+ return async (ctx) => {
18
+ const access = await ctx.services.resolve(IAccess).for(ctx);
19
+ return await access.can(permission, objectOf === undefined ? undefined : await objectOf(ctx));
20
+ };
21
+ }