@platform-mesh/portal-server-lib 0.9.96 → 0.9.97

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 (52) hide show
  1. package/dist/portal-options/index.d.ts +4 -0
  2. package/dist/portal-options/index.js +4 -0
  3. package/dist/portal-options/index.js.map +1 -1
  4. package/dist/portal-options/service-providers/content-configuration-service-providers.service.d.ts +4 -1
  5. package/dist/portal-options/service-providers/content-configuration-service-providers.service.js +14 -3
  6. package/dist/portal-options/service-providers/content-configuration-service-providers.service.js.map +1 -1
  7. package/dist/portal-options/service-providers/kubernetes-service-providers.service.d.ts +4 -2
  8. package/dist/portal-options/service-providers/kubernetes-service-providers.service.js +10 -4
  9. package/dist/portal-options/service-providers/kubernetes-service-providers.service.js.map +1 -1
  10. package/dist/portal-options/services/kcp-k8s.service.d.ts +0 -1
  11. package/dist/portal-options/services/kcp-k8s.service.js +4 -13
  12. package/dist/portal-options/services/kcp-k8s.service.js.map +1 -1
  13. package/dist/portal-options/services/permissions/adapters/authz-webhook.service.d.ts +14 -0
  14. package/dist/portal-options/services/permissions/adapters/authz-webhook.service.js +183 -0
  15. package/dist/portal-options/services/permissions/adapters/authz-webhook.service.js.map +1 -0
  16. package/dist/portal-options/services/permissions/models/permissions.model.d.ts +51 -0
  17. package/dist/portal-options/services/permissions/models/permissions.model.js +2 -0
  18. package/dist/portal-options/services/permissions/models/permissions.model.js.map +1 -0
  19. package/dist/portal-options/services/permissions/permissions-proxy.service.d.ts +9 -0
  20. package/dist/portal-options/services/permissions/permissions-proxy.service.js +48 -0
  21. package/dist/portal-options/services/permissions/permissions-proxy.service.js.map +1 -0
  22. package/dist/portal-options/services/permissions/permissions.controller.d.ts +9 -0
  23. package/dist/portal-options/services/permissions/permissions.controller.js +49 -0
  24. package/dist/portal-options/services/permissions/permissions.controller.js.map +1 -0
  25. package/dist/portal-options/utils/account-hierarchy-resolver.js +7 -3
  26. package/dist/portal-options/utils/account-hierarchy-resolver.js.map +1 -1
  27. package/dist/portal-options/utils/build-workspace-path.util.d.ts +1 -0
  28. package/dist/portal-options/utils/build-workspace-path.util.js +4 -0
  29. package/dist/portal-options/utils/build-workspace-path.util.js.map +1 -0
  30. package/dist/portal-options/utils/extract-resource-definitions.util.d.ts +7 -0
  31. package/dist/portal-options/utils/extract-resource-definitions.util.js +33 -0
  32. package/dist/portal-options/utils/extract-resource-definitions.util.js.map +1 -0
  33. package/package.json +1 -1
  34. package/src/portal-options/index.ts +4 -0
  35. package/src/portal-options/service-providers/content-configuration-service-providers.service.spec.ts +8 -1
  36. package/src/portal-options/service-providers/content-configuration-service-providers.service.ts +17 -6
  37. package/src/portal-options/service-providers/kubernetes-service-providers.service.spec.ts +13 -4
  38. package/src/portal-options/service-providers/kubernetes-service-providers.service.ts +21 -8
  39. package/src/portal-options/services/kcp-k8s.service.ts +4 -15
  40. package/src/portal-options/services/permissions/adapters/authz-webhook.service.spec.ts +500 -0
  41. package/src/portal-options/services/permissions/adapters/authz-webhook.service.ts +238 -0
  42. package/src/portal-options/services/permissions/models/permissions.model.ts +56 -0
  43. package/src/portal-options/services/permissions/permissions-proxy.service.spec.ts +232 -0
  44. package/src/portal-options/services/permissions/permissions-proxy.service.ts +58 -0
  45. package/src/portal-options/services/permissions/permissions.controller.spec.ts +143 -0
  46. package/src/portal-options/services/permissions/permissions.controller.ts +31 -0
  47. package/src/portal-options/utils/account-hierarchy-resolver.ts +13 -8
  48. package/src/portal-options/utils/build-workspace-path.util.spec.ts +59 -0
  49. package/src/portal-options/utils/build-workspace-path.util.ts +21 -0
  50. package/src/portal-options/utils/extract-resource-definitions.util.spec.ts +303 -0
  51. package/src/portal-options/utils/extract-resource-definitions.util.ts +62 -0
  52. package/tsconfig.json +2 -1
