Skip to content

Commit 6767dad

Browse files
akurinnoyclaude
andauthored
fix: wait for SCC permission before patching DevWorkspace on first login (#1602)
* feat: add SccPermissionApi service for SelfSubjectAccessReview Adds a new backend service that checks whether the current user has 'use' permission on a specific SCC via SelfSubjectAccessReview. This is the backend foundation for fixing the CRW-10504 race condition. Assisted-by: Claude Opus 4.6 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Oleksii Kurinnyi <okurinny@redhat.com> * feat: add GET /api/namespace/:namespace/scc-permission/:scc route Exposes the SCC permission check as a REST endpoint. Uses the user's Bearer token to create a SelfSubjectAccessReview, checking if the user can 'use' the specified SCC in the given namespace. Assisted-by: Claude Opus 4.6 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Oleksii Kurinnyi <okurinny@redhat.com> * feat: add frontend API client for SCC permission check Assisted-by: Claude Opus 4.6 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Oleksii Kurinnyi <okurinny@redhat.com> * feat: add CreatingStepCheckSccPermission progress step Polls the backend SCC permission endpoint before the SCC attribute patch. Skips immediately when no SCC is configured (vanilla K8s). Fails open on API errors. Times out after 60 seconds with a user-facing message. Assisted-by: Claude Opus 4.6 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Oleksii Kurinnyi <okurinny@redhat.com> * feat(scc): wire SCC permission check into workspace creation flow Wire CreatingStepCheckSccPermission into WorkspaceProgress creation wizard: - Add Step.SCC_CHECK enum value between CONFLICT_CHECK and APPLY - Add step announcement label: "Waiting for namespace provisioning to complete" - Add getCheckSccPermissionStep() method following existing step getter pattern - Insert SCC check between conflict check and apply steps in creation flow The SCC permission check now runs after the existing workspace conflict check and before applying the devfile, ensuring the namespace is ready before workspace creation proceeds. Assisted-by: Claude Opus 4.6 Signed-off-by: Oleksii Kurinnyi <okurinny@redhat.com> * fix: update WorkspaceProgress tests for new SCC check step Add mock for CheckSccPermission step, update step count from 8 to 9, add SCC_CHECK to doneSteps in restart test, update snapshots, and fix import sort order in app.ts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Oleksii Kurinnyi <okurinny@redhat.com> * test: add route test for SCC permission endpoint Assisted-by: Claude Opus 4.6 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Oleksii Kurinnyi <okurinny@redhat.com> * refactor: use shared namespacedSccSchema for SCC permission route Assisted-by: Claude Opus 4.6 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Oleksii Kurinnyi <okurinny@redhat.com> * refactor: reuse AxiosWrapper instance in SCC permission API client Avoids creating a new AxiosWrapper per poll iteration during the SCC permission check loop. Assisted-by: Claude Opus 4.6 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Oleksii Kurinnyi <okurinny@redhat.com> * test: use jest.spyOn for console.warn mock in SCC permission test Replace global console.warn assignment with scoped jest.spyOn + jest.restoreAllMocks to avoid leaking into other test suites. Assisted-by: Claude Opus 4.6 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Oleksii Kurinnyi <okurinny@redhat.com> * test: add timeout test for SCC permission check Covers the 60s timeout path where permission is never granted, verifying the step reports an error. Assisted-by: Claude Opus 4.6 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Oleksii Kurinnyi <okurinny@redhat.com> * fix: show SCC name in permission check step text Display the specific SCC being waited on (e.g. "Waiting for permission to use the 'container-build' SCC") instead of the generic "Waiting for namespace provisioning to complete". Assisted-by: Claude Opus 4.6 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Oleksii Kurinnyi <okurinny@redhat.com> * test: add denied and API error tests for SCC permission route Cover the denied (permitted=false) and API failure (500) paths in addition to the existing happy path test. Assisted-by: Claude Opus 4.6 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Oleksii Kurinnyi <okurinny@redhat.com> * fix: add SCC permission mock to workspace creation time check test The new SCC check step polls GET /scc-permission/:scc during workspace creation. Without a mock response the step polls indefinitely, blocking the "Starting workspace" step from becoming active. Assisted-by: Claude Opus 4.6 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Oleksii Kurinnyi <okurinny@redhat.com> --------- Signed-off-by: Oleksii Kurinnyi <okurinny@redhat.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 161e780 commit 6767dad

18 files changed

Lines changed: 829 additions & 15 deletions

File tree

packages/dashboard-backend/src/app.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { registerKubeConfigRoute } from '@/routes/api/kubeConfig';
4545
import { registerPersonalAccessTokenRoutes } from '@/routes/api/personalAccessToken';
4646
import { registerPodmanLoginRoute } from '@/routes/api/podmanLogin';
4747
import { registerPodsRoutes } from '@/routes/api/pods';
48+
import { registerSccPermissionRoute } from '@/routes/api/sccPermission';
4849
import { registerServerConfigRoute } from '@/routes/api/serverConfig';
4950
import { registerSShKeysRoutes } from '@/routes/api/sshKeys';
5051
import { registerWebsocket } from '@/routes/api/websocket';
@@ -143,6 +144,8 @@ export default async function buildApp(server: FastifyInstance): Promise<unknown
143144

144145
registerBackupRoutes(server),
145146

147+
registerSccPermissionRoute(server),
148+
146149
registerAiConfigRoutes(server),
147150

148151
registerAiRegistryRoute(isLocalRun(), server),

packages/dashboard-backend/src/constants/schemas.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,3 +381,16 @@ export const namespacedWorkspacePreferencesTrustedSourceSchema: JSONSchema7 = {
381381
},
382382
required: ['source'],
383383
};
384+
385+
export const namespacedSccSchema: JSONSchema7 = {
386+
type: 'object',
387+
properties: {
388+
namespace: {
389+
type: 'string',
390+
},
391+
scc: {
392+
type: 'string',
393+
},
394+
},
395+
required: ['namespace', 'scc'],
396+
};

