bazis 0.139.0 → 0.140.1
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 +6 -1
- package/core/access/catalog/AccessCatalog.ts +62 -7
- package/core/access/catalog/IAccessCatalog.service.ts +28 -2
- package/core/access/catalog/PermissionSourceLoader.background.ts +49 -0
- package/core/access/catalog/PermissionSources.service.ts +292 -0
- package/core/access/catalog/moduleFields.ts +60 -6
- package/core/access/catalog/permissions.ts +50 -3
- package/core/access/check/Access.ts +55 -28
- package/core/access/check/accessConfig.ts +12 -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 +36 -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/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/scripts/di-generate.ts +34 -4
- package/package.json +1 -1
|
@@ -2,6 +2,8 @@ import { Module, singleton } from "../di";
|
|
|
2
2
|
import { AccessCatalog } from "./catalog/AccessCatalog";
|
|
3
3
|
import { IAccessCatalog } from "./catalog/IAccessCatalog.service";
|
|
4
4
|
import { definePermissions } from "./catalog/permissions";
|
|
5
|
+
import { PermissionSourceLoader } from "./catalog/PermissionSourceLoader.background";
|
|
6
|
+
import { PermissionSourcesService } from "./catalog/PermissionSources.service";
|
|
5
7
|
import { Access } from "./check/Access";
|
|
6
8
|
import { accessConfig } from "./check/accessConfig";
|
|
7
9
|
import { IAccess } from "./check/IAccess.service";
|
|
@@ -20,17 +22,20 @@ export const accessPermissions = definePermissions({
|
|
|
20
22
|
* the catalog of the modules' permissions, checks and list filters by the
|
|
21
23
|
* grants of the application's `AccessPolicy`, the default rights system by
|
|
22
24
|
* `@Module({ roles })`, and the catalog over HTTP. An application replaces the
|
|
23
|
-
* rights system by registering its own `AccessPolicy`.
|
|
25
|
+
* rights system by registering its own `AccessPolicy`. Permissions that appear
|
|
26
|
+
* while the application runs come from `@Module({ permissionSources })`.
|
|
24
27
|
*/
|
|
25
28
|
@Module({
|
|
26
29
|
config: [accessConfig],
|
|
27
30
|
permissions: accessPermissions,
|
|
28
31
|
providers: [
|
|
32
|
+
singleton(PermissionSourcesService),
|
|
29
33
|
singleton(IAccessCatalog, AccessCatalog),
|
|
30
34
|
singleton(AccessPolicy, RolesAccessPolicy),
|
|
31
35
|
singleton(IAccess, Access),
|
|
32
36
|
],
|
|
33
37
|
controllers: [AccessCatalogController],
|
|
38
|
+
background: [PermissionSourceLoader],
|
|
34
39
|
exports: [IAccessCatalog, IAccess, AccessPolicy],
|
|
35
40
|
})
|
|
36
41
|
export class AccessModule {}
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import type { Class } from "../../di";
|
|
2
2
|
import type { ServiceProvider } from "../../di/ServiceProvider";
|
|
3
|
+
import type { ConfigView } from "../../kernel";
|
|
4
|
+
import type { AccessConfig } from "../check/accessConfig";
|
|
5
|
+
import { grantProblem } from "../check/conditions";
|
|
3
6
|
import type { References } from "../contracts/References";
|
|
7
|
+
import type { Grant } from "../contracts/types";
|
|
4
8
|
import type { RolePermission } from "../roles/roles";
|
|
5
9
|
import { IAccessCatalog } from "./IAccessCatalog.service";
|
|
6
10
|
import { declarationsOf } from "./moduleFields";
|
|
7
11
|
import type { PermissionSpec } from "./permissions";
|
|
12
|
+
import type { PermissionSourcesService, PermissionSourceState } from "./PermissionSources.service";
|
|
8
13
|
|
|
9
|
-
/** A permission
|
|
14
|
+
/** A permission with the module that declares it: in `@Module({ permissions })` or through a source. */
|
|
10
15
|
export interface OwnedPermission {
|
|
11
16
|
readonly key: string;
|
|
12
17
|
readonly spec: PermissionSpec;
|
|
13
18
|
readonly module: string;
|
|
19
|
+
/** The prefix of the source that gave the permission (ACCESS-001 §3.1); a declared one has none. */
|
|
20
|
+
readonly source?: string;
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
/** A reference kind of `@Module({ references })`. */
|
|
@@ -27,13 +34,16 @@ export interface DeclaredRole {
|
|
|
27
34
|
readonly module: string;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
|
-
/** The catalog
|
|
37
|
+
/** The catalog: the container's module contributions and the permissions of the sources. */
|
|
31
38
|
export class AccessCatalog extends IAccessCatalog {
|
|
32
39
|
private readonly byKey: ReadonlyMap<string, OwnedPermission>;
|
|
33
40
|
private readonly byKind: ReadonlyMap<string, OwnedReferenceKind>;
|
|
34
41
|
private readonly byName: ReadonlyMap<string, DeclaredRole>;
|
|
35
42
|
|
|
36
|
-
|
|
43
|
+
/** The declared permissions and the ones of the sources as one sorted list, good for one revision of the sources. */
|
|
44
|
+
private merged: { readonly revision: number; readonly list: readonly OwnedPermission[] } | undefined;
|
|
45
|
+
|
|
46
|
+
constructor(services: ServiceProvider, private readonly sourced: PermissionSourcesService, private readonly config: ConfigView<AccessConfig>) {
|
|
37
47
|
super();
|
|
38
48
|
const { permissions, kinds, roles } = declarationsOf(services);
|
|
39
49
|
this.byKey = new Map([...permissions].sort((a, b) => a.key.localeCompare(b.key)).map((item) => [item.key, item]));
|
|
@@ -42,19 +52,64 @@ export class AccessCatalog extends IAccessCatalog {
|
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
permissions(): readonly OwnedPermission[] {
|
|
45
|
-
|
|
55
|
+
const revision = this.sourced.revision;
|
|
56
|
+
if (this.merged?.revision !== revision) {
|
|
57
|
+
this.merged = { revision, list: [...this.byKey.values(), ...this.sourced.permissions()].sort((a, b) => a.key.localeCompare(b.key)) };
|
|
58
|
+
}
|
|
59
|
+
return [...this.merged.list];
|
|
46
60
|
}
|
|
47
61
|
|
|
48
62
|
permission(key: string): OwnedPermission | undefined {
|
|
49
|
-
return this.byKey.get(key);
|
|
63
|
+
return this.byKey.get(key) ?? this.sourced.permission(key);
|
|
50
64
|
}
|
|
51
65
|
|
|
52
66
|
require(key: string): OwnedPermission {
|
|
53
|
-
const permission = this.
|
|
54
|
-
if (permission === undefined) throw
|
|
67
|
+
const permission = this.permission(key);
|
|
68
|
+
if (permission === undefined) throw this.unknown(key);
|
|
55
69
|
return permission;
|
|
56
70
|
}
|
|
57
71
|
|
|
72
|
+
async find(key: string, options: { readonly verify?: boolean } = {}): Promise<OwnedPermission | undefined> {
|
|
73
|
+
const declared = this.byKey.get(key);
|
|
74
|
+
if (declared !== undefined) return declared;
|
|
75
|
+
if (this.sourced.prefixOf(key) === undefined) throw this.unknown(key);
|
|
76
|
+
const permission = this.sourced.permission(key);
|
|
77
|
+
if (permission !== undefined && options.verify !== true) return permission;
|
|
78
|
+
// A table another instance has just created, or a field it has just added, must not wait for the poll.
|
|
79
|
+
await this.sourced.verify(key);
|
|
80
|
+
return this.sourced.permission(key);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async grantProblem(permission: string, grant: Grant): Promise<string | undefined> {
|
|
84
|
+
const maxInList = this.config.get("maxInList");
|
|
85
|
+
const declared = this.byKey.get(permission);
|
|
86
|
+
if (declared !== undefined) return grantProblem(declared.spec, grant, maxInList);
|
|
87
|
+
if (this.sourced.prefixOf(permission) === undefined) return `permission "${permission}" is not declared`;
|
|
88
|
+
const problemNow = (): string | undefined => {
|
|
89
|
+
const found = this.sourced.permission(permission);
|
|
90
|
+
return found === undefined ? `permission "${permission}" does not exist` : grantProblem(found.spec, grant, maxInList);
|
|
91
|
+
};
|
|
92
|
+
await this.sourced.verify(permission);
|
|
93
|
+
const problem = problemNow();
|
|
94
|
+
// A rule is saved rarely, and a wrong refusal costs the administrator: ask the version once more, past the limit.
|
|
95
|
+
return problem !== undefined && await this.sourced.verify(permission, true) ? problemNow() : problem;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async changed(prefix: string, keys?: readonly string[]): Promise<void> {
|
|
99
|
+
await this.sourced.changed(prefix, keys);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
sources(): readonly PermissionSourceState[] {
|
|
103
|
+
return this.sourced.state();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private unknown(key: string): TypeError {
|
|
107
|
+
const prefix = this.sourced.prefixOf(key);
|
|
108
|
+
return new TypeError(prefix === undefined
|
|
109
|
+
? `Permission "${key}" is not declared: add it to definePermissions(...) of a module's permissions.`
|
|
110
|
+
: `Permission "${key}" does not exist: the permission source "${prefix}" does not give it now.`);
|
|
111
|
+
}
|
|
112
|
+
|
|
58
113
|
referenceKinds(): readonly OwnedReferenceKind[] {
|
|
59
114
|
return [...this.byKind.values()];
|
|
60
115
|
}
|
|
@@ -1,15 +1,41 @@
|
|
|
1
|
+
import type { Grant } from "../contracts/types";
|
|
1
2
|
import type { DeclaredRole, OwnedPermission, OwnedReferenceKind } from "./AccessCatalog";
|
|
3
|
+
import type { PermissionSourceState } from "./PermissionSources.service";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* What the application's modules declared about access: permissions, reference
|
|
5
|
-
* kinds and default roles (ACCESS-001 §3, §6, §8)
|
|
6
|
-
* is built.
|
|
7
|
+
* kinds and default roles (ACCESS-001 §3, §6, §8), checked when the application
|
|
8
|
+
* is built, and the permissions of the sources (§3.1), read while it runs.
|
|
7
9
|
*/
|
|
8
10
|
export abstract class IAccessCatalog {
|
|
11
|
+
/** The declared permissions and the ones the sources give now. */
|
|
9
12
|
abstract permissions(): readonly OwnedPermission[];
|
|
10
13
|
abstract permission(key: string): OwnedPermission | undefined;
|
|
11
14
|
/** The permission or an error: asking for an undeclared one is a bug of the caller. */
|
|
12
15
|
abstract require(key: string): OwnedPermission;
|
|
16
|
+
/**
|
|
17
|
+
* The permission for a check. A key of a source that the source does not
|
|
18
|
+
* give now is `undefined` — nobody has it; before answering so the catalog
|
|
19
|
+
* compares the source's version (`verify` — even when the key is there: a
|
|
20
|
+
* grant named an attribute the catalog does not know). A key that is neither
|
|
21
|
+
* declared nor of a source is an error, like in {@link require}.
|
|
22
|
+
*/
|
|
23
|
+
abstract find(key: string, options?: { readonly verify?: boolean }): Promise<OwnedPermission | undefined>;
|
|
24
|
+
/**
|
|
25
|
+
* Why the grant cannot be used for the permission now, or `undefined`: the
|
|
26
|
+
* checks a request runs (ACCESS-001 §4.2). A rights system calls it when it
|
|
27
|
+
* saves a rule and answers 422, instead of learning it from a warning in the
|
|
28
|
+
* log of a request.
|
|
29
|
+
*/
|
|
30
|
+
abstract grantProblem(permission: string, grant: Grant): Promise<string | undefined>;
|
|
31
|
+
/**
|
|
32
|
+
* The data of the source of this prefix changed: read its permissions again
|
|
33
|
+
* now, all of them or only `keys`. Call it after the transaction is
|
|
34
|
+
* committed; other instances learn it from the source's version.
|
|
35
|
+
*/
|
|
36
|
+
abstract changed(prefix: string, keys?: readonly string[]): Promise<void>;
|
|
37
|
+
/** The sources and what is known about them now. */
|
|
38
|
+
abstract sources(): readonly PermissionSourceState[];
|
|
13
39
|
abstract referenceKinds(): readonly OwnedReferenceKind[];
|
|
14
40
|
abstract referenceKind(kind: string): OwnedReferenceKind | undefined;
|
|
15
41
|
abstract roles(): readonly DeclaredRole[];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { HostedService, HostedServiceHealth } from "../../di";
|
|
2
|
+
import type { PermissionSourcesService } from "./PermissionSources.service";
|
|
3
|
+
|
|
4
|
+
/** After the infrastructure and the schema (phases below), before the HTTP host (phase 10). */
|
|
5
|
+
const PHASE = 5;
|
|
6
|
+
/** How often a failed source is tried again when the versions are not polled. */
|
|
7
|
+
const RETRY_SECONDS = 5;
|
|
8
|
+
/** A failure or two is a database that blinked; `/health` reports a source that keeps failing. */
|
|
9
|
+
const UNHEALTHY_AFTER = 3;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Reads the permission sources before the application takes requests and
|
|
13
|
+
* then asks their versions every `access.sourcePollSeconds` (ACCESS-001 §3.1).
|
|
14
|
+
* A source that fails does not stop the start: its permissions do not exist
|
|
15
|
+
* until a later reading succeeds, and `/health` says so. Without a poll
|
|
16
|
+
* (`sourcePollSeconds: 0`) only the sources that failed are tried again.
|
|
17
|
+
*/
|
|
18
|
+
export class PermissionSourceLoader implements HostedService {
|
|
19
|
+
readonly phase = PHASE;
|
|
20
|
+
readonly diagnosticName = "access:permission-sources";
|
|
21
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
22
|
+
private polling: Promise<void> | undefined;
|
|
23
|
+
|
|
24
|
+
constructor(private readonly sources: PermissionSourcesService) {}
|
|
25
|
+
|
|
26
|
+
async start(): Promise<void> {
|
|
27
|
+
if (this.sources.count === 0) return;
|
|
28
|
+
await this.sources.load();
|
|
29
|
+
const poll = this.sources.pollSeconds;
|
|
30
|
+
this.timer = setInterval(() => {
|
|
31
|
+
// One question at a time: a slow source is not asked again before it answers.
|
|
32
|
+
this.polling ??= (poll > 0 ? this.sources.poll() : this.sources.retry()).catch(() => undefined).finally(() => { this.polling = undefined; });
|
|
33
|
+
}, (poll > 0 ? poll : RETRY_SECONDS) * 1000);
|
|
34
|
+
this.timer.unref?.();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async stop(): Promise<void> {
|
|
38
|
+
if (this.timer !== undefined) clearInterval(this.timer);
|
|
39
|
+
this.timer = undefined;
|
|
40
|
+
await this.polling;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Unhealthy: a source without permissions at all, or one whose readings keep failing. */
|
|
44
|
+
checkHealth(): HostedServiceHealth {
|
|
45
|
+
const failed = this.sources.state().filter((source) => !source.loaded || source.failures >= UNHEALTHY_AFTER);
|
|
46
|
+
if (failed.length === 0) return { healthy: true };
|
|
47
|
+
return { healthy: false, details: failed.map((source) => `permission source "${source.prefix}": ${source.loaded ? "" : "not loaded: "}${source.error ?? "no reading yet"}`).join("; ") };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import type { Class, ServiceProvider } from "../../di";
|
|
2
|
+
import { LOGGER, type ConfigView } from "../../kernel";
|
|
3
|
+
import type { AccessConfig } from "../check/accessConfig";
|
|
4
|
+
import type { PermissionSource } from "../contracts/PermissionSource";
|
|
5
|
+
import type { OwnedPermission } from "./AccessCatalog";
|
|
6
|
+
import { declarationsOf } from "./moduleFields";
|
|
7
|
+
import { frozenSpec, lookupsOf, permissionProblems, underPrefix, type PermissionSpec } from "./permissions";
|
|
8
|
+
|
|
9
|
+
/** A source of `@Module({ permissionSources })` with the module that declares it. */
|
|
10
|
+
export interface OwnedPermissionSource {
|
|
11
|
+
readonly prefix: string;
|
|
12
|
+
readonly type: Class<PermissionSource>;
|
|
13
|
+
readonly module: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** What is known about a source now: for `/health` and the administrator. */
|
|
17
|
+
export interface PermissionSourceState {
|
|
18
|
+
readonly prefix: string;
|
|
19
|
+
readonly module: string;
|
|
20
|
+
/** Whether its permissions have been read at least once. */
|
|
21
|
+
readonly loaded: boolean;
|
|
22
|
+
/** The version of the data the permissions were read at. */
|
|
23
|
+
readonly version: string | undefined;
|
|
24
|
+
/** Why the last reading failed; the permissions read before it stay. */
|
|
25
|
+
readonly error: string | undefined;
|
|
26
|
+
/** Readings and version questions that failed in a row; `0` after a success. */
|
|
27
|
+
readonly failures: number;
|
|
28
|
+
readonly permissions: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Source extends OwnedPermissionSource {
|
|
32
|
+
permissions: ReadonlyMap<string, OwnedPermission>;
|
|
33
|
+
loaded: boolean;
|
|
34
|
+
version: string | undefined;
|
|
35
|
+
error: string | undefined;
|
|
36
|
+
failures: number;
|
|
37
|
+
/** When the version was last compared, for the limit of {@link PermissionSourcesService.verify}. */
|
|
38
|
+
verifiedAt: number;
|
|
39
|
+
/** Readings of one source go one after another. */
|
|
40
|
+
queue: Promise<unknown>;
|
|
41
|
+
/** A call that ran out of time and has not answered yet: no new call starts beside it. */
|
|
42
|
+
unanswered: Promise<void> | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** How often a missing key or attribute may ask a source for its version. */
|
|
46
|
+
const VERIFY_EVERY_MS = 1000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The permissions of the application's sources (ACCESS-001 §3.1): read before
|
|
50
|
+
* the application takes requests, read again when a source's version changes.
|
|
51
|
+
* A specification passes the checks of a declared one; a bad one is logged and
|
|
52
|
+
* the permission does not exist — closed, never open. A reading that fails
|
|
53
|
+
* leaves the permissions read before it. A source that does not answer in
|
|
54
|
+
* `access.sourceTimeoutSeconds` has failed: it holds neither the start nor a
|
|
55
|
+
* request.
|
|
56
|
+
*/
|
|
57
|
+
export class PermissionSourcesService {
|
|
58
|
+
private readonly sources: readonly Source[];
|
|
59
|
+
private readonly kinds: ReadonlySet<string>;
|
|
60
|
+
private changes = 0;
|
|
61
|
+
|
|
62
|
+
constructor(private readonly services: ServiceProvider, private readonly config: ConfigView<AccessConfig>) {
|
|
63
|
+
const { sources, kinds } = declarationsOf(services);
|
|
64
|
+
this.kinds = new Set(kinds.map((kind) => kind.kind));
|
|
65
|
+
this.sources = [...sources]
|
|
66
|
+
.sort((left, right) => left.prefix.localeCompare(right.prefix))
|
|
67
|
+
.map((source) => ({ ...source, permissions: new Map(), loaded: false, version: undefined, error: undefined, failures: 0, verifiedAt: 0, queue: Promise.resolve(), unanswered: undefined }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The prefix of the source that owns the key, or `undefined` — the key is not of a source. */
|
|
71
|
+
prefixOf(key: string): string | undefined {
|
|
72
|
+
return this.sources.find((source) => underPrefix(key, source.prefix))?.prefix;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
permission(key: string): OwnedPermission | undefined {
|
|
76
|
+
for (const source of this.sources) if (underPrefix(key, source.prefix)) return source.permissions.get(key);
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
permissions(): OwnedPermission[] {
|
|
81
|
+
return this.sources.flatMap((source) => [...source.permissions.values()]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
state(): readonly PermissionSourceState[] {
|
|
85
|
+
return this.sources.map(({ prefix, module, loaded, version, error, failures, permissions }) => ({ prefix, module, loaded, version, error, failures, permissions: permissions.size }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Grows with every reading that changed the permissions: a list built from them is good until it does. */
|
|
89
|
+
get revision(): number {
|
|
90
|
+
return this.changes;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
get count(): number {
|
|
94
|
+
return this.sources.length;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The seconds between two questions about the versions; `0` — never ask. */
|
|
98
|
+
get pollSeconds(): number {
|
|
99
|
+
return this.sources.length === 0 ? 0 : this.config.get("sourcePollSeconds");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Reads every source: at start, before the application takes requests. */
|
|
103
|
+
async load(): Promise<void> {
|
|
104
|
+
await Promise.all(this.sources.map((source) => this.enqueue(source, () => this.read(source))));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Asks every source for its version and reads again the ones that changed or have not been read. */
|
|
108
|
+
async poll(): Promise<void> {
|
|
109
|
+
await Promise.all(this.sources.map((source) => this.enqueue(source, () => this.compare(source))));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Tries again the sources whose last reading failed: without a poll nothing else would. */
|
|
113
|
+
async retry(): Promise<void> {
|
|
114
|
+
await Promise.all(this.sources
|
|
115
|
+
.filter((source) => !source.loaded || source.error !== undefined)
|
|
116
|
+
.map((source) => this.enqueue(source, () => this.compare(source))));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The source of the prefix changed. With `keys` only these permissions are
|
|
121
|
+
* read now; the version stays the old one, so the next comparison reads the
|
|
122
|
+
* whole prefix — another instance may have changed other keys meanwhile.
|
|
123
|
+
*/
|
|
124
|
+
async changed(prefix: string, keys?: readonly string[]): Promise<void> {
|
|
125
|
+
const source = this.sources.find((item) => item.prefix === prefix);
|
|
126
|
+
if (source === undefined) throw new TypeError(`No permission source owns the prefix "${prefix}": name it in permissionSources of a module.`);
|
|
127
|
+
await this.enqueue(source, () => (keys === undefined || !source.loaded ? this.read(source) : this.readKeys(source, keys)));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Compares the version of the key's source before the catalog answers "no
|
|
132
|
+
* such permission" or drops a grant: a table another instance has just
|
|
133
|
+
* created must not wait for the poll. Not more often than once a second for
|
|
134
|
+
* a prefix, unless `force`. `true` — the permissions were read again.
|
|
135
|
+
*/
|
|
136
|
+
async verify(key: string, force = false): Promise<boolean> {
|
|
137
|
+
const source = this.sources.find((item) => underPrefix(key, item.prefix));
|
|
138
|
+
if (source === undefined) return false;
|
|
139
|
+
return await this.enqueue(source, async () => {
|
|
140
|
+
// A clock set back gives a negative time: compare then, do not wait for the clock to catch up.
|
|
141
|
+
const since = Date.now() - source.verifiedAt;
|
|
142
|
+
if (!force && since >= 0 && since < VERIFY_EVERY_MS) return false;
|
|
143
|
+
return await this.compare(source);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private enqueue<T>(source: Source, work: () => Promise<T>): Promise<T> {
|
|
148
|
+
const result = source.queue.then(work);
|
|
149
|
+
source.queue = result.catch(() => undefined);
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `true` — the version differed (or nothing was read yet) and the permissions were read again. */
|
|
154
|
+
private async compare(source: Source): Promise<boolean> {
|
|
155
|
+
source.verifiedAt = Date.now();
|
|
156
|
+
if (source.loaded) {
|
|
157
|
+
try {
|
|
158
|
+
const version = await this.use(source, async (instance) => versionOf(await instance.version()));
|
|
159
|
+
if (version === source.version) {
|
|
160
|
+
source.error = undefined;
|
|
161
|
+
source.failures = 0;
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
this.failed(source, error);
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return await this.read(source);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async read(source: Source): Promise<boolean> {
|
|
173
|
+
source.verifiedAt = Date.now();
|
|
174
|
+
try {
|
|
175
|
+
// The version first: data changed during the reading gives a newer version to the next comparison.
|
|
176
|
+
const { version, declarations } = await this.use(source, async (instance) => ({ version: versionOf(await instance.version()), declarations: await instance.load() }));
|
|
177
|
+
if (typeof declarations !== "object" || declarations === null || Array.isArray(declarations)) throw new TypeError("load() must return an object: key → permission");
|
|
178
|
+
const permissions = new Map<string, OwnedPermission>();
|
|
179
|
+
for (const key of Object.keys(declarations).sort()) {
|
|
180
|
+
const permission = this.accept(source, key, declarations[key]!);
|
|
181
|
+
if (permission !== undefined) permissions.set(key, permission);
|
|
182
|
+
}
|
|
183
|
+
source.permissions = permissions;
|
|
184
|
+
source.version = version;
|
|
185
|
+
source.loaded = true;
|
|
186
|
+
source.error = undefined;
|
|
187
|
+
source.failures = 0;
|
|
188
|
+
this.changes += 1;
|
|
189
|
+
return true;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
this.failed(source, error);
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private async readKeys(source: Source, keys: readonly string[]): Promise<boolean> {
|
|
197
|
+
try {
|
|
198
|
+
const declarations = await this.use(source, async (instance) => await instance.load(keys));
|
|
199
|
+
if (typeof declarations !== "object" || declarations === null || Array.isArray(declarations)) throw new TypeError("load(keys) must return an object: key → permission");
|
|
200
|
+
const permissions = new Map(source.permissions);
|
|
201
|
+
for (const key of keys) {
|
|
202
|
+
const spec = Object.hasOwn(declarations, key) ? declarations[key] : undefined;
|
|
203
|
+
const permission = spec === undefined ? undefined : this.accept(source, key, spec);
|
|
204
|
+
if (permission === undefined) permissions.delete(key);
|
|
205
|
+
else permissions.set(key, permission);
|
|
206
|
+
}
|
|
207
|
+
source.permissions = new Map([...permissions].sort(([left], [right]) => left.localeCompare(right)));
|
|
208
|
+
source.error = undefined;
|
|
209
|
+
source.failures = 0;
|
|
210
|
+
this.changes += 1;
|
|
211
|
+
return true;
|
|
212
|
+
} catch (error) {
|
|
213
|
+
this.failed(source, error);
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The permission, or `undefined` with the problems logged: it does not exist then. */
|
|
219
|
+
private accept(source: Source, key: string, spec: PermissionSpec): OwnedPermission | undefined {
|
|
220
|
+
let problems: string[];
|
|
221
|
+
let accepted: OwnedPermission | undefined;
|
|
222
|
+
try {
|
|
223
|
+
problems = underPrefix(key, source.prefix) ? permissionProblems(key, spec) : [`permission "${key}": the key must start with "${source.prefix}."`];
|
|
224
|
+
if (problems.length === 0) {
|
|
225
|
+
for (const [name, attribute] of Object.entries(spec.on ?? {})) {
|
|
226
|
+
if (attribute.ref !== undefined && !this.kinds.has(attribute.ref)) problems.push(`permission "${key}": attribute "${name}" refers to kind "${attribute.ref}", which no module declares in references`);
|
|
227
|
+
}
|
|
228
|
+
for (const lookup of lookupsOf(spec)) {
|
|
229
|
+
if (!this.services.has(lookup)) problems.push(`permission "${key}": ${lookup.name} is not registered: add it to providers of ${source.module}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (problems.length === 0) accepted = { key, spec: frozenSpec(spec), module: source.module, source: source.prefix };
|
|
233
|
+
} catch (error) {
|
|
234
|
+
// Data of any shape may come from a source: what cannot even be checked is one bad permission, not a failed reading.
|
|
235
|
+
problems = [`permission "${key}": cannot be checked: ${error instanceof Error ? error.message : String(error)}`];
|
|
236
|
+
}
|
|
237
|
+
if (accepted === undefined) this.services.tryResolve(LOGGER)?.error("access permission of a source rejected", { source: source.prefix, module: source.module, permission: key, problems });
|
|
238
|
+
return accepted;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* One call of the source in a scope of its own, with a deadline. A call that
|
|
243
|
+
* ran out of time cannot be stopped: its scope is closed when it answers, and
|
|
244
|
+
* until then no new call starts, so a database that hangs is asked once.
|
|
245
|
+
*/
|
|
246
|
+
private async use<T>(source: Source, work: (instance: PermissionSource) => Promise<T>): Promise<T> {
|
|
247
|
+
if (source.unanswered !== undefined) throw new Error("the previous call of the source has not answered yet");
|
|
248
|
+
const seconds = this.config.get("sourceTimeoutSeconds");
|
|
249
|
+
const scope = this.services.createScope();
|
|
250
|
+
const working = (async () => await work(scope.resolve(source.type)))();
|
|
251
|
+
const answered = working.then(() => undefined, () => undefined).then(async () => {
|
|
252
|
+
source.unanswered = undefined;
|
|
253
|
+
await scope.dispose().catch(() => undefined);
|
|
254
|
+
});
|
|
255
|
+
let late = false;
|
|
256
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
257
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
258
|
+
timer = setTimeout(() => {
|
|
259
|
+
late = true;
|
|
260
|
+
source.unanswered = answered;
|
|
261
|
+
reject(new Error(`the source did not answer in ${seconds} s (access.sourceTimeoutSeconds)`));
|
|
262
|
+
}, seconds * 1000);
|
|
263
|
+
});
|
|
264
|
+
try {
|
|
265
|
+
return await Promise.race([working, deadline]);
|
|
266
|
+
} finally {
|
|
267
|
+
clearTimeout(timer);
|
|
268
|
+
if (!late) await answered;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private failed(source: Source, error: unknown): void {
|
|
273
|
+
source.error = error instanceof Error ? error.message : String(error);
|
|
274
|
+
source.failures += 1;
|
|
275
|
+
this.services.tryResolve(LOGGER)?.error("access permission source failed", {
|
|
276
|
+
source: source.prefix,
|
|
277
|
+
module: source.module,
|
|
278
|
+
error: source.error,
|
|
279
|
+
state: source.loaded ? "the permissions read before stay" : "the source has no permissions",
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* A version is what the catalog compares to learn of a change: `undefined`,
|
|
286
|
+
* `null` or an empty string would look like a version that never changes.
|
|
287
|
+
*/
|
|
288
|
+
function versionOf(value: unknown): string {
|
|
289
|
+
if (typeof value === "string" && value !== "") return value;
|
|
290
|
+
if ((typeof value === "number" && Number.isFinite(value)) || typeof value === "bigint") return String(value);
|
|
291
|
+
throw new TypeError(`version() must return a non-empty string or a number, got ${value === null ? "null" : typeof value === "string" ? "an empty string" : typeof value}`);
|
|
292
|
+
}
|
|
@@ -11,19 +11,25 @@ import {
|
|
|
11
11
|
} from "../../di";
|
|
12
12
|
import { grantProblem } from "../check/conditions";
|
|
13
13
|
import { AccessSetupError } from "../contracts/errors";
|
|
14
|
+
import type { PermissionSource } from "../contracts/PermissionSource";
|
|
14
15
|
import type { References } from "../contracts/References";
|
|
15
16
|
import { isDeclaredRoles, rolePermission, type RoleDeclarations, type RolePermission } from "../roles/roles";
|
|
16
17
|
import { IAccess } from "../check/IAccess.service";
|
|
17
18
|
import type { DeclaredRole, OwnedPermission, OwnedReferenceKind } from "./AccessCatalog";
|
|
18
|
-
import { isDeclaredPermissions, isReferenceKind, lookupsOf, permissionProblems, type PermissionDeclarations, type PermissionSpec } from "./permissions";
|
|
19
|
+
import { isDeclaredPermissions, isReferenceKind, isSourcePrefix, lookupsOf, permissionProblems, underPrefix, type PermissionDeclarations, type PermissionSpec } from "./permissions";
|
|
20
|
+
import type { OwnedPermissionSource } from "./PermissionSources.service";
|
|
19
21
|
|
|
20
22
|
interface PermissionPayload { readonly key: string; readonly spec: PermissionSpec }
|
|
21
23
|
interface ReferencePayload { readonly kind: string; readonly type: Class<References> }
|
|
22
24
|
interface RolePayload { readonly name: string; readonly permissions: "all" | readonly RolePermission[] }
|
|
25
|
+
interface SourcePayload { readonly prefix: string; readonly type: Class<PermissionSource> }
|
|
23
26
|
|
|
24
27
|
const PERMISSION_CHANNEL = createModuleOwnedMetadataChannel<PermissionPayload>("access.permission/v1");
|
|
25
28
|
const REFERENCE_CHANNEL = createModuleOwnedMetadataChannel<ReferencePayload>("access.reference/v1");
|
|
26
29
|
const ROLE_CHANNEL = createModuleOwnedMetadataChannel<RolePayload>("access.role/v1");
|
|
30
|
+
const SOURCE_CHANNEL = createModuleOwnedMetadataChannel<SourcePayload>("access.permission-source/v1");
|
|
31
|
+
/** The classes of `permissionSources`: the field registers them, like the classes of `references`. */
|
|
32
|
+
const SOURCE_CLASS_CHANNEL = createModuleOwnedProviderChannel<{ readonly prefix: string }, object>("access.permission-source-class/v1");
|
|
27
33
|
/** The classes of `references`: the field registers them, like `tools` and `controllers`. */
|
|
28
34
|
const REFERENCE_CLASS_CHANNEL = createModuleOwnedProviderChannel<{ readonly kind: string }, object>("access.reference-class/v1");
|
|
29
35
|
/** The `lookup` classes of `permissions`: the field registers them too. */
|
|
@@ -38,11 +44,12 @@ interface AccessModuleFields {
|
|
|
38
44
|
readonly permissions?: unknown;
|
|
39
45
|
readonly references?: unknown;
|
|
40
46
|
readonly roles?: unknown;
|
|
47
|
+
readonly permissionSources?: unknown;
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
registerModuleOwnedProviderContributor((metadata: BazisModuleMetadata, context) => {
|
|
44
51
|
const fields = metadata as BazisModuleMetadata & AccessModuleFields;
|
|
45
|
-
if (fields.permissions === undefined && fields.references === undefined && fields.roles === undefined) return;
|
|
52
|
+
if (fields.permissions === undefined && fields.references === undefined && fields.roles === undefined && fields.permissionSources === undefined) return;
|
|
46
53
|
const module = moduleName(metadata);
|
|
47
54
|
if (fields.permissions !== undefined) {
|
|
48
55
|
if (!isDeclaredPermissions(fields.permissions)) throw new AccessSetupError(`${module}: permissions must be a definePermissions(...) result.`);
|
|
@@ -84,6 +91,27 @@ registerModuleOwnedProviderContributor((metadata: BazisModuleMetadata, context)
|
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
93
|
}
|
|
94
|
+
if (fields.permissionSources !== undefined) {
|
|
95
|
+
const sources = fields.permissionSources;
|
|
96
|
+
if (typeof sources !== "object" || sources === null || Array.isArray(sources)) throw new AccessSetupError(`${module}: permissionSources must map a key prefix to a PermissionSource class.`);
|
|
97
|
+
const problems: string[] = [];
|
|
98
|
+
for (const [prefix, type] of Object.entries(sources)) {
|
|
99
|
+
if (!isSourcePrefix(prefix)) problems.push(`prefix "${prefix}" must be one or several key segments in lower case, like "data" or "dm.records"`);
|
|
100
|
+
if (typeof type !== "function") problems.push(`prefix "${prefix}" must name a PermissionSource class`);
|
|
101
|
+
}
|
|
102
|
+
if (problems.length > 0) throw setupError(module, "permissionSources", problems);
|
|
103
|
+
const registered = new Set<unknown>();
|
|
104
|
+
for (const [prefix, type] of Object.entries(sources)) {
|
|
105
|
+
context.addMetadata(SOURCE_CHANNEL, { prefix, type: type as Class<PermissionSource> });
|
|
106
|
+
if (registered.has(type)) continue;
|
|
107
|
+
registered.add(type);
|
|
108
|
+
try {
|
|
109
|
+
context.registerScoped(SOURCE_CLASS_CHANNEL, scoped(type as Class<object>), { prefix });
|
|
110
|
+
} catch {
|
|
111
|
+
// The module registered the class its own way: that registration stands.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
87
115
|
if (fields.roles !== undefined) {
|
|
88
116
|
if (!isDeclaredRoles(fields.roles)) throw new AccessSetupError(`${module}: roles must be a defineRoles(...) result.`);
|
|
89
117
|
const problems: string[] = [];
|
|
@@ -102,10 +130,11 @@ registerModuleOwnedContributionValidator((snapshot) => {
|
|
|
102
130
|
const permissions = snapshot.getMetadataContributions(PERMISSION_CHANNEL);
|
|
103
131
|
const references = snapshot.getMetadataContributions(REFERENCE_CHANNEL);
|
|
104
132
|
const roles = snapshot.getMetadataContributions(ROLE_CHANNEL);
|
|
105
|
-
const
|
|
133
|
+
const sources = snapshot.getMetadataContributions(SOURCE_CHANNEL);
|
|
134
|
+
const first = permissions[0] ?? references[0] ?? roles[0] ?? sources[0];
|
|
106
135
|
if (first === undefined) return;
|
|
107
136
|
if (snapshot.countProviders(IAccess) === 0) {
|
|
108
|
-
throw new AccessSetupError(`${first.ownerName} declares access (permissions, references or
|
|
137
|
+
throw new AccessSetupError(`${first.ownerName} declares access (permissions, references, roles or permission sources), but the application does not import AccessModule (bazis/core/access).`);
|
|
109
138
|
}
|
|
110
139
|
|
|
111
140
|
const owners = new Map<string, string>();
|
|
@@ -117,6 +146,24 @@ registerModuleOwnedContributionValidator((snapshot) => {
|
|
|
117
146
|
specs.set(payload.key, payload.spec);
|
|
118
147
|
}
|
|
119
148
|
|
|
149
|
+
// A source owns every key under its prefix: no declared key and no other source may be there.
|
|
150
|
+
const prefixes = new Map<string, string>();
|
|
151
|
+
for (const { payload, ownerName } of sources) {
|
|
152
|
+
for (const [other, otherOwner] of prefixes) {
|
|
153
|
+
if (other === payload.prefix) throw new AccessSetupError(`Permission source prefix "${payload.prefix}" is declared twice: in ${otherOwner} and in ${ownerName}.`);
|
|
154
|
+
if (underPrefix(other, payload.prefix) || underPrefix(payload.prefix, other)) {
|
|
155
|
+
throw new AccessSetupError(`Permission source prefixes "${other}" (${otherOwner}) and "${payload.prefix}" (${ownerName}) overlap: one lies under the other.`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
prefixes.set(payload.prefix, ownerName);
|
|
159
|
+
if (snapshot.countProviders(payload.type) === 0) throw new AccessSetupError(`${ownerName}: ${payload.type.name} of permission source "${payload.prefix}" is not registered.`);
|
|
160
|
+
for (const [key, keyOwner] of owners) {
|
|
161
|
+
if (key === payload.prefix || underPrefix(key, payload.prefix)) {
|
|
162
|
+
throw new AccessSetupError(`Permission "${key}" of ${keyOwner} lies under the prefix "${payload.prefix}" of a permission source of ${ownerName}: a source owns every key of its prefix.`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
120
167
|
const kinds = new Map<string, string>();
|
|
121
168
|
for (const { payload, ownerName } of references) {
|
|
122
169
|
const other = kinds.get(payload.kind);
|
|
@@ -160,12 +207,13 @@ function importsExport(metadata: BazisModuleMetadata, type: unknown): boolean {
|
|
|
160
207
|
}
|
|
161
208
|
|
|
162
209
|
/** What the modules of the application's container declared. */
|
|
163
|
-
export function declarationsOf(services: ServiceProvider): { permissions: OwnedPermission[]; kinds: OwnedReferenceKind[]; roles: DeclaredRole[] } {
|
|
210
|
+
export function declarationsOf(services: ServiceProvider): { permissions: OwnedPermission[]; kinds: OwnedReferenceKind[]; roles: DeclaredRole[]; sources: OwnedPermissionSource[] } {
|
|
164
211
|
if (!(services instanceof DiContainer)) throw new AccessSetupError("Access declarations require the framework DiContainer.");
|
|
165
212
|
const permissions: OwnedPermission[] = services.getModuleOwnedMetadataContributions(PERMISSION_CHANNEL).map(({ payload, ownerName }) => ({ ...payload, module: ownerName }));
|
|
166
213
|
const kinds: OwnedReferenceKind[] = services.getModuleOwnedMetadataContributions(REFERENCE_CHANNEL).map(({ payload, ownerName }) => ({ ...payload, module: ownerName }));
|
|
167
214
|
const roles: DeclaredRole[] = services.getModuleOwnedMetadataContributions(ROLE_CHANNEL).map(({ payload, ownerName }) => ({ ...payload, module: ownerName }));
|
|
168
|
-
|
|
215
|
+
const sources: OwnedPermissionSource[] = services.getModuleOwnedMetadataContributions(SOURCE_CHANNEL).map(({ payload, ownerName }) => ({ ...payload, module: ownerName }));
|
|
216
|
+
return { permissions, kinds, roles, sources };
|
|
169
217
|
}
|
|
170
218
|
|
|
171
219
|
function setupError(module: string, field: string, problems: readonly string[]): AccessSetupError {
|
|
@@ -186,5 +234,11 @@ declare module "../../di/module/types/BazisModule" {
|
|
|
186
234
|
readonly references?: Readonly<Record<string, Class<References>>>;
|
|
187
235
|
/** Roles of the default rights system, from `defineRoles` (ACCESS-001 §8). */
|
|
188
236
|
readonly roles?: RoleDeclarations;
|
|
237
|
+
/**
|
|
238
|
+
* Permissions that appear while the application runs (ACCESS-001 §3.1):
|
|
239
|
+
* key prefix → its `PermissionSource` class. The field registers the class;
|
|
240
|
+
* the source owns every key under the prefix.
|
|
241
|
+
*/
|
|
242
|
+
readonly permissionSources?: Readonly<Record<string, Class<PermissionSource>>>;
|
|
189
243
|
}
|
|
190
244
|
}
|