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.
- package/core/access/Access.module.ts +36 -0
- package/core/access/catalog/AccessCatalog.ts +73 -0
- package/core/access/catalog/IAccessCatalog.service.ts +17 -0
- package/core/access/catalog/moduleFields.ts +153 -0
- package/core/access/catalog/permissions.ts +127 -0
- package/core/access/check/Access.ts +191 -0
- package/core/access/check/IAccess.service.ts +20 -0
- package/core/access/check/accessConfig.ts +18 -0
- package/core/access/check/can.ts +21 -0
- package/core/access/check/conditions.ts +132 -0
- package/core/access/check/filter.ts +132 -0
- package/core/access/contracts/AccessPolicy.ts +29 -0
- package/core/access/contracts/AttributeLookup.ts +17 -0
- package/core/access/contracts/References.ts +33 -0
- package/core/access/contracts/errors.ts +13 -0
- package/core/access/contracts/types.ts +66 -0
- package/core/access/generated/deps.ts +14 -0
- package/core/access/http/AccessCatalog.controller.ts +51 -0
- package/core/access/index.ts +35 -0
- package/core/access/roles/RolesAccessPolicy.ts +64 -0
- package/core/access/roles/roles.ts +62 -0
- package/core/access/testing/index.ts +54 -0
- package/core/background/BackgroundService.ts +1 -1
- package/core/di/README.md +5 -5
- package/core/di/errors/ClassDependenciesMismatchError.ts +1 -1
- package/core/di/internal/GraphValidator.ts +1 -1
- package/core/di/module/autoDeps.ts +11 -1
- package/core/di/module/createContainer.ts +1 -1
- package/core/di/module/providerSelection.ts +1 -1
- package/core/grpc/{GrpcClient.ts → GrpcClientBase.ts} +1 -1
- package/core/grpc/grpcClients.ts +79 -0
- package/core/grpc/index.ts +2 -2
- package/core/http/options.ts +1 -1
- package/core/http-client/httpClients.ts +82 -0
- package/core/http-client/index.ts +11 -91
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/scripts/di-generate-target.ts +9 -0
- package/core/scripts/di-generate.ts +177 -9
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +15 -11
- package/core/workflow/definitions_module/access/Definitions.permissions.ts +23 -0
- package/core/workflow/definitions_module/access/Process.references.ts +27 -0
- package/core/workflow/definitions_module/code/CodeProcessCatalog.ts +4 -3
- package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +2 -2
- package/core/workflow/definitions_module/http/Processes.controller.ts +23 -14
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +9 -0
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +4 -0
- package/core/workflow/generated/deps.ts +56 -0
- package/core/workflow/index.ts +1 -1
- package/core/workflow/internal/WorkflowTools.ts +2 -2
- package/core/workflow/internal/http.ts +18 -17
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +17 -34
- package/core/workflow/runtime_module/access/Runtime.permissions.ts +28 -0
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +1 -1
- package/core/workflow/runtime_module/background/WorkflowWorker.ts +1 -10
- package/core/workflow/runtime_module/config/Runtime.config.ts +11 -1
- package/core/workflow/runtime_module/http/Events.controller.ts +5 -3
- package/core/workflow/runtime_module/http/Instances.controller.ts +25 -15
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +4 -3
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +3 -0
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +16 -5
- package/core/workflow/tasks_module/WorkflowTasks.module.ts +8 -10
- package/core/workflow/tasks_module/access/Tasks.permissions.ts +13 -0
- package/core/workflow/tasks_module/events/TaskEvents.ts +29 -8
- package/core/workflow/tasks_module/http/Tasks.controller.ts +21 -12
- package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +8 -3
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +41 -42
- package/core/workflow/testing/index.ts +1 -0
- package/index.ts +1 -1
- package/library/http-client/{HttpClient.ts → HttpClientBase.ts} +5 -5
- package/library/http-client/HttpClientFactory.ts +5 -5
- package/library/http-client/index.ts +3 -3
- package/library/terminal/messages.ts +2 -2
- package/package.json +3 -1
- package/core/grpc/grpcClientProvider.ts +0 -9
- package/core/workflow/access/WorkflowAccess.ts +0 -52
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { PermissionSpec } from "../catalog/permissions";
|
|
2
|
+
import type { AccessValue, Condition, Grant, Scope, ValueTest } from "../contracts/types";
|
|
3
|
+
|
|
4
|
+
export const MAX_DEPTH = 8;
|
|
5
|
+
export const MAX_NODES = 200;
|
|
6
|
+
|
|
7
|
+
/** Why a grant cannot be used for the permission, or `undefined` (ACCESS-001 §4.2). */
|
|
8
|
+
export function grantProblem(spec: PermissionSpec, grant: Grant, maxInList: number): string | undefined {
|
|
9
|
+
if (typeof grant !== "object" || grant === null) return "a grant must be an object";
|
|
10
|
+
if (grant.scopes !== "all" && (!Array.isArray(grant.scopes) || !grant.scopes.every(isScope))) return 'scopes must be "all" or a list of strings and null';
|
|
11
|
+
if (grant.fields !== undefined) {
|
|
12
|
+
if (spec.fields !== true) return "the permission has no field lists";
|
|
13
|
+
for (const side of ["read", "write"] as const) {
|
|
14
|
+
const list = grant.fields[side];
|
|
15
|
+
if (list !== undefined && list !== "all" && !(Array.isArray(list) && list.every((field) => typeof field === "string"))) return `fields.${side} must be "all" or a list of field names`;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (grant.where === undefined) return undefined;
|
|
19
|
+
const counter = { nodes: 0 };
|
|
20
|
+
return conditionProblem(spec, grant.where, 1, counter, maxInList);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function conditionProblem(spec: PermissionSpec, condition: Condition, depth: number, counter: { nodes: number }, maxInList: number): string | undefined {
|
|
24
|
+
if (depth > MAX_DEPTH) return `the condition is deeper than ${MAX_DEPTH}`;
|
|
25
|
+
if (++counter.nodes > MAX_NODES) return `the condition has more than ${MAX_NODES} nodes`;
|
|
26
|
+
if (typeof condition !== "object" || condition === null || Array.isArray(condition)) return "a condition must be an object";
|
|
27
|
+
const keys = Object.keys(condition);
|
|
28
|
+
if (keys.length !== 1) return `a condition must have exactly one key, got ${keys.length === 0 ? "none" : keys.join(", ")}`;
|
|
29
|
+
const key = keys[0]!;
|
|
30
|
+
const value = (condition as Record<string, unknown>)[key];
|
|
31
|
+
if (key === "all" || key === "any") {
|
|
32
|
+
if (!Array.isArray(value) || value.length === 0) return `${key} must be a non-empty list`;
|
|
33
|
+
for (const inner of value) {
|
|
34
|
+
const problem = conditionProblem(spec, inner as Condition, depth + 1, counter, maxInList);
|
|
35
|
+
if (problem !== undefined) return problem;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
if (key === "not") return conditionProblem(spec, value as Condition, depth + 1, counter, maxInList);
|
|
40
|
+
if (key === "own") {
|
|
41
|
+
if (value !== true) return "own must be true";
|
|
42
|
+
return spec.owner === undefined ? "the permission declares no owner, own cannot be used" : undefined;
|
|
43
|
+
}
|
|
44
|
+
if (spec.on?.[key] === undefined) return `unknown attribute "${key}"`;
|
|
45
|
+
return testProblem(key, value, maxInList);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function testProblem(attribute: string, test: unknown, maxInList: number): string | undefined {
|
|
49
|
+
if (typeof test !== "object" || test === null || Array.isArray(test)) return `the test of "${attribute}" must be an object`;
|
|
50
|
+
const keys = Object.keys(test);
|
|
51
|
+
if (keys.length !== 1) return `the test of "${attribute}" must have exactly one of eq, in, notIn, isNull`;
|
|
52
|
+
const [op] = keys;
|
|
53
|
+
const value = (test as Record<string, unknown>)[op!];
|
|
54
|
+
switch (op) {
|
|
55
|
+
case "eq":
|
|
56
|
+
return isValue(value) && value !== null ? undefined : `${attribute}.eq must be a string, number or boolean (an empty value is tested with isNull)`;
|
|
57
|
+
case "in":
|
|
58
|
+
case "notIn":
|
|
59
|
+
if (!Array.isArray(value) || !value.every((item) => isValue(item) && item !== null)) return `${attribute}.${op} must be a list of strings, numbers or booleans (an empty value is tested with isNull)`;
|
|
60
|
+
return value.length > maxInList ? `${attribute}.${op} has more than ${maxInList} values` : undefined;
|
|
61
|
+
case "isNull":
|
|
62
|
+
return typeof value === "boolean" ? undefined : `${attribute}.isNull must be true or false`;
|
|
63
|
+
default:
|
|
64
|
+
return `unknown test "${op}" of "${attribute}"`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The value of a condition on an object, in SQL's three-valued logic so that a
|
|
70
|
+
* check and a list filter agree: an attribute the object does not carry, or a
|
|
71
|
+
* `null` compared with a value, is unknown; unknown at the top denies.
|
|
72
|
+
*/
|
|
73
|
+
export function evaluate(condition: Condition, object: Readonly<Record<string, unknown>>, spec: PermissionSpec, ownerIds: readonly string[]): boolean | undefined {
|
|
74
|
+
if ("all" in condition && Array.isArray(condition.all)) {
|
|
75
|
+
let result: boolean | undefined = true;
|
|
76
|
+
for (const inner of condition.all as readonly Condition[]) {
|
|
77
|
+
const value = evaluate(inner, object, spec, ownerIds);
|
|
78
|
+
if (value === false) return false;
|
|
79
|
+
if (value === undefined) result = undefined;
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
if ("any" in condition && Array.isArray(condition.any)) {
|
|
84
|
+
let result: boolean | undefined = false;
|
|
85
|
+
for (const inner of condition.any as readonly Condition[]) {
|
|
86
|
+
const value = evaluate(inner, object, spec, ownerIds);
|
|
87
|
+
if (value === true) return true;
|
|
88
|
+
if (value === undefined) result = undefined;
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
if ("not" in condition && isCondition(condition.not)) {
|
|
93
|
+
const value = evaluate(condition.not, object, spec, ownerIds);
|
|
94
|
+
return value === undefined ? undefined : !value;
|
|
95
|
+
}
|
|
96
|
+
if ("own" in condition && condition.own === true) {
|
|
97
|
+
const owner = object[spec.owner!];
|
|
98
|
+
if (owner === undefined) return undefined;
|
|
99
|
+
if (owner === null) return false;
|
|
100
|
+
return ownerIds.includes(String(owner));
|
|
101
|
+
}
|
|
102
|
+
const [attribute, test] = Object.entries(condition)[0] as [string, ValueTest];
|
|
103
|
+
return testValue(object[attribute], test);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function testValue(raw: unknown, test: ValueTest): boolean | undefined {
|
|
107
|
+
if (raw === undefined) return undefined;
|
|
108
|
+
if ("isNull" in test) return (raw === null) === test.isNull;
|
|
109
|
+
if (raw === null) return undefined;
|
|
110
|
+
if ("eq" in test) return same(raw, test.eq);
|
|
111
|
+
const found = ("in" in test ? test.in : test.notIn).some((value) => same(raw, value));
|
|
112
|
+
return "in" in test ? found : !found;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Ids come as numbers from one side and as strings from the other. */
|
|
116
|
+
function same(raw: unknown, value: AccessValue): boolean {
|
|
117
|
+
if (typeof raw === typeof value) return raw === value;
|
|
118
|
+
if ((typeof raw === "number" || typeof raw === "string") && (typeof value === "number" || typeof value === "string")) return String(raw) === String(value);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function isValue(value: unknown): value is AccessValue {
|
|
123
|
+
return value === null || typeof value === "string" || typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isScope(value: unknown): value is Scope {
|
|
127
|
+
return value === null || typeof value === "string";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isCondition(value: unknown): value is Condition {
|
|
131
|
+
return typeof value === "object" && value !== null;
|
|
132
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Operand, type Predicate } from "../../../library/orm";
|
|
2
|
+
import type { Class } from "../../di";
|
|
3
|
+
import type { PermissionSpec } from "../catalog/permissions";
|
|
4
|
+
import type { AttributeLookup } from "../contracts/AttributeLookup";
|
|
5
|
+
import { AccessFilterTooLargeError } from "../contracts/errors";
|
|
6
|
+
import type { AccessValue, Condition, Grant, Scope, ValueTest } from "../contracts/types";
|
|
7
|
+
|
|
8
|
+
/** A condition on the columns of the listed rows. */
|
|
9
|
+
export type ColumnCondition =
|
|
10
|
+
| { readonly all: readonly ColumnCondition[] }
|
|
11
|
+
| { readonly any: readonly ColumnCondition[] }
|
|
12
|
+
| { readonly not: ColumnCondition }
|
|
13
|
+
| { readonly column: string; readonly in: readonly AccessValue[] }
|
|
14
|
+
| { readonly column: string; readonly isNull: boolean };
|
|
15
|
+
|
|
16
|
+
/** Which rows of a list the subject may see (ACCESS-001 §4.3). */
|
|
17
|
+
export type AccessFilter =
|
|
18
|
+
| { readonly kind: "all" }
|
|
19
|
+
| { readonly kind: "none" }
|
|
20
|
+
| { readonly kind: "where"; readonly where: ColumnCondition };
|
|
21
|
+
|
|
22
|
+
type Built = ColumnCondition | boolean;
|
|
23
|
+
|
|
24
|
+
export interface FilterInput {
|
|
25
|
+
readonly permission: string;
|
|
26
|
+
readonly spec: PermissionSpec;
|
|
27
|
+
readonly grants: "all" | readonly Grant[];
|
|
28
|
+
readonly ownerIds: readonly string[];
|
|
29
|
+
/** The partition of the request for a permission with `scope: "request"`. */
|
|
30
|
+
readonly requestScope: Scope | undefined;
|
|
31
|
+
readonly maxInList: number;
|
|
32
|
+
readonly lookup: (type: Class<AttributeLookup>) => AttributeLookup;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function buildFilter(input: FilterInput): Promise<AccessFilter> {
|
|
36
|
+
if (input.grants === "all") return { kind: "all" };
|
|
37
|
+
if (input.spec.scope === "request" && input.requestScope === undefined) {
|
|
38
|
+
throw new TypeError(`The list filter of "${input.permission}" needs the partition of the request: filter(permission, { scope }).`);
|
|
39
|
+
}
|
|
40
|
+
const parts: Built[] = [];
|
|
41
|
+
for (const grant of input.grants) {
|
|
42
|
+
const scope = await scopeCondition(input, grant);
|
|
43
|
+
parts.push(scope === false ? false : and([scope, grant.where === undefined ? true : await whereCondition(input, grant.where)]));
|
|
44
|
+
}
|
|
45
|
+
const result = or(parts);
|
|
46
|
+
return result === true ? { kind: "all" } : result === false ? { kind: "none" } : { kind: "where", where: result };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function scopeCondition(input: FilterInput, grant: Grant): Promise<Built> {
|
|
50
|
+
const source = input.spec.scope ?? "none";
|
|
51
|
+
if (grant.scopes === "all") return true;
|
|
52
|
+
if (source === "none") return grant.scopes.includes(null);
|
|
53
|
+
if (source === "request") return grant.scopes.includes(input.requestScope!);
|
|
54
|
+
if ("column" in source) return inColumn(source.column, grant.scopes);
|
|
55
|
+
return await fromLookup(input, source.lookup, grant.scopes);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function whereCondition(input: FilterInput, condition: Condition): Promise<Built> {
|
|
59
|
+
if ("all" in condition && Array.isArray(condition.all)) return and(await Promise.all((condition.all as readonly Condition[]).map((inner) => whereCondition(input, inner))));
|
|
60
|
+
if ("any" in condition && Array.isArray(condition.any)) return or(await Promise.all((condition.any as readonly Condition[]).map((inner) => whereCondition(input, inner))));
|
|
61
|
+
if ("not" in condition && typeof condition.not === "object" && condition.not !== null) return not(await whereCondition(input, condition.not as Condition));
|
|
62
|
+
if ("own" in condition && condition.own === true) return inColumn(input.spec.owner!, input.ownerIds);
|
|
63
|
+
const [name, test] = Object.entries(condition)[0] as [string, ValueTest];
|
|
64
|
+
const attribute = input.spec.on![name]!;
|
|
65
|
+
if (attribute.lookup !== undefined) {
|
|
66
|
+
const lookup = attribute.lookup;
|
|
67
|
+
if ("isNull" in test) return test.isNull ? await fromLookup(input, lookup, [null]) : not(await fromLookup(input, lookup, [null]));
|
|
68
|
+
if ("eq" in test) return await fromLookup(input, lookup, [test.eq]);
|
|
69
|
+
if ("in" in test) return await fromLookup(input, lookup, test.in);
|
|
70
|
+
return not(await fromLookup(input, lookup, test.notIn));
|
|
71
|
+
}
|
|
72
|
+
const column = attribute.column ?? name;
|
|
73
|
+
if ("isNull" in test) return { column, isNull: test.isNull };
|
|
74
|
+
if ("eq" in test) return { column, in: [test.eq] };
|
|
75
|
+
if ("in" in test) return test.in.length === 0 ? false : { column, in: test.in };
|
|
76
|
+
return test.notIn.length === 0 ? { column, isNull: false } : not({ column, in: test.notIn });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function fromLookup(input: FilterInput, type: Class<AttributeLookup>, values: readonly AccessValue[]): Promise<Built> {
|
|
80
|
+
const found = await input.lookup(type).toColumn(values);
|
|
81
|
+
if (found.in.length > input.maxInList) {
|
|
82
|
+
throw new AccessFilterTooLargeError(`The list filter of "${input.permission}": ${type.name} gave ${found.in.length} values of "${found.column}", more than access.maxInList (${input.maxInList}).`);
|
|
83
|
+
}
|
|
84
|
+
return inColumn(found.column, found.in);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `column in values`; `null` among them means an empty column. */
|
|
88
|
+
function inColumn(column: string, values: readonly AccessValue[]): Built {
|
|
89
|
+
const known = values.filter((value) => value !== null);
|
|
90
|
+
return or([known.length === 0 ? false : { column, in: known }, values.includes(null) ? { column, isNull: true } : false]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function and(parts: readonly Built[]): Built {
|
|
94
|
+
if (parts.includes(false)) return false;
|
|
95
|
+
const rest = parts.filter((part): part is ColumnCondition => part !== true);
|
|
96
|
+
return rest.length === 0 ? true : rest.length === 1 ? rest[0]! : { all: rest };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function or(parts: readonly Built[]): Built {
|
|
100
|
+
if (parts.includes(true)) return true;
|
|
101
|
+
const rest = parts.filter((part): part is ColumnCondition => part !== false);
|
|
102
|
+
return rest.length === 0 ? false : rest.length === 1 ? rest[0]! : { any: rest };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function not(part: Built): Built {
|
|
106
|
+
return typeof part === "boolean" ? !part : { not: part };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The filter as an ORM condition: `"all"` — no restriction, `"none"` — no rows
|
|
111
|
+
* at all (answer an empty page without a query).
|
|
112
|
+
*
|
|
113
|
+
* ```ts
|
|
114
|
+
* const where = grantsToWhere(await access.filter("orders.read"));
|
|
115
|
+
* if (where === "none") return { items: [], total: 0 };
|
|
116
|
+
* let query = db.orders.asNoTracking();
|
|
117
|
+
* if (where !== "all") query = query.where(() => where);
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
export function grantsToWhere(filter: AccessFilter): Predicate | "all" | "none" {
|
|
121
|
+
if (filter.kind !== "where") return filter.kind;
|
|
122
|
+
return toPredicate(filter.where);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function toPredicate(condition: ColumnCondition): Predicate {
|
|
126
|
+
if ("all" in condition) return condition.all.map(toPredicate).reduce((left, right) => left.and(right));
|
|
127
|
+
if ("any" in condition) return condition.any.map(toPredicate).reduce((left, right) => left.or(right));
|
|
128
|
+
if ("not" in condition) return toPredicate(condition.not).not();
|
|
129
|
+
const column = new Operand(condition.column);
|
|
130
|
+
if ("isNull" in condition) return condition.isNull ? column.isNull() : column.isNotNull();
|
|
131
|
+
return condition.in.length === 1 ? column.eq(condition.in[0]) : column.in(condition.in);
|
|
132
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AuthorizeContext } from "../../http";
|
|
2
|
+
import type { AccessSubject, Grants, Scope } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The rights system of the application (ACCESS-001 §4.1): a CMS roles module,
|
|
6
|
+
* `bazis/identity`, or the roles of `@Module({ roles })` by default
|
|
7
|
+
* (`RolesAccessPolicy`). It answers with data — grants — and the framework
|
|
8
|
+
* checks objects and filters lists by them itself. The application replaces
|
|
9
|
+
* the default in its module: `singleton(AccessPolicy, AppAccessPolicy)` (or
|
|
10
|
+
* `scoped`, when it needs a DbContext).
|
|
11
|
+
*/
|
|
12
|
+
export abstract class AccessPolicy {
|
|
13
|
+
/** Who makes the request: usually reads the principal the application's route check put into `ctx.state`. */
|
|
14
|
+
abstract subject(ctx: AuthorizeContext): AccessSubject | undefined | Promise<AccessSubject | undefined>;
|
|
15
|
+
|
|
16
|
+
/** What the subject is given by the permission. */
|
|
17
|
+
abstract grants(subject: AccessSubject, permission: string): Grants | Promise<Grants>;
|
|
18
|
+
|
|
19
|
+
/** The owner column values that mean "me" for `{ own: true }`: usually the bare account id. */
|
|
20
|
+
abstract ownerIds(subject: AccessSubject): readonly string[] | Promise<readonly string[]>;
|
|
21
|
+
|
|
22
|
+
/** Whom the subject is in each partition, for tasks: `user:<id>`, `role:<code>`, kinds of the application. */
|
|
23
|
+
abstract principals(subject: AccessSubject, scopes: readonly Scope[]): Promise<readonly ScopePrincipals[]>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ScopePrincipals {
|
|
27
|
+
readonly scope: Scope;
|
|
28
|
+
readonly principals: readonly string[];
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AccessValue } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Turns a condition on an attribute that is not a column of the listed rows
|
|
5
|
+
* into a condition on a column (ACCESS-001 §3): `folder in [Sales]` →
|
|
6
|
+
* `processKey in [keys of the folder's processes]`. A class of the declaring
|
|
7
|
+
* module, registered in its `providers`; it is resolved in the request scope.
|
|
8
|
+
*/
|
|
9
|
+
export abstract class AttributeLookup {
|
|
10
|
+
/** The column and its values equivalent to "the attribute is one of `values`" (`null` — the attribute is empty). */
|
|
11
|
+
abstract toColumn(values: readonly AccessValue[]): Promise<LookupColumn> | LookupColumn;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LookupColumn {
|
|
15
|
+
readonly column: string;
|
|
16
|
+
readonly in: readonly AccessValue[];
|
|
17
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { LocalizedText, Scope } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Values of a reference kind (ACCESS-001 §6): what the administrator picks in a
|
|
5
|
+
* condition («which tables», «which processes») and what a process checks for
|
|
6
|
+
* `x-ref`. A module declares the kinds it owns in `@Module({ references })`;
|
|
7
|
+
* the class is registered in its `providers` and resolved in the request scope.
|
|
8
|
+
*/
|
|
9
|
+
export abstract class References {
|
|
10
|
+
/** Which of `ids` exist. */
|
|
11
|
+
abstract exists(ids: readonly string[], context: ReferenceContext): Promise<ReadonlySet<string>>;
|
|
12
|
+
|
|
13
|
+
/** Titles of `ids`; unknown ones are left out. */
|
|
14
|
+
abstract describe(ids: readonly string[], context: ReferenceContext): Promise<readonly ReferenceItem[]>;
|
|
15
|
+
|
|
16
|
+
/** Values whose title or id contains `text` (empty — all), page by page. */
|
|
17
|
+
abstract search(text: string, context: ReferenceContext, page: ReferencePage): Promise<readonly ReferenceItem[]>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ReferenceItem {
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly title: LocalizedText;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ReferenceContext {
|
|
26
|
+
readonly scope: Scope;
|
|
27
|
+
readonly language?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ReferencePage {
|
|
31
|
+
readonly limit: number;
|
|
32
|
+
readonly offset: number;
|
|
33
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** A problem of declared permissions, references or roles; the application reports it as a configuration error. */
|
|
2
|
+
export class AccessSetupError extends Error {
|
|
3
|
+
override readonly name = "AccessSetupError";
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A list filter that cannot be built: a lookup gave more values than
|
|
8
|
+
* `access.maxInList` (ACCESS-001 §4.3). The request fails with 500.
|
|
9
|
+
*/
|
|
10
|
+
export class AccessFilterTooLargeError extends Error {
|
|
11
|
+
override readonly name = "AccessFilterTooLargeError";
|
|
12
|
+
readonly code = "access_filter_too_large";
|
|
13
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** A text for people: one string, or a text per language (`{ ru: "…", en: "…" }`). */
|
|
2
|
+
export type LocalizedText = string | Readonly<Record<string, string>>;
|
|
3
|
+
|
|
4
|
+
/** A value an attribute of an object can have in a condition. */
|
|
5
|
+
export type AccessValue = string | number | boolean | null;
|
|
6
|
+
|
|
7
|
+
/** A partition of the application (a CMS space, a tenant); `null` — no partition. */
|
|
8
|
+
export type Scope = string | null;
|
|
9
|
+
|
|
10
|
+
/** A test of one attribute. */
|
|
11
|
+
export type ValueTest =
|
|
12
|
+
| { readonly eq: AccessValue }
|
|
13
|
+
| { readonly in: readonly AccessValue[] }
|
|
14
|
+
| { readonly notIn: readonly AccessValue[] }
|
|
15
|
+
| { readonly isNull: boolean };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A condition of a grant (ACCESS-001 §4.2): a tree over the attributes the
|
|
19
|
+
* permission declares in `on`. `{ own: true }` — the object belongs to the
|
|
20
|
+
* subject. Values are final: the rights system substitutes "my organization"
|
|
21
|
+
* and the like itself.
|
|
22
|
+
*/
|
|
23
|
+
export type Condition =
|
|
24
|
+
| { readonly all: readonly Condition[] }
|
|
25
|
+
| { readonly any: readonly Condition[] }
|
|
26
|
+
| { readonly not: Condition }
|
|
27
|
+
| { readonly own: true }
|
|
28
|
+
| { readonly [attribute: string]: ValueTest };
|
|
29
|
+
|
|
30
|
+
/** Field lists of a grant for a permission with `fields: true`. */
|
|
31
|
+
export interface GrantFields {
|
|
32
|
+
readonly read?: readonly string[] | "all";
|
|
33
|
+
readonly write?: readonly string[] | "all";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What a subject is given by one permission: in these partitions, on these conditions. */
|
|
37
|
+
export interface Grant {
|
|
38
|
+
readonly scopes: "all" | readonly Scope[];
|
|
39
|
+
readonly where?: Condition;
|
|
40
|
+
readonly fields?: GrantFields;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `"all"` — everything without conditions (a superuser); `[]` — nothing. Grants add up with "or". */
|
|
44
|
+
export type Grants = "all" | readonly Grant[];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Who acts: `sub` is `"<account kind>:<id>"` so that accounts of different
|
|
48
|
+
* tables with one id do not merge; `act` — on whose behalf (an organization).
|
|
49
|
+
* A rights system may add its own fields: the framework hands the same object
|
|
50
|
+
* back to it.
|
|
51
|
+
*/
|
|
52
|
+
export interface AccessSubject {
|
|
53
|
+
readonly sub: string;
|
|
54
|
+
readonly act?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What is checked: the attribute values of the object, its partition (one or
|
|
59
|
+
* several) under `scope`, and the owner column under the name the permission
|
|
60
|
+
* declares in `owner`. Without an object `can` answers whether the subject has
|
|
61
|
+
* the permission anywhere.
|
|
62
|
+
*/
|
|
63
|
+
export interface AccessObject {
|
|
64
|
+
readonly scope?: Scope | readonly Scope[];
|
|
65
|
+
readonly [attribute: string]: unknown;
|
|
66
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// This file is auto-generated by `bun run di:generate` (framework.packs of bazis.config.json).
|
|
2
|
+
// Do not edit manually.
|
|
3
|
+
import { registerFrameworkClassDeps } from "../../di/module/autoDeps";
|
|
4
|
+
import { ServiceProvider } from "../../di/ServiceProvider";
|
|
5
|
+
import { AccessCatalog } from "../catalog/AccessCatalog";
|
|
6
|
+
import { IAccessCatalog } from "../catalog/IAccessCatalog.service";
|
|
7
|
+
import { Access } from "../check/Access";
|
|
8
|
+
import { RolesAccessPolicy } from "../roles/RolesAccessPolicy";
|
|
9
|
+
|
|
10
|
+
registerFrameworkClassDeps([
|
|
11
|
+
[Access, [IAccessCatalog, "Config:access"]],
|
|
12
|
+
[AccessCatalog, [ServiceProvider]],
|
|
13
|
+
[RolesAccessPolicy, [IAccessCatalog, "Config:access"]],
|
|
14
|
+
]);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { contextOnlyController } from "../../http/Routing/contextOnlyControllers";
|
|
2
|
+
import { Authorize, BadRequestError, Controller, Get, NotFoundError, type HttpContext } from "../../http";
|
|
3
|
+
import { IAccessCatalog } from "../catalog/IAccessCatalog.service";
|
|
4
|
+
import { can } from "../check/can";
|
|
5
|
+
import type { Scope } from "../contracts/types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The catalog for the administrator's screens of the rights system
|
|
9
|
+
* (ACCESS-001 §7): what can be allowed, and the values to pick in conditions.
|
|
10
|
+
* Read only: roles live in the rights system.
|
|
11
|
+
*/
|
|
12
|
+
@Controller("access")
|
|
13
|
+
export class AccessCatalogController {
|
|
14
|
+
@Get("catalog")
|
|
15
|
+
@Authorize(can("access.catalog.read"))
|
|
16
|
+
catalogList(ctx: HttpContext) {
|
|
17
|
+
return {
|
|
18
|
+
data: ctx.services.resolve(IAccessCatalog).permissions().map(({ key, spec, module }) => ({
|
|
19
|
+
key,
|
|
20
|
+
module,
|
|
21
|
+
title: spec.title,
|
|
22
|
+
...(spec.description === undefined ? {} : { description: spec.description }),
|
|
23
|
+
scope: typeof spec.scope === "object" ? ("column" in spec.scope ? "column" : "lookup") : spec.scope ?? "none",
|
|
24
|
+
attributes: Object.entries(spec.on ?? {}).map(([name, attribute]) => ({ name, ...(attribute.ref === undefined ? { type: attribute.type } : { ref: attribute.ref }) })),
|
|
25
|
+
own: spec.owner !== undefined,
|
|
26
|
+
fields: spec.fields === true,
|
|
27
|
+
})),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Get("references/:kind")
|
|
32
|
+
@Authorize(can("access.catalog.read"))
|
|
33
|
+
async references(ctx: HttpContext) {
|
|
34
|
+
const kind = ctx.services.resolve(IAccessCatalog).referenceKind(String(ctx.params.kind ?? ""));
|
|
35
|
+
if (kind === undefined) throw new NotFoundError();
|
|
36
|
+
const limit = integer(ctx.query("limit"), "limit", 50, 1, 200);
|
|
37
|
+
const offset = integer(ctx.query("offset"), "offset", 0, 0, 1_000_000);
|
|
38
|
+
const scope: Scope = ctx.query("scope") ?? null;
|
|
39
|
+
const items = await ctx.services.resolve(kind.type).search(ctx.query("search") ?? "", { scope }, { limit, offset });
|
|
40
|
+
return { data: items };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
contextOnlyController(AccessCatalogController);
|
|
45
|
+
|
|
46
|
+
function integer(raw: string | undefined, name: string, fallback: number, min: number, max: number): number {
|
|
47
|
+
if (raw === undefined || raw === "") return fallback;
|
|
48
|
+
const value = Number(raw);
|
|
49
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) throw new BadRequestError(`${name} must be an integer ${min}–${max}`);
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access as a layer of the framework (ACCESS-001): modules declare
|
|
3
|
+
* permissions, the application's rights system answers with grants, the
|
|
4
|
+
* framework checks objects and filters lists by them.
|
|
5
|
+
*/
|
|
6
|
+
import "./catalog/moduleFields";
|
|
7
|
+
import "./generated/deps";
|
|
8
|
+
|
|
9
|
+
export { AccessModule } from "./Access.module";
|
|
10
|
+
export { definePermissions, type AttributeSpec, type PermissionDeclarations, type PermissionSpec, type ScopeSource } from "./catalog/permissions";
|
|
11
|
+
export { IAccessCatalog } from "./catalog/IAccessCatalog.service";
|
|
12
|
+
export type { DeclaredRole, OwnedPermission, OwnedReferenceKind } from "./catalog/AccessCatalog";
|
|
13
|
+
export { IAccess } from "./check/IAccess.service";
|
|
14
|
+
export { SubjectAccess, maskFields, type FieldAccess } from "./check/Access";
|
|
15
|
+
export { accessConfig } from "./check/accessConfig";
|
|
16
|
+
export { can } from "./check/can";
|
|
17
|
+
export { grantsToWhere, type AccessFilter, type ColumnCondition } from "./check/filter";
|
|
18
|
+
export { AccessPolicy, type ScopePrincipals } from "./contracts/AccessPolicy";
|
|
19
|
+
export { AttributeLookup, type LookupColumn } from "./contracts/AttributeLookup";
|
|
20
|
+
export { References, type ReferenceContext, type ReferenceItem, type ReferencePage } from "./contracts/References";
|
|
21
|
+
export { AccessFilterTooLargeError, AccessSetupError } from "./contracts/errors";
|
|
22
|
+
export type {
|
|
23
|
+
AccessObject,
|
|
24
|
+
AccessSubject,
|
|
25
|
+
AccessValue,
|
|
26
|
+
Condition,
|
|
27
|
+
Grant,
|
|
28
|
+
GrantFields,
|
|
29
|
+
Grants,
|
|
30
|
+
LocalizedText,
|
|
31
|
+
Scope,
|
|
32
|
+
ValueTest,
|
|
33
|
+
} from "./contracts/types";
|
|
34
|
+
export { defineRoles, type RoleDeclarations, type RoleGrant } from "./roles/roles";
|
|
35
|
+
export { RolesAccessPolicy, type RoleSubject } from "./roles/RolesAccessPolicy";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ConfigView } from "../../kernel";
|
|
2
|
+
import { PRINCIPAL_STATE_KEY, type AuthorizeContext, type RequestPrincipal } from "../../http";
|
|
3
|
+
import type { IAccessCatalog } from "../catalog/IAccessCatalog.service";
|
|
4
|
+
import type { AccessConfig } from "../check/accessConfig";
|
|
5
|
+
import { AccessPolicy, type ScopePrincipals } from "../contracts/AccessPolicy";
|
|
6
|
+
import type { AccessSubject, Grant, Grants, Scope } from "../contracts/types";
|
|
7
|
+
|
|
8
|
+
/** The subject of the default rights system: the account id and its roles from the token. */
|
|
9
|
+
export interface RoleSubject extends AccessSubject {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly roles: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The default `AccessPolicy` (ACCESS-001 §8); an application replaces it by
|
|
16
|
+
* registering its own: `singleton(AccessPolicy, AppAccessPolicy)`. The principal of the route check is the subject, its roles come from the
|
|
17
|
+
* `access.roleClaim` claim, their permissions from `@Module({ roles })`. A role
|
|
18
|
+
* nobody declared gives nothing; without roles everything is denied. A step
|
|
19
|
+
* until `bazis/identity`.
|
|
20
|
+
*/
|
|
21
|
+
export class RolesAccessPolicy extends AccessPolicy {
|
|
22
|
+
constructor(private readonly catalog: IAccessCatalog, private readonly config: ConfigView<AccessConfig>) {
|
|
23
|
+
super();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
subject(ctx: AuthorizeContext): RoleSubject | undefined {
|
|
27
|
+
const principal = ctx.state.get(PRINCIPAL_STATE_KEY) as RequestPrincipal | undefined;
|
|
28
|
+
const id = principal?.subject;
|
|
29
|
+
if (id === undefined || id === "") return undefined;
|
|
30
|
+
const kindClaim = this.config.get("kindClaim");
|
|
31
|
+
const kind = kindClaim === "" ? undefined : principal!.findFirst?.(kindClaim);
|
|
32
|
+
const roles = (principal!.findFirst?.(this.config.get("roleClaim")) ?? "").split(/[\s,]+/).filter((role) => role !== "");
|
|
33
|
+
return { sub: kind === undefined || kind === "" ? id : `${kind}:${id}`, id, roles };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
grants(subject: AccessSubject, permission: string): Grants {
|
|
37
|
+
const grants: Grant[] = [];
|
|
38
|
+
for (const name of rolesOf(subject)) {
|
|
39
|
+
const role = this.catalog.role(name);
|
|
40
|
+
if (role === undefined) continue;
|
|
41
|
+
if (role.permissions === "all") return "all";
|
|
42
|
+
for (const item of role.permissions) if (item.permission === permission) grants.push(item.grant);
|
|
43
|
+
}
|
|
44
|
+
return grants;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
ownerIds(subject: AccessSubject): readonly string[] {
|
|
48
|
+
return [idOf(subject)];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async principals(subject: AccessSubject, scopes: readonly Scope[]): Promise<readonly ScopePrincipals[]> {
|
|
52
|
+
const principals = [`user:${idOf(subject)}`, ...rolesOf(subject).map((role) => `role:${role}`)];
|
|
53
|
+
return scopes.map((scope) => ({ scope, principals }));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function rolesOf(subject: AccessSubject): readonly string[] {
|
|
58
|
+
const roles = (subject as Partial<RoleSubject>).roles;
|
|
59
|
+
return Array.isArray(roles) ? roles : [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function idOf(subject: AccessSubject): string {
|
|
63
|
+
return (subject as Partial<RoleSubject>).id ?? subject.sub;
|
|
64
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Condition, Grant, GrantFields, Scope } from "../contracts/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A permission of a role: its key, `"<key>:own"` for «only one's own», or a
|
|
5
|
+
* grant with conditions.
|
|
6
|
+
*/
|
|
7
|
+
export type RoleGrant =
|
|
8
|
+
| string
|
|
9
|
+
| {
|
|
10
|
+
readonly permission: string;
|
|
11
|
+
readonly scopes?: "all" | readonly Scope[];
|
|
12
|
+
readonly where?: Condition;
|
|
13
|
+
readonly fields?: GrantFields;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Role → `"all"` (everything) or its permissions. */
|
|
17
|
+
export type RoleDeclarations = Readonly<Record<string, "all" | readonly RoleGrant[]>>;
|
|
18
|
+
|
|
19
|
+
const DECLARED = new WeakSet<object>();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Roles of the default rights system (ACCESS-001 §8), for an application
|
|
23
|
+
* without its own one: the subject's roles come from the token, their
|
|
24
|
+
* permissions from here. The result goes to `@Module({ roles })`.
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* export const appRoles = defineRoles({
|
|
28
|
+
* admin: "all",
|
|
29
|
+
* manager: ["workflow.instances.start", "workflow.instances.read"],
|
|
30
|
+
* user: ["workflow.instances.read:own", "workflow.tasks.work"],
|
|
31
|
+
* });
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function defineRoles<const T extends RoleDeclarations>(roles: T): T {
|
|
35
|
+
const frozen = Object.freeze({ ...roles });
|
|
36
|
+
DECLARED.add(frozen);
|
|
37
|
+
return frozen;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isDeclaredRoles(value: unknown): value is RoleDeclarations {
|
|
41
|
+
return typeof value === "object" && value !== null && DECLARED.has(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One permission of a role as a grant. */
|
|
45
|
+
export interface RolePermission {
|
|
46
|
+
readonly permission: string;
|
|
47
|
+
readonly grant: Grant;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `"orders.read:own"` → `orders.read` with `{ own: true }`. */
|
|
51
|
+
export function rolePermission(item: RoleGrant): RolePermission {
|
|
52
|
+
if (typeof item === "string") {
|
|
53
|
+
if (item.endsWith(":own")) return { permission: item.slice(0, -4), grant: { scopes: "all", where: { own: true } } };
|
|
54
|
+
return { permission: item, grant: { scopes: "all" } };
|
|
55
|
+
}
|
|
56
|
+
const grant: Grant = {
|
|
57
|
+
scopes: item.scopes ?? "all",
|
|
58
|
+
...(item.where === undefined ? {} : { where: item.where }),
|
|
59
|
+
...(item.fields === undefined ? {} : { fields: item.fields }),
|
|
60
|
+
};
|
|
61
|
+
return { permission: item.permission, grant };
|
|
62
|
+
}
|