packages/dashboard-backend/src/devworkspaceClient/__mocks__/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
ILogsApi,
2828
IPersonalAccessTokenApi,
2929
IPodApi,
30+
ISccPermissionApi,
3031
IServerConfigApi,
3132
IShhKeysApi,
3233
IWorkspacePreferencesApi,
@@ -91,6 +92,10 @@ export class DevWorkspaceClient implements IDevWorkspaceClient {
9192
get aiRegistryApi(): IAiRegistryApi {
9293
throw new Error('Method not implemented.');
9394
}
95+
96+
get sccPermissionApi(): ISccPermissionApi {
97+
throw new Error('Method not implemented.');
98+
}
9499
}
95100

96101
export class DevWorkspaceSingletonClient implements IDevWorkspaceSingletonClient {

packages/dashboard-backend/src/devworkspaceClient/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { LogsApiService } from '@/devworkspaceClient/services/logsApi';
2828
import { PersonalAccessTokenService } from '@/devworkspaceClient/services/personalAccessTokenApi';
2929
import { PodApiService } from '@/devworkspaceClient/services/podApi';
3030
import { PodmanApiService } from '@/devworkspaceClient/services/podmanApi';
31+
import { SccPermissionApiService } from '@/devworkspaceClient/services/sccPermissionApi';
3132
import { ServerConfigApiService } from '@/devworkspaceClient/services/serverConfigApi';
3233
import { SshKeysService } from '@/devworkspaceClient/services/sshKeysApi';
3334
import { WorkspacePreferencesApiService } from '@/devworkspaceClient/services/workspacePreferencesApi';
@@ -49,6 +50,7 @@ import {
4950
IPersonalAccessTokenApi,
5051
IPodApi,
5152
IPodmanApi,
53+
ISccPermissionApi,
5254
IServerConfigApi,
5355
IShhKeysApi,
5456
IWorkspacePreferencesApi,
@@ -138,4 +140,8 @@ export class DevWorkspaceClient implements IDevWorkspaceClient {
138140
get aiRegistryApi(): IAiRegistryApi {
139141
return new AiRegistryApiService(this.kubeConfig);
140142
}
143+
144+
get sccPermissionApi(): ISccPermissionApi {
145+
return new SccPermissionApiService(this.kubeConfig);
146+
}
141147
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright (c) 2018-2025 Red Hat, Inc.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Red Hat, Inc. - initial API and implementation
11+
*/
12+
13+
// Generated by Claude Opus 4.6
14+
15+
import * as k8s from '@kubernetes/client-node';
16+
17+
import { SccPermissionApiService } from '@/devworkspaceClient/services/sccPermissionApi';
18+
19+
const mockCreateSelfSubjectAccessReview = jest.fn();
20+
21+
describe('SccPermissionApiService', () => {
22+
let service: SccPermissionApiService;
23+
24+
beforeEach(() => {
25+
jest.clearAllMocks();
26+
27+
const kc = new k8s.KubeConfig();
28+
kc.makeApiClient = jest.fn().mockReturnValue({
29+
createSelfSubjectAccessReview: mockCreateSelfSubjectAccessReview,
30+
});
31+
32+
service = new SccPermissionApiService(kc);
33+
});
34+
35+
it('should return true when SCC permission is allowed', async () => {
36+
mockCreateSelfSubjectAccessReview.mockResolvedValue({
37+
status: { allowed: true },
38+
});
39+
40+
const result = await service.checkSccPermission('test-namespace', 'container-build');
41+
42+
expect(result).toBe(true);
43+
});
44+
45+
it('should return false when SCC permission is denied', async () => {
46+
mockCreateSelfSubjectAccessReview.mockResolvedValue({
47+
status: { allowed: false },
48+
});
49+
50+
const result = await service.checkSccPermission('test-namespace', 'container-build');
51+
52+
expect(result).toBe(false);
53+
});
54+
55+
it('should return false when status is undefined', async () => {
56+
mockCreateSelfSubjectAccessReview.mockResolvedValue({});
57+
58+
const result = await service.checkSccPermission('test-namespace', 'container-build');
59+
60+
expect(result).toBe(false);
61+
});
62+
63+
it('should pass correct resourceAttributes', async () => {
64+
mockCreateSelfSubjectAccessReview.mockResolvedValue({
65+
status: { allowed: true },
66+
});
67+
68+
await service.checkSccPermission('user-ns', 'container-build');
69+
70+
expect(mockCreateSelfSubjectAccessReview).toHaveBeenCalledWith({
71+
body: expect.objectContaining({
72+
spec: {
73+
resourceAttributes: {
74+
namespace: 'user-ns',
75+
verb: 'use',
76+
group: 'security.openshift.io',
77+
resource: 'securitycontextconstraints',
78+
name: 'container-build',
79+
},
80+
},
81+
}),
82+
});
83+
});
84+
85+
it('should throw on API failure', async () => {
86+
mockCreateSelfSubjectAccessReview.mockRejectedValue(new Error('API unreachable'));
87+
88+
await expect(service.checkSccPermission('test-ns', 'container-build')).rejects.toThrow(
89+
'Unable to check SCC permission',
90+
);
91+
});
92+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright (c) 2018-2025 Red Hat, Inc.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Red Hat, Inc. - initial API and implementation
11+
*/
12+
13+
// Generated by Claude Opus 4.6
14+
15+
import * as k8s from '@kubernetes/client-node';
16+
17+
import { createError } from '@/devworkspaceClient/services/helpers/createError';
18+
import { ISccPermissionApi } from '@/devworkspaceClient/types';
19+
20+
const ERROR_LABEL = 'SCC_PERMISSION_API_ERROR';
21+
22+
export class SccPermissionApiService implements ISccPermissionApi {
23+
private readonly authorizationApi: k8s.AuthorizationV1Api;
24+
25+
constructor(kc: k8s.KubeConfig) {
26+
this.authorizationApi = kc.makeApiClient(k8s.AuthorizationV1Api);
27+
}
28+
29+
async checkSccPermission(namespace: string, sccName: string): Promise<boolean> {
30+
try {
31+
const review: k8s.V1SelfSubjectAccessReview = {
32+
apiVersion: 'authorization.k8s.io/v1',
33+
kind: 'SelfSubjectAccessReview',
34+
spec: {
35+
resourceAttributes: {
36+
namespace,
37+
verb: 'use',
38+
group: 'security.openshift.io',
39+
resource: 'securitycontextconstraints',
40+
name: sccName,
41+
},
42+
},
43+
};
44+
45+
const response = await this.authorizationApi.createSelfSubjectAccessReview({
46+
body: review,
47+
});
48+
return response.status?.allowed === true;
49+
} catch (error) {
50+
throw createError(error, ERROR_LABEL, 'Unable to check SCC permission');
51+
}
52+
}
53+
}

packages/dashboard-backend/src/devworkspaceClient/types/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,7 @@ export interface IDevWorkspaceClient {
523523
editorsApi: IEditorsApi;
524524
aiProviderKeyApi: IAiProviderKeyApi;
525525
aiRegistryApi: IAiRegistryApi;
526+
sccPermissionApi: ISccPermissionApi;
526527
}
527528

528529
export interface IDevWorkspaceSingletonClient {
@@ -619,6 +620,14 @@ export interface IAiProviderKeyApi {
619620
delete(namespace: string, providerId: string): Promise<void>;
620621
}
621622

623+
export interface ISccPermissionApi {
624+
/**
625+
* Checks whether the current user has 'use' permission on a specific SCC
626+
* via SelfSubjectAccessReview.
627+
*/
628+
checkSccPermission(namespace: string, sccName: string): Promise<boolean>;
629+
}
630+
622631
export interface IBackupApi {
623632
/**
624633
* Get cluster-wide backup configuration
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Copyright (c) 2018-2025 Red Hat, Inc.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Red Hat, Inc. - initial API and implementation
11+
*/
12+
13+
import { FastifyInstance } from 'fastify';
14+
15+
import { baseApiPath } from '@/constants/config';
16+
import { DevWorkspaceClient } from '@/devworkspaceClient';
17+
import { getDevWorkspaceClient } from '@/routes/api/helpers/getDevWorkspaceClient';
18+
import { setup, teardown } from '@/utils/appBuilder';
19+
20+
jest.mock('../helpers/getToken.ts');
21+
jest.mock('../helpers/getDevWorkspaceClient.ts');
22+
jest.mock('../helpers/getServiceAccountToken.ts');
23+
24+
describe('SCC Permission Route', () => {
25+
let app: FastifyInstance;
26+
const namespace = 'user-che';
27+
const scc = 'container-build';
28+
29+
beforeAll(async () => {
30+
app = await setup();
31+
});
32+
33+
afterAll(() => {
34+
teardown(app);
35+
jest.clearAllMocks();
36+
});
37+
38+
test(`GET ${baseApiPath}/namespace/:namespace/scc-permission/:scc - permitted`, async () => {
39+
const res = await app
40+
.inject()
41+
.get(`${baseApiPath}/namespace/${namespace}/scc-permission/${scc}`);
42+
43+
expect(res.statusCode).toEqual(200);
44+
expect(res.json()).toEqual({ permitted: true });
45+
});
46+
47+
test(`GET ${baseApiPath}/namespace/:namespace/scc-permission/:scc - denied`, async () => {
48+
(getDevWorkspaceClient as jest.Mock).mockImplementationOnce(() => {
49+
return {
50+
sccPermissionApi: {
51+
checkSccPermission: () => Promise.resolve(false),
52+
},
53+
} as unknown as DevWorkspaceClient;
54+
});
55+
56+
const res = await app
57+
.inject()
58+
.get(`${baseApiPath}/namespace/${namespace}/scc-permission/${scc}`);
59+
60+
expect(res.statusCode).toEqual(200);
61+
expect(res.json()).toEqual({ permitted: false });
62+
});
63+
64+
test(`GET ${baseApiPath}/namespace/:namespace/scc-permission/:scc - API error`, async () => {
65+
(getDevWorkspaceClient as jest.Mock).mockImplementationOnce(() => {
66+
return {
67+
sccPermissionApi: {
68+
checkSccPermission: () => Promise.reject(new Error('K8s API unreachable')),
69+
},
70+
} as unknown as DevWorkspaceClient;
71+
});
72+
73+
const res = await app
74+
.inject()
75+
.get(`${baseApiPath}/namespace/${namespace}/scc-permission/${scc}`);
76+
77+
expect(res.statusCode).toEqual(500);
78+
});
79+
});

packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDevWorkspaceClient.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
IPersonalAccessTokenApi,
4040
IPodApi,
4141
IPodmanApi,
42+
ISccPermissionApi,
4243
IServerConfigApi,
4344
IShhKeysApi,
4445
IWorkspacePreferencesApi,
@@ -314,6 +315,9 @@ export const getDevWorkspaceClient = jest.fn(
314315
aiRegistryApi: {
315316
get: () => Promise.resolve(stubAiRegistry),
316317
} as IAiRegistryApi,
318+
sccPermissionApi: {
319+
checkSccPermission: (_namespace, _sccName) => Promise.resolve(true),
320+
} as ISccPermissionApi,
317321
} as DevWorkspaceClient;
318322
},
319323
);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright (c) 2018-2025 Red Hat, Inc.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Red Hat, Inc. - initial API and implementation
11+
*/
12+
13+
// Generated by Claude Opus 4.6
14+
15+
import { FastifyInstance, FastifyRequest } from 'fastify';
16+
17+
import { baseApiPath } from '@/constants/config';
18+
import { namespacedSccSchema } from '@/constants/schemas';
19+
import { getDevWorkspaceClient } from '@/routes/api/helpers/getDevWorkspaceClient';
20+
import { getToken } from '@/routes/api/helpers/getToken';
21+
import { getSchema } from '@/services/helpers';
22+
23+
const tags = ['SCC Permission'];
24+
25+
export function registerSccPermissionRoute(instance: FastifyInstance) {
26+
instance.register(async server => {
27+
server.get(
28+
`${baseApiPath}/namespace/:namespace/scc-permission/:scc`,
29+
getSchema({ tags, params: namespacedSccSchema }),
30+
async function (request: FastifyRequest) {
31+
const { namespace, scc } = request.params as { namespace: string; scc: string };
32+
const token = getToken(request);
33+
34+
const { sccPermissionApi } = getDevWorkspaceClient(token);
35+
const permitted = await sccPermissionApi.checkSccPermission(namespace, scc);
36+
37+
return { permitted };
38+
},
39+
);
40+
});
41+
}

0 commit comments

Comments
 (0)