bazis 0.138.0 → 0.140.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/README.md +13 -5
- package/core/access/Access.module.ts +6 -1
- package/core/access/catalog/AccessCatalog.ts +56 -7
- package/core/access/catalog/IAccessCatalog.service.ts +28 -2
- package/core/access/catalog/PermissionSourceLoader.ts +43 -0
- package/core/access/catalog/PermissionSources.ts +222 -0
- package/core/access/catalog/moduleFields.ts +60 -6
- package/core/access/catalog/permissions.ts +46 -1
- package/core/access/check/Access.ts +55 -28
- package/core/access/check/accessConfig.ts +9 -1
- package/core/access/check/can.ts +2 -2
- package/core/access/check/conditions.ts +69 -11
- package/core/access/check/decimal.ts +61 -0
- package/core/access/check/filter.ts +39 -8
- package/core/access/contracts/PermissionSource.ts +32 -0
- package/core/access/contracts/types.ts +12 -2
- package/core/access/generated/deps.ts +5 -1
- package/core/access/http/AccessCatalog.controller.ts +9 -2
- package/core/access/index.ts +12 -1
- package/core/agent/AgentContextBuilder.ts +119 -16
- package/core/agent/AgentRuntime.ts +8 -0
- package/core/agent/AgentToolExecutor.ts +7 -0
- package/core/agent/ModelProvider.ts +6 -0
- package/core/agent/README.md +35 -3
- package/core/agent/errors.ts +32 -0
- package/core/agent/index.ts +7 -0
- package/core/agent/internal/AgentExecutionDriver.ts +598 -24
- package/core/agent/internal/AgentExecutionState.ts +26 -3
- package/core/agent/semantic.ts +60 -1
- package/core/cache/SPEC.md +3 -3
- package/core/di/README.md +10 -3
- package/core/infra/anthropic.ts +11 -0
- package/core/infra/connectors/anthropic.ts +440 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/infra/connectors/llm.ts +12 -0
- package/core/infra/connectors/llmRouter.ts +12 -0
- package/core/infra/connectors/openaiCompatible.ts +128 -63
- package/core/infra/connectors/openaiTextStream.ts +5 -2
- package/core/infra/connectors/toolNames.ts +49 -0
- package/core/scripts/di-generate.ts +34 -4
- package/core/websocket/DESIGN.md +2 -2
- package/index.ts +5 -0
- package/library/orm/Repository/README.md +14 -11
- package/package.json +7 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Class } from "../../di";
|
|
2
2
|
import type { AttributeLookup } from "../contracts/AttributeLookup";
|
|
3
3
|
import type { LocalizedText } from "../contracts/types";
|
|
4
|
-
import type { BazisPermissions } from "../index";
|
|
4
|
+
import type { BazisPermissions, BazisPermissionSources } from "../index";
|
|
5
5
|
|
|
6
6
|
/** An attribute of the object of a permission: what a condition may test (ACCESS-001 §3). */
|
|
7
7
|
export interface AttributeSpec {
|
|
@@ -46,6 +46,16 @@ export type PermissionsOf<T extends PermissionDeclarations> = { readonly [K in k
|
|
|
46
46
|
/** A declared permission. */
|
|
47
47
|
export type PermissionKey = Extract<keyof BazisPermissions, string>;
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* A permission of a source (ACCESS-001 §3.1): any key under a prefix of
|
|
51
|
+
* `@Module({ permissionSources })`. Whether it exists is known only while the
|
|
52
|
+
* application runs; a key the source does not give is a permission nobody has.
|
|
53
|
+
*/
|
|
54
|
+
export type SourcePermissionKey = `${Extract<keyof BazisPermissionSources, string>}.${string}`;
|
|
55
|
+
|
|
56
|
+
/** What `can`, `access.can`, `access.filter` and `access.fields` accept. */
|
|
57
|
+
export type AnyPermissionKey = PermissionKey | SourcePermissionKey;
|
|
58
|
+
|
|
49
59
|
/** A permission with an `owner`: the only kind `"<key>:own"` makes sense for. */
|
|
50
60
|
export type OwnPermissionKey = { [K in keyof BazisPermissions]: BazisPermissions[K] extends { readonly owner: string } ? Extract<K, string> : never }[keyof BazisPermissions];
|
|
51
61
|
|
|
@@ -81,6 +91,8 @@ const RESERVED = new Set(["all", "any", "not", "own", "scope"]);
|
|
|
81
91
|
export function permissionProblems(key: string, spec: PermissionSpec): string[] {
|
|
82
92
|
const problems: string[] = [];
|
|
83
93
|
const at = `permission "${key}"`;
|
|
94
|
+
if (typeof spec !== "object" || spec === null || Array.isArray(spec)) return [`${at}: must be an object with a title`];
|
|
95
|
+
if (spec.on !== undefined && (typeof spec.on !== "object" || spec.on === null || Array.isArray(spec.on))) return [`${at}: on must map attribute names to their descriptions`];
|
|
84
96
|
if (!KEY.test(key) || key.length > 128) problems.push(`${at}: the key must look like "<module>.<object>.<action>" in lower case`);
|
|
85
97
|
if (!isText(spec.title, 200)) problems.push(`${at}: title must be a non-empty text up to 200 characters`);
|
|
86
98
|
if (spec.description !== undefined && !isText(spec.description, 2000)) problems.push(`${at}: description must be a non-empty text up to 2000 characters`);
|
|
@@ -95,6 +107,10 @@ export function permissionProblems(key: string, spec: PermissionSpec): string[]
|
|
|
95
107
|
for (const [name, attribute] of Object.entries(spec.on ?? {})) {
|
|
96
108
|
if (!NAME.test(name)) problems.push(`${at}: attribute "${name}" must be a name of letters, digits and _`);
|
|
97
109
|
else if (RESERVED.has(name)) problems.push(`${at}: attribute "${name}" is a reserved word of conditions`);
|
|
110
|
+
if (typeof attribute !== "object" || attribute === null) {
|
|
111
|
+
problems.push(`${at}: attribute "${name}" must be an object with ref or type`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
98
114
|
if ((attribute.ref === undefined) === (attribute.type === undefined)) problems.push(`${at}: attribute "${name}" needs exactly one of ref and type`);
|
|
99
115
|
if (attribute.ref !== undefined && !KEY.test(attribute.ref)) problems.push(`${at}: attribute "${name}": ref must be a reference kind like "<module>.<kind>"`);
|
|
100
116
|
if (attribute.type !== undefined && !["string", "number", "boolean"].includes(attribute.type)) problems.push(`${at}: attribute "${name}": type must be string, number or boolean`);
|
|
@@ -119,6 +135,34 @@ export function isReferenceKind(value: string): boolean {
|
|
|
119
135
|
return KEY.test(value) && value.length <= 128;
|
|
120
136
|
}
|
|
121
137
|
|
|
138
|
+
const PREFIX = /^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/;
|
|
139
|
+
|
|
140
|
+
/** A prefix of a permission source: one or several segments of a key (`data`, `dm.records`). */
|
|
141
|
+
export function isSourcePrefix(value: string): boolean {
|
|
142
|
+
return PREFIX.test(value) && value.length <= 64;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Whether the key lies under the prefix: `data.orders.read` under `data`. */
|
|
146
|
+
export function underPrefix(key: string, prefix: string): boolean {
|
|
147
|
+
return key.startsWith(`${prefix}.`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A frozen copy of a specification a source gave: the catalog keeps it, the source may change its own. */
|
|
151
|
+
export function frozenSpec(spec: PermissionSpec): PermissionSpec {
|
|
152
|
+
const scope = typeof spec.scope === "object" ? { ...spec.scope } : spec.scope;
|
|
153
|
+
const on = spec.on === undefined ? undefined : Object.fromEntries(Object.entries(spec.on).map(([name, attribute]) => [name, { ...attribute }]));
|
|
154
|
+
const title = typeof spec.title === "object" ? { ...spec.title } : spec.title;
|
|
155
|
+
const description = typeof spec.description === "object" ? { ...spec.description } : spec.description;
|
|
156
|
+
return deepFreeze({
|
|
157
|
+
title,
|
|
158
|
+
...(description === undefined ? {} : { description }),
|
|
159
|
+
...(scope === undefined ? {} : { scope }),
|
|
160
|
+
...(on === undefined ? {} : { on }),
|
|
161
|
+
...(spec.owner === undefined ? {} : { owner: spec.owner }),
|
|
162
|
+
...(spec.fields === undefined ? {} : { fields: spec.fields }),
|
|
163
|
+
} as PermissionSpec);
|
|
164
|
+
}
|
|
165
|
+
|
|
122
166
|
function isText(value: unknown, max: number): boolean {
|
|
123
167
|
if (typeof value === "string") return value.trim() !== "" && value.length <= max;
|
|
124
168
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
@@ -132,6 +176,7 @@ function isName(value: unknown): value is string {
|
|
|
132
176
|
|
|
133
177
|
function deepFreeze<T>(value: T): T {
|
|
134
178
|
if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
|
|
179
|
+
// A lookup is a class: a function, left as it is.
|
|
135
180
|
for (const item of Object.values(value)) if (typeof item === "object") deepFreeze(item);
|
|
136
181
|
return Object.freeze(value);
|
|
137
182
|
}
|
|
@@ -3,7 +3,8 @@ import { UnauthorizedError, type AuthorizeContext } from "../../http";
|
|
|
3
3
|
import { LOGGER, type ConfigView } from "../../kernel";
|
|
4
4
|
import type { AccessConfig } from "./accessConfig";
|
|
5
5
|
import type { IAccessCatalog } from "../catalog/IAccessCatalog.service";
|
|
6
|
-
import type {
|
|
6
|
+
import type { OwnedPermission } from "../catalog/AccessCatalog";
|
|
7
|
+
import type { AnyPermissionKey, PermissionSpec } from "../catalog/permissions";
|
|
7
8
|
import { AccessPolicy, type ScopePrincipals } from "../contracts/AccessPolicy";
|
|
8
9
|
import type { AttributeLookup } from "../contracts/AttributeLookup";
|
|
9
10
|
import type { AccessObject, AccessSubject, Condition, Grant, Grants, Scope } from "../contracts/types";
|
|
@@ -56,9 +57,19 @@ export class Access extends IAccess {
|
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
/**
|
|
60
|
+
/** The grants of a permission with the specification they were checked against. */
|
|
61
|
+
interface Given {
|
|
62
|
+
readonly spec: PermissionSpec;
|
|
63
|
+
readonly grants: Grants;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What one subject may do; grants are asked once per permission. A permission
|
|
68
|
+
* of a source that the source does not give now does not exist: nobody has it,
|
|
69
|
+
* and the rights system is not asked (ACCESS-001 §3.1).
|
|
70
|
+
*/
|
|
60
71
|
export class SubjectAccess {
|
|
61
|
-
private readonly grantCache = new Map<string, Promise<
|
|
72
|
+
private readonly grantCache = new Map<string, Promise<Given | undefined>>();
|
|
62
73
|
private owners: Promise<readonly string[]> | undefined;
|
|
63
74
|
|
|
64
75
|
constructor(
|
|
@@ -70,31 +81,28 @@ export class SubjectAccess {
|
|
|
70
81
|
) {}
|
|
71
82
|
|
|
72
83
|
/** The grants of the permission; a grant the permission cannot use is dropped with a warning. */
|
|
73
|
-
grants(permission:
|
|
74
|
-
|
|
75
|
-
if (grants === undefined) {
|
|
76
|
-
grants = this.load(permission);
|
|
77
|
-
this.grantCache.set(permission, grants);
|
|
78
|
-
}
|
|
79
|
-
return grants;
|
|
84
|
+
async grants(permission: AnyPermissionKey): Promise<Grants> {
|
|
85
|
+
return (await this.given(permission))?.grants ?? [];
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
/**
|
|
83
89
|
* Whether the subject may do `permission` on `object`. Without an object —
|
|
84
90
|
* whether it has the permission anywhere (a route before the object is known).
|
|
85
91
|
*/
|
|
86
|
-
async can(permission:
|
|
87
|
-
const
|
|
88
|
-
|
|
92
|
+
async can(permission: AnyPermissionKey, object?: AccessObject): Promise<boolean> {
|
|
93
|
+
const given = await this.given(permission);
|
|
94
|
+
if (given === undefined) return false;
|
|
95
|
+
const { spec, grants } = given;
|
|
89
96
|
if (grants === "all") return true;
|
|
90
97
|
if (object === undefined) return grants.length > 0;
|
|
91
98
|
return (await this.matching(permission, spec, grants, object)).length > 0;
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
/** Which rows of a list the subject may see; `scope` — the partition of the request for `scope: "request"`. */
|
|
95
|
-
async filter(permission:
|
|
96
|
-
const
|
|
97
|
-
|
|
102
|
+
async filter(permission: AnyPermissionKey, options: { readonly scope?: Scope } = {}): Promise<AccessFilter> {
|
|
103
|
+
const given = await this.given(permission);
|
|
104
|
+
if (given === undefined) return { kind: "none" };
|
|
105
|
+
const { spec, grants } = given;
|
|
98
106
|
return await buildFilter({
|
|
99
107
|
permission,
|
|
100
108
|
spec,
|
|
@@ -107,9 +115,10 @@ export class SubjectAccess {
|
|
|
107
115
|
}
|
|
108
116
|
|
|
109
117
|
/** The fields of `object` the subject may read and write (ACCESS-001 §4.4). */
|
|
110
|
-
async fields(permission:
|
|
111
|
-
const
|
|
112
|
-
|
|
118
|
+
async fields(permission: AnyPermissionKey, object: AccessObject): Promise<FieldAccess> {
|
|
119
|
+
const given = await this.given(permission);
|
|
120
|
+
if (given === undefined) return { read: [], write: [] };
|
|
121
|
+
const { spec, grants } = given;
|
|
113
122
|
if (grants === "all") return { read: "all", write: "all" };
|
|
114
123
|
const matching = await this.matching(permission, spec, grants, object);
|
|
115
124
|
return { read: union(matching, "read"), write: union(matching, "write") };
|
|
@@ -126,19 +135,37 @@ export class SubjectAccess {
|
|
|
126
135
|
return this.owners;
|
|
127
136
|
}
|
|
128
137
|
|
|
129
|
-
private
|
|
130
|
-
|
|
138
|
+
private given(permission: string): Promise<Given | undefined> {
|
|
139
|
+
let given = this.grantCache.get(permission);
|
|
140
|
+
if (given === undefined) {
|
|
141
|
+
given = this.load(permission);
|
|
142
|
+
this.grantCache.set(permission, given);
|
|
143
|
+
}
|
|
144
|
+
return given;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `undefined` — a permission of a source that does not exist now. */
|
|
148
|
+
private async load(permission: string): Promise<Given | undefined> {
|
|
149
|
+
let owned: OwnedPermission | undefined = await this.catalog.find(permission);
|
|
150
|
+
if (owned === undefined) return undefined;
|
|
131
151
|
const grants = await this.policy.grants(this.subject, permission);
|
|
132
|
-
if (grants === "all") return "all";
|
|
152
|
+
if (grants === "all") return { spec: owned.spec, grants: "all" };
|
|
133
153
|
if (!Array.isArray(grants)) {
|
|
134
154
|
this.warn(permission, "the rights system must answer \"all\" or a list of grants");
|
|
135
|
-
return [];
|
|
155
|
+
return { spec: owned.spec, grants: [] };
|
|
136
156
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
157
|
+
let problems = grants.map((grant) => grantProblem(owned!.spec, grant, this.maxInList));
|
|
158
|
+
if (owned.source !== undefined && problems.some((problem) => problem !== undefined)) {
|
|
159
|
+
// The rule may name a field another instance has just added: compare the source's version before dropping it.
|
|
160
|
+
const fresh = await this.catalog.find(permission, { verify: true });
|
|
161
|
+
if (fresh === undefined) return undefined;
|
|
162
|
+
if (fresh !== owned) {
|
|
163
|
+
owned = fresh;
|
|
164
|
+
problems = grants.map((grant) => grantProblem(fresh.spec, grant, this.maxInList));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
problems.forEach((problem) => { if (problem !== undefined) this.warn(permission, problem); });
|
|
168
|
+
return { spec: owned.spec, grants: grants.filter((_, index) => problems[index] === undefined) };
|
|
142
169
|
}
|
|
143
170
|
|
|
144
171
|
private async matching(permission: string, spec: PermissionSpec, grants: readonly Grant[], object: AccessObject): Promise<Grant[]> {
|
|
@@ -8,11 +8,19 @@ export interface AccessConfig {
|
|
|
8
8
|
roleClaim: string;
|
|
9
9
|
/** Values of one list condition and of a lookup result. */
|
|
10
10
|
maxInList: number;
|
|
11
|
+
/**
|
|
12
|
+
* Seconds between two questions to the permission sources about their
|
|
13
|
+
* version (ACCESS-001 §3.1): how long another instance may keep a removed
|
|
14
|
+
* permission. `0` — never ask: one instance, or the application calls
|
|
15
|
+
* `IAccessCatalog.changed` on every instance itself.
|
|
16
|
+
*/
|
|
17
|
+
sourcePollSeconds: number;
|
|
11
18
|
}
|
|
12
19
|
|
|
13
20
|
export const accessConfig = defineConfig<AccessConfig>("access", {
|
|
14
|
-
default: { kindClaim: "", roleClaim: "role", maxInList: 1000 },
|
|
21
|
+
default: { kindClaim: "", roleClaim: "role", maxInList: 1000, sourcePollSeconds: 5 },
|
|
15
22
|
validate: {
|
|
16
23
|
maxInList: (value) => Number.isSafeInteger(value) && value >= 1 && value <= 100_000 ? undefined : "must be an integer 1–100000",
|
|
24
|
+
sourcePollSeconds: (value) => Number.isFinite(value) && value >= 0 && value <= 3600 ? undefined : "must be a number 0–3600",
|
|
17
25
|
},
|
|
18
26
|
});
|
package/core/access/check/can.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AuthorizeCheck, AuthorizeContext } from "../../http";
|
|
2
|
-
import type {
|
|
2
|
+
import type { AnyPermissionKey } from "../catalog/permissions";
|
|
3
3
|
import type { AccessObject } from "../contracts/types";
|
|
4
4
|
import { IAccess } from "./IAccess.service";
|
|
5
5
|
|
|
@@ -14,7 +14,7 @@ import { IAccess } from "./IAccess.service";
|
|
|
14
14
|
* @Post() create(ctx: HttpContext) { … }
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
|
-
export function can(permission:
|
|
17
|
+
export function can(permission: AnyPermissionKey, objectOf?: (ctx: AuthorizeContext) => AccessObject | Promise<AccessObject>): AuthorizeCheck {
|
|
18
18
|
return async (ctx) => {
|
|
19
19
|
const access = await ctx.services.resolve(IAccess).for(ctx);
|
|
20
20
|
return await access.can(permission, objectOf === undefined ? undefined : await objectOf(ctx));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { PermissionSpec } from "../catalog/permissions";
|
|
2
|
-
import type { AccessValue, Condition, Grant, Scope, ValueTest } from "../contracts/types";
|
|
1
|
+
import type { AttributeSpec, PermissionSpec } from "../catalog/permissions";
|
|
2
|
+
import type { AccessValue, CompareTest, Condition, Grant, Scope, ValueTest } from "../contracts/types";
|
|
3
|
+
import { compareDecimals, decimalOf } from "./decimal";
|
|
3
4
|
|
|
4
5
|
export const MAX_DEPTH = 8;
|
|
5
6
|
export const MAX_NODES = 200;
|
|
@@ -41,30 +42,53 @@ function conditionProblem(spec: PermissionSpec, condition: Condition, depth: num
|
|
|
41
42
|
if (value !== true) return "own must be true";
|
|
42
43
|
return spec.owner === undefined ? "the permission declares no owner, own cannot be used" : undefined;
|
|
43
44
|
}
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
const attribute = spec.on?.[key];
|
|
46
|
+
if (attribute === undefined) return `unknown attribute "${key}"`;
|
|
47
|
+
return testProblem(key, attribute, value, maxInList);
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
function testProblem(attribute: string, test: unknown, maxInList: number): string | undefined {
|
|
50
|
+
function testProblem(attribute: string, spec: AttributeSpec, test: unknown, maxInList: number): string | undefined {
|
|
49
51
|
if (typeof test !== "object" || test === null || Array.isArray(test)) return `the test of "${attribute}" must be an object`;
|
|
50
52
|
const keys = Object.keys(test);
|
|
51
|
-
if (keys.length !== 1) return `the test of "${attribute}" must have exactly one of eq, in, notIn, isNull`;
|
|
53
|
+
if (keys.length !== 1) return `the test of "${attribute}" must have exactly one of eq, in, notIn, isNull, gt, gte, lt, lte`;
|
|
52
54
|
const [op] = keys;
|
|
53
55
|
const value = (test as Record<string, unknown>)[op!];
|
|
56
|
+
const numeric = spec.type === "number";
|
|
54
57
|
switch (op) {
|
|
55
58
|
case "eq":
|
|
59
|
+
if (numeric) return isNumeric(value) ? undefined : `${attribute}.eq must be a number: the attribute is a number`;
|
|
56
60
|
return isValue(value) && value !== null ? undefined : `${attribute}.eq must be a string, number or boolean (an empty value is tested with isNull)`;
|
|
57
61
|
case "in":
|
|
58
62
|
case "notIn":
|
|
59
|
-
if (
|
|
63
|
+
if (numeric) {
|
|
64
|
+
if (!Array.isArray(value) || !value.every(isNumeric)) return `${attribute}.${op} must be a list of numbers: the attribute is a number`;
|
|
65
|
+
} else if (!Array.isArray(value) || !value.every((item) => isValue(item) && item !== null)) {
|
|
66
|
+
return `${attribute}.${op} must be a list of strings, numbers or booleans (an empty value is tested with isNull)`;
|
|
67
|
+
}
|
|
60
68
|
return value.length > maxInList ? `${attribute}.${op} has more than ${maxInList} values` : undefined;
|
|
61
69
|
case "isNull":
|
|
62
70
|
return typeof value === "boolean" ? undefined : `${attribute}.isNull must be true or false`;
|
|
71
|
+
case "gt":
|
|
72
|
+
case "gte":
|
|
73
|
+
case "lt":
|
|
74
|
+
case "lte":
|
|
75
|
+
if (!comparable(spec)) return `${attribute}.${op}: only an attribute of type "number" with a column can be compared${spec.lookup !== undefined ? " (this one has a lookup)" : ""}`;
|
|
76
|
+
return typeof value === "number" && Number.isFinite(value) ? undefined : `${attribute}.${op} must be a number`;
|
|
63
77
|
default:
|
|
64
78
|
return `unknown test "${op}" of "${attribute}"`;
|
|
65
79
|
}
|
|
66
80
|
}
|
|
67
81
|
|
|
82
|
+
/** Whether `gt`, `gte`, `lt`, `lte` may test the attribute: a number in a column of the listed rows. */
|
|
83
|
+
export function comparable(attribute: AttributeSpec): boolean {
|
|
84
|
+
return attribute.type === "number" && attribute.lookup === undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A number, or a decimal string for a value a `double` cannot hold. */
|
|
88
|
+
function isNumeric(value: unknown): boolean {
|
|
89
|
+
return (typeof value === "number" || typeof value === "string") && decimalOf(value) !== undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
68
92
|
/**
|
|
69
93
|
* The value of a condition on an object, in SQL's three-valued logic so that a
|
|
70
94
|
* check and a list filter agree: an attribute the object does not carry, or a
|
|
@@ -100,16 +124,50 @@ export function evaluate(condition: Condition, object: Readonly<Record<string, u
|
|
|
100
124
|
return ownerIds.includes(String(owner));
|
|
101
125
|
}
|
|
102
126
|
const [attribute, test] = Object.entries(condition)[0] as [string, ValueTest];
|
|
103
|
-
return testValue(object[attribute], test);
|
|
127
|
+
return testValue(object[attribute], test, spec.on?.[attribute]?.type === "number");
|
|
104
128
|
}
|
|
105
129
|
|
|
106
|
-
|
|
130
|
+
const COMPARE: Readonly<Record<CompareTest, (order: number) => boolean>> = {
|
|
131
|
+
gt: (order) => order > 0,
|
|
132
|
+
gte: (order) => order >= 0,
|
|
133
|
+
lt: (order) => order < 0,
|
|
134
|
+
lte: (order) => order <= 0,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
function testValue(raw: unknown, test: ValueTest, numeric: boolean): boolean | undefined {
|
|
107
138
|
if (raw === undefined) return undefined;
|
|
108
139
|
if ("isNull" in test) return (raw === null) === test.isNull;
|
|
109
140
|
if (raw === null) return undefined;
|
|
141
|
+
if (numeric) return testNumber(raw, test);
|
|
110
142
|
if ("eq" in test) return same(raw, test.eq);
|
|
111
|
-
|
|
112
|
-
|
|
143
|
+
if ("in" in test || "notIn" in test) {
|
|
144
|
+
const found = ("in" in test ? test.in : test.notIn).some((value) => same(raw, value));
|
|
145
|
+
return "in" in test ? found : !found;
|
|
146
|
+
}
|
|
147
|
+
// A comparison of an attribute that is not a number: such a grant is dropped before it gets here.
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A number attribute is compared as a number, the way the database does it:
|
|
153
|
+
* `"100000.00"` of a `numeric` column equals `100000`. The comparison is exact
|
|
154
|
+
* and decimal, so a check and a list filter agree. Not a number — unknown.
|
|
155
|
+
*/
|
|
156
|
+
function testNumber(raw: unknown, test: Exclude<ValueTest, { readonly isNull: boolean }>): boolean | undefined {
|
|
157
|
+
const left = decimalOf(raw);
|
|
158
|
+
if (left === undefined) return undefined;
|
|
159
|
+
const order = (value: unknown): number | undefined => {
|
|
160
|
+
const right = decimalOf(value);
|
|
161
|
+
return right === undefined ? undefined : compareDecimals(left, right);
|
|
162
|
+
};
|
|
163
|
+
if ("in" in test || "notIn" in test) {
|
|
164
|
+
const found = ("in" in test ? test.in : test.notIn).some((value) => order(value) === 0);
|
|
165
|
+
return "in" in test ? found : !found;
|
|
166
|
+
}
|
|
167
|
+
const [op, value] = Object.entries(test)[0] as ["eq" | CompareTest, unknown];
|
|
168
|
+
const result = order(value);
|
|
169
|
+
if (result === undefined) return undefined;
|
|
170
|
+
return op === "eq" ? result === 0 : COMPARE[op](result);
|
|
113
171
|
}
|
|
114
172
|
|
|
115
173
|
/** Ids come as numbers from one side and as strings from the other. */
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact decimal numbers for the tests of a number attribute (ACCESS-001 §4.2).
|
|
3
|
+
* A `numeric` column comes from the database as a string; turning it into a
|
|
4
|
+
* `double` would make a check disagree with the list filter the database runs.
|
|
5
|
+
*/
|
|
6
|
+
export interface Decimal {
|
|
7
|
+
readonly negative: boolean;
|
|
8
|
+
/** Digits before the point, without leading zeros; `""` — zero. */
|
|
9
|
+
readonly integer: string;
|
|
10
|
+
/** Digits after the point, without trailing zeros. */
|
|
11
|
+
readonly fraction: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const FORM = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/;
|
|
15
|
+
/** Enough for a `double` (1e308, 5e-324) and far beyond a column of money. */
|
|
16
|
+
const MAX_EXPONENT = 1000;
|
|
17
|
+
const MAX_DIGITS = 1100;
|
|
18
|
+
|
|
19
|
+
/** The decimal of a number, a bigint or a decimal string; `undefined` — not a number. */
|
|
20
|
+
export function decimalOf(value: unknown): Decimal | undefined {
|
|
21
|
+
if (typeof value === "number") return Number.isFinite(value) ? parse(String(value)) : undefined;
|
|
22
|
+
if (typeof value === "bigint") return parse(String(value));
|
|
23
|
+
if (typeof value === "string") return parse(value.trim());
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parse(text: string): Decimal | undefined {
|
|
28
|
+
if (text.length > MAX_DIGITS) return undefined;
|
|
29
|
+
const match = FORM.exec(text);
|
|
30
|
+
if (match === null) return undefined;
|
|
31
|
+
const [, sign, whole = "", part = "", power] = match;
|
|
32
|
+
if (whole === "" && part === "") return undefined;
|
|
33
|
+
const exponent = power === undefined ? 0 : Number(power);
|
|
34
|
+
if (Math.abs(exponent) > MAX_EXPONENT) return undefined;
|
|
35
|
+
// Move the point by the exponent: digits = whole + part, the point stands after `whole.length + exponent` digits.
|
|
36
|
+
const digits = whole + part;
|
|
37
|
+
const point = whole.length + exponent;
|
|
38
|
+
const integer = (point <= 0 ? "" : point >= digits.length ? digits + "0".repeat(point - digits.length) : digits.slice(0, point)).replace(/^0+/, "");
|
|
39
|
+
const fraction = (point <= 0 ? "0".repeat(-point) + digits : point >= digits.length ? "" : digits.slice(point)).replace(/0+$/, "");
|
|
40
|
+
return { negative: sign === "-" && (integer !== "" || fraction !== ""), integer, fraction };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Below zero — `left` is less, above — greater, zero — the numbers are equal. */
|
|
44
|
+
export function compareDecimals(left: Decimal, right: Decimal): number {
|
|
45
|
+
if (left.negative !== right.negative) return left.negative ? -1 : 1;
|
|
46
|
+
const order = compareMagnitudes(left, right);
|
|
47
|
+
return order === 0 ? 0 : left.negative ? -order : order;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function compareMagnitudes(left: Decimal, right: Decimal): number {
|
|
51
|
+
if (left.integer.length !== right.integer.length) return left.integer.length - right.integer.length;
|
|
52
|
+
if (left.integer !== right.integer) return left.integer < right.integer ? -1 : 1;
|
|
53
|
+
if (left.fraction === right.fraction) return 0;
|
|
54
|
+
const length = Math.max(left.fraction.length, right.fraction.length);
|
|
55
|
+
return left.fraction.padEnd(length, "0") < right.fraction.padEnd(length, "0") ? -1 : 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The decimal in plain notation, without an exponent: `-0.5`, `100000.01`. */
|
|
59
|
+
export function decimalText(value: Decimal): string {
|
|
60
|
+
return `${value.negative ? "-" : ""}${value.integer === "" ? "0" : value.integer}${value.fraction === "" ? "" : `.${value.fraction}`}`;
|
|
61
|
+
}
|
|
@@ -3,15 +3,21 @@ import type { Class } from "../../di";
|
|
|
3
3
|
import type { PermissionSpec } from "../catalog/permissions";
|
|
4
4
|
import type { AttributeLookup } from "../contracts/AttributeLookup";
|
|
5
5
|
import { AccessFilterTooLargeError } from "../contracts/errors";
|
|
6
|
-
import type { AccessValue, Condition, Grant, Scope, ValueTest } from "../contracts/types";
|
|
6
|
+
import type { AccessValue, CompareTest, Condition, Grant, Scope, ValueTest } from "../contracts/types";
|
|
7
|
+
import { decimalOf, decimalText } from "./decimal";
|
|
7
8
|
|
|
8
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* A condition on the columns of the listed rows. `op` — a comparison of a
|
|
11
|
+
* number column with a value (ACCESS-001 §4.2). A module that lists rows
|
|
12
|
+
* without the ORM translates this form itself.
|
|
13
|
+
*/
|
|
9
14
|
export type ColumnCondition =
|
|
10
15
|
| { readonly all: readonly ColumnCondition[] }
|
|
11
16
|
| { readonly any: readonly ColumnCondition[] }
|
|
12
17
|
| { readonly not: ColumnCondition }
|
|
13
18
|
| { readonly column: string; readonly in: readonly AccessValue[] }
|
|
14
|
-
| { readonly column: string; readonly isNull: boolean }
|
|
19
|
+
| { readonly column: string; readonly isNull: boolean }
|
|
20
|
+
| { readonly column: string; readonly op: CompareTest; readonly value: number };
|
|
15
21
|
|
|
16
22
|
/** Which rows of a list the subject may see (ACCESS-001 §4.3). */
|
|
17
23
|
export type AccessFilter =
|
|
@@ -67,13 +73,19 @@ async function whereCondition(input: FilterInput, condition: Condition): Promise
|
|
|
67
73
|
if ("isNull" in test) return test.isNull ? await fromLookup(input, lookup, [null]) : not(await fromLookup(input, lookup, [null]));
|
|
68
74
|
if ("eq" in test) return await fromLookup(input, lookup, [test.eq]);
|
|
69
75
|
if ("in" in test) return await fromLookup(input, lookup, test.in);
|
|
70
|
-
return not(await fromLookup(input, lookup, test.notIn));
|
|
76
|
+
if ("notIn" in test) return not(await fromLookup(input, lookup, test.notIn));
|
|
77
|
+
// A lookup turns "one of the values" into a column and cannot turn a range: such a grant is dropped before it gets here.
|
|
78
|
+
throw new TypeError(`The list filter of "${input.permission}": attribute "${name}" has a lookup and cannot be compared.`);
|
|
71
79
|
}
|
|
72
80
|
const column = attribute.column ?? name;
|
|
73
81
|
if ("isNull" in test) return { column, isNull: test.isNull };
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
82
|
+
// A number given as a string ("1e3", "012.50") goes to the database in plain notation.
|
|
83
|
+
const values = (list: readonly AccessValue[]): readonly AccessValue[] => (attribute.type === "number" ? list.map(plainNumber) : list);
|
|
84
|
+
if ("eq" in test) return { column, in: values([test.eq]) };
|
|
85
|
+
if ("in" in test) return test.in.length === 0 ? false : { column, in: values(test.in) };
|
|
86
|
+
if ("notIn" in test) return test.notIn.length === 0 ? { column, isNull: false } : not({ column, in: values(test.notIn) });
|
|
87
|
+
const [op, value] = Object.entries(test)[0] as [CompareTest, number];
|
|
88
|
+
return { column, op, value };
|
|
77
89
|
}
|
|
78
90
|
|
|
79
91
|
async function fromLookup(input: FilterInput, type: Class<AttributeLookup>, values: readonly AccessValue[]): Promise<Built> {
|
|
@@ -84,6 +96,12 @@ async function fromLookup(input: FilterInput, type: Class<AttributeLookup>, valu
|
|
|
84
96
|
return inColumn(found.column, found.in);
|
|
85
97
|
}
|
|
86
98
|
|
|
99
|
+
function plainNumber(value: AccessValue): AccessValue {
|
|
100
|
+
if (typeof value !== "string") return value;
|
|
101
|
+
const decimal = decimalOf(value);
|
|
102
|
+
return decimal === undefined ? value : decimalText(decimal);
|
|
103
|
+
}
|
|
104
|
+
|
|
87
105
|
/** `column in values`; `null` among them means an empty column. */
|
|
88
106
|
function inColumn(column: string, values: readonly AccessValue[]): Built {
|
|
89
107
|
const known = values.filter((value) => value !== null);
|
|
@@ -128,5 +146,18 @@ function toPredicate(condition: ColumnCondition): Predicate {
|
|
|
128
146
|
if ("not" in condition) return toPredicate(condition.not).not();
|
|
129
147
|
const column = new Operand(condition.column);
|
|
130
148
|
if ("isNull" in condition) return condition.isNull ? column.isNull() : column.isNotNull();
|
|
131
|
-
|
|
149
|
+
if ("op" in condition) return column[condition.op](parameter(condition.value));
|
|
150
|
+
const values = condition.in.map(parameter);
|
|
151
|
+
return values.length === 1 ? column.eq(values[0]) : column.in(values);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A fraction goes to the database as decimal text, not as a `double`: a
|
|
156
|
+
* `decimal` column of the ORM refuses a float, and the text keeps the number
|
|
157
|
+
* the rule names (`100000.01`) for `integer` and `real` columns too.
|
|
158
|
+
*/
|
|
159
|
+
function parameter(value: AccessValue): AccessValue {
|
|
160
|
+
if (typeof value !== "number" || Number.isSafeInteger(value)) return value;
|
|
161
|
+
const decimal = decimalOf(value);
|
|
162
|
+
return decimal === undefined ? value : decimalText(decimal);
|
|
132
163
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PermissionDeclarations } from "../catalog/permissions";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Permissions that appear while the application runs (ACCESS-001 §3.1): a
|
|
5
|
+
* data manager's tables and their fields. A class of the declaring module,
|
|
6
|
+
* named in `@Module({ permissionSources: { <prefix>: Class } })`; the catalog
|
|
7
|
+
* creates it in a scope of its own for one reading, so it may take a
|
|
8
|
+
* `DbContext`. It owns every key that starts with `<prefix>.`.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* export class DataPermissionSource extends PermissionSource {
|
|
12
|
+
* constructor(private readonly db: DataDbContext) { super(); }
|
|
13
|
+
* async version() { return String((await this.db.meta.first()).version); }
|
|
14
|
+
* async load() { return Object.fromEntries((await this.db.tables.toList()).map((table) => [`data.${table.code}.read`, specOf(table)])); }
|
|
15
|
+
* }
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export abstract class PermissionSource {
|
|
19
|
+
/**
|
|
20
|
+
* The version of the source's data: a counter or a timestamp that changes in
|
|
21
|
+
* one transaction with the data. Every instance of the application asks it
|
|
22
|
+
* every `access.sourcePollSeconds` and reads the permissions again when it
|
|
23
|
+
* differs.
|
|
24
|
+
*/
|
|
25
|
+
abstract version(): string | Promise<string>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The permissions of the prefix, in the form of `definePermissions`. With
|
|
29
|
+
* `keys` — only these: a key missing from the answer is a removed permission.
|
|
30
|
+
*/
|
|
31
|
+
abstract load(keys?: readonly string[]): PermissionDeclarations | Promise<PermissionDeclarations>;
|
|
32
|
+
}
|
|
@@ -7,12 +7,22 @@ export type AccessValue = string | number | boolean | null;
|
|
|
7
7
|
/** A partition of the application (a CMS space, a tenant); `null` — no partition. */
|
|
8
8
|
export type Scope = string | null;
|
|
9
9
|
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* A test of one attribute. The comparisons `gt`, `gte`, `lt`, `lte` are for an
|
|
12
|
+
* attribute of `type: "number"` with a `column` only (ACCESS-001 §4.2).
|
|
13
|
+
*/
|
|
11
14
|
export type ValueTest =
|
|
12
15
|
| { readonly eq: AccessValue }
|
|
13
16
|
| { readonly in: readonly AccessValue[] }
|
|
14
17
|
| { readonly notIn: readonly AccessValue[] }
|
|
15
|
-
| { readonly isNull: boolean }
|
|
18
|
+
| { readonly isNull: boolean }
|
|
19
|
+
| { readonly gt: number }
|
|
20
|
+
| { readonly gte: number }
|
|
21
|
+
| { readonly lt: number }
|
|
22
|
+
| { readonly lte: number };
|
|
23
|
+
|
|
24
|
+
/** The comparisons of a number attribute. */
|
|
25
|
+
export type CompareTest = "gt" | "gte" | "lt" | "lte";
|
|
16
26
|
|
|
17
27
|
/**
|
|
18
28
|
* A condition of a grant (ACCESS-001 §4.2): a tree over the attributes the
|
|
@@ -4,11 +4,15 @@ import { registerFrameworkClassDeps } from "../../di/module/autoDeps";
|
|
|
4
4
|
import { ServiceProvider } from "../../di/ServiceProvider";
|
|
5
5
|
import { AccessCatalog } from "../catalog/AccessCatalog";
|
|
6
6
|
import { IAccessCatalog } from "../catalog/IAccessCatalog.service";
|
|
7
|
+
import { PermissionSourceLoader } from "../catalog/PermissionSourceLoader";
|
|
8
|
+
import { PermissionSources } from "../catalog/PermissionSources";
|
|
7
9
|
import { Access } from "../check/Access";
|
|
8
10
|
import { RolesAccessPolicy } from "../roles/RolesAccessPolicy";
|
|
9
11
|
|
|
10
12
|
registerFrameworkClassDeps([
|
|
11
13
|
[Access, [IAccessCatalog, "Config:access"]],
|
|
12
|
-
[AccessCatalog, [ServiceProvider]],
|
|
14
|
+
[AccessCatalog, [ServiceProvider, PermissionSources, "Config:access"]],
|
|
15
|
+
[PermissionSourceLoader, [PermissionSources]],
|
|
16
|
+
[PermissionSources, [ServiceProvider, "Config:access"]],
|
|
13
17
|
[RolesAccessPolicy, [IAccessCatalog, "Config:access"]],
|
|
14
18
|
]);
|
|
@@ -2,6 +2,7 @@ import { contextOnlyController } from "../../http/Routing/contextOnlyControllers
|
|
|
2
2
|
import { Authorize, BadRequestError, Controller, Get, NotFoundError, type HttpContext } from "../../http";
|
|
3
3
|
import { IAccessCatalog } from "../catalog/IAccessCatalog.service";
|
|
4
4
|
import { can } from "../check/can";
|
|
5
|
+
import { comparable } from "../check/conditions";
|
|
5
6
|
import type { Scope } from "../contracts/types";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -15,13 +16,19 @@ export class AccessCatalogController {
|
|
|
15
16
|
@Authorize(can("access.catalog.read"))
|
|
16
17
|
catalogList(ctx: HttpContext) {
|
|
17
18
|
return {
|
|
18
|
-
data: ctx.services.resolve(IAccessCatalog).permissions().map(({ key, spec, module }) => ({
|
|
19
|
+
data: ctx.services.resolve(IAccessCatalog).permissions().map(({ key, spec, module, source }) => ({
|
|
19
20
|
key,
|
|
20
21
|
module,
|
|
22
|
+
...(source === undefined ? {} : { source }),
|
|
21
23
|
title: spec.title,
|
|
22
24
|
...(spec.description === undefined ? {} : { description: spec.description }),
|
|
23
25
|
scope: typeof spec.scope === "object" ? ("column" in spec.scope ? "column" : "lookup") : spec.scope ?? "none",
|
|
24
|
-
attributes: Object.entries(spec.on ?? {}).map(([name, attribute]) => ({
|
|
26
|
+
attributes: Object.entries(spec.on ?? {}).map(([name, attribute]) => ({
|
|
27
|
+
name,
|
|
28
|
+
...(attribute.ref === undefined ? { type: attribute.type } : { ref: attribute.ref }),
|
|
29
|
+
// The administrator's screen offers >, ≥, <, ≤ only here (ACCESS-001 §4.2).
|
|
30
|
+
...(comparable(attribute) ? { compare: true } : {}),
|
|
31
|
+
})),
|
|
25
32
|
own: spec.owner !== undefined,
|
|
26
33
|
fields: spec.fields === true,
|
|
27
34
|
})),
|