@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { K8sRequestContext, K8sResourceDescriptor } from '../models/k8s.js';
|
|
2
|
+
import { buildWorkspacePath } from '../utils/build-workspace-path.util.js';
|
|
2
3
|
import { getOrganization } from '../utils/domain.js';
|
|
3
4
|
import {
|
|
4
5
|
CoreV1Api,
|
|
@@ -65,20 +66,8 @@ export class KcpKubernetesService {
|
|
|
65
66
|
return this.k8sCoreV1Api;
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
private buildWorkspacePath(organization?: string, account?: string) {
|
|
69
|
-
let path = `root:orgs`;
|
|
70
|
-
if (organization) {
|
|
71
|
-
path += `:${organization}`;
|
|
72
|
-
}
|
|
73
|
-
if (account) {
|
|
74
|
-
path += `:${account}`; // FIXME: how are nested accounts and paths handled in the portal?
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return path;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
69
|
getKcpVirtualWorkspaceUrl(organization: string, account: string) {
|
|
81
|
-
const path =
|
|
70
|
+
const path = buildWorkspacePath([organization, account]);
|
|
82
71
|
return new URL(
|
|
83
72
|
`${this.baseUrl.origin}/services/contentconfigurations/clusters/${path}`,
|
|
84
73
|
);
|
|
@@ -90,14 +79,14 @@ export class KcpKubernetesService {
|
|
|
90
79
|
workspacePath?: string,
|
|
91
80
|
) {
|
|
92
81
|
const path =
|
|
93
|
-
workspacePath ||
|
|
82
|
+
workspacePath || buildWorkspacePath([organization, account]);
|
|
94
83
|
return new URL(`${this.baseUrl.origin}/clusters/${path}`);
|
|
95
84
|
}
|
|
96
85
|
|
|
97
86
|
getKcpWorkspacePublicUrl(request: Request) {
|
|
98
87
|
const organization = getOrganization(request);
|
|
99
88
|
const account = request.query?.['core_platform-mesh_io_account'];
|
|
100
|
-
const path =
|
|
89
|
+
const path = buildWorkspacePath([organization, account]);
|
|
101
90
|
|
|
102
91
|
const kcpUrl = process.env.KCP_URL || '';
|
|
103
92
|
if (kcpUrl) {
|
|
@@ -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
|
+
});
|