@@ -0,0 +1,500 @@
1
+ import { HttpService } from '@nestjs/axios';
2
+ import { Test, TestingModule } from '@nestjs/testing';
3
+ import { of, throwError } from 'rxjs';
4
+ import {
5
+ AuthorizationRequest,
6
+ SubjectAccessReview,
7
+ } from '../models/permissions.model.js';
8
+ import { AuthzWebhookService } from './authz-webhook.service.js';
9
+
10
+ // Build a valid JWT token with the given payload
11
+ function makeJwt(payload: Record<string, unknown>): string {
12
+ const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString(
13
+ 'base64url',
14
+ );
15
+ const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
16
+ return `${header}.${body}.sig`;
17
+ }
18
+
19
+ const WEBHOOK_URL = 'https://authz.example.com';
20
+ // The service posts to `${webhookUrl}/batch-authz ` (note the trailing space).
21
+ const BATCH_URL = `${WEBHOOK_URL}/batch-authz `;
22
+
23
+ // The posted body is the SubjectAccessReview[] array itself.
24
+ function postedItems(post: jest.Mock): SubjectAccessReview[] {
25
+ return post.mock.calls[0][1] as SubjectAccessReview[];
26
+ }
27
+
28
+ // Helper that allows every submitted verb.
29
+ function allowAll() {
30
+ return (_url: string, items: SubjectAccessReview[]) =>
31
+ of({
32
+ data: items.map((item) => ({ id: item.metadata.name, allowed: true })),
33
+ });
34
+ }
35
+
36
+ function makeBaseAuthzRequest(
37
+ overrides: Partial<AuthorizationRequest> = {},
38
+ ): AuthorizationRequest {
39
+ return {
40
+ token: makeJwt({ email: 'user@example.com' }),
41
+ organization: 'my-org',
42
+ accountPath: 'sub-path',
43
+ checks: [
44
+ {
45
+ resource: 'Accounts',
46
+ group: 'core.example.com',
47
+ actions: ['get', 'list'],
48
+ },
49
+ ],
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ function makeInstanceRequest(
55
+ overrides: Partial<AuthorizationRequest> = {},
56
+ ): AuthorizationRequest {
57
+ return {
58
+ token: makeJwt({ email: 'user@example.com' }),
59
+ organization: 'my-org',
60
+ accountPath: 'sub-path',
61
+ checks: [
62
+ {
63
+ resource: 'HttpBins',
64
+ group: 'example.com',
65
+ namespace: 'default',
66
+ name: 'my-bin',
67
+ actions: ['get', 'delete'],
68
+ },
69
+ ],
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ describe('AuthzWebhookService', () => {
75
+ let service: AuthzWebhookService;
76
+ let httpService: { post: jest.Mock };
77
+ const OLD_ENV = process.env;
78
+
79
+ beforeEach(async () => {
80
+ process.env = { ...OLD_ENV };
81
+ process.env['OPENMFP_PORTAL_CONTEXT_AUTHZ_WEBHOOK_URL'] = WEBHOOK_URL;
82
+
83
+ httpService = { post: jest.fn() };
84
+
85
+ const module: TestingModule = await Test.createTestingModule({
86
+ providers: [
87
+ AuthzWebhookService,
88
+ { provide: HttpService, useValue: httpService },
89
+ ],
90
+ }).compile();
91
+
92
+ service = module.get(AuthzWebhookService);
93
+ });
94
+
95
+ afterEach(() => {
96
+ process.env = OLD_ENV;
97
+ });
98
+
99
+ // ──────────────────────────────────────────────────────────────────────────
100
+ // Constructor / env-var handling
101
+ // ──────────────────────────────────────────────────────────────────────────
102
+ describe('when OPENMFP_PORTAL_CONTEXT_AUTHZ_WEBHOOK_URL is not set', () => {
103
+ beforeEach(async () => {
104
+ delete process.env['OPENMFP_PORTAL_CONTEXT_AUTHZ_WEBHOOK_URL'];
105
+
106
+ const module: TestingModule = await Test.createTestingModule({
107
+ providers: [
108
+ AuthzWebhookService,
109
+ { provide: HttpService, useValue: httpService },
110
+ ],
111
+ }).compile();
112
+
113
+ service = module.get(AuthzWebhookService);
114
+ });
115
+
116
+ it('checkActionsForResource returns undefined without calling http', async () => {
117
+ const result = await service.checkActionsForResource(
118
+ makeBaseAuthzRequest(),
119
+ );
120
+ expect(result).toBeUndefined();
121
+ expect(httpService.post).not.toHaveBeenCalled();
122
+ });
123
+
124
+ it('checkActionsForInstance returns undefined without calling http', async () => {
125
+ const result = await service.checkActionsForInstance(
126
+ makeInstanceRequest(),
127
+ );
128
+ expect(result).toBeUndefined();
129
+ expect(httpService.post).not.toHaveBeenCalled();
130
+ });
131
+ });
132
+
133
+ // ──────────────────────────────────────────────────────────────────────────
134
+ // checkActionsForResource — Tier 1
135
+ // ──────────────────────────────────────────────────────────────────────────
136
+ describe('checkActionsForResource', () => {
137
+ it('returns undefined when checks array is empty', async () => {
138
+ const result = await service.checkActionsForResource(
139
+ makeBaseAuthzRequest({ checks: [] }),
140
+ );
141
+ expect(result).toBeUndefined();
142
+ expect(httpService.post).not.toHaveBeenCalled();
143
+ });
144
+
145
+ it('builds one SubjectAccessReview per {resource, verb} pair', async () => {
146
+ httpService.post.mockReturnValue(of({ data: [] }));
147
+
148
+ await service.checkActionsForResource(makeBaseAuthzRequest());
149
+
150
+ // 1 resource * 2 actions = 2 items
151
+ expect(postedItems(httpService.post)).toHaveLength(2);
152
+ });
153
+
154
+ it('posts the SubjectAccessReview array to the /batch-authz endpoint', async () => {
155
+ httpService.post.mockReturnValue(of({ data: [] }));
156
+
157
+ await service.checkActionsForResource(makeBaseAuthzRequest());
158
+
159
+ expect(httpService.post.mock.calls[0][0]).toBe(BATCH_URL);
160
+ const items = postedItems(httpService.post);
161
+ expect(items[0].kind).toBe('SubjectAccessReview');
162
+ expect(items[0].apiVersion).toBe('authorization.k8s.io/v1');
163
+ });
164
+
165
+ it('maps allowed:true results to Permission[] and excludes denied verbs', async () => {
166
+ httpService.post.mockImplementation((_url, items: SubjectAccessReview[]) =>
167
+ of({
168
+ data: [
169
+ { id: items[0].metadata.name, allowed: true },
170
+ { id: items[1].metadata.name, allowed: false },
171
+ ],
172
+ }),
173
+ );
174
+
175
+ const result = await service.checkActionsForResource(
176
+ makeBaseAuthzRequest(),
177
+ );
178
+ // only 'get' (item 0) is allowed; 'list' (item 1) is denied
179
+ expect(result).toEqual([{ resource: 'Accounts', actions: ['get'] }]);
180
+ });
181
+
182
+ it('returns empty array when all verbs are denied', async () => {
183
+ httpService.post.mockImplementation((_url, items: SubjectAccessReview[]) =>
184
+ of({
185
+ data: items.map((item) => ({
186
+ id: item.metadata.name,
187
+ allowed: false,
188
+ })),
189
+ }),
190
+ );
191
+
192
+ const result = await service.checkActionsForResource(
193
+ makeBaseAuthzRequest(),
194
+ );
195
+ expect(result).toEqual([]);
196
+ });
197
+
198
+ it('returns undefined on HTTP error (fail-open)', async () => {
199
+ httpService.post.mockReturnValue(
200
+ throwError(() => new Error('network error')),
201
+ );
202
+
203
+ const result = await service.checkActionsForResource(
204
+ makeBaseAuthzRequest(),
205
+ );
206
+ expect(result).toBeUndefined();
207
+ });
208
+
209
+ it('treats a non-array response body as no results (empty array)', async () => {
210
+ httpService.post.mockReturnValue(of({ data: undefined }));
211
+
212
+ const result = await service.checkActionsForResource(
213
+ makeBaseAuthzRequest(),
214
+ );
215
+ expect(result).toEqual([]);
216
+ });
217
+
218
+ it('joins organization and accountPath into the cluster-path extra when non-empty', async () => {
219
+ httpService.post.mockReturnValue(of({ data: [] }));
220
+
221
+ await service.checkActionsForResource(
222
+ makeBaseAuthzRequest({ organization: 'org', accountPath: 'acc1:acc2' }),
223
+ );
224
+
225
+ const items = postedItems(httpService.post);
226
+ expect(
227
+ items[0].spec.extra['authorization.kubernetes.io/cluster-path'],
228
+ ).toEqual(['root:orgs:org:acc1:acc2']);
229
+ });
230
+
231
+ it('uses organization only for cluster-path when accountPath is empty', async () => {
232
+ httpService.post.mockReturnValue(of({ data: [] }));
233
+
234
+ await service.checkActionsForResource(
235
+ makeBaseAuthzRequest({ organization: 'my-org', accountPath: '' }),
236
+ );
237
+
238
+ const items = postedItems(httpService.post);
239
+ expect(
240
+ items[0].spec.extra['authorization.kubernetes.io/cluster-path'],
241
+ ).toEqual(['root:orgs:my-org']);
242
+ });
243
+
244
+ it('extracts email from JWT token for the user field', async () => {
245
+ httpService.post.mockReturnValue(of({ data: [] }));
246
+ const token = makeJwt({ email: 'alice@example.com' });
247
+
248
+ await service.checkActionsForResource(makeBaseAuthzRequest({ token }));
249
+
250
+ expect(postedItems(httpService.post)[0].spec.user).toBe(
251
+ 'alice@example.com',
252
+ );
253
+ });
254
+
255
+ it('uses empty string for user when JWT has no email', async () => {
256
+ httpService.post.mockReturnValue(of({ data: [] }));
257
+ const token = makeJwt({ sub: 'some-user-id' });
258
+
259
+ await service.checkActionsForResource(makeBaseAuthzRequest({ token }));
260
+
261
+ expect(postedItems(httpService.post)[0].spec.user).toBe('');
262
+ });
263
+
264
+ it('uses empty string for user when token is malformed', async () => {
265
+ httpService.post.mockReturnValue(of({ data: [] }));
266
+
267
+ await service.checkActionsForResource(
268
+ makeBaseAuthzRequest({ token: 'not-a-jwt' }),
269
+ );
270
+
271
+ expect(postedItems(httpService.post)[0].spec.user).toBe('');
272
+ });
273
+
274
+ it('omits namespace and name from resourceAttributes (Tier 1 is cluster-scoped)', async () => {
275
+ httpService.post.mockReturnValue(of({ data: [] }));
276
+
277
+ await service.checkActionsForResource(makeBaseAuthzRequest());
278
+
279
+ const attrs = postedItems(httpService.post)[0].spec.resourceAttributes;
280
+ expect(attrs.namespace).toBeUndefined();
281
+ expect(attrs.name).toBeUndefined();
282
+ });
283
+
284
+ it('lowercases the resource name in resourceAttributes', async () => {
285
+ httpService.post.mockReturnValue(of({ data: [] }));
286
+
287
+ await service.checkActionsForResource(
288
+ makeBaseAuthzRequest({
289
+ checks: [{ resource: 'FooItems', group: '', actions: ['get'] }],
290
+ }),
291
+ );
292
+
293
+ expect(
294
+ postedItems(httpService.post)[0].spec.resourceAttributes.resource,
295
+ ).toBe('fooitems');
296
+ });
297
+
298
+ it('merges permissions for multiple checks across resources', async () => {
299
+ httpService.post.mockImplementation(allowAll());
300
+
301
+ const result = await service.checkActionsForResource(
302
+ makeBaseAuthzRequest({
303
+ checks: [
304
+ { resource: 'A', group: '', actions: ['get'] },
305
+ { resource: 'B', group: '', actions: ['list'] },
306
+ ],
307
+ }),
308
+ );
309
+
310
+ expect(result).toHaveLength(2);
311
+ const resources = result!.map((p) => p.resource);
312
+ expect(resources).toContain('A');
313
+ expect(resources).toContain('B');
314
+ });
315
+
316
+ it('ignores result ids with no matching correlation entry', async () => {
317
+ httpService.post.mockReturnValue(
318
+ of({ data: [{ id: 'unknown-id', allowed: true }] }),
319
+ );
320
+
321
+ const result = await service.checkActionsForResource(
322
+ makeBaseAuthzRequest(),
323
+ );
324
+ expect(result).toEqual([]);
325
+ });
326
+ });
327
+
328
+ // ──────────────────────────────────────────────────────────────────────────
329
+ // checkActionsForInstance — Tier 2
330
+ // ──────────────────────────────────────────────────────────────────────────
331
+ describe('checkActionsForInstance', () => {
332
+ it('returns undefined when actions array is empty (no items built)', async () => {
333
+ const result = await service.checkActionsForInstance(
334
+ makeInstanceRequest({
335
+ checks: [
336
+ {
337
+ resource: 'HttpBins',
338
+ group: 'example.com',
339
+ namespace: 'default',
340
+ name: 'my-bin',
341
+ actions: [],
342
+ },
343
+ ],
344
+ }),
345
+ );
346
+
347
+ expect(result).toBeUndefined();
348
+ expect(httpService.post).not.toHaveBeenCalled();
349
+ });
350
+
351
+ it('sends one SubjectAccessReview per action verb', async () => {
352
+ httpService.post.mockReturnValue(of({ data: [] }));
353
+
354
+ await service.checkActionsForInstance(makeInstanceRequest());
355
+
356
+ // 2 actions = 2 items
357
+ expect(postedItems(httpService.post)).toHaveLength(2);
358
+ });
359
+
360
+ it('includes name and namespace in resourceAttributes', async () => {
361
+ httpService.post.mockReturnValue(of({ data: [] }));
362
+
363
+ await service.checkActionsForInstance(makeInstanceRequest());
364
+
365
+ const attrs = postedItems(httpService.post)[0].spec.resourceAttributes;
366
+ expect(attrs.name).toBe('my-bin');
367
+ expect(attrs.namespace).toBe('default');
368
+ });
369
+
370
+ it('omits name from resourceAttributes when not provided', async () => {
371
+ httpService.post.mockReturnValue(of({ data: [] }));
372
+
373
+ await service.checkActionsForInstance(
374
+ makeInstanceRequest({
375
+ checks: [
376
+ {
377
+ resource: 'HttpBins',
378
+ group: 'example.com',
379
+ namespace: 'default',
380
+ actions: ['get'],
381
+ },
382
+ ],
383
+ }),
384
+ );
385
+
386
+ expect(
387
+ postedItems(httpService.post)[0].spec.resourceAttributes.name,
388
+ ).toBeUndefined();
389
+ });
390
+
391
+ it('returns allowed verbs as Permission[]', async () => {
392
+ httpService.post.mockImplementation((_url, items: SubjectAccessReview[]) =>
393
+ of({
394
+ data: [
395
+ { id: items[0].metadata.name, allowed: true },
396
+ { id: items[1].metadata.name, allowed: false },
397
+ ],
398
+ }),
399
+ );
400
+
401
+ const result = await service.checkActionsForInstance(
402
+ makeInstanceRequest(),
403
+ );
404
+
405
+ expect(result).toEqual([
406
+ {
407
+ resource: 'HttpBins',
408
+ namespace: 'default',
409
+ name: 'my-bin',
410
+ actions: ['get'],
411
+ },
412
+ ]);
413
+ });
414
+
415
+ it('returns empty array when all verbs are denied (not undefined)', async () => {
416
+ httpService.post.mockImplementation((_url, items: SubjectAccessReview[]) =>
417
+ of({
418
+ data: items.map((item) => ({
419
+ id: item.metadata.name,
420
+ allowed: false,
421
+ })),
422
+ }),
423
+ );
424
+
425
+ const result = await service.checkActionsForInstance(
426
+ makeInstanceRequest(),
427
+ );
428
+ // items were sent but all denied — empty array, NOT undefined
429
+ expect(result).toEqual([]);
430
+ });
431
+
432
+ it('returns undefined on HTTP error (fail-open)', async () => {
433
+ httpService.post.mockReturnValue(
434
+ throwError(() => new Error('webhook down')),
435
+ );
436
+
437
+ const result = await service.checkActionsForInstance(
438
+ makeInstanceRequest(),
439
+ );
440
+ expect(result).toBeUndefined();
441
+ });
442
+
443
+ it('groups verbs by resource|namespace|name key in Permission[]', async () => {
444
+ httpService.post.mockImplementation(allowAll());
445
+
446
+ const result = await service.checkActionsForInstance(
447
+ makeInstanceRequest({
448
+ checks: [
449
+ {
450
+ resource: 'MyResources',
451
+ group: 'example.com',
452
+ namespace: 'my-ns',
453
+ name: 'my-name',
454
+ actions: ['get', 'delete'],
455
+ },
456
+ ],
457
+ }),
458
+ );
459
+
460
+ expect(result).toHaveLength(1);
461
+ expect(result![0].resource).toBe('MyResources');
462
+ expect(result![0].namespace).toBe('my-ns');
463
+ expect(result![0].name).toBe('my-name');
464
+ expect(result![0].actions).toContain('get');
465
+ expect(result![0].actions).toContain('delete');
466
+ });
467
+
468
+ it('omits namespace from the grouped permission when the instance is cluster-scoped', async () => {
469
+ httpService.post.mockImplementation(allowAll());
470
+
471
+ const result = await service.checkActionsForInstance(
472
+ makeInstanceRequest({
473
+ checks: [
474
+ {
475
+ resource: 'Clusters',
476
+ group: 'example.com',
477
+ name: 'cluster-1',
478
+ actions: ['get'],
479
+ },
480
+ ],
481
+ }),
482
+ );
483
+
484
+ expect(result).toHaveLength(1);
485
+ expect(result![0].namespace).toBeUndefined();
486
+ expect(result![0].name).toBe('cluster-1');
487
+ });
488
+
489
+ it('ignores result ids with no matching correlation entry', async () => {
490
+ httpService.post.mockReturnValue(
491
+ of({ data: [{ id: 'unknown-id', allowed: true }] }),
492
+ );
493
+
494
+ const result = await service.checkActionsForInstance(
495
+ makeInstanceRequest(),
496
+ );
497
+ expect(result).toEqual([]);
498
+ });
499
+ });
500
+ });