bazis 0.139.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.
@@ -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";
6
+ import { PermissionSources } from "./catalog/PermissionSources";
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(PermissionSources),
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 { PermissionSources, PermissionSourceState } from "./PermissionSources";
8
13
 
9
- /** A permission of `@Module({ permissions })` with the module that declares it. */
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,13 @@ export interface DeclaredRole {
27
34
  readonly module: string;
28
35
  }
29
36
 
30
- /** The catalog read from the container's module contributions. */
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
- constructor(services: ServiceProvider) {
43
+ constructor(services: ServiceProvider, private readonly sourced: PermissionSources, private readonly config: ConfigView<AccessConfig>) {
37
44
  super();
38
45
  const { permissions, kinds, roles } = declarationsOf(services);
39
46
  this.byKey = new Map([...permissions].sort((a, b) => a.key.localeCompare(b.key)).map((item) => [item.key, item]));
@@ -42,19 +49,61 @@ export class AccessCatalog extends IAccessCatalog {
42
49
  }
43
50
 
44
51
  permissions(): readonly OwnedPermission[] {
45
- return [...this.byKey.values()];
52
+ const sourced = this.sourced.permissions();
53
+ return sourced.length === 0 ? [...this.byKey.values()] : [...this.byKey.values(), ...sourced].sort((a, b) => a.key.localeCompare(b.key));
46
54
  }
47
55
 
48
56
  permission(key: string): OwnedPermission | undefined {
49
- return this.byKey.get(key);
57
+ return this.byKey.get(key) ?? this.sourced.permission(key);
50
58
  }
51
59
 
52
60
  require(key: string): OwnedPermission {
53
- const permission = this.byKey.get(key);
54
- if (permission === undefined) throw new TypeError(`Permission "${key}" is not declared: add it to definePermissions(...) of a module's permissions.`);
61
+ const permission = this.permission(key);
62
+ if (permission === undefined) throw this.unknown(key);
55
63
  return permission;
56
64
  }
57
65
 
66
+ async find(key: string, options: { readonly verify?: boolean } = {}): Promise<OwnedPermission | undefined> {
67
+ const declared = this.byKey.get(key);
68
+ if (declared !== undefined) return declared;
69
+ if (this.sourced.prefixOf(key) === undefined) throw this.unknown(key);
70
+ const permission = this.sourced.permission(key);
71
+ if (permission !== undefined && options.verify !== true) return permission;
72
+ // A table another instance has just created, or a field it has just added, must not wait for the poll.
73
+ await this.sourced.verify(key);
74
+ return this.sourced.permission(key);
75
+ }
76
+
77
+ async grantProblem(permission: string, grant: Grant): Promise<string | undefined> {
78
+ const maxInList = this.config.get("maxInList");
79
+ const declared = this.byKey.get(permission);
80
+ if (declared !== undefined) return grantProblem(declared.spec, grant, maxInList);
81
+ if (this.sourced.prefixOf(permission) === undefined) return `permission "${permission}" is not declared`;
82
+ const problemNow = (): string | undefined => {
83
+ const found = this.sourced.permission(permission);
84
+ return found === undefined ? `permission "${permission}" does not exist` : grantProblem(found.spec, grant, maxInList);
85
+ };
86
+ await this.sourced.verify(permission);
87
+ const problem = problemNow();
88
+ // A rule is saved rarely, and a wrong refusal costs the administrator: ask the version once more, past the limit.
89
+ return problem !== undefined && await this.sourced.verify(permission, true) ? problemNow() : problem;
90
+ }
91
+
92
+ async changed(prefix: string, keys?: readonly string[]): Promise<void> {
93
+ await this.sourced.changed(prefix, keys);
94
+ }
95
+
96
+ sources(): readonly PermissionSourceState[] {
97
+ return this.sourced.state();
98
+ }
99
+
100
+ private unknown(key: string): TypeError {
101
+ const prefix = this.sourced.prefixOf(key);
102
+ return new TypeError(prefix === undefined
103
+ ? `Permission "${key}" is not declared: add it to definePermissions(...) of a module's permissions.`
104
+ : `Permission "${key}" does not exist: the permission source "${prefix}" does not give it now.`);
105
+ }
106
+
58
107
  referenceKinds(): readonly OwnedReferenceKind[] {
59
108
  return [...this.byKind.values()];
60
109
  }
@@ -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";
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). Checked when the application
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,43 @@
1
+ import type { HostedService, HostedServiceHealth } from "../../di";
2
+ import type { PermissionSources } from "./PermissionSources";
3
+
4
+ /** After the infrastructure and the schema (phases below), before the HTTP host (phase 10). */
5
+ const PHASE = 5;
6
+
7
+ /**
8
+ * Reads the permission sources before the application takes requests and
9
+ * then asks their versions every `access.sourcePollSeconds` (ACCESS-001 §3.1).
10
+ * A source that fails does not stop the start: its permissions do not exist
11
+ * until a later reading succeeds, and `/health` says so.
12
+ */
13
+ export class PermissionSourceLoader implements HostedService {
14
+ readonly phase = PHASE;
15
+ readonly diagnosticName = "access:permission-sources";
16
+ private timer: ReturnType<typeof setInterval> | undefined;
17
+ private polling: Promise<void> | undefined;
18
+
19
+ constructor(private readonly sources: PermissionSources) {}
20
+
21
+ async start(): Promise<void> {
22
+ await this.sources.load();
23
+ const seconds = this.sources.pollSeconds;
24
+ if (seconds <= 0) return;
25
+ this.timer = setInterval(() => {
26
+ // One question at a time: a slow source is not asked again before it answers.
27
+ this.polling ??= this.sources.poll().finally(() => { this.polling = undefined; });
28
+ }, seconds * 1000);
29
+ this.timer.unref?.();
30
+ }
31
+
32
+ async stop(): Promise<void> {
33
+ if (this.timer !== undefined) clearInterval(this.timer);
34
+ this.timer = undefined;
35
+ await this.polling;
36
+ }
37
+
38
+ checkHealth(): HostedServiceHealth {
39
+ const failed = this.sources.state().filter((source) => !source.loaded || source.error !== undefined);
40
+ if (failed.length === 0) return { healthy: true };
41
+ return { healthy: false, details: failed.map((source) => `permission source "${source.prefix}": ${source.loaded ? "" : "not loaded: "}${source.error ?? "no reading yet"}`).join("; ") };
42
+ }
43
+ }
@@ -0,0 +1,222 @@
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
+ readonly permissions: number;
27
+ }
28
+
29
+ interface Source extends OwnedPermissionSource {
30
+ permissions: ReadonlyMap<string, OwnedPermission>;
31
+ loaded: boolean;
32
+ version: string | undefined;
33
+ error: string | undefined;
34
+ /** When the version was last compared, for the limit of {@link PermissionSources.verify}. */
35
+ verifiedAt: number;
36
+ /** Readings of one source go one after another. */
37
+ queue: Promise<unknown>;
38
+ }
39
+
40
+ /** How often a missing key or attribute may ask a source for its version. */
41
+ const VERIFY_EVERY_MS = 1000;
42
+
43
+ /**
44
+ * The permissions of the application's sources (ACCESS-001 §3.1): read before
45
+ * the application takes requests, read again when a source's version changes.
46
+ * A specification passes the checks of a declared one; a bad one is logged and
47
+ * the permission does not exist — closed, never open. A reading that fails
48
+ * leaves the permissions read before it.
49
+ */
50
+ export class PermissionSources {
51
+ private readonly sources: readonly Source[];
52
+ private readonly kinds: ReadonlySet<string>;
53
+
54
+ constructor(private readonly services: ServiceProvider, private readonly config: ConfigView<AccessConfig>) {
55
+ const { sources, kinds } = declarationsOf(services);
56
+ this.kinds = new Set(kinds.map((kind) => kind.kind));
57
+ this.sources = [...sources]
58
+ .sort((left, right) => left.prefix.localeCompare(right.prefix))
59
+ .map((source) => ({ ...source, permissions: new Map(), loaded: false, version: undefined, error: undefined, verifiedAt: 0, queue: Promise.resolve() }));
60
+ }
61
+
62
+ /** The prefix of the source that owns the key, or `undefined` — the key is not of a source. */
63
+ prefixOf(key: string): string | undefined {
64
+ return this.sources.find((source) => underPrefix(key, source.prefix))?.prefix;
65
+ }
66
+
67
+ permission(key: string): OwnedPermission | undefined {
68
+ for (const source of this.sources) if (underPrefix(key, source.prefix)) return source.permissions.get(key);
69
+ return undefined;
70
+ }
71
+
72
+ permissions(): OwnedPermission[] {
73
+ return this.sources.flatMap((source) => [...source.permissions.values()]);
74
+ }
75
+
76
+ state(): readonly PermissionSourceState[] {
77
+ return this.sources.map(({ prefix, module, loaded, version, error, permissions }) => ({ prefix, module, loaded, version, error, permissions: permissions.size }));
78
+ }
79
+
80
+ /** The seconds between two questions about the versions; `0` — never ask. */
81
+ get pollSeconds(): number {
82
+ return this.sources.length === 0 ? 0 : this.config.get("sourcePollSeconds");
83
+ }
84
+
85
+ /** Reads every source: at start, before the application takes requests. */
86
+ async load(): Promise<void> {
87
+ await Promise.all(this.sources.map((source) => this.enqueue(source, () => this.read(source))));
88
+ }
89
+
90
+ /** Asks every source for its version and reads again the ones that changed or have not been read. */
91
+ async poll(): Promise<void> {
92
+ await Promise.all(this.sources.map((source) => this.enqueue(source, () => this.compare(source))));
93
+ }
94
+
95
+ /**
96
+ * The source of the prefix changed. With `keys` only these permissions are
97
+ * read now; the version stays the old one, so the next comparison reads the
98
+ * whole prefix — another instance may have changed other keys meanwhile.
99
+ */
100
+ async changed(prefix: string, keys?: readonly string[]): Promise<void> {
101
+ const source = this.sources.find((item) => item.prefix === prefix);
102
+ if (source === undefined) throw new TypeError(`No permission source owns the prefix "${prefix}": name it in permissionSources of a module.`);
103
+ await this.enqueue(source, () => (keys === undefined || !source.loaded ? this.read(source) : this.readKeys(source, keys)));
104
+ }
105
+
106
+ /**
107
+ * Compares the version of the key's source before the catalog answers "no
108
+ * such permission" or drops a grant: a table another instance has just
109
+ * created must not wait for the poll. Not more often than once a second for
110
+ * a prefix, unless `force`. `true` — the permissions were read again.
111
+ */
112
+ async verify(key: string, force = false): Promise<boolean> {
113
+ const source = this.sources.find((item) => underPrefix(key, item.prefix));
114
+ if (source === undefined) return false;
115
+ return await this.enqueue(source, async () => {
116
+ if (!force && Date.now() - source.verifiedAt < VERIFY_EVERY_MS) return false;
117
+ return await this.compare(source);
118
+ });
119
+ }
120
+
121
+ private enqueue<T>(source: Source, work: () => Promise<T>): Promise<T> {
122
+ const result = source.queue.then(work);
123
+ source.queue = result.catch(() => undefined);
124
+ return result;
125
+ }
126
+
127
+ /** `true` — the version differed (or nothing was read yet) and the permissions were read again. */
128
+ private async compare(source: Source): Promise<boolean> {
129
+ source.verifiedAt = Date.now();
130
+ if (source.loaded) {
131
+ try {
132
+ const version = await this.use(source, async (instance) => String(await instance.version()));
133
+ if (version === source.version) {
134
+ source.error = undefined;
135
+ return false;
136
+ }
137
+ } catch (error) {
138
+ this.failed(source, error);
139
+ return false;
140
+ }
141
+ }
142
+ return await this.read(source);
143
+ }
144
+
145
+ private async read(source: Source): Promise<boolean> {
146
+ source.verifiedAt = Date.now();
147
+ try {
148
+ // The version first: data changed during the reading gives a newer version to the next comparison.
149
+ const { version, declarations } = await this.use(source, async (instance) => ({ version: String(await instance.version()), declarations: await instance.load() }));
150
+ if (typeof declarations !== "object" || declarations === null || Array.isArray(declarations)) throw new TypeError("load() must return an object: key → permission");
151
+ const permissions = new Map<string, OwnedPermission>();
152
+ for (const key of Object.keys(declarations).sort()) {
153
+ const permission = this.accept(source, key, declarations[key]!);
154
+ if (permission !== undefined) permissions.set(key, permission);
155
+ }
156
+ source.permissions = permissions;
157
+ source.version = version;
158
+ source.loaded = true;
159
+ source.error = undefined;
160
+ return true;
161
+ } catch (error) {
162
+ this.failed(source, error);
163
+ return false;
164
+ }
165
+ }
166
+
167
+ private async readKeys(source: Source, keys: readonly string[]): Promise<boolean> {
168
+ try {
169
+ const declarations = await this.use(source, async (instance) => await instance.load(keys));
170
+ if (typeof declarations !== "object" || declarations === null || Array.isArray(declarations)) throw new TypeError("load(keys) must return an object: key → permission");
171
+ const permissions = new Map(source.permissions);
172
+ for (const key of keys) {
173
+ const spec = Object.hasOwn(declarations, key) ? declarations[key] : undefined;
174
+ const permission = spec === undefined ? undefined : this.accept(source, key, spec);
175
+ if (permission === undefined) permissions.delete(key);
176
+ else permissions.set(key, permission);
177
+ }
178
+ source.permissions = new Map([...permissions].sort(([left], [right]) => left.localeCompare(right)));
179
+ return true;
180
+ } catch (error) {
181
+ this.failed(source, error);
182
+ return false;
183
+ }
184
+ }
185
+
186
+ /** The permission, or `undefined` with the problems logged: it does not exist then. */
187
+ private accept(source: Source, key: string, spec: PermissionSpec): OwnedPermission | undefined {
188
+ const problems = underPrefix(key, source.prefix) ? permissionProblems(key, spec) : [`permission "${key}": the key must start with "${source.prefix}."`];
189
+ if (problems.length === 0) {
190
+ for (const [name, attribute] of Object.entries(spec.on ?? {})) {
191
+ 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`);
192
+ }
193
+ for (const lookup of lookupsOf(spec)) {
194
+ if (!this.services.has(lookup)) problems.push(`permission "${key}": ${lookup.name} is not registered: add it to providers of ${source.module}`);
195
+ }
196
+ }
197
+ if (problems.length > 0) {
198
+ this.services.tryResolve(LOGGER)?.error("access permission of a source rejected", { source: source.prefix, module: source.module, permission: key, problems });
199
+ return undefined;
200
+ }
201
+ return { key, spec: frozenSpec(spec), module: source.module, source: source.prefix };
202
+ }
203
+
204
+ private async use<T>(source: Source, work: (instance: PermissionSource) => Promise<T>): Promise<T> {
205
+ const scope = this.services.createScope();
206
+ try {
207
+ return await work(scope.resolve(source.type));
208
+ } finally {
209
+ await scope.dispose();
210
+ }
211
+ }
212
+
213
+ private failed(source: Source, error: unknown): void {
214
+ source.error = error instanceof Error ? error.message : String(error);
215
+ this.services.tryResolve(LOGGER)?.error("access permission source failed", {
216
+ source: source.prefix,
217
+ module: source.module,
218
+ error: source.error,
219
+ state: source.loaded ? "the permissions read before stay" : "the source has no permissions",
220
+ });
221
+ }
222
+ }
@@ -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";
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 first = permissions[0] ?? references[0] ?? roles[0];
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 roles), but the application does not import AccessModule (bazis/core/access).`);
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
- return { permissions, kinds, roles };
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
  }