@platform-mesh/portal-server-lib 0.9.95 → 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.
- package/dist/portal-options/index.d.ts +4 -0
- package/dist/portal-options/index.js +4 -0
- package/dist/portal-options/index.js.map +1 -1
- package/dist/portal-options/logout-callback.service.js +16 -8
- package/dist/portal-options/logout-callback.service.js.map +1 -1
- package/dist/portal-options/service-providers/content-configuration-service-providers.service.d.ts +4 -1
- package/dist/portal-options/service-providers/content-configuration-service-providers.service.js +14 -3
- package/dist/portal-options/service-providers/content-configuration-service-providers.service.js.map +1 -1
- package/dist/portal-options/service-providers/kubernetes-service-providers.service.d.ts +4 -2
- package/dist/portal-options/service-providers/kubernetes-service-providers.service.js +10 -4
- package/dist/portal-options/service-providers/kubernetes-service-providers.service.js.map +1 -1
- package/dist/portal-options/services/kcp-k8s.service.d.ts +0 -1
- package/dist/portal-options/services/kcp-k8s.service.js +4 -13
- package/dist/portal-options/services/kcp-k8s.service.js.map +1 -1
- package/dist/portal-options/services/permissions/adapters/authz-webhook.service.d.ts +14 -0
- package/dist/portal-options/services/permissions/adapters/authz-webhook.service.js +183 -0
- package/dist/portal-options/services/permissions/adapters/authz-webhook.service.js.map +1 -0
- package/dist/portal-options/services/permissions/models/permissions.model.d.ts +51 -0
- package/dist/portal-options/services/permissions/models/permissions.model.js +2 -0
- package/dist/portal-options/services/permissions/models/permissions.model.js.map +1 -0
- package/dist/portal-options/services/permissions/permissions-proxy.service.d.ts +9 -0
- package/dist/portal-options/services/permissions/permissions-proxy.service.js +48 -0
- package/dist/portal-options/services/permissions/permissions-proxy.service.js.map +1 -0
- package/dist/portal-options/services/permissions/permissions.controller.d.ts +9 -0
- package/dist/portal-options/services/permissions/permissions.controller.js +49 -0
- package/dist/portal-options/services/permissions/permissions.controller.js.map +1 -0
- package/dist/portal-options/utils/account-hierarchy-resolver.js +7 -3
- package/dist/portal-options/utils/account-hierarchy-resolver.js.map +1 -1
- package/dist/portal-options/utils/build-workspace-path.util.d.ts +1 -0
- package/dist/portal-options/utils/build-workspace-path.util.js +4 -0
- package/dist/portal-options/utils/build-workspace-path.util.js.map +1 -0
- package/dist/portal-options/utils/extract-resource-definitions.util.d.ts +7 -0
- package/dist/portal-options/utils/extract-resource-definitions.util.js +33 -0
- package/dist/portal-options/utils/extract-resource-definitions.util.js.map +1 -0
- package/package.json +1 -1
- package/src/portal-options/index.ts +4 -0
- package/src/portal-options/logout-callback.service.spec.ts +57 -1
- package/src/portal-options/logout-callback.service.ts +34 -8
- package/src/portal-options/service-providers/content-configuration-service-providers.service.spec.ts +8 -1
- package/src/portal-options/service-providers/content-configuration-service-providers.service.ts +17 -6
- package/src/portal-options/service-providers/kubernetes-service-providers.service.spec.ts +13 -4
- package/src/portal-options/service-providers/kubernetes-service-providers.service.ts +21 -8
- package/src/portal-options/services/kcp-k8s.service.ts +4 -15
- package/src/portal-options/services/permissions/adapters/authz-webhook.service.spec.ts +500 -0
- package/src/portal-options/services/permissions/adapters/authz-webhook.service.ts +238 -0
- package/src/portal-options/services/permissions/models/permissions.model.ts +56 -0
- package/src/portal-options/services/permissions/permissions-proxy.service.spec.ts +232 -0
- package/src/portal-options/services/permissions/permissions-proxy.service.ts +58 -0
- package/src/portal-options/services/permissions/permissions.controller.spec.ts +143 -0
- package/src/portal-options/services/permissions/permissions.controller.ts +31 -0
- package/src/portal-options/utils/account-hierarchy-resolver.ts +13 -8
- package/src/portal-options/utils/build-workspace-path.util.spec.ts +59 -0
- package/src/portal-options/utils/build-workspace-path.util.ts +21 -0
- package/src/portal-options/utils/extract-resource-definitions.util.spec.ts +303 -0
- package/src/portal-options/utils/extract-resource-definitions.util.ts +62 -0
- package/tsconfig.json +2 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { UnauthorizedException } from '@nestjs/common';
|
|
2
|
+
import { HeaderParserService } from '@openmfp/portal-server-lib';
|
|
3
|
+
import { Test, TestingModule } from '@nestjs/testing';
|
|
4
|
+
import { mock } from 'jest-mock-extended';
|
|
5
|
+
import { PermissionsController } from './permissions.controller.js';
|
|
6
|
+
import { PermissionsProxyService } from './permissions-proxy.service.js';
|
|
7
|
+
import { AuthorizationRequest, Permission } from './models/permissions.model.js';
|
|
8
|
+
|
|
9
|
+
function makeBody(
|
|
10
|
+
overrides: Partial<AuthorizationRequest> = {},
|
|
11
|
+
): AuthorizationRequest {
|
|
12
|
+
return {
|
|
13
|
+
token: 'ignored-body-token',
|
|
14
|
+
organization: 'my-org',
|
|
15
|
+
accountPath: 'sub-path',
|
|
16
|
+
checks: [
|
|
17
|
+
{
|
|
18
|
+
resource: 'HttpBins',
|
|
19
|
+
group: 'example.com',
|
|
20
|
+
namespace: 'default',
|
|
21
|
+
name: 'my-bin',
|
|
22
|
+
actions: ['get', 'delete'],
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const request = {} as Request;
|
|
30
|
+
|
|
31
|
+
describe('PermissionsController', () => {
|
|
32
|
+
let controller: PermissionsController;
|
|
33
|
+
let proxyService: jest.Mocked<PermissionsProxyService>;
|
|
34
|
+
let headerParser: jest.Mocked<HeaderParserService>;
|
|
35
|
+
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
proxyService = mock<PermissionsProxyService>();
|
|
38
|
+
headerParser = mock<HeaderParserService>();
|
|
39
|
+
headerParser.extractBearerToken.mockReturnValue('header-token');
|
|
40
|
+
|
|
41
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
42
|
+
controllers: [PermissionsController],
|
|
43
|
+
providers: [
|
|
44
|
+
{ provide: PermissionsProxyService, useValue: proxyService },
|
|
45
|
+
{ provide: HeaderParserService, useValue: headerParser },
|
|
46
|
+
],
|
|
47
|
+
}).compile();
|
|
48
|
+
|
|
49
|
+
controller = module.get(PermissionsController);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('checkResource', () => {
|
|
53
|
+
it('extracts the token from the request header and forwards it to the proxy', async () => {
|
|
54
|
+
proxyService.checkResourceInstance.mockResolvedValue([]);
|
|
55
|
+
|
|
56
|
+
const body = makeBody();
|
|
57
|
+
await controller.checkResource(request, body);
|
|
58
|
+
|
|
59
|
+
expect(headerParser.extractBearerToken).toHaveBeenCalledWith(request);
|
|
60
|
+
expect(proxyService.checkResourceInstance).toHaveBeenCalledWith({
|
|
61
|
+
...body,
|
|
62
|
+
token: 'header-token',
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('overrides any token present in the body with the header token', async () => {
|
|
67
|
+
proxyService.checkResourceInstance.mockResolvedValue([]);
|
|
68
|
+
|
|
69
|
+
await controller.checkResource(request, makeBody({ token: 'body-token' }));
|
|
70
|
+
|
|
71
|
+
const passedReq = proxyService.checkResourceInstance.mock.calls[0][0];
|
|
72
|
+
expect(passedReq.token).toBe('header-token');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('throws UnauthorizedException when no bearer token is present', async () => {
|
|
76
|
+
headerParser.extractBearerToken.mockReturnValue(undefined);
|
|
77
|
+
|
|
78
|
+
await expect(
|
|
79
|
+
controller.checkResource(request, makeBody()),
|
|
80
|
+
).rejects.toBeInstanceOf(UnauthorizedException);
|
|
81
|
+
|
|
82
|
+
expect(proxyService.checkResourceInstance).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('returns the proxy result when defined', async () => {
|
|
86
|
+
const proxyResult: Permission[] = [
|
|
87
|
+
{
|
|
88
|
+
resource: 'HttpBin',
|
|
89
|
+
namespace: 'default',
|
|
90
|
+
name: 'my-bin',
|
|
91
|
+
actions: ['get', 'delete'],
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
proxyService.checkResourceInstance.mockResolvedValue(proxyResult);
|
|
95
|
+
|
|
96
|
+
const result = await controller.checkResource(request, makeBody());
|
|
97
|
+
|
|
98
|
+
expect(result).toEqual(proxyResult);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns empty array when proxy returns undefined (fail-open)', async () => {
|
|
102
|
+
proxyService.checkResourceInstance.mockResolvedValue(undefined);
|
|
103
|
+
|
|
104
|
+
const result = await controller.checkResource(request, makeBody());
|
|
105
|
+
|
|
106
|
+
expect(result).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('returns empty array when proxy returns empty array', async () => {
|
|
110
|
+
proxyService.checkResourceInstance.mockResolvedValue([]);
|
|
111
|
+
|
|
112
|
+
const result = await controller.checkResource(request, makeBody());
|
|
113
|
+
|
|
114
|
+
expect(result).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('returns multiple Permission entries from proxy', async () => {
|
|
118
|
+
const permissions: Permission[] = [
|
|
119
|
+
{ resource: 'HttpBin', namespace: 'default', name: 'bin-1', actions: ['get'] },
|
|
120
|
+
{ resource: 'HttpBin', namespace: 'other', name: 'bin-2', actions: ['delete'] },
|
|
121
|
+
];
|
|
122
|
+
proxyService.checkResourceInstance.mockResolvedValue(permissions);
|
|
123
|
+
|
|
124
|
+
const result = await controller.checkResource(request, makeBody());
|
|
125
|
+
|
|
126
|
+
expect(result).toHaveLength(2);
|
|
127
|
+
expect(result).toEqual(permissions);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('passes checks array from body unchanged to proxy', async () => {
|
|
131
|
+
proxyService.checkResourceInstance.mockResolvedValue([]);
|
|
132
|
+
|
|
133
|
+
const body = makeBody({
|
|
134
|
+
checks: [{ resource: 'Foos', group: 'foo', actions: ['list'] }],
|
|
135
|
+
});
|
|
136
|
+
await controller.checkResource(request, body);
|
|
137
|
+
|
|
138
|
+
const passedReq = proxyService.checkResourceInstance.mock.calls[0][0];
|
|
139
|
+
expect(passedReq.checks[0].resource).toBe('Foos');
|
|
140
|
+
expect(passedReq.checks[0].actions).toEqual(['list']);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Body, Controller, Post, Req, UnauthorizedException } from '@nestjs/common';
|
|
2
|
+
import { HeaderParserService } from '@openmfp/portal-server-lib';
|
|
3
|
+
import { AuthorizationRequest, Permission } from './models/permissions.model.js';
|
|
4
|
+
import { PermissionsProxyService } from './permissions-proxy.service.js';
|
|
5
|
+
|
|
6
|
+
@Controller('rest/permissions')
|
|
7
|
+
export class PermissionsController {
|
|
8
|
+
constructor(
|
|
9
|
+
private readonly permissionsProxyService: PermissionsProxyService,
|
|
10
|
+
private readonly headerParser: HeaderParserService,
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
@Post('resource-check')
|
|
14
|
+
async checkResource(
|
|
15
|
+
@Req() request: Request,
|
|
16
|
+
@Body() body: AuthorizationRequest,
|
|
17
|
+
): Promise<Permission[]> {
|
|
18
|
+
const token = this.headerParser.extractBearerToken(request);
|
|
19
|
+
|
|
20
|
+
if (!token) {
|
|
21
|
+
throw new UnauthorizedException();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const result = await this.permissionsProxyService.checkResourceInstance({
|
|
25
|
+
...body,
|
|
26
|
+
token,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return result ?? [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -8,7 +8,7 @@ export const updateEntityTypeFromAccountPath = (
|
|
|
8
8
|
accountPath: string,
|
|
9
9
|
): ContentConfiguration => {
|
|
10
10
|
contentConfiguration.luigiConfigFragment.data.nodes.forEach((node) => {
|
|
11
|
-
if (!node.entityType
|
|
11
|
+
if (!node.entityType?.includes(ACCOUNT_ENTITY_TYPE)) {
|
|
12
12
|
return;
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -31,8 +31,9 @@ export const updateAccountNodeChildren = (
|
|
|
31
31
|
contentConfiguration: ContentConfiguration,
|
|
32
32
|
accountPath: string,
|
|
33
33
|
): ContentConfiguration => {
|
|
34
|
-
const
|
|
35
|
-
contentConfiguration.luigiConfigFragment.data.nodes[0]?.children
|
|
34
|
+
const children =
|
|
35
|
+
contentConfiguration.luigiConfigFragment.data.nodes[0]?.children;
|
|
36
|
+
const accountChildrenNode = Array.isArray(children) ? children[0] : undefined;
|
|
36
37
|
|
|
37
38
|
if (accountChildrenNode) {
|
|
38
39
|
const nextHierarchyLevel =
|
|
@@ -40,11 +41,13 @@ export const updateAccountNodeChildren = (
|
|
|
40
41
|
const previousPathSegment = accountChildrenNode.pathSegment;
|
|
41
42
|
const nextPathSegment = `:${nextHierarchyLevel}_${ACCOUNT_ENTITY_TYPE}Id`;
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
if (previousPathSegment) {
|
|
45
|
+
replaceStringDeep(
|
|
46
|
+
accountChildrenNode,
|
|
47
|
+
previousPathSegment,
|
|
48
|
+
nextPathSegment,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
return contentConfiguration;
|
|
@@ -62,4 +65,6 @@ export const processContentConfigurationForAccountHierarchy = (
|
|
|
62
65
|
|
|
63
66
|
return updateEntityTypeFromAccountPath(contentConfiguration, accountPath);
|
|
64
67
|
}
|
|
68
|
+
|
|
69
|
+
return contentConfiguration;
|
|
65
70
|
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { buildWorkspacePath } from './build-workspace-path.util.js';
|
|
2
|
+
|
|
3
|
+
describe('buildWorkspacePath', () => {
|
|
4
|
+
it('returns the root when no segments are provided', () => {
|
|
5
|
+
expect(buildWorkspacePath()).toBe('root:orgs');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it('returns the root for an empty segments array', () => {
|
|
9
|
+
expect(buildWorkspacePath([])).toBe('root:orgs');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('appends a single organization segment', () => {
|
|
13
|
+
expect(buildWorkspacePath(['orgX'])).toBe('root:orgs:orgX');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('appends organization and account segments in order', () => {
|
|
17
|
+
expect(buildWorkspacePath(['org', 'account'])).toBe(
|
|
18
|
+
'root:orgs:org:account',
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('skips a leading undefined organization and keeps the account', () => {
|
|
23
|
+
expect(buildWorkspacePath([undefined, 'accY'])).toBe('root:orgs:accY');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('skips a trailing undefined account and keeps the organization', () => {
|
|
27
|
+
expect(buildWorkspacePath(['orgX', undefined])).toBe('root:orgs:orgX');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('treats a composite path segment verbatim as one segment', () => {
|
|
31
|
+
expect(buildWorkspacePath(['sub:a'])).toBe('root:orgs:sub:a');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('appends a composite account path segment after the root', () => {
|
|
35
|
+
expect(buildWorkspacePath(['acc1:acc2'])).toBe('root:orgs:acc1:acc2');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('skips empty-string, null and undefined segments', () => {
|
|
39
|
+
expect(buildWorkspacePath(['', null, undefined])).toBe('root:orgs');
|
|
40
|
+
expect(buildWorkspacePath(['org', '', 'acc'])).toBe('root:orgs:org:acc');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('call-site parity', () => {
|
|
44
|
+
it('matches kcp-k8s buildWorkspacePath([organization, account])', () => {
|
|
45
|
+
expect(buildWorkspacePath(['org1', 'acc1'])).toBe('root:orgs:org1:acc1');
|
|
46
|
+
expect(buildWorkspacePath(['org1', undefined])).toBe('root:orgs:org1');
|
|
47
|
+
expect(buildWorkspacePath([undefined, undefined])).toBe('root:orgs');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('matches authz-webhook buildWorkspacePath([organization, accountPath])', () => {
|
|
51
|
+
// organization + composite accountPath → both joined
|
|
52
|
+
expect(buildWorkspacePath(['org', 'acc1:acc2'])).toBe(
|
|
53
|
+
'root:orgs:org:acc1:acc2',
|
|
54
|
+
);
|
|
55
|
+
// accountPath empty → only organization
|
|
56
|
+
expect(buildWorkspacePath(['my-org', ''])).toBe('root:orgs:my-org');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a KCP workspace/cluster path rooted at `root:orgs`.
|
|
3
|
+
*
|
|
4
|
+
* Truthy segments are appended in order, each prefixed with `:`. Empty,
|
|
5
|
+
* `undefined` or `null` segments are skipped. A segment may itself be a
|
|
6
|
+
* composite path (e.g. `sub:a`) — it is appended verbatim as a single segment,
|
|
7
|
+
* so `['org', 'account']` yields `root:orgs:org:account` while `['sub:a']`
|
|
8
|
+
* yields `root:orgs:sub:a`.
|
|
9
|
+
*
|
|
10
|
+
* This is the minimal common abstraction shared by the two call sites:
|
|
11
|
+
* - kcp-k8s: `buildWorkspacePath([organization, account])`
|
|
12
|
+
* - authz-webhook: `buildWorkspacePath([organization, accountPath])`
|
|
13
|
+
*/
|
|
14
|
+
export function buildWorkspacePath(
|
|
15
|
+
segments: (string | undefined | null)[] = [],
|
|
16
|
+
): string {
|
|
17
|
+
return segments.reduce<string>(
|
|
18
|
+
(path, segment) => (segment ? `${path}:${segment}` : path),
|
|
19
|
+
'root:orgs',
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { ContentConfiguration } from '@openmfp/portal-server-lib';
|
|
2
|
+
import { PermissionsDefinition } from '../services/permissions/models/permissions.model.js';
|
|
3
|
+
import { extractResourceDefinitions } from './extract-resource-definitions.util.js';
|
|
4
|
+
|
|
5
|
+
function makeCC(
|
|
6
|
+
nodes: ContentConfiguration['luigiConfigFragment']['data']['nodes'] = [],
|
|
7
|
+
): ContentConfiguration {
|
|
8
|
+
return {
|
|
9
|
+
name: 'test',
|
|
10
|
+
creationTimestamp: '',
|
|
11
|
+
luigiConfigFragment: { data: { nodes } },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makePd(
|
|
16
|
+
overrides: Partial<PermissionsDefinition> = {},
|
|
17
|
+
): PermissionsDefinition {
|
|
18
|
+
return {
|
|
19
|
+
group: 'core.platform-mesh.io',
|
|
20
|
+
resource: 'Accounts',
|
|
21
|
+
entityActions: ['get', 'update', 'delete'],
|
|
22
|
+
resourceActions: ['create', 'list'],
|
|
23
|
+
entityContextKey: 'entityName',
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('extractResourceDefinitions', () => {
|
|
29
|
+
it('returns empty array for empty content configurations', () => {
|
|
30
|
+
expect(extractResourceDefinitions([])).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns empty array when no nodes have resourceDefinition', () => {
|
|
34
|
+
const result = extractResourceDefinitions([makeCC([{ context: {} }])]);
|
|
35
|
+
expect(result).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('ignores resourceDefinition without entity', () => {
|
|
39
|
+
const result = extractResourceDefinitions([
|
|
40
|
+
makeCC([
|
|
41
|
+
{
|
|
42
|
+
context: {
|
|
43
|
+
resourceDefinition: {
|
|
44
|
+
permissionsDefinition: makePd(),
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
]),
|
|
49
|
+
]);
|
|
50
|
+
expect(result).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('ignores resourceDefinition with entity but without permissionsDefinition', () => {
|
|
54
|
+
const result = extractResourceDefinitions([
|
|
55
|
+
makeCC([
|
|
56
|
+
{
|
|
57
|
+
context: {
|
|
58
|
+
resourceDefinition: {
|
|
59
|
+
entity: 'Foo',
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
]),
|
|
64
|
+
]);
|
|
65
|
+
expect(result).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('collects resourceDefinition that has entity and permissionsDefinition', () => {
|
|
69
|
+
const pd = makePd();
|
|
70
|
+
const result = extractResourceDefinitions([
|
|
71
|
+
makeCC([
|
|
72
|
+
{
|
|
73
|
+
context: {
|
|
74
|
+
resourceDefinition: {
|
|
75
|
+
entity: 'Account',
|
|
76
|
+
permissionsDefinition: pd,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
]),
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
expect(result).toHaveLength(1);
|
|
84
|
+
expect(result[0]).toMatchObject({
|
|
85
|
+
entity: 'Account',
|
|
86
|
+
permissionsDefinition: pd,
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('merges duplicate entities across nodes, set-deduping resourceActions', () => {
|
|
91
|
+
const result = extractResourceDefinitions([
|
|
92
|
+
makeCC([
|
|
93
|
+
{
|
|
94
|
+
context: {
|
|
95
|
+
resourceDefinition: {
|
|
96
|
+
entity: 'Account',
|
|
97
|
+
permissionsDefinition: makePd({
|
|
98
|
+
resourceActions: ['create', 'list'],
|
|
99
|
+
}),
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
context: {
|
|
105
|
+
resourceDefinition: {
|
|
106
|
+
entity: 'Account',
|
|
107
|
+
permissionsDefinition: makePd({
|
|
108
|
+
resourceActions: ['list', 'watch'],
|
|
109
|
+
}),
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
]),
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
expect(result).toHaveLength(1);
|
|
117
|
+
expect(result[0].entity).toBe('Account');
|
|
118
|
+
// 'list' appears in both → deduplicated
|
|
119
|
+
expect(result[0].permissionsDefinition?.resourceActions).toEqual([
|
|
120
|
+
'create',
|
|
121
|
+
'list',
|
|
122
|
+
'watch',
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('merges duplicate entities across content configurations, set-deduping resourceActions', () => {
|
|
127
|
+
const result = extractResourceDefinitions([
|
|
128
|
+
makeCC([
|
|
129
|
+
{
|
|
130
|
+
context: {
|
|
131
|
+
resourceDefinition: {
|
|
132
|
+
entity: 'Pod',
|
|
133
|
+
permissionsDefinition: makePd({
|
|
134
|
+
resource: 'Pods',
|
|
135
|
+
resourceActions: ['list', 'create'],
|
|
136
|
+
}),
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
]),
|
|
141
|
+
makeCC([
|
|
142
|
+
{
|
|
143
|
+
context: {
|
|
144
|
+
resourceDefinition: {
|
|
145
|
+
entity: 'Pod',
|
|
146
|
+
permissionsDefinition: makePd({
|
|
147
|
+
resource: 'Pods',
|
|
148
|
+
resourceActions: ['create', 'watch'],
|
|
149
|
+
}),
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
]),
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
expect(result).toHaveLength(1);
|
|
157
|
+
expect(result[0].permissionsDefinition?.resourceActions).toEqual([
|
|
158
|
+
'list',
|
|
159
|
+
'create',
|
|
160
|
+
'watch',
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('keeps the latest permissionsDefinition fields when merging duplicates', () => {
|
|
165
|
+
const result = extractResourceDefinitions([
|
|
166
|
+
makeCC([
|
|
167
|
+
{
|
|
168
|
+
context: {
|
|
169
|
+
resourceDefinition: {
|
|
170
|
+
entity: 'Account',
|
|
171
|
+
permissionsDefinition: makePd({ group: 'old.group' }),
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
context: {
|
|
177
|
+
resourceDefinition: {
|
|
178
|
+
entity: 'Account',
|
|
179
|
+
permissionsDefinition: makePd({ group: 'new.group' }),
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
]),
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
expect(result[0].permissionsDefinition?.group).toBe('new.group');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('collects resource definitions from nested children nodes', () => {
|
|
190
|
+
const result = extractResourceDefinitions([
|
|
191
|
+
makeCC([
|
|
192
|
+
{
|
|
193
|
+
context: {},
|
|
194
|
+
children: [
|
|
195
|
+
{
|
|
196
|
+
context: {
|
|
197
|
+
resourceDefinition: {
|
|
198
|
+
entity: 'NestedEntity',
|
|
199
|
+
permissionsDefinition: makePd(),
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
]),
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
expect(result).toHaveLength(1);
|
|
209
|
+
expect(result[0].entity).toBe('NestedEntity');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('handles deeply nested children nodes', () => {
|
|
213
|
+
const result = extractResourceDefinitions([
|
|
214
|
+
makeCC([
|
|
215
|
+
{
|
|
216
|
+
context: {},
|
|
217
|
+
children: [
|
|
218
|
+
{
|
|
219
|
+
context: {},
|
|
220
|
+
children: [
|
|
221
|
+
{
|
|
222
|
+
context: {
|
|
223
|
+
resourceDefinition: {
|
|
224
|
+
entity: 'DeepEntity',
|
|
225
|
+
permissionsDefinition: makePd(),
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
]),
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
expect(result).toHaveLength(1);
|
|
237
|
+
expect(result[0].entity).toBe('DeepEntity');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('collects definitions from multiple content configurations', () => {
|
|
241
|
+
const result = extractResourceDefinitions([
|
|
242
|
+
makeCC([
|
|
243
|
+
{
|
|
244
|
+
context: {
|
|
245
|
+
resourceDefinition: {
|
|
246
|
+
entity: 'Alpha',
|
|
247
|
+
permissionsDefinition: makePd({ resource: 'Alphas' }),
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
]),
|
|
252
|
+
makeCC([
|
|
253
|
+
{
|
|
254
|
+
context: {
|
|
255
|
+
resourceDefinition: {
|
|
256
|
+
entity: 'Beta',
|
|
257
|
+
permissionsDefinition: makePd({ resource: 'Betas' }),
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
]),
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
expect(result).toHaveLength(2);
|
|
265
|
+
const entities = result.map((r) => r.entity);
|
|
266
|
+
expect(entities).toContain('Alpha');
|
|
267
|
+
expect(entities).toContain('Beta');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('handles content configurations with null or missing nodes gracefully', () => {
|
|
271
|
+
const noNodes = {
|
|
272
|
+
name: 'empty',
|
|
273
|
+
creationTimestamp: '',
|
|
274
|
+
luigiConfigFragment: { data: {} },
|
|
275
|
+
} as unknown as ContentConfiguration;
|
|
276
|
+
|
|
277
|
+
expect(() => extractResourceDefinitions([noNodes])).not.toThrow();
|
|
278
|
+
expect(extractResourceDefinitions([noNodes])).toEqual([]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('treats an undefined resourceActions as an empty list when merging', () => {
|
|
282
|
+
const result = extractResourceDefinitions([
|
|
283
|
+
makeCC([
|
|
284
|
+
{
|
|
285
|
+
context: {
|
|
286
|
+
resourceDefinition: {
|
|
287
|
+
entity: 'Account',
|
|
288
|
+
permissionsDefinition: {
|
|
289
|
+
group: 'g',
|
|
290
|
+
resource: 'Accounts',
|
|
291
|
+
entityActions: ['get'],
|
|
292
|
+
entityContextKey: 'entityName',
|
|
293
|
+
} as unknown as PermissionsDefinition,
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
]),
|
|
298
|
+
]);
|
|
299
|
+
|
|
300
|
+
expect(result).toHaveLength(1);
|
|
301
|
+
expect(result[0].permissionsDefinition?.resourceActions).toEqual([]);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { ContentConfiguration } from '@openmfp/portal-server-lib';
|
|
2
|
+
import { PermissionsDefinition } from '../services/permissions/models/permissions.model.js';
|
|
3
|
+
|
|
4
|
+
export interface ResourceDefinitionLocal {
|
|
5
|
+
entity: string;
|
|
6
|
+
permissionsDefinition?: PermissionsDefinition;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function mergeResourceActions(
|
|
10
|
+
existing: string[] | undefined,
|
|
11
|
+
incoming: string[] | undefined,
|
|
12
|
+
): string[] {
|
|
13
|
+
return [...new Set([...(existing ?? []), ...(incoming ?? [])])];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function collectFromNodes(
|
|
17
|
+
nodes: ContentConfiguration['luigiConfigFragment']['data']['nodes'],
|
|
18
|
+
acc: Map<string, ResourceDefinitionLocal>,
|
|
19
|
+
): void {
|
|
20
|
+
for (const node of nodes) {
|
|
21
|
+
const rd = node.context?.['resourceDefinition'] as
|
|
22
|
+
| ResourceDefinitionLocal
|
|
23
|
+
| undefined;
|
|
24
|
+
|
|
25
|
+
if (rd?.entity !== undefined && rd.permissionsDefinition !== undefined) {
|
|
26
|
+
const existing = acc.get(rd.entity);
|
|
27
|
+
|
|
28
|
+
// Keep the latest permissionsDefinition, but union its resourceActions
|
|
29
|
+
// with the previously collected one's (dedupe across duplicate entities).
|
|
30
|
+
acc.set(rd.entity, {
|
|
31
|
+
...rd,
|
|
32
|
+
permissionsDefinition: {
|
|
33
|
+
...rd.permissionsDefinition,
|
|
34
|
+
resourceActions: mergeResourceActions(
|
|
35
|
+
existing?.permissionsDefinition?.resourceActions,
|
|
36
|
+
rd.permissionsDefinition.resourceActions,
|
|
37
|
+
),
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const children = node.children;
|
|
43
|
+
if (Array.isArray(children) && children.length > 0) {
|
|
44
|
+
collectFromNodes(children, acc);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function extractResourceDefinitions(
|
|
50
|
+
contentConfigurations: ContentConfiguration[],
|
|
51
|
+
): ResourceDefinitionLocal[] {
|
|
52
|
+
const acc = new Map<string, ResourceDefinitionLocal>();
|
|
53
|
+
|
|
54
|
+
for (const cc of contentConfigurations) {
|
|
55
|
+
const nodes = cc.luigiConfigFragment?.data?.nodes;
|
|
56
|
+
if (Array.isArray(nodes)) {
|
|
57
|
+
collectFromNodes(nodes, acc);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return Array.from(acc.values());
|
|
62
|
+
}
|