@seifer-webapp-factory/authorization 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +17 -0
  2. package/backend/templates/cache/ttl-cache.ts +41 -0
  3. package/backend/templates/config/config-fragment.ts +41 -0
  4. package/backend/templates/nestjs/authz.controller.ts +253 -0
  5. package/backend/templates/nestjs/authz.module.ts +158 -0
  6. package/backend/templates/nestjs/tokens.ts +41 -0
  7. package/backend/templates/persistence/migrations/0001_authz.sql +45 -0
  8. package/backend/templates/persistence/migrations/index.ts +84 -0
  9. package/backend/templates/persistence/pg-policy-store.ts +193 -0
  10. package/backend/templates/persistence/seed.ts +60 -0
  11. package/dist/backend/src/index.d.ts +10 -0
  12. package/dist/backend/src/index.d.ts.map +1 -0
  13. package/dist/backend/src/index.js +9 -0
  14. package/dist/backend/src/index.js.map +1 -0
  15. package/dist/backend/src/policy.d.ts +13 -0
  16. package/dist/backend/src/policy.d.ts.map +1 -0
  17. package/dist/backend/src/policy.js +49 -0
  18. package/dist/backend/src/policy.js.map +1 -0
  19. package/dist/backend/src/ports.d.ts +90 -0
  20. package/dist/backend/src/ports.d.ts.map +1 -0
  21. package/dist/backend/src/ports.js +2 -0
  22. package/dist/backend/src/ports.js.map +1 -0
  23. package/dist/backend/src/services.d.ts +81 -0
  24. package/dist/backend/src/services.d.ts.map +1 -0
  25. package/dist/backend/src/services.js +234 -0
  26. package/dist/backend/src/services.js.map +1 -0
  27. package/dist/contract/endpoints.d.ts +433 -0
  28. package/dist/contract/endpoints.d.ts.map +1 -0
  29. package/dist/contract/endpoints.js +57 -0
  30. package/dist/contract/endpoints.js.map +1 -0
  31. package/dist/contract/errors.d.ts +33 -0
  32. package/dist/contract/errors.d.ts.map +1 -0
  33. package/dist/contract/errors.js +51 -0
  34. package/dist/contract/errors.js.map +1 -0
  35. package/dist/contract/events.d.ts +50 -0
  36. package/dist/contract/events.d.ts.map +1 -0
  37. package/dist/contract/events.js +13 -0
  38. package/dist/contract/events.js.map +1 -0
  39. package/dist/contract/index.d.ts +10 -0
  40. package/dist/contract/index.d.ts.map +1 -0
  41. package/dist/contract/index.js +10 -0
  42. package/dist/contract/index.js.map +1 -0
  43. package/dist/contract/permissions.d.ts +35 -0
  44. package/dist/contract/permissions.d.ts.map +1 -0
  45. package/dist/contract/permissions.js +37 -0
  46. package/dist/contract/permissions.js.map +1 -0
  47. package/dist/contract/schemas.d.ts +288 -0
  48. package/dist/contract/schemas.d.ts.map +1 -0
  49. package/dist/contract/schemas.js +91 -0
  50. package/dist/contract/schemas.js.map +1 -0
  51. package/dist/frontend/src/client.d.ts +31 -0
  52. package/dist/frontend/src/client.d.ts.map +1 -0
  53. package/dist/frontend/src/client.js +83 -0
  54. package/dist/frontend/src/client.js.map +1 -0
  55. package/dist/frontend/src/composables.d.ts +62 -0
  56. package/dist/frontend/src/composables.d.ts.map +1 -0
  57. package/dist/frontend/src/composables.js +170 -0
  58. package/dist/frontend/src/composables.js.map +1 -0
  59. package/dist/frontend/src/guards.d.ts +12 -0
  60. package/dist/frontend/src/guards.d.ts.map +1 -0
  61. package/dist/frontend/src/guards.js +10 -0
  62. package/dist/frontend/src/guards.js.map +1 -0
  63. package/dist/frontend/src/index.d.ts +12 -0
  64. package/dist/frontend/src/index.d.ts.map +1 -0
  65. package/dist/frontend/src/index.js +9 -0
  66. package/dist/frontend/src/index.js.map +1 -0
  67. package/dist/manifest.d.ts +56 -0
  68. package/dist/manifest.d.ts.map +1 -0
  69. package/dist/manifest.js +100 -0
  70. package/dist/manifest.js.map +1 -0
  71. package/dist/scaffolder/core/config.d.ts +86 -0
  72. package/dist/scaffolder/core/config.d.ts.map +1 -0
  73. package/dist/scaffolder/core/config.js +92 -0
  74. package/dist/scaffolder/core/config.js.map +1 -0
  75. package/dist/scaffolder/core/errors.d.ts +46 -0
  76. package/dist/scaffolder/core/errors.d.ts.map +1 -0
  77. package/dist/scaffolder/core/errors.js +60 -0
  78. package/dist/scaffolder/core/errors.js.map +1 -0
  79. package/dist/scaffolder/core/extend.d.ts +86 -0
  80. package/dist/scaffolder/core/extend.d.ts.map +1 -0
  81. package/dist/scaffolder/core/extend.js +94 -0
  82. package/dist/scaffolder/core/extend.js.map +1 -0
  83. package/dist/scaffolder/core/materialize.d.ts +71 -0
  84. package/dist/scaffolder/core/materialize.d.ts.map +1 -0
  85. package/dist/scaffolder/core/materialize.js +47 -0
  86. package/dist/scaffolder/core/materialize.js.map +1 -0
  87. package/dist/scaffolder/core/ports.d.ts +39 -0
  88. package/dist/scaffolder/core/ports.d.ts.map +1 -0
  89. package/dist/scaffolder/core/ports.js +33 -0
  90. package/dist/scaffolder/core/ports.js.map +1 -0
  91. package/dist/scaffolder/core/presence.d.ts +34 -0
  92. package/dist/scaffolder/core/presence.d.ts.map +1 -0
  93. package/dist/scaffolder/core/presence.js +29 -0
  94. package/dist/scaffolder/core/presence.js.map +1 -0
  95. package/dist/scaffolder/core/three-way-merge.d.ts +113 -0
  96. package/dist/scaffolder/core/three-way-merge.d.ts.map +1 -0
  97. package/dist/scaffolder/core/three-way-merge.js +184 -0
  98. package/dist/scaffolder/core/three-way-merge.js.map +1 -0
  99. package/dist/scaffolder/index.d.ts +25 -0
  100. package/dist/scaffolder/index.d.ts.map +1 -0
  101. package/dist/scaffolder/index.js +24 -0
  102. package/dist/scaffolder/index.js.map +1 -0
  103. package/frontend/templates/components/PermissionMatrix.vue +134 -0
  104. package/frontend/templates/i18n/en.json +61 -0
  105. package/frontend/templates/i18n/nl.json +61 -0
  106. package/frontend/templates/middleware/permission.ts +54 -0
  107. package/frontend/templates/pages/access-assignments.vue +151 -0
  108. package/frontend/templates/pages/role-editor.vue +169 -0
  109. package/frontend/templates/pages/roles-list.vue +84 -0
  110. package/frontend/templates/plugins/authz.client.ts +108 -0
  111. package/frontend/templates/runtime.ts +60 -0
  112. package/package.json +76 -0
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # @seifer-webapp-factory/authorization
2
+
3
+ Tweede **capability-module**: verticale, pluggable full-stack autorisatie-feature (RBAC + rol-hiërarchie,
4
+ rol-toewijzing, default-deny-enforcement via een PDP-guard, optionele ReBAC-ownership, en een admin-UI
5
+ voor rollen/permissies/toewijzingen) die de `@seifer-webapp-factory/kits` samenbindt via één contract.
6
+
7
+ De **eerste module met een module→module-afhankelijkheid**: `requires.modules: ['authentication']`. Ze
8
+ consumeert het geauthenticeerde `Subject` uit de auth-sessie (via de `SubjectProvider`-port) en levert de
9
+ effectieve permissies terug die de sessie draagt.
10
+
11
+ - Mechanisme (pinned dependency): `@seifer-webapp-factory/authorization/{contract,backend,frontend,manifest,scaffolder}`.
12
+ - Surface (gematerialiseerd, project-eigendom): `backend/templates`, `frontend/templates`.
13
+ - Composeert de `access-control`-kit (RBAC/hiërarchie/enforcement/ReBAC/PDP) — voegt **geen** nieuw
14
+ mechanisme toe.
15
+
16
+ Zie `../authorization.md` (plan, met de gelockte best-practice-beslissingen) en `./` (backlog: 10 epics,
17
+ 22 features, 44 user stories) in de monorepo.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * US-Z0402 — Een in-memory {@link PermissionCache} met een korte TTL per entry. De resolver
3
+ * invalideert expliciet bij elke mutatie (grant/revoke/role-change); de TTL is enkel een vangnet zodat
4
+ * een gemiste invalidatie nooit langer dan `ttlSeconds` stale blijft. De klok wordt geïnjecteerd zodat
5
+ * de vervaltijd deterministisch te testen is (geen `Date.now()` in de cache zelf).
6
+ */
7
+ import type { Clock, PermissionCache, ResolvedAccess } from '../../src/index.js';
8
+
9
+ interface CacheEntry {
10
+ value: ResolvedAccess;
11
+ /** Absolute vervaltijd in ms sinds epoch (via de geïnjecteerde klok). */
12
+ expiresAt: number;
13
+ }
14
+
15
+ /** Bouwt een TTL-cache; `ttlSeconds` komt uit de config (`cacheTtlSeconds`). */
16
+ export function ttlPermissionCache(ttlSeconds: number, clock: Clock): PermissionCache {
17
+ const store = new Map<string, CacheEntry>();
18
+ const nowMs = (): number => clock.now().getTime();
19
+
20
+ return {
21
+ async get(subjectId: string): Promise<ResolvedAccess | null> {
22
+ const entry = store.get(subjectId);
23
+ if (entry === undefined) return null;
24
+ // Verlopen entries worden lui opgeruimd bij het lezen.
25
+ if (entry.expiresAt <= nowMs()) {
26
+ store.delete(subjectId);
27
+ return null;
28
+ }
29
+ return entry.value;
30
+ },
31
+ async set(subjectId: string, value: ResolvedAccess): Promise<void> {
32
+ store.set(subjectId, { value, expiresAt: nowMs() + ttlSeconds * 1000 });
33
+ },
34
+ async invalidate(subjectId: string): Promise<void> {
35
+ store.delete(subjectId);
36
+ },
37
+ async clear(): Promise<void> {
38
+ store.clear();
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * US-Z0603 — Config-fragment voor de authorization-capability. Een Zod-schema dat de host in zijn eigen
3
+ * app-config-schema mengt (config-kit). Bevat geen secrets. De defaults maken de module direct bruikbaar:
4
+ * RBAC-only, harde enforcement, korte cache-TTL en een minimale set default-rollen (admin + member).
5
+ */
6
+ import { z } from 'zod';
7
+ import { AUTHZ_MANAGE } from '../../../contract/index.js';
8
+
9
+ /** Eén default-rol: sleutel + weergavenaam + permissie-sleutels. */
10
+ export const defaultRoleSchema = z.object({
11
+ key: z.string().min(1).max(64),
12
+ name: z.string().min(1).max(128),
13
+ permissions: z.array(z.string().min(1)).default([]),
14
+ });
15
+
16
+ /** Het volledige authorization-config-fragment. */
17
+ export const authzConfigSchema = z.object({
18
+ /** Autorisatie-model: puur RBAC, of RBAC + ReBAC (ownership/relatie-checks, US-Z0501). */
19
+ model: z.enum(['rbac', 'rbac+rebac']).default('rbac'),
20
+ /** `enforce` = weiger bij ontbrekende permissie; `shadow` = log-only, laat door (US-Z0704). */
21
+ enforcementMode: z.enum(['enforce', 'shadow']).default('enforce'),
22
+ /** Korte TTL (seconden) van de opgeloste-permissies-cache (US-Z0402). */
23
+ cacheTtlSeconds: z.number().int().positive().default(30),
24
+ /** Rollen die bij een verse installatie worden geseed (US-Z0603/Z0702). */
25
+ defaultRoles: z
26
+ .array(defaultRoleSchema)
27
+ .default([
28
+ { key: 'admin', name: 'Beheerder', permissions: [AUTHZ_MANAGE] },
29
+ { key: 'member', name: 'Lid', permissions: [] },
30
+ ]),
31
+ /** Het subject dat de eerste beheer-rol krijgt (bootstrap, US-Z0702). Optioneel. */
32
+ firstAdminSubjectId: z.string().min(1).optional(),
33
+ });
34
+
35
+ /** Het afgeleide, gevalideerde config-type. */
36
+ export type AuthzConfig = z.infer<typeof authzConfigSchema>;
37
+
38
+ /** Parse (met defaults) een ruwe config-invoer tot een `AuthzConfig`. */
39
+ export function parseAuthzConfig(input: unknown = {}): AuthzConfig {
40
+ return authzConfigSchema.parse(input);
41
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * De NestJS-surface voor de 12 contract-endpoints (AUTHZ_ENDPOINTS). Puur *plumbing*: (1) resolveert de
3
+ * identiteit via de geïnjecteerde SubjectProvider, (2) bouwt de caller-`Actor` uit zijn effectieve
4
+ * permissies, (3) handhaaft `requiredPermission` (default-deny; in `shadow`-mode log-only, US-Z0704),
5
+ * (4) valideert de body met de contract-Zod-schema's, (5) roept de mechanism-service aan en (6) vertaalt
6
+ * een `AuthzError` via de gedeelde taxonomie naar HTTP. Denial-reasons lekken nooit policy-interne
7
+ * details (no-catalog-leak, US-Z0703).
8
+ *
9
+ * DI is EXPLICIET via `@Inject(TOKEN)` (geen type-based DI) zodat de controller onder vitest/esbuild
10
+ * draait zonder decorator-metadata.
11
+ */
12
+ import {
13
+ Controller,
14
+ Delete,
15
+ Get,
16
+ HttpException,
17
+ Inject,
18
+ Param,
19
+ Patch,
20
+ Post,
21
+ Req,
22
+ } from '@nestjs/common';
23
+ import { ZodError, type ZodTypeAny, type z } from 'zod';
24
+ import {
25
+ AUTHZ_ERROR_TAXONOMY,
26
+ AUTHZ_MANAGE,
27
+ AuthzError,
28
+ assignmentRequestSchema,
29
+ checkRequestSchema,
30
+ createRoleRequestSchema,
31
+ relationRequestSchema,
32
+ updateRoleRequestSchema,
33
+ } from '../../../contract/index.js';
34
+ import type { Actor, SubjectProvider } from '../../src/index.js';
35
+ import {
36
+ AUTHZ_MODULE_DEPS,
37
+ AUTHZ_SUBJECT_PROVIDER,
38
+ AUTHZ_SURFACE_CONFIG,
39
+ type AuthzModuleDeps,
40
+ type AuthzSurfaceConfig,
41
+ } from './tokens.js';
42
+
43
+ /** Minimale request-vorm die de controller nodig heeft (geen harde framework-type-dep). */
44
+ interface AuthzRequest {
45
+ body: unknown;
46
+ }
47
+
48
+ @Controller()
49
+ export class AuthzController {
50
+ constructor(
51
+ @Inject(AUTHZ_MODULE_DEPS) private readonly deps: AuthzModuleDeps,
52
+ @Inject(AUTHZ_SUBJECT_PROVIDER) private readonly subjects: SubjectProvider,
53
+ @Inject(AUTHZ_SURFACE_CONFIG) private readonly surface: AuthzSurfaceConfig,
54
+ ) {}
55
+
56
+ // --- US-Z0201 GET /authz/roles ---
57
+ @Get('authz/roles')
58
+ async listRoles(@Req() req: AuthzRequest) {
59
+ try {
60
+ await this.authorize(req, AUTHZ_MANAGE);
61
+ return { roles: await this.deps.roles.list() };
62
+ } catch (err) {
63
+ this.toHttp(err);
64
+ }
65
+ }
66
+
67
+ // --- US-Z0201 POST /authz/roles ---
68
+ @Post('authz/roles')
69
+ async createRole(@Req() req: AuthzRequest) {
70
+ try {
71
+ const actor = await this.authorize(req, AUTHZ_MANAGE);
72
+ const body = this.parse(createRoleRequestSchema, req.body);
73
+ return await this.deps.roles.create(actor, body);
74
+ } catch (err) {
75
+ this.toHttp(err);
76
+ }
77
+ }
78
+
79
+ // --- US-Z0202 PATCH /authz/roles/:id ---
80
+ @Patch('authz/roles/:id')
81
+ async updateRole(@Req() req: AuthzRequest, @Param('id') id: string) {
82
+ try {
83
+ const actor = await this.authorize(req, AUTHZ_MANAGE);
84
+ const body = this.parse(updateRoleRequestSchema, req.body);
85
+ return await this.deps.roles.update(actor, id, body);
86
+ } catch (err) {
87
+ this.toHttp(err);
88
+ }
89
+ }
90
+
91
+ // --- US-Z0201 DELETE /authz/roles/:id ---
92
+ @Delete('authz/roles/:id')
93
+ async deleteRole(@Req() req: AuthzRequest, @Param('id') id: string) {
94
+ try {
95
+ const actor = await this.authorize(req, AUTHZ_MANAGE);
96
+ await this.deps.roles.remove(actor, id);
97
+ return { ok: true as const };
98
+ } catch (err) {
99
+ this.toHttp(err);
100
+ }
101
+ }
102
+
103
+ // --- US-Z0203 GET /authz/permissions ---
104
+ @Get('authz/permissions')
105
+ async listPermissions(@Req() req: AuthzRequest) {
106
+ try {
107
+ await this.authorize(req, AUTHZ_MANAGE);
108
+ return { permissions: this.deps.roles.permissions() };
109
+ } catch (err) {
110
+ this.toHttp(err);
111
+ }
112
+ }
113
+
114
+ // --- US-Z0305 GET /authz/subjects/:id/roles ---
115
+ @Get('authz/subjects/:id/roles')
116
+ async subjectRoles(@Req() req: AuthzRequest, @Param('id') id: string) {
117
+ try {
118
+ await this.authorize(req, AUTHZ_MANAGE);
119
+ return { subjectId: id, roles: await this.deps.assignments.subjectRoles(id) };
120
+ } catch (err) {
121
+ this.toHttp(err);
122
+ }
123
+ }
124
+
125
+ // --- US-Z0301 POST /authz/assignments ---
126
+ @Post('authz/assignments')
127
+ async assignRole(@Req() req: AuthzRequest) {
128
+ try {
129
+ const actor = await this.authorize(req, AUTHZ_MANAGE);
130
+ const body = this.parse(assignmentRequestSchema, req.body);
131
+ await this.deps.assignments.assign(actor, body.subjectId, body.roleId);
132
+ return { ok: true as const };
133
+ } catch (err) {
134
+ this.toHttp(err);
135
+ }
136
+ }
137
+
138
+ // --- US-Z0303 DELETE /authz/assignments ---
139
+ @Delete('authz/assignments')
140
+ async revokeRole(@Req() req: AuthzRequest) {
141
+ try {
142
+ const actor = await this.authorize(req, AUTHZ_MANAGE);
143
+ const body = this.parse(assignmentRequestSchema, req.body);
144
+ await this.deps.assignments.revoke(actor, body.subjectId, body.roleId);
145
+ return { ok: true as const };
146
+ } catch (err) {
147
+ this.toHttp(err);
148
+ }
149
+ }
150
+
151
+ // --- US-Z0401 GET /authz/me/permissions (alleen auth, geen authz.manage) ---
152
+ @Get('authz/me/permissions')
153
+ async mePermissions(@Req() req: AuthzRequest) {
154
+ try {
155
+ const actor = await this.authorize(req, null);
156
+ const resolved = await this.deps.resolver.resolve(actor.id);
157
+ return { subjectId: resolved.subjectId, roles: resolved.roleKeys, permissions: resolved.permissions };
158
+ } catch (err) {
159
+ this.toHttp(err);
160
+ }
161
+ }
162
+
163
+ // --- US-Z0403 POST /authz/check (alleen auth, geen authz.manage) ---
164
+ @Post('authz/check')
165
+ async check(@Req() req: AuthzRequest) {
166
+ try {
167
+ const actor = await this.authorize(req, null);
168
+ const body = this.parse(checkRequestSchema, req.body);
169
+ return await this.deps.pdp.check(actor.id, body);
170
+ } catch (err) {
171
+ this.toHttp(err);
172
+ }
173
+ }
174
+
175
+ // --- US-Z0501 POST /authz/relations ---
176
+ @Post('authz/relations')
177
+ async writeRelation(@Req() req: AuthzRequest) {
178
+ try {
179
+ await this.authorize(req, AUTHZ_MANAGE);
180
+ const body = this.parse(relationRequestSchema, req.body);
181
+ await this.deps.relations.write(body);
182
+ return { ok: true as const };
183
+ } catch (err) {
184
+ this.toHttp(err);
185
+ }
186
+ }
187
+
188
+ // --- US-Z0501 DELETE /authz/relations ---
189
+ @Delete('authz/relations')
190
+ async removeRelation(@Req() req: AuthzRequest) {
191
+ try {
192
+ await this.authorize(req, AUTHZ_MANAGE);
193
+ const body = this.parse(relationRequestSchema, req.body);
194
+ await this.deps.relations.remove(body);
195
+ return { ok: true as const };
196
+ } catch (err) {
197
+ this.toHttp(err);
198
+ }
199
+ }
200
+
201
+ // --- Interne surface-helpers ---
202
+
203
+ /**
204
+ * Resolveert de identiteit, bouwt de `Actor` uit zijn effectieve permissies en handhaaft
205
+ * `requiredPermission`. Geen identiteit → `unauthenticated` (401). Ontbrekende permissie → een
206
+ * `access.denied`-event; in `enforce`-mode volgt een `forbidden` (403), in `shadow`-mode laat de
207
+ * controller de call door (log-only, US-Z0704).
208
+ */
209
+ private async authorize(req: AuthzRequest, requiredPermission: string | null): Promise<Actor> {
210
+ const identity = await this.subjects.current(req);
211
+ if (identity === null) throw new AuthzError('unauthenticated');
212
+
213
+ const resolved = await this.deps.resolver.resolve(identity.id);
214
+ const actor: Actor = { id: identity.id, permissions: new Set(resolved.permissions) };
215
+
216
+ if (requiredPermission !== null && !actor.permissions.has(requiredPermission)) {
217
+ this.deps.events.emit({
218
+ name: 'access.denied',
219
+ occurredAt: this.deps.clock.now().toISOString(),
220
+ actorId: actor.id,
221
+ requested: requiredPermission,
222
+ });
223
+ // `shadow`: niet blokkeren — enkel loggen en doorlaten (observatie-modus, US-Z0704).
224
+ if (this.surface.enforcementMode === 'enforce') throw new AuthzError('forbidden');
225
+ }
226
+ return actor;
227
+ }
228
+
229
+ private parse<S extends ZodTypeAny>(schema: S, body: unknown): z.infer<S> {
230
+ return schema.parse(body);
231
+ }
232
+
233
+ /**
234
+ * Vertaal een fout naar HTTP. Zod → `validation_failed` (422, met veldpaden zonder waarden);
235
+ * `AuthzError` → `AUTHZ_ERROR_TAXONOMY`-status + contract-body; al-gevormde `HttpException` blijft;
236
+ * onbekend bubbelt (Nest → 500, geen leak).
237
+ */
238
+ private toHttp(err: unknown): never {
239
+ if (err instanceof HttpException) throw err;
240
+ if (err instanceof ZodError) {
241
+ const fields = err.flatten().fieldErrors as Record<string, string[]>;
242
+ throw new HttpException(
243
+ { code: 'validation_failed', message: AUTHZ_ERROR_TAXONOMY.validation_failed.i18nKey, fields },
244
+ AUTHZ_ERROR_TAXONOMY.validation_failed.httpStatus,
245
+ );
246
+ }
247
+ if (err instanceof AuthzError) {
248
+ const descriptor = AUTHZ_ERROR_TAXONOMY[err.code];
249
+ throw new HttpException(err.toBody(), descriptor.httpStatus);
250
+ }
251
+ throw err;
252
+ }
253
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * `AuthzModule.forRoot(options)` — de NestJS-surface die de framework-vrije mechanism-laag (backend/src)
3
+ * samenbindt met de pg-policy-store, de TTL-cache en de audit-log-kit. Alles wordt met EXPLICIETE
4
+ * providers (`useValue`/token) gewired — geen type-based DI — zodat de module ook onder vitest/esbuild
5
+ * draait (geen decorator-metadata). Fail-fast op ontbrekende poorten (pool/subjectProvider).
6
+ */
7
+ import { Module, type DynamicModule, type Provider } from '@nestjs/common';
8
+ import type { Pool } from '@seifer-webapp-factory/kits/backend/persistence';
9
+ import { createAuditLog, inMemoryAuditStore, type AuditLog } from '@seifer-webapp-factory/kits/backend/audit-log';
10
+ import {
11
+ AUTHZ_EVENTS,
12
+ AUTHZ_PERMISSIONS,
13
+ type AuthzEvent,
14
+ type AuthzEventSink,
15
+ type PermissionCatalog,
16
+ } from '../../../contract/index.js';
17
+ import {
18
+ AssignmentService,
19
+ AuthorizationPdp,
20
+ PermissionResolver,
21
+ RelationService,
22
+ RoleService,
23
+ type Clock,
24
+ type SubjectProvider,
25
+ } from '../../src/index.js';
26
+ import { ttlPermissionCache } from '../cache/ttl-cache.js';
27
+ import { pgPolicyStore } from '../persistence/pg-policy-store.js';
28
+ import { parseAuthzConfig, type AuthzConfig } from '../config/config-fragment.js';
29
+ import { AuthzController } from './authz.controller.js';
30
+ import {
31
+ AUTHZ_AUDIT_LOG,
32
+ AUTHZ_MODULE_DEPS,
33
+ AUTHZ_SUBJECT_PROVIDER,
34
+ AUTHZ_SURFACE_CONFIG,
35
+ type AuthzModuleDeps,
36
+ type AuthzSurfaceConfig,
37
+ } from './tokens.js';
38
+
39
+ // Her-exporteer de tokens/typen zodat consumenten alles vanuit de module kunnen importeren.
40
+ export {
41
+ AUTHZ_AUDIT_LOG,
42
+ AUTHZ_MODULE_DEPS,
43
+ AUTHZ_SUBJECT_PROVIDER,
44
+ AUTHZ_SURFACE_CONFIG,
45
+ type AuthzModuleDeps,
46
+ type AuthzSurfaceConfig,
47
+ } from './tokens.js';
48
+
49
+ /** De default-klok: echte wandkloktijd. Tests injecteren een deterministische variant. */
50
+ const systemClock: Clock = { now: () => new Date() };
51
+
52
+ export interface AuthzModuleOptions {
53
+ /** Persistence-kit-pool waarover de pg-policy-store draait (verplicht). */
54
+ pool: Pool;
55
+ /** Leest het geauthenticeerde subject uit de request-context (de seam met authentication) (verplicht). */
56
+ subjectProvider: SubjectProvider;
57
+ /** Ruwe config-invoer; wordt met defaults geparsed tot een {@link AuthzConfig}. */
58
+ config?: unknown;
59
+ /** Audit-log-sink (US-Z0705). Default: een in-memory audit-log (query-baar in tests). */
60
+ auditLog?: AuditLog;
61
+ /** Extra event-sink naast de audit-log (host-hooks/analytics). */
62
+ events?: AuthzEventSink;
63
+ /** Injecteerbare klok (deterministische TTL's/timestamps in tests). Default systeemklok. */
64
+ clock?: Clock;
65
+ /** Permissie-catalogus; de host mengt hier zijn app-permissies bij. Default: `AUTHZ_PERMISSIONS`. */
66
+ catalog?: PermissionCatalog;
67
+ }
68
+
69
+ @Module({})
70
+ export class AuthzModule {
71
+ static forRoot(options: AuthzModuleOptions): DynamicModule {
72
+ requireOption('pool', options.pool);
73
+ requireOption('subjectProvider', options.subjectProvider, ['current']);
74
+
75
+ const config: AuthzConfig = parseAuthzConfig(options.config);
76
+ const clock: Clock = options.clock ?? systemClock;
77
+ const catalog: PermissionCatalog = options.catalog ?? AUTHZ_PERMISSIONS;
78
+ const rebacEnabled = config.model === 'rbac+rebac';
79
+
80
+ // --- pg-adapter + TTL-cache over de kit-pool ---
81
+ const store = pgPolicyStore(options.pool);
82
+ const cache = ttlPermissionCache(config.cacheTtlSeconds, clock);
83
+
84
+ // --- audit-log (US-Z0705): authz-events → onveranderlijk logboek ---
85
+ const auditLog =
86
+ options.auditLog ??
87
+ createAuditLog(inMemoryAuditStore(), {
88
+ eventTypes: [...AUTHZ_EVENTS],
89
+ clock: () => clock.now().getTime(),
90
+ });
91
+ const events = auditEventSink(auditLog, options.events);
92
+
93
+ // --- mechanism-services (framework-vrij) ---
94
+ const roles = new RoleService({ store, catalog, events, clock });
95
+ const assignments = new AssignmentService({ store, cache, events, clock });
96
+ const resolver = new PermissionResolver({ store, cache });
97
+ const pdp = new AuthorizationPdp({ resolver, store, rebacEnabled });
98
+ const relations = new RelationService({ store, rebacEnabled });
99
+
100
+ const deps: AuthzModuleDeps = { roles, assignments, resolver, pdp, relations, events, clock };
101
+ const surfaceConfig: AuthzSurfaceConfig = { enforcementMode: config.enforcementMode };
102
+
103
+ const providers: Provider[] = [
104
+ { provide: AUTHZ_MODULE_DEPS, useValue: deps },
105
+ { provide: AUTHZ_SUBJECT_PROVIDER, useValue: options.subjectProvider },
106
+ { provide: AUTHZ_AUDIT_LOG, useValue: auditLog },
107
+ { provide: AUTHZ_SURFACE_CONFIG, useValue: surfaceConfig },
108
+ ];
109
+
110
+ return {
111
+ module: AuthzModule,
112
+ controllers: [AuthzController],
113
+ providers,
114
+ exports: [AUTHZ_MODULE_DEPS, AUTHZ_AUDIT_LOG],
115
+ };
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Adapteert de {@link AuthzEventSink} naar een audit-log-append (US-Z0705). Emit is synchroon (`void`);
121
+ * de append draait fire-and-forget met een zwaluwstaart-catch zodat een logboek-hapering nooit de
122
+ * authz-flow breekt. Payloads bevatten alleen identificatie (nooit policy-interne details).
123
+ */
124
+ function auditEventSink(audit: AuditLog, extra?: AuthzEventSink): AuthzEventSink {
125
+ return {
126
+ emit(event: AuthzEvent): void {
127
+ extra?.emit(event);
128
+ void audit
129
+ .append({
130
+ actor: event.actorId,
131
+ action: event.name,
132
+ target: targetOf(event),
133
+ meta: { occurredAt: event.occurredAt },
134
+ })
135
+ .catch(() => undefined);
136
+ },
137
+ };
138
+ }
139
+
140
+ /** Bepaalt een audit-`target` per event-vorm (subject, rol of gevraagde permissie). */
141
+ function targetOf(event: AuthzEvent): string {
142
+ if ('subjectId' in event) return event.subjectId;
143
+ if ('roleId' in event) return event.roleId;
144
+ if ('requested' in event) return event.requested;
145
+ // Onbereikbaar: elke event-vorm dekt één van de bovenstaande takken. Fallback op de actor.
146
+ return (event as { actorId: string }).actorId;
147
+ }
148
+
149
+ function requireOption(name: string, value: unknown, methods: string[] = []): void {
150
+ if (value === null || value === undefined || typeof value !== 'object') {
151
+ throw new Error(`AuthzModule.forRoot: "${name}" ontbreekt of is geen object`);
152
+ }
153
+ for (const method of methods) {
154
+ if (typeof (value as Record<string, unknown>)[method] !== 'function') {
155
+ throw new Error(`AuthzModule.forRoot: "${name}" implementeert "${method}" niet`);
156
+ }
157
+ }
158
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * DI-tokens + kleine surface-typen voor de authorization-module. In een apart bestand zodat de
3
+ * controller en de module ze allebei kunnen importeren zonder circulaire import. Alle DI verloopt
4
+ * expliciet via deze tokens (`@Inject(TOKEN)` + `useValue`) — geen type-based DI — zodat de module ook
5
+ * onder vitest/esbuild draait (geen decorator-metadata nodig).
6
+ */
7
+ import type { InjectionToken } from '@nestjs/common';
8
+ import type { AuthzEventSink } from '../../../contract/index.js';
9
+ import type {
10
+ AuthorizationPdp,
11
+ AssignmentService,
12
+ Clock,
13
+ PermissionResolver,
14
+ RelationService,
15
+ RoleService,
16
+ } from '../../src/index.js';
17
+
18
+ export const AUTHZ_MODULE_DEPS: InjectionToken = Symbol.for('authorization.module-deps');
19
+ export const AUTHZ_SUBJECT_PROVIDER: InjectionToken = Symbol.for('authorization.subject-provider');
20
+ export const AUTHZ_AUDIT_LOG: InjectionToken = Symbol.for('authorization.audit-log');
21
+ export const AUTHZ_SURFACE_CONFIG: InjectionToken = Symbol.for('authorization.surface-config');
22
+
23
+ /**
24
+ * De gebundelde mechanism-services + klok/events die de controller (surface) via één token krijgt.
25
+ * De module bouwt dit object en registreert het met `useValue`.
26
+ */
27
+ export interface AuthzModuleDeps {
28
+ roles: RoleService;
29
+ assignments: AssignmentService;
30
+ resolver: PermissionResolver;
31
+ pdp: AuthorizationPdp;
32
+ relations: RelationService;
33
+ events: AuthzEventSink;
34
+ clock: Clock;
35
+ }
36
+
37
+ /** Runtime-config die de surface (controller) nodig heeft naast de mechanism-services. */
38
+ export interface AuthzSurfaceConfig {
39
+ /** `enforce` = weiger bij ontbrekende permissie; `shadow` = log-only, laat door (US-Z0704). */
40
+ enforcementMode: 'enforce' | 'shadow';
41
+ }
@@ -0,0 +1,45 @@
1
+ -- US-Z0604 — authorization capability, migratie 0001_authz (raw SQL, DBA-review).
2
+ -- Idempotent (IF NOT EXISTS). De TypeScript-variant in index.ts draagt dezelfde SQL als string en is
3
+ -- de bron die de MigrationRunner draait. Expand-contract: de kolom `scope` wordt nu al (nullable)
4
+ -- meegeleverd, ook al gebruikt de huidige RBAC-vorm hem nog niet.
5
+
6
+ -- Rollen + rol-hiërarchie (US-Z0201/Z0202). `parent_role_id` verwijst naar de bovenliggende rol.
7
+ CREATE TABLE IF NOT EXISTS authz_roles (
8
+ id text PRIMARY KEY,
9
+ key text NOT NULL UNIQUE,
10
+ name text NOT NULL,
11
+ parent_role_id text REFERENCES authz_roles(id)
12
+ );
13
+
14
+ -- Code-declared permissie-catalogus, gesynchroniseerd naar de DB (US-Z0203).
15
+ CREATE TABLE IF NOT EXISTS authz_permissions (
16
+ key text PRIMARY KEY,
17
+ description text NOT NULL DEFAULT ''
18
+ );
19
+
20
+ -- Koppeling rol → permissie (genormaliseerd; geaggregeerd tot StoredRole.permissions).
21
+ CREATE TABLE IF NOT EXISTS authz_role_permissions (
22
+ role_id text NOT NULL REFERENCES authz_roles(id),
23
+ permission_key text NOT NULL,
24
+ PRIMARY KEY (role_id, permission_key)
25
+ );
26
+
27
+ -- Rol-toewijzingen aan subjects (US-Z0301). `scope` is nullable (expand-contract, nog ongebruikt).
28
+ CREATE TABLE IF NOT EXISTS authz_role_assignments (
29
+ subject_id text NOT NULL,
30
+ role_id text NOT NULL REFERENCES authz_roles(id),
31
+ scope text,
32
+ granted_at timestamptz NOT NULL DEFAULT now(),
33
+ granted_by text,
34
+ PRIMARY KEY (subject_id, role_id)
35
+ );
36
+ CREATE INDEX IF NOT EXISTS authz_role_assignments_role_idx ON authz_role_assignments(role_id);
37
+
38
+ -- ReBAC-relatietuples (US-Z0501), alleen gebruikt wanneer `model: 'rbac+rebac'`.
39
+ CREATE TABLE IF NOT EXISTS authz_relations (
40
+ subject_id text NOT NULL,
41
+ relation text NOT NULL,
42
+ object_type text NOT NULL,
43
+ object_id text NOT NULL,
44
+ PRIMARY KEY (subject_id, relation, object_type, object_id)
45
+ );
@@ -0,0 +1,84 @@
1
+ /**
2
+ * US-Z0604 — Schema-migraties voor de authorization-capability. De raw SQL staat als string in dit
3
+ * bestand (bron die de host-migratierunner draait); een identieke, door DBA leesbare kopie ligt ernaast
4
+ * in `0001_authz.sql`. Elke migratie is een stabiele `id` + idempotente SQL (IF NOT EXISTS).
5
+ *
6
+ * `0001_authz` legt vast:
7
+ * - `authz_roles` — rollen + rol-hiërarchie (US-Z0201/Z0202)
8
+ * - `authz_permissions` — code-declared permissie-catalogus, DB-gesynchroniseerd (US-Z0203)
9
+ * - `authz_role_permissions` — koppeling rol → permissie
10
+ * - `authz_role_assignments` — rol-toewijzingen aan subjects; `scope` nullable (expand-contract)
11
+ * - `authz_relations` — ReBAC-relatietuples (US-Z0501, achter config-toggle)
12
+ * Ids zijn `text` (gen_random_uuid); tijd-kolommen zijn `timestamptz`.
13
+ */
14
+
15
+ import type { Migration } from '@seifer-webapp-factory/kits/backend/persistence';
16
+
17
+ /**
18
+ * `0001_authz` in het persistence-kit-{@link Migration}-formaat (stabiele id + idempotente up/down).
19
+ * Elk statement gaat via een aparte `ctx.execute` (de runner verwacht één statement per call).
20
+ * De MigrationRunner past de migraties in volgorde toe en houdt een ledger bij.
21
+ */
22
+ const authzInit: Migration = {
23
+ id: '0001_authz',
24
+ async up(ctx) {
25
+ await ctx.execute(`
26
+ CREATE TABLE IF NOT EXISTS authz_roles (
27
+ id text PRIMARY KEY,
28
+ key text NOT NULL UNIQUE,
29
+ name text NOT NULL,
30
+ parent_role_id text REFERENCES authz_roles(id)
31
+ )
32
+ `);
33
+ await ctx.execute(`
34
+ CREATE TABLE IF NOT EXISTS authz_permissions (
35
+ key text PRIMARY KEY,
36
+ description text NOT NULL DEFAULT ''
37
+ )
38
+ `);
39
+ await ctx.execute(`
40
+ CREATE TABLE IF NOT EXISTS authz_role_permissions (
41
+ role_id text NOT NULL REFERENCES authz_roles(id),
42
+ permission_key text NOT NULL,
43
+ PRIMARY KEY (role_id, permission_key)
44
+ )
45
+ `);
46
+ await ctx.execute(`
47
+ CREATE TABLE IF NOT EXISTS authz_role_assignments (
48
+ subject_id text NOT NULL,
49
+ role_id text NOT NULL REFERENCES authz_roles(id),
50
+ scope text,
51
+ granted_at timestamptz NOT NULL DEFAULT now(),
52
+ granted_by text,
53
+ PRIMARY KEY (subject_id, role_id)
54
+ )
55
+ `);
56
+ await ctx.execute('CREATE INDEX IF NOT EXISTS authz_role_assignments_role_idx ON authz_role_assignments(role_id)');
57
+ await ctx.execute(`
58
+ CREATE TABLE IF NOT EXISTS authz_relations (
59
+ subject_id text NOT NULL,
60
+ relation text NOT NULL,
61
+ object_type text NOT NULL,
62
+ object_id text NOT NULL,
63
+ PRIMARY KEY (subject_id, relation, object_type, object_id)
64
+ )
65
+ `);
66
+ },
67
+ async down(ctx) {
68
+ for (const table of AUTHZ_MANAGED_TABLES) {
69
+ await ctx.execute(`DROP TABLE IF EXISTS ${table}`);
70
+ }
71
+ },
72
+ };
73
+
74
+ /** Alle authorization-migraties in toepassingsvolgorde. */
75
+ export const authzMigrations: Migration[] = [authzInit];
76
+
77
+ /** Tabellen die een test-reset-helper mag leegmaken (exclusief de migratie-ledger). */
78
+ export const AUTHZ_MANAGED_TABLES = [
79
+ 'authz_relations',
80
+ 'authz_role_assignments',
81
+ 'authz_role_permissions',
82
+ 'authz_permissions',
83
+ 'authz_roles',
84
+ ];