From 02fc7cd3e05ae322968a6b445b9547a8dd0a0d18 Mon Sep 17 00:00:00 2001 From: man4ish Date: Sun, 4 Oct 2026 11:28:05 -0500 Subject: [PATCH] feat: accept workspace.launch as an alternative to platform.manage_infra Launching/stopping/creating a personal IDE workspace is a self-service action, not platform-infrastructure administration -- but every mutating route here required platform.manage_infra, an admin-only permission with no self-service equivalent. workspace.launch (omnibioai-auth's permission registry, grantable to a regular role like scientist) now satisfies the same gate; platform.manage_infra keeps working unchanged for admin accounts. requireIdentity accepts either a single permission name (every pre-existing call site) or an array where holding any one is sufficient. Co-Authored-By: Claude Sonnet 5 --- server.js | 25 ++++++++++++++++++++----- src/server.test.js | 16 ++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/server.js b/server.js index 6d10038..ee7cebd 100644 --- a/server.js +++ b/server.js @@ -30,6 +30,15 @@ app.use((req, res, next) => { // cookie, so a foreign page cannot borrow a visitor's authority. const IAM_URL = process.env.IAM_URL || 'http://auth-service:8001'; const CONTROL_PERMISSION = 'platform.manage_infra'; +// Launching/stopping/creating your own personal IDE workspace is a +// self-service action, not platform-infrastructure administration -- +// workspace.launch (omnibioai-auth's permission registry) is grantable +// to a regular role like scientist, unlike platform.manage_infra which +// only admin/platform_admin ever hold. requireIdentity below accepts +// either, so an admin account's existing manage_infra grant keeps +// working unchanged. +const WORKSPACE_LAUNCH_PERMISSION = 'workspace.launch'; +const CONTROL_PERMISSIONS = [CONTROL_PERMISSION, WORKSPACE_LAUNCH_PERMISSION]; const manifests = new Map(); function manifestKey(identity, workspaceId) { @@ -77,10 +86,16 @@ async function verifyIdentity(authorization) { } function requireIdentity(permission) { + // `permission` may be a single name (every pre-existing call site) or + // an array of names where holding ANY one is sufficient (CONTROL_PERMISSIONS + // above) -- an admin's platform.manage_infra and a scientist's + // workspace.launch both satisfy the same gate, neither implies the other. + const required = Array.isArray(permission) ? permission : permission ? [permission] : []; return async (req, res, next) => { const result = await verifyIdentity(req.headers.authorization); if (result.denied) return res.status(result.denied).json({ error: result.error }); - if (permission && !(result.identity.permissions || []).includes(permission)) { + const held = result.identity.permissions || []; + if (required.length > 0 && !required.some((p) => held.includes(p))) { return res.status(403).json({ error: 'insufficient permissions' }); } req.identity = result.identity; @@ -213,7 +228,7 @@ app.get('/api/launcher/status/:tool', requireIdentity(), async (req, res) => { } }); -app.post('/api/launcher/start/:tool', requireIdentity(CONTROL_PERMISSION), async (req, res) => { +app.post('/api/launcher/start/:tool', requireIdentity(CONTROL_PERMISSIONS), async (req, res) => { const tool = TOOLS[req.params.tool]; if (!tool) return res.status(400).json({ error: 'unknown tool' }); try { @@ -224,7 +239,7 @@ app.post('/api/launcher/start/:tool', requireIdentity(CONTROL_PERMISSION), async } }); -app.post('/api/launcher/stop/:tool', requireIdentity(CONTROL_PERMISSION), async (req, res) => { +app.post('/api/launcher/stop/:tool', requireIdentity(CONTROL_PERMISSIONS), async (req, res) => { const tool = TOOLS[req.params.tool]; if (!tool) return res.status(400).json({ error: 'unknown tool' }); try { @@ -251,7 +266,7 @@ app.post('/api/launcher/v1/workspaces/validate', requireIdentity(), (req, res) = } catch (error) { apiError(res, error); } }); -app.post('/api/launcher/v1/workspaces', requireIdentity(CONTROL_PERMISSION), async (req, res) => { +app.post('/api/launcher/v1/workspaces', requireIdentity(CONTROL_PERMISSIONS), async (req, res) => { const identity = requireAuthoritativeIdentity(req, res); if (!identity) return; try { @@ -305,7 +320,7 @@ app.get('/api/launcher/v1/workspaces/:workspaceId/manifest', requireIdentity(), res.json(manifest); }); -app.post('/api/launcher/v1/workspaces/from-run', requireIdentity(CONTROL_PERMISSION), async (req, res) => { +app.post('/api/launcher/v1/workspaces/from-run', requireIdentity(CONTROL_PERMISSIONS), async (req, res) => { const identity = requireAuthoritativeIdentity(req, res); if (!identity) return; try { diff --git a/src/server.test.js b/src/server.test.js index 09e7077..c9bdf59 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -264,5 +264,21 @@ describe('launcher Express API', () => { expect(serverModule.authoritativeIdentity({})).toBeNull(); expect(serverModule.authoritativeIdentity(null)).toBeNull(); }); + + test('workspace.launch alone (without platform.manage_infra) is sufficient to start, stop, and create a workspace -- a self-service permission, not platform-infra admin', async () => { + iamReply({ valid: true, user_id: 6, org_id: 1, permissions: ['workspace.launch'] }); + dockerReply(200, { Id: 'container-1' }); + expect((await requestApp('POST', '/api/launcher/start/jupyter', GOOD)).status).not.toBe(403); + dockerReply(200, {}); + expect((await requestApp('POST', '/api/launcher/stop/jupyter', GOOD)).status).not.toBe(403); + expect((await requestApp('POST', '/api/launcher/v1/workspaces', GOOD)).status).not.toBe(403); + }); + + test('neither platform.manage_infra nor workspace.launch is still insufficient', async () => { + iamReply({ valid: true, user_id: 6, org_id: 1, permissions: ['dataset.read'] }); + expect(await requestApp('POST', '/api/launcher/start/jupyter', GOOD)).toMatchObject({ + status: 403, body: { error: 'insufficient permissions' }, + }); + }); }); });