Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions src/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
});
});
});
});
Loading