From 9922d8e765a7fbd917cec1473153fa462a46d0cd Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 29 Jul 2026 09:42:14 -0400 Subject: [PATCH] feat(rbac): make stack-scoped grants node-specific (#1727) * feat(rbac): make stack-scoped grants node-specific Qualify stack role assignments as (nodeId, stackName), migrate legacy rows to the default node, and forward bound multi-action evidence on Proxy/Pilot hops so scoped users keep least-privilege remote access without shipping the full grant table. * fix: mirror scoped-stack-auth-evidence capability to frontend, sanitize node id in role assignment log Backend added the scoped-stack-auth-evidence capability without the matching frontend entry, failing the capability parity test. The role assignment log also interpolated the node id without sanitizeForLog, unlike the rest of the line. * fix(rbac): honor node-wide scopes and fix proxied DELETE cleanup Node-scoped grants now authorize that role's stack actions on the same node in the backend resolver, frontend can(), and remote evidence. Proxied DELETE cleanup uses the gate-stashed route because pathRewrite mutates req.path before proxyRes. Add proxy integration coverage and drop the stale scoped-permissions screenshot. * fix(rbac): preserve node-qualified grants during repair --- .../blueprints-remote-deploy.test.ts | 10 +- backend/src/__tests__/blueprints.test.ts | 47 +- .../__tests__/permissions-stack-rbac.test.ts | 236 ++++++++++ .../proxy-scoped-stack-evidence.test.ts | 327 +++++++++++++ .../role-assignments-node-qualified.test.ts | 430 ++++++++++++++++++ .../scoped-stack-auth-evidence.test.ts | 127 ++++++ ...stack-delete-cascades-mesh-opt-out.test.ts | 9 +- backend/src/__tests__/stackRouteAuth.test.ts | 155 +++++++ backend/src/__tests__/users-rbac.test.ts | 174 +++++-- .../src/helpers/assertStackExistsOnNode.ts | 81 ++++ backend/src/helpers/stackRouteAuth.ts | 224 +++++++++ backend/src/middleware/auth.ts | 25 +- backend/src/middleware/permissions.ts | 81 +++- backend/src/proxy/remoteNodeProxy.ts | 96 +++- backend/src/routes/permissions.ts | 4 +- backend/src/routes/users.ts | 74 ++- backend/src/services/BlueprintService.ts | 1 + backend/src/services/CapabilityRegistry.ts | 10 + backend/src/services/DatabaseService.ts | 158 ++++++- .../services/DeployedStackDeletionService.ts | 2 +- backend/src/services/license-headers.ts | 10 + backend/src/types/express.ts | 17 + docs/features/rbac.mdx | 21 +- docs/images/rbac/scoped-permissions.png | Bin 31916 -> 0 bytes frontend/src/components/EditorLayout.tsx | 3 +- .../components/EditorLayout/EditorView.tsx | 10 +- .../EditorLayout/MobileStackDetail.tsx | 4 +- .../components/EditorLayout/ShellOverlays.tsx | 6 +- .../EditorLayout/editor-view-blocks.tsx | 4 +- .../hooks/useSidebarContextMenu.ts | 8 +- .../components/networking/NetworkingView.tsx | 2 +- .../src/components/settings/UsersSection.tsx | 120 ++++- .../src/components/stack/EnvironmentPanel.tsx | 2 +- frontend/src/context/AuthContext.tsx | 23 +- frontend/src/lib/__tests__/resolveCan.test.ts | 79 ++++ frontend/src/lib/capabilities.ts | 2 + frontend/src/lib/resolveCan.ts | 48 ++ 37 files changed, 2487 insertions(+), 143 deletions(-) create mode 100644 backend/src/__tests__/permissions-stack-rbac.test.ts create mode 100644 backend/src/__tests__/proxy-scoped-stack-evidence.test.ts create mode 100644 backend/src/__tests__/role-assignments-node-qualified.test.ts create mode 100644 backend/src/__tests__/scoped-stack-auth-evidence.test.ts create mode 100644 backend/src/__tests__/stackRouteAuth.test.ts create mode 100644 backend/src/helpers/assertStackExistsOnNode.ts create mode 100644 backend/src/helpers/stackRouteAuth.ts delete mode 100644 docs/images/rbac/scoped-permissions.png create mode 100644 frontend/src/lib/__tests__/resolveCan.test.ts create mode 100644 frontend/src/lib/resolveCan.ts diff --git a/backend/src/__tests__/blueprints-remote-deploy.test.ts b/backend/src/__tests__/blueprints-remote-deploy.test.ts index 9bf91e72..534a536e 100644 --- a/backend/src/__tests__/blueprints-remote-deploy.test.ts +++ b/backend/src/__tests__/blueprints-remote-deploy.test.ts @@ -263,7 +263,7 @@ describe('BlueprintService remote deploy', () => { expect(DatabaseService.getInstance().getDeployment(bp.id, node.id)?.status).toBe('failed'); }); - it('does not clear hub role assignments when withdrawing a remote deployment', async () => { + it('clears hub role assignments for the withdrawn remote stack tuple', async () => { const bcrypt = await import('bcrypt'); const db = DatabaseService.getInstance(); const node = seedRemoteNode(); @@ -282,20 +282,20 @@ describe('BlueprintService remote deploy', () => { username: `remote-wd-rbac-${counter}`, password_hash: hash, role: 'viewer', }); db.addRoleAssignment({ - user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bpObj.name, + user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bpObj.name, node_id: node.id, }); vi.spyOn(axios, 'post').mockResolvedValue({ status: 200, data: { status: 'withdrawn' } }); const delSpy = vi.spyOn(axios, 'delete'); - const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByResource'); + const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByStack'); const result = await BlueprintService.getInstance().withdrawFromNode(bpObj, nodeObj); expect(result.status).toBe('withdrawn'); expect(delSpy).not.toHaveBeenCalled(); - expect(rbacSpy).not.toHaveBeenCalled(); + expect(rbacSpy).toHaveBeenCalledWith(node.id, bpObj.name); expect(db.getAllRoleAssignments(userId) - .some((a) => a.resource_type === 'stack' && a.resource_id === bpObj.name)).toBe(true); + .some((a) => a.resource_type === 'stack' && a.resource_id === bpObj.name && a.node_id === node.id)).toBe(false); db.deleteUser(userId); }); diff --git a/backend/src/__tests__/blueprints.test.ts b/backend/src/__tests__/blueprints.test.ts index 261a0e22..dffbafc8 100644 --- a/backend/src/__tests__/blueprints.test.ts +++ b/backend/src/__tests__/blueprints.test.ts @@ -493,10 +493,10 @@ describe('BlueprintService local withdraw clears stack-scoped role assignments', const userId = db.addUser({ username: `bp-rbac-${counter}`, password_hash: hash, role: 'viewer' }); const otherNodeId = seedNode(); db.addRoleAssignment({ - user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bp.name, + user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bp.name, node_id: nodeId, }); db.addRoleAssignment({ - user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack', + user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack', node_id: nodeId, }); db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(otherNodeId), @@ -552,33 +552,42 @@ describe('BlueprintService local withdraw clears stack-scoped role assignments', const { bp, node, nodeId, userId, deleteStackSpy, db } = await arrangeLocalWithdraw(); const fsErr = Object.assign(new Error('permission denied'), { code: 'EACCES' }); deleteStackSpy.mockRejectedValue(fsErr); - const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByResource'); + const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByStack'); - const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node); + try { + const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node); - expect(outcome.status).toBe('failed'); - expect(rbacSpy).not.toHaveBeenCalled(); - expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed'); - expect(hasAssignment(userId, 'stack', bp.name)).toBe(true); - db.deleteUser(userId); + expect(outcome.status).toBe('failed'); + expect(rbacSpy).not.toHaveBeenCalled(); + expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed'); + expect(hasAssignment(userId, 'stack', bp.name)).toBe(true); + } finally { + rbacSpy.mockRestore(); + db.deleteUser(userId); + } }); it('fails withdraw and keeps the deployment when role-assignment cleanup throws', async () => { const { bp, node, nodeId, userId, db } = await arrangeLocalWithdraw(); - vi.spyOn(db, 'deleteRoleAssignmentsByResource') + const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByStack') .mockImplementation(() => { throw new Error('simulated rbac cleanup failure'); }); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node); + try { + const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node); - expect(outcome.status).toBe('failed'); - expect(db.getDeployment(bp.id, nodeId)).toBeDefined(); - expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed'); - expect(errorSpy.mock.calls.some((args) => - typeof args[0] === 'string' && args[0].includes('Secondary DB cleanup failed'), - )).toBe(true); - expect(hasAssignment(userId, 'stack', bp.name)).toBe(true); - db.deleteUser(userId); + expect(outcome.status).toBe('failed'); + expect(db.getDeployment(bp.id, nodeId)).toBeDefined(); + expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed'); + expect(errorSpy.mock.calls.some((args) => + typeof args[0] === 'string' && args[0].includes('Secondary DB cleanup failed'), + )).toBe(true); + expect(hasAssignment(userId, 'stack', bp.name)).toBe(true); + } finally { + rbacSpy.mockRestore(); + errorSpy.mockRestore(); + db.deleteUser(userId); + } }); }); diff --git a/backend/src/__tests__/permissions-stack-rbac.test.ts b/backend/src/__tests__/permissions-stack-rbac.test.ts new file mode 100644 index 00000000..33d5bae9 --- /dev/null +++ b/backend/src/__tests__/permissions-stack-rbac.test.ts @@ -0,0 +1,236 @@ +/** + * Unit tests for checkPermission evidence + scopedActionsForStack with + * node-qualified stack grants. Uses mocked Request objects where possible. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import type { Request } from 'express'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import { + checkPermission, + scopedActionsForStack, + type PermissionAction, +} from '../middleware/permissions'; + +let tmpDir: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let viewerId: number; +let defaultNodeId: number; +let otherNodeId: number; + +function mockReq(partial: { + userId: number; + role: 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor'; + username?: string; + nodeId?: number; + proxyTier?: 'paid' | 'community'; + scopedStackEvidence?: { + stackName: string; + actions: ReadonlySet; + }; +}): Request { + return { + user: { + username: partial.username ?? 'test-user', + role: partial.role, + userId: partial.userId, + }, + nodeId: partial.nodeId ?? defaultNodeId, + proxyTier: partial.proxyTier ?? 'paid', + scopedStackEvidence: partial.scopedStackEvidence, + } as Request; +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + + const db = DatabaseService.getInstance(); + defaultNodeId = db.getDefaultNode()!.id!; + otherNodeId = db.addNode({ + name: 'perm-other-node', + type: 'remote', + api_url: 'http://192.168.1.60:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const hash = await bcrypt.hash('password123', 1); + viewerId = db.addUser({ username: 'perm-viewer', password_hash: hash, role: 'viewer' }); +}); + +afterAll(() => { + const db = DatabaseService.getInstance(); + db.deleteRoleAssignmentsByUser(viewerId); + db.deleteUser(viewerId); + db.deleteNode(otherNodeId); + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +describe('scopedActionsForStack', () => { + it('includes edit and deploy for a node-admin stack assignment', () => { + const db = DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'stack', + resource_id: 'actions-stack', + node_id: defaultNodeId, + }); + + const actions = scopedActionsForStack(viewerId, defaultNodeId, 'actions-stack'); + expect(actions).toContain('stack:edit'); + expect(actions).toContain('stack:deploy'); + expect(actions).toContain('stack:read'); + expect(actions).toContain('stack:delete'); + expect(actions).toContain('stack:create'); + expect(actions).not.toContain('node:manage'); + expect(actions).not.toContain('system:users'); + expect(actions.every((a) => a.startsWith('stack:'))).toBe(true); + + expect(scopedActionsForStack(viewerId, otherNodeId, 'actions-stack')).toEqual([]); + + db.deleteRoleAssignmentsByStack(defaultNodeId, 'actions-stack'); + }); +}); + +describe('checkPermission with node-scoped stack grants', () => { + it('viewer + scoped deploy grant succeeds for stack:deploy when req.nodeId matches', () => { + const db = DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'deployer', + resource_type: 'stack', + resource_id: 'deploy-me', + node_id: defaultNodeId, + }); + + const req = mockReq({ userId: viewerId, role: 'viewer', nodeId: defaultNodeId }); + expect(checkPermission(req, 'stack:deploy', 'stack', 'deploy-me')).toBe(true); + expect(checkPermission(req, 'stack:read', 'stack', 'deploy-me')).toBe(true); + expect(checkPermission(req, 'stack:edit', 'stack', 'deploy-me')).toBe(false); + + const wrongNode = mockReq({ userId: viewerId, role: 'viewer', nodeId: otherNodeId }); + expect(checkPermission(wrongNode, 'stack:deploy', 'stack', 'deploy-me')).toBe(false); + + db.deleteRoleAssignmentsByStack(defaultNodeId, 'deploy-me'); + }); + + it('node-scoped Node Admin authorizes stack actions on that node only', () => { + const db = DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(defaultNodeId), + }); + + const sameNode = mockReq({ userId: viewerId, role: 'viewer', nodeId: defaultNodeId }); + expect(checkPermission(sameNode, 'stack:edit', 'stack', 'any-stack')).toBe(true); + expect(checkPermission(sameNode, 'stack:deploy', 'stack', 'other-stack')).toBe(true); + expect(checkPermission(sameNode, 'node:manage', 'node', String(defaultNodeId))).toBe(true); + + const wrongNode = mockReq({ userId: viewerId, role: 'viewer', nodeId: otherNodeId }); + expect(checkPermission(wrongNode, 'stack:edit', 'stack', 'any-stack')).toBe(false); + + const assignments = db.getAllRoleAssignments(viewerId).filter( + (a) => a.resource_type === 'node' && a.resource_id === String(defaultNodeId), + ); + for (const a of assignments) db.deleteRoleAssignment(a.id!); + }); +}); + +describe('scopedActionsForStack with node-scoped grants', () => { + it('includes stack:* actions from a node-wide grant on the same node', () => { + const db = DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(defaultNodeId), + }); + + const actions = scopedActionsForStack(viewerId, defaultNodeId, 'fleet-wide'); + expect(actions).toContain('stack:edit'); + expect(actions).toContain('stack:deploy'); + expect(actions).not.toContain('node:manage'); + expect(scopedActionsForStack(viewerId, otherNodeId, 'fleet-wide')).toEqual([]); + + const assignments = db.getAllRoleAssignments(viewerId).filter( + (a) => a.resource_type === 'node' && a.resource_id === String(defaultNodeId), + ); + for (const a of assignments) db.deleteRoleAssignment(a.id!); + }); +}); + +describe('checkPermission scopedStackEvidence', () => { + it('allows when action is a member of the evidenced set for the same stack', () => { + // Use actions the global viewer role does not already grant so the + // evidence path is what authorizes (not ROLE_PERMISSIONS.viewer). + const req = mockReq({ + userId: 0, + role: 'viewer', + scopedStackEvidence: { + stackName: 'evidenced', + actions: new Set(['stack:deploy', 'stack:edit']), + }, + }); + expect(checkPermission(req, 'stack:deploy', 'stack', 'evidenced')).toBe(true); + expect(checkPermission(req, 'stack:edit', 'stack', 'evidenced')).toBe(true); + }); + + it('denies when the action is absent from the evidenced set', () => { + const req = mockReq({ + userId: 0, + role: 'viewer', + scopedStackEvidence: { + stackName: 'evidenced', + actions: new Set(['stack:read']), + }, + }); + expect(checkPermission(req, 'stack:deploy', 'stack', 'evidenced')).toBe(false); + expect(checkPermission(req, 'stack:edit', 'stack', 'evidenced')).toBe(false); + }); + + it('denies when the stack name does not match evidence', () => { + const req = mockReq({ + userId: 0, + role: 'viewer', + scopedStackEvidence: { + stackName: 'evidenced', + actions: new Set(['stack:deploy']), + }, + }); + expect(checkPermission(req, 'stack:deploy', 'stack', 'other-stack')).toBe(false); + }); + + it('ignores evidence for unscoped checks (no resourceType/resourceId)', () => { + const req = mockReq({ + userId: 0, + role: 'viewer', + scopedStackEvidence: { + stackName: 'evidenced', + actions: new Set(['stack:deploy', 'system:users']), + }, + }); + expect(checkPermission(req, 'stack:deploy')).toBe(false); + expect(checkPermission(req, 'system:users')).toBe(false); + }); + + it('does not honor non-stack actions even when present in the evidence set', () => { + const req = mockReq({ + userId: 0, + role: 'viewer', + scopedStackEvidence: { + stackName: 'evidenced', + actions: new Set(['stack:deploy', 'system:users', 'node:manage']), + }, + }); + expect(checkPermission(req, 'system:users', 'stack', 'evidenced')).toBe(false); + expect(checkPermission(req, 'node:manage', 'stack', 'evidenced')).toBe(false); + expect(checkPermission(req, 'stack:deploy', 'stack', 'evidenced')).toBe(true); + }); +}); diff --git a/backend/src/__tests__/proxy-scoped-stack-evidence.test.ts b/backend/src/__tests__/proxy-scoped-stack-evidence.test.ts new file mode 100644 index 00000000..06d3dc27 --- /dev/null +++ b/backend/src/__tests__/proxy-scoped-stack-evidence.test.ts @@ -0,0 +1,327 @@ +/** + * Orchestrated hub → remote proxy coverage for scoped stack elevation and + * DELETE tuple cleanup. Exercises createRemoteProxyMiddleware through + * live loopback remotes (not helper-only unit tests). + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import http from 'http'; +import bcrypt from 'bcrypt'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { + PROXY_SCOPED_STACK_NAME_HEADER, + PROXY_SCOPED_STACK_ACTIONS_HEADER, +} from '../services/license-headers'; + +let tmpDir: string; +let app: import('express').Express; +let viewerBearer: string; +let viewerId: number; +let evidenceServer: http.Server; +let noEvidenceServer: http.Server; +let failDeleteServer: http.Server; +let wrongNodeServer: http.Server; +let evidenceNodeId: number; +let noEvidenceNodeId: number; +let failDeleteNodeId: number; +let wrongNodeId: number; + +interface CapturedHop { + method: string; + url: string; + stackNameHeader: string | undefined; + stackActionsHeader: string | undefined; +} + +const evidenceHops: CapturedHop[] = []; +const failDeleteHops: CapturedHop[] = []; + +function captureHop(req: http.IncomingMessage, into: CapturedHop[]): void { + into.push({ + method: req.method ?? '', + url: req.url ?? '', + stackNameHeader: req.headers[PROXY_SCOPED_STACK_NAME_HEADER] as string | undefined, + stackActionsHeader: req.headers[PROXY_SCOPED_STACK_ACTIONS_HEADER] as string | undefined, + }); +} + +function evidenceRemote(): http.Server { + return http.createServer((req, res) => { + if (req.url?.startsWith('/api/meta')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '0.93.0', + capabilities: ['cross-node-rbac', 'scoped-stack-auth-evidence'], + })); + return; + } + captureHop(req, evidenceHops); + if (req.method === 'DELETE') { + res.writeHead(204); + res.end(); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +function noEvidenceRemote(): http.Server { + return http.createServer((req, res) => { + if (req.url?.startsWith('/api/meta')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '0.93.0', + capabilities: ['cross-node-rbac'], + })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +function failDeleteRemote(): http.Server { + return http.createServer((req, res) => { + if (req.url?.startsWith('/api/meta')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '0.93.0', + capabilities: ['cross-node-rbac', 'scoped-stack-auth-evidence'], + })); + return; + } + captureHop(req, failDeleteHops); + if (req.method === 'DELETE') { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'upstream delete failed' })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +async function listen(server: http.Server): Promise { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return (server.address() as import('net').AddressInfo).port; +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + const { DatabaseService } = await import('../services/DatabaseService'); + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + + const db = DatabaseService.getInstance(); + const hash = await bcrypt.hash('password123', 1); + viewerId = db.addUser({ username: 'scoped-proxy-viewer', password_hash: hash, role: 'viewer' }); + const viewer = db.getUserByUsername('scoped-proxy-viewer')!; + viewerBearer = jwt.sign( + { username: 'scoped-proxy-viewer', role: 'viewer', tv: viewer.token_version }, + TEST_JWT_SECRET, + { expiresIn: '5m' }, + ); + + evidenceServer = evidenceRemote(); + noEvidenceServer = noEvidenceRemote(); + failDeleteServer = failDeleteRemote(); + wrongNodeServer = evidenceRemote(); + const evidencePort = await listen(evidenceServer); + const noEvidencePort = await listen(noEvidenceServer); + const failDeletePort = await listen(failDeleteServer); + const wrongNodePort = await listen(wrongNodeServer); + + evidenceNodeId = db.addNode({ + name: 'evidence-remote', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: `http://127.0.0.1:${evidencePort}`, + api_token: 'evidence-token', + }); + noEvidenceNodeId = db.addNode({ + name: 'no-evidence-remote', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: `http://127.0.0.1:${noEvidencePort}`, + api_token: 'no-evidence-token', + }); + failDeleteNodeId = db.addNode({ + name: 'fail-delete-remote', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: `http://127.0.0.1:${failDeletePort}`, + api_token: 'fail-delete-token', + }); + wrongNodeId = db.addNode({ + name: 'wrong-node-remote', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: `http://127.0.0.1:${wrongNodePort}`, + api_token: 'wrong-node-token', + }); +}); + +afterAll(async () => { + await new Promise((resolve) => evidenceServer.close(() => resolve())); + await new Promise((resolve) => noEvidenceServer.close(() => resolve())); + await new Promise((resolve) => failDeleteServer.close(() => resolve())); + await new Promise((resolve) => wrongNodeServer.close(() => resolve())); + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +describe('remote proxy scoped stack evidence and DELETE cleanup', () => { + it('elevates a matching stack grant and forwards bound evidence headers', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'deployer', + resource_type: 'stack', + resource_id: 'shared-name', + node_id: evidenceNodeId, + }); + evidenceHops.length = 0; + + const res = await request(app) + .post('/api/stacks/shared-name/deploy') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(evidenceNodeId)); + + expect(res.status).toBe(200); + const hop = evidenceHops.find((h) => h.url?.includes('/stacks/shared-name/deploy')); + expect(hop).toBeDefined(); + expect(hop!.stackNameHeader).toBe('shared-name'); + expect(hop!.stackActionsHeader).toContain('stack:deploy'); + expect(hop!.stackActionsHeader).not.toContain('node:manage'); + + db.deleteRoleAssignmentsByStack(evidenceNodeId, 'shared-name'); + }); + + it('denies the same stack name on a node without a grant', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'deployer', + resource_type: 'stack', + resource_id: 'shared-name', + node_id: evidenceNodeId, + }); + + const res = await request(app) + .post('/api/stacks/shared-name/deploy') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(wrongNodeId)); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + + db.deleteRoleAssignmentsByStack(evidenceNodeId, 'shared-name'); + }); + + it('denies scoped elevation when the remote lacks scoped-stack-auth-evidence', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'deployer', + resource_type: 'stack', + resource_id: 'needs-evidence', + node_id: noEvidenceNodeId, + }); + + const res = await request(app) + .post('/api/stacks/needs-evidence/deploy') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(noEvidenceNodeId)); + + expect(res.status).toBe(403); + expect(res.body.error).toMatch(/scoped stack authorization/i); + + db.deleteRoleAssignmentsByStack(noEvidenceNodeId, 'needs-evidence'); + }); + + it('clears the hub grant tuple after a successful remote DELETE', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'stack', + resource_id: 'doomed', + node_id: evidenceNodeId, + }); + expect( + db.getRoleAssignments(viewerId, 'stack', 'doomed', evidenceNodeId), + ).toHaveLength(1); + + const res = await request(app) + .delete('/api/stacks/doomed') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(evidenceNodeId)); + + expect(res.status).toBe(204); + expect( + db.getRoleAssignments(viewerId, 'stack', 'doomed', evidenceNodeId), + ).toHaveLength(0); + }); + + it('preserves the hub grant tuple when remote DELETE is non-2xx', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'stack', + resource_id: 'keep-me', + node_id: failDeleteNodeId, + }); + + const res = await request(app) + .delete('/api/stacks/keep-me') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(failDeleteNodeId)); + + expect(res.status).toBe(500); + expect( + db.getRoleAssignments(viewerId, 'stack', 'keep-me', failDeleteNodeId), + ).toHaveLength(1); + + db.deleteRoleAssignmentsByStack(failDeleteNodeId, 'keep-me'); + }); + + it('builds evidence from a node-scoped grant on the target node', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(evidenceNodeId), + }); + evidenceHops.length = 0; + + const res = await request(app) + .post('/api/stacks/node-wide-stack/deploy') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(evidenceNodeId)); + + expect(res.status).toBe(200); + const hop = evidenceHops.find((h) => h.url?.includes('/stacks/node-wide-stack/deploy')); + expect(hop).toBeDefined(); + expect(hop!.stackNameHeader).toBe('node-wide-stack'); + expect(hop!.stackActionsHeader).toContain('stack:deploy'); + expect(hop!.stackActionsHeader).toContain('stack:edit'); + + const assignments = db.getAllRoleAssignments(viewerId).filter( + (a) => a.resource_type === 'node' && a.resource_id === String(evidenceNodeId), + ); + for (const a of assignments) db.deleteRoleAssignment(a.id!); + }); +}); diff --git a/backend/src/__tests__/role-assignments-node-qualified.test.ts b/backend/src/__tests__/role-assignments-node-qualified.test.ts new file mode 100644 index 00000000..4fdd7cf6 --- /dev/null +++ b/backend/src/__tests__/role-assignments-node-qualified.test.ts @@ -0,0 +1,430 @@ +/** + * migrateRoleAssignmentsNodeQualified: legacy rebuild, default remap, + * no-default omit, sqlite_master idempotency probe, unique indexes, + * deleteNode stack-grant cleanup, preserved ids/timestamps. + */ +import path from 'path'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import { DatabaseService } from '../services/DatabaseService'; + +function resetDatabaseSingleton(): void { + const holder = DatabaseService as unknown as { instance?: DatabaseService }; + const existing = holder.instance; + if (existing) { + try { + existing.getDb().close(); + } catch { + // already closed + } + holder.instance = undefined; + } +} + +type IndexRow = { name: string; sql: string | null }; +type AssignmentRow = { + id: number; + user_id: number; + role: string; + resource_type: string; + resource_id: string; + node_id: number | null; + created_at: number; +}; + +function roleAssignmentsTableSql(raw: Database.Database): string { + return ( + (raw.prepare( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'role_assignments'", + ).get() as { sql: string } | undefined)?.sql ?? '' + ); +} + +function roleAssignmentIndexes(raw: Database.Database): Map { + const rows = raw.prepare( + "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'role_assignments'", + ).all() as IndexRow[]; + return new Map(rows.map((r) => [r.name, r.sql ?? ''])); +} + +/** Rewrite role_assignments to the pre-node_id schema and seed legacy rows. */ +function seedLegacyRoleAssignments( + dbPath: string, + seed: { + userId: number; + stackRows: Array<{ id: number; role: string; resource_id: string; created_at: number }>; + nodeRows: Array<{ id: number; role: string; resource_id: string; created_at: number }>; + }, +): void { + const raw = new Database(dbPath); + try { + raw.exec('PRAGMA foreign_keys = OFF'); + raw.exec('DROP TABLE IF EXISTS role_assignments'); + raw.exec('DROP TABLE IF EXISTS role_assignments_new'); + raw.exec(` + CREATE TABLE role_assignments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_role_assignments_user ON role_assignments(user_id); + CREATE INDEX IF NOT EXISTS idx_role_assignments_resource + ON role_assignments(resource_type, resource_id); + `); + const insert = raw.prepare(` + INSERT INTO role_assignments (id, user_id, role, resource_type, resource_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `); + for (const row of seed.stackRows) { + insert.run(row.id, seed.userId, row.role, 'stack', row.resource_id, row.created_at); + } + for (const row of seed.nodeRows) { + insert.run(row.id, seed.userId, row.role, 'node', row.resource_id, row.created_at); + } + } finally { + raw.close(); + } +} + +function removeRoleAssignmentsCheck(raw: Database.Database): void { + raw.exec(` + CREATE TABLE role_assignments_without_check ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + node_id INTEGER, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE + ); + INSERT INTO role_assignments_without_check + SELECT * FROM role_assignments; + DROP TABLE role_assignments; + ALTER TABLE role_assignments_without_check RENAME TO role_assignments; + CREATE INDEX idx_role_assignments_user ON role_assignments(user_id); + CREATE INDEX idx_role_assignments_resource ON role_assignments(resource_type, resource_id); + CREATE UNIQUE INDEX idx_role_assignments_stack_unique + ON role_assignments(user_id, role, resource_type, resource_id, node_id) + WHERE resource_type = 'stack'; + CREATE UNIQUE INDEX idx_role_assignments_node_unique + ON role_assignments(user_id, role, resource_type, resource_id) + WHERE resource_type = 'node'; + `); +} + +function expectFinalSchema(raw: Database.Database): void { + const tableSql = roleAssignmentsTableSql(raw); + expect(tableSql).toContain("resource_type = 'stack' AND node_id IS NOT NULL"); + expect(tableSql).toContain("resource_type = 'node' AND node_id IS NULL"); + + const indexes = roleAssignmentIndexes(raw); + const stackUnique = indexes.get('idx_role_assignments_stack_unique') ?? ''; + const nodeUnique = indexes.get('idx_role_assignments_node_unique') ?? ''; + + expect(stackUnique).toMatch(/user_id/i); + expect(stackUnique).toMatch(/role/i); + expect(stackUnique).toMatch(/resource_type/i); + expect(stackUnique).toMatch(/resource_id/i); + expect(stackUnique).toMatch(/node_id/i); + expect(stackUnique).toMatch(/WHERE\s+resource_type\s*=\s*'stack'/i); + + expect(nodeUnique).toMatch(/user_id/i); + expect(nodeUnique).toMatch(/role/i); + expect(nodeUnique).toMatch(/resource_type/i); + expect(nodeUnique).toMatch(/resource_id/i); + expect(nodeUnique).toMatch(/WHERE\s+resource_type\s*=\s*'node'/i); + const nodeCols = nodeUnique.replace(/WHERE[\s\S]*/i, ''); + expect(nodeCols).not.toMatch(/node_id/); +} + +describe('migrateRoleAssignmentsNodeQualified', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await setupTestDb(); + }); + + afterEach(() => { + resetDatabaseSingleton(); + cleanupTestDb(tmpDir); + }); + + it('remaps legacy stack rows to the default node and preserves ids/timestamps', async () => { + const db = DatabaseService.getInstance(); + const defaultNodeId = db.getDefaultNode()!.id!; + const remoteNodeId = db.addNode({ + name: 'mig-remote', + type: 'remote', + api_url: 'http://192.168.1.50:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'mig-remap', password_hash: hash, role: 'viewer' }); + + const stackCreatedAt = 1_700_000_000_001; + const nodeCreatedAt = 1_700_000_000_002; + resetDatabaseSingleton(); + seedLegacyRoleAssignments(path.join(tmpDir, 'sencho.db'), { + userId, + stackRows: [ + { id: 41, role: 'deployer', resource_id: 'web', created_at: stackCreatedAt }, + { id: 42, role: 'viewer', resource_id: 'api', created_at: stackCreatedAt + 1 }, + ], + nodeRows: [ + { id: 51, role: 'node-admin', resource_id: String(remoteNodeId), created_at: nodeCreatedAt }, + ], + }); + + process.env.DATA_DIR = tmpDir; + const migrated = DatabaseService.getInstance(); + const raw = migrated.getDb(); + expectFinalSchema(raw); + + const rows = raw.prepare( + 'SELECT * FROM role_assignments ORDER BY id', + ).all() as AssignmentRow[]; + expect(rows).toHaveLength(3); + + const stackWeb = rows.find((r) => r.id === 41)!; + expect(stackWeb.resource_type).toBe('stack'); + expect(stackWeb.resource_id).toBe('web'); + expect(stackWeb.node_id).toBe(defaultNodeId); + expect(stackWeb.created_at).toBe(stackCreatedAt); + expect(stackWeb.role).toBe('deployer'); + + const stackApi = rows.find((r) => r.id === 42)!; + expect(stackApi.node_id).toBe(defaultNodeId); + expect(stackApi.created_at).toBe(stackCreatedAt + 1); + + const nodeGrant = rows.find((r) => r.id === 51)!; + expect(nodeGrant.resource_type).toBe('node'); + expect(nodeGrant.node_id).toBeNull(); + expect(nodeGrant.created_at).toBe(nodeCreatedAt); + }); + + it('omits legacy stack rows when no default node exists', async () => { + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'mig-no-default-remote', + type: 'remote', + api_url: 'http://192.168.1.51:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'mig-omit', password_hash: hash, role: 'viewer' }); + db.getDb().prepare('UPDATE nodes SET is_default = 0').run(); + expect(db.getDefaultNode()).toBeUndefined(); + + resetDatabaseSingleton(); + seedLegacyRoleAssignments(path.join(tmpDir, 'sencho.db'), { + userId, + stackRows: [ + { id: 61, role: 'deployer', resource_id: 'orphan-stack', created_at: 99 }, + ], + nodeRows: [ + { id: 62, role: 'deployer', resource_id: String(remoteNodeId), created_at: 100 }, + ], + }); + + process.env.DATA_DIR = tmpDir; + const migrated = DatabaseService.getInstance(); + const rows = migrated.getDb().prepare( + 'SELECT * FROM role_assignments ORDER BY id', + ).all() as AssignmentRow[]; + + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe(62); + expect(rows[0].resource_type).toBe('node'); + expect(rows[0].node_id).toBeNull(); + }); + + it('second init is idempotent via sqlite_master CHECK and partial-index WHERE probes', async () => { + const db = DatabaseService.getInstance(); + const defaultNodeId = db.getDefaultNode()!.id!; + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'mig-idem', password_hash: hash, role: 'viewer' }); + + resetDatabaseSingleton(); + seedLegacyRoleAssignments(path.join(tmpDir, 'sencho.db'), { + userId, + stackRows: [ + { id: 71, role: 'deployer', resource_id: 'idem-stack', created_at: 200 }, + ], + nodeRows: [], + }); + + process.env.DATA_DIR = tmpDir; + const first = DatabaseService.getInstance(); + expectFinalSchema(first.getDb()); + const before = first.getDb().prepare( + 'SELECT id, node_id, created_at FROM role_assignments WHERE id = 71', + ).get() as { id: number; node_id: number; created_at: number }; + expect(before.node_id).toBe(defaultNodeId); + + resetDatabaseSingleton(); + process.env.DATA_DIR = tmpDir; + const second = DatabaseService.getInstance(); + expectFinalSchema(second.getDb()); + const after = second.getDb().prepare( + 'SELECT id, node_id, created_at FROM role_assignments WHERE id = 71', + ).get() as { id: number; node_id: number; created_at: number }; + expect(after).toEqual(before); + + const stale = second.getDb().prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'role_assignments_new'", + ).get(); + expect(stale).toBeUndefined(); + }); + + it('preserves node-qualified stack rows when repairing a missing index', async () => { + const db = DatabaseService.getInstance(); + const defaultNodeId = db.getDefaultNode()!.id!; + const remoteNodeId = db.addNode({ + name: 'mig-repair-remote', + type: 'remote', + api_url: 'http://192.168.1.54:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'mig-repair', password_hash: hash, role: 'viewer' }); + + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'shared-stack', node_id: defaultNodeId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'shared-stack', node_id: remoteNodeId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'node-admin', resource_type: 'node', + resource_id: String(remoteNodeId), + }); + const before = db.getAllRoleAssignments(userId); + db.getDb().exec('DROP INDEX idx_role_assignments_stack_unique'); + + resetDatabaseSingleton(); + process.env.DATA_DIR = tmpDir; + const repaired = DatabaseService.getInstance(); + expectFinalSchema(repaired.getDb()); + + const rows = repaired.getAllRoleAssignments(userId); + expect(rows).toEqual(before); + }); + + it('preserves node-qualified rows without a default node when repairing the table check', async () => { + const db = DatabaseService.getInstance(); + const defaultNodeId = db.getDefaultNode()!.id!; + const remoteNodeId = db.addNode({ + name: 'mig-repair-no-default', + type: 'remote', + api_url: 'http://192.168.1.55:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'mig-repair-check', password_hash: hash, role: 'viewer' }); + + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'local-stack', node_id: defaultNodeId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'admin', resource_type: 'stack', + resource_id: 'remote-stack', node_id: remoteNodeId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'viewer', resource_type: 'node', + resource_id: String(remoteNodeId), + }); + const before = db.getAllRoleAssignments(userId); + db.getDb().prepare('UPDATE nodes SET is_default = 0').run(); + removeRoleAssignmentsCheck(db.getDb()); + + resetDatabaseSingleton(); + process.env.DATA_DIR = tmpDir; + const repaired = DatabaseService.getInstance(); + expectFinalSchema(repaired.getDb()); + expect(repaired.getAllRoleAssignments(userId)).toEqual(before); + }); + + it('unique indexes use exact column sets including role', () => { + const raw = DatabaseService.getInstance().getDb(); + const indexes = roleAssignmentIndexes(raw); + const stackUnique = indexes.get('idx_role_assignments_stack_unique') ?? ''; + const nodeUnique = indexes.get('idx_role_assignments_node_unique') ?? ''; + + expect(stackUnique.replace(/\s+/g, ' ')).toMatch( + /ON role_assignments\s*\(\s*user_id\s*,\s*role\s*,\s*resource_type\s*,\s*resource_id\s*,\s*node_id\s*\)/i, + ); + expect(nodeUnique.replace(/\s+/g, ' ')).toMatch( + /ON role_assignments\s*\(\s*user_id\s*,\s*role\s*,\s*resource_type\s*,\s*resource_id\s*\)/i, + ); + }); + + it('deleteNode clears stack grants by node_id and preserves other nodes', async () => { + const db = DatabaseService.getInstance(); + const defaultNodeId = db.getDefaultNode()!.id!; + const doomedId = db.addNode({ + name: 'mig-doomed', + type: 'remote', + api_url: 'http://192.168.1.52:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const survivorId = db.addNode({ + name: 'mig-survivor', + type: 'remote', + api_url: 'http://192.168.1.53:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'mig-delnode', password_hash: hash, role: 'viewer' }); + + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'shared-name', node_id: doomedId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'shared-name', node_id: survivorId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'local-only', node_id: defaultNodeId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'node-admin', resource_type: 'node', + resource_id: String(doomedId), + }); + + db.deleteNode(doomedId); + + const remaining = db.getAllRoleAssignments(userId); + expect(remaining.some((a) => a.node_id === doomedId)).toBe(false); + expect(remaining.some((a) => a.resource_type === 'node' && a.resource_id === String(doomedId))).toBe(false); + expect(remaining.some((a) => a.node_id === survivorId && a.resource_id === 'shared-name')).toBe(true); + expect(remaining.some((a) => a.node_id === defaultNodeId && a.resource_id === 'local-only')).toBe(true); + + db.deleteUser(userId); + db.deleteNode(survivorId); + }); +}); diff --git a/backend/src/__tests__/scoped-stack-auth-evidence.test.ts b/backend/src/__tests__/scoped-stack-auth-evidence.test.ts new file mode 100644 index 00000000..678db9d2 --- /dev/null +++ b/backend/src/__tests__/scoped-stack-auth-evidence.test.ts @@ -0,0 +1,127 @@ +/** + * Machine-auth scoped stack evidence: headers are trusted only under + * node_proxy / pilot_tunnel, and only when the name + actions pair is valid. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import type { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { + PROXY_SCOPED_STACK_NAME_HEADER, + PROXY_SCOPED_STACK_ACTIONS_HEADER, + PROXY_ROLE_HEADER, +} from '../services/license-headers'; +import { checkPermission } from '../middleware/permissions'; + +let tmpDir: string; +let authMiddleware: typeof import('../middleware/auth').authMiddleware; + +function runAuth(req: Partial): Promise { + return new Promise((resolve, reject) => { + const fullReq = Object.assign( + { cookies: {} as Record, headers: {} as Record }, + req, + { + headers: { ...(req.headers ?? {}) }, + cookies: {}, + }, + ) as Request; + let settled = false; + const res = { + status: () => res, + json: (body: unknown) => { + if (!settled) { + settled = true; + reject(new Error(`authMiddleware rejected: ${JSON.stringify(body)}`)); + } + return res; + }, + } as unknown as Response; + const next: NextFunction = (err?: unknown) => { + if (settled) return; + settled = true; + if (err) reject(err); + else resolve(fullReq); + }; + void Promise.resolve(authMiddleware(fullReq, res, next)).catch(reject); + }); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ authMiddleware } = await import('../middleware/auth')); + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); +}); + +afterAll(() => { + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +describe('scoped stack evidence under machine auth', () => { + it('attaches evidence for node_proxy when headers are valid', async () => { + const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const req = await runAuth({ + headers: { + authorization: `Bearer ${token}`, + [PROXY_ROLE_HEADER]: 'viewer', + [PROXY_SCOPED_STACK_NAME_HEADER]: 'web', + [PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:edit,stack:deploy', + }, + }); + expect(req.scopedStackEvidence?.stackName).toBe('web'); + expect(req.scopedStackEvidence?.actions.has('stack:edit')).toBe(true); + expect(req.scopedStackEvidence?.actions.has('stack:deploy')).toBe(true); + expect(checkPermission(req, 'stack:deploy', 'stack', 'web')).toBe(true); + expect(checkPermission(req, 'stack:edit', 'stack', 'web')).toBe(true); + }); + + it('attaches evidence for pilot_tunnel the same way', async () => { + const token = jwt.sign({ scope: 'pilot_tunnel' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const req = await runAuth({ + headers: { + authorization: `Bearer ${token}`, + [PROXY_ROLE_HEADER]: 'viewer', + [PROXY_SCOPED_STACK_NAME_HEADER]: 'api', + [PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:read', + }, + }); + expect(req.scopedStackEvidence?.stackName).toBe('api'); + expect(checkPermission(req, 'stack:read', 'stack', 'api')).toBe(true); + }); + + it('treats malformed actions as absent evidence', async () => { + const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const req = await runAuth({ + headers: { + authorization: `Bearer ${token}`, + [PROXY_ROLE_HEADER]: 'viewer', + [PROXY_SCOPED_STACK_NAME_HEADER]: 'web', + [PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:edit,not-real', + }, + }); + expect(req.scopedStackEvidence).toBeUndefined(); + expect(checkPermission(req, 'stack:edit', 'stack', 'web')).toBe(false); + }); + + it('ignores evidence headers on a user session JWT', async () => { + const { DatabaseService } = await import('../services/DatabaseService'); + const { TEST_USERNAME } = await import('./helpers/setupTestDb'); + const user = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME); + expect(user).toBeDefined(); + const token = jwt.sign( + { username: user!.username, role: user!.role, tv: user!.token_version }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + const req = await runAuth({ + headers: { + authorization: `Bearer ${token}`, + [PROXY_SCOPED_STACK_NAME_HEADER]: 'web', + [PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:deploy,stack:edit', + }, + }); + expect(req.scopedStackEvidence).toBeUndefined(); + }); +}); diff --git a/backend/src/__tests__/stack-delete-cascades-mesh-opt-out.test.ts b/backend/src/__tests__/stack-delete-cascades-mesh-opt-out.test.ts index e1ad3b0a..a188b776 100644 --- a/backend/src/__tests__/stack-delete-cascades-mesh-opt-out.test.ts +++ b/backend/src/__tests__/stack-delete-cascades-mesh-opt-out.test.ts @@ -142,8 +142,9 @@ describe('DELETE /api/stacks/:stackName clears stack-scoped role assignments', ( name: 'stack-del-rbac-node', type: 'remote', api_url: 'http://test:1852', api_token: '', compose_dir: '/tmp', is_default: false, }); - db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'api' }); - db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack' }); + const defaultNodeId = db.getDefaultNode()!.id; + db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'api', node_id: defaultNodeId }); + db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack', node_id: defaultNodeId }); db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(otherNodeId) }); const res = await request(app) @@ -152,8 +153,8 @@ describe('DELETE /api/stacks/:stackName clears stack-scoped role assignments', ( expect(res.status).toBe(200); const remaining = db.getAllRoleAssignments(userId); - expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'api')).toBe(false); - expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'other-stack')).toBe(true); + expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'api' && a.node_id === defaultNodeId)).toBe(false); + expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'other-stack' && a.node_id === defaultNodeId)).toBe(true); expect(remaining.some((a) => a.resource_type === 'node' && a.resource_id === String(otherNodeId))).toBe(true); db.deleteUser(userId); diff --git a/backend/src/__tests__/stackRouteAuth.test.ts b/backend/src/__tests__/stackRouteAuth.test.ts new file mode 100644 index 00000000..4c115647 --- /dev/null +++ b/backend/src/__tests__/stackRouteAuth.test.ts @@ -0,0 +1,155 @@ +/** + * Pure classifyStackApiPath coverage for hub stack RBAC gating. + */ +import { describe, it, expect } from 'vitest'; +import { + classifyStackApiPath, + formatScopedStackActionsHeader, + parseScopedStackActionsHeader, +} from '../helpers/stackRouteAuth'; +import type { PermissionAction } from '../middleware/permissions'; + +describe('classifyStackApiPath', () => { + describe('named-stack families', () => { + it('maps read routes to stack:read', () => { + expect(classifyStackApiPath('GET', '/stacks/web')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:read', + }); + expect(classifyStackApiPath('GET', '/stacks/web/env')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:read', + }); + expect(classifyStackApiPath('GET', '/stacks/web/git-source')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:read', + }); + expect(classifyStackApiPath('POST', '/stacks/web/drift/recheck')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:read', + }); + }); + + it('maps edit routes to stack:edit', () => { + expect(classifyStackApiPath('PUT', '/stacks/web')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:edit', + }); + expect(classifyStackApiPath('PUT', '/stacks/web/env')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:edit', + }); + expect(classifyStackApiPath('PUT', '/stacks/web/git-source')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:edit', + }); + expect(classifyStackApiPath('DELETE', '/stacks/web/git-source')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:edit', + }); + }); + + it('maps deploy routes and service lifecycle ops to stack:deploy', () => { + expect(classifyStackApiPath('POST', '/stacks/web/deploy')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:deploy', + }); + expect(classifyStackApiPath('POST', '/stacks/web/update')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:deploy', + }); + expect(classifyStackApiPath('POST', '/stacks/web/services/api/restart')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:deploy', + }); + expect(classifyStackApiPath('GET', '/stacks/web/services/api/recovery')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:deploy', + }); + }); + + it('maps stack DELETE to stack:delete', () => { + expect(classifyStackApiPath('DELETE', '/stacks/web')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:delete', + }); + }); + + it('treats git-source/apply primary as stack:edit', () => { + expect(classifyStackApiPath('POST', '/stacks/web/git-source/apply')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:edit', + }); + }); + }); + + describe('static exclusions', () => { + it('classifies collection and create paths as static', () => { + expect(classifyStackApiPath('GET', '/stacks')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('GET', '/stacks/')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('POST', '/stacks')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('GET', '/stacks/statuses')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('GET', '/stacks/discovery')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('POST', '/stacks/import/scan')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('POST', '/stacks/import/move')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('POST', '/stacks/bulk')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('POST', '/stacks/from-git')).toEqual({ kind: 'static' }); + }); + + it('classifies non-/stacks paths as static', () => { + expect(classifyStackApiPath('GET', '/nodes')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('GET', '/users')).toEqual({ kind: 'static' }); + }); + }); + + describe('encoding and trailing slashes', () => { + it('decodes percent-encoded stack names', () => { + expect(classifyStackApiPath('GET', '/stacks/my%2Dstack')).toEqual({ + kind: 'named-stack', stackName: 'my-stack', action: 'stack:read', + }); + expect(classifyStackApiPath('POST', '/stacks/web%5Fprod/deploy')).toEqual({ + kind: 'named-stack', stackName: 'web_prod', action: 'stack:deploy', + }); + }); + + it('strips trailing slashes before matching', () => { + expect(classifyStackApiPath('GET', '/stacks/web/')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:read', + }); + expect(classifyStackApiPath('POST', '/stacks/web/deploy/')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:deploy', + }); + expect(classifyStackApiPath('GET', '/stacks/statuses/')).toEqual({ kind: 'static' }); + }); + + it('ignores query strings', () => { + expect(classifyStackApiPath('GET', '/stacks/web?nodeId=1')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:read', + }); + }); + }); + + describe('fail-closed unknown-named', () => { + it('returns unknown-named for unrecognized /stacks//... suffixes', () => { + expect(classifyStackApiPath('GET', '/stacks/web/weird')).toEqual({ kind: 'unknown-named' }); + expect(classifyStackApiPath('POST', '/stacks/web/not-a-real-action')).toEqual({ + kind: 'unknown-named', + }); + expect(classifyStackApiPath('POST', '/stacks/web/services/api/recovery')).toEqual({ + kind: 'unknown-named', + }); + }); + + it('returns unknown-named for invalid stack name segments', () => { + expect(classifyStackApiPath('GET', '/stacks/bad name')).toEqual({ kind: 'unknown-named' }); + expect(classifyStackApiPath('GET', '/stacks/%2E%2E')).toEqual({ kind: 'unknown-named' }); + }); + }); +}); + +describe('scoped stack actions header encode/decode', () => { + it('round-trips a PermissionAction set', () => { + const actions: PermissionAction[] = ['stack:edit', 'stack:deploy', 'stack:read']; + const encoded = formatScopedStackActionsHeader(actions); + expect(parseScopedStackActionsHeader(encoded)).toEqual(actions); + }); + + it('returns null for malformed tokens', () => { + expect(parseScopedStackActionsHeader('stack:edit,not-a-real-action')).toBeNull(); + expect(parseScopedStackActionsHeader('')).toBeNull(); + expect(parseScopedStackActionsHeader(' ')).toBeNull(); + }); + + it('deduplicates while preserving first-seen order', () => { + expect(parseScopedStackActionsHeader('stack:edit,stack:deploy,stack:edit')).toEqual([ + 'stack:edit', + 'stack:deploy', + ]); + }); +}); diff --git a/backend/src/__tests__/users-rbac.test.ts b/backend/src/__tests__/users-rbac.test.ts index c5917576..8facd099 100644 --- a/backend/src/__tests__/users-rbac.test.ts +++ b/backend/src/__tests__/users-rbac.test.ts @@ -9,11 +9,22 @@ import bcrypt from 'bcrypt'; import crypto from 'crypto'; import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD, TEST_JWT_SECRET } from './helpers/setupTestDb'; import { generateApiToken } from '../utils/apiTokenFormat'; +import { assertStackExistsOnNode } from '../helpers/assertStackExistsOnNode'; + +vi.mock('../helpers/assertStackExistsOnNode', () => ({ + assertStackExistsOnNode: vi.fn(async () => ({ ok: true as const })), +})); let tmpDir: string; let app: import('express').Express; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +function defaultNodeId(): number { + const node = DatabaseService.getInstance().getDefaultNode(); + if (!node) throw new Error('test default node missing'); + return node.id; +} + /** Sign a JWT for a given user with optional token_version (tv). */ function authToken(username: string, role: string = 'admin', tv?: number): string { const payload: Record = { username, role }; @@ -383,20 +394,103 @@ describe('Scoped Role Assignments', () => { }); it('POST /api/users/:id/roles creates assignment (201)', async () => { + const nodeId = defaultNodeId(); const res = await request(app) .post(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`) - .send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack' }); + .send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack', node_id: nodeId }); expect(res.status).toBe(201); expect(res.body.role).toBe('deployer'); expect(res.body.resource_type).toBe('stack'); + expect(res.body.node_id).toBe(nodeId); + }); + + it('POST /api/users/:id/roles rejects stack assignment without node_id (400)', async () => { + const res = await request(app) + .post(`/api/users/${targetUserId}/roles`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ role: 'deployer', resource_type: 'stack', resource_id: 'no-node-stack' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/node_id/i); + }); + + it('POST /api/users/:id/roles rejects when stack does not exist on node (400)', async () => { + vi.mocked(assertStackExistsOnNode).mockResolvedValueOnce({ + ok: false, + error: 'Stack not found on node', + }); + const res = await request(app) + .post(`/api/users/${targetUserId}/roles`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + role: 'deployer', + resource_type: 'stack', + resource_id: 'missing-stack', + node_id: defaultNodeId(), + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/not found/i); + }); + + it('POST /api/users/:id/roles rejects node_id qualifier on node assignments (400)', async () => { + const res = await request(app) + .post(`/api/users/${targetUserId}/roles`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + role: 'deployer', + resource_type: 'node', + resource_id: String(defaultNodeId()), + node_id: defaultNodeId(), + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/must not be set/i); + }); + + it('POST /api/users/:id/roles rejects nonexistent numeric node resource_id (400)', async () => { + const res = await request(app) + .post(`/api/users/${targetUserId}/roles`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + role: 'deployer', + resource_type: 'node', + resource_id: '999999', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Node not found/i); + }); + + it('POST /api/users/:id/roles creates node assignment without node_id (201)', async () => { + const res = await request(app) + .post(`/api/users/${targetUserId}/roles`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + role: 'deployer', + resource_type: 'node', + resource_id: String(defaultNodeId()), + }); + expect(res.status).toBe(201); + expect(res.body.resource_type).toBe('node'); + expect(res.body.node_id).toBeNull(); + }); + + it('POST /api/users/:id/roles rejects a noncanonical node resource_id (400)', async () => { + const res = await request(app) + .post(`/api/users/${targetUserId}/roles`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + role: 'deployer', + resource_type: 'node', + resource_id: `0${defaultNodeId()}`, + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/canonical/i); }); it('POST /api/users/:id/roles rejects duplicate (409)', async () => { const res = await request(app) .post(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`) - .send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack' }); + .send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack', node_id: defaultNodeId() }); expect(res.status).toBe(409); }); @@ -420,7 +514,7 @@ describe('Scoped Role Assignments', () => { const res = await request(app) .post(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`) - .send({ role: 'deployer', resource_type: 'stack', resource_id: 'community-stack' }); + .send({ role: 'deployer', resource_type: 'stack', resource_id: 'community-stack', node_id: defaultNodeId() }); expect(res.status).toBe(403); expect(res.body.code).toBe('PAID_REQUIRED'); } finally { @@ -452,7 +546,8 @@ describe('GET /api/permissions/me', () => { const db = DatabaseService.getInstance(); const hash = await bcrypt.hash('password123', 1); const id = db.addUser({ username: 'permcheck', password_hash: hash, role: 'viewer' }); - db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack' }); + const nodeId = defaultNodeId(); + db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack', node_id: nodeId }); const user = db.getUserById(id)!; const token = authToken('permcheck', 'viewer', user.token_version); @@ -461,7 +556,7 @@ describe('GET /api/permissions/me', () => { .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.globalRole).toBe('viewer'); - expect(res.body.scopedPermissions['stack:my-stack']).toBeDefined(); + expect(res.body.scopedPermissions[`stack:${nodeId}:my-stack`]).toBeDefined(); // Cleanup db.deleteRoleAssignmentsByUser(id); @@ -474,7 +569,13 @@ describe('GET /api/permissions/me', () => { const svc = LicenseService.getInstance(); const hash = await bcrypt.hash('password123', 1); const id = db.addUser({ username: 'permcheck-community', password_hash: hash, role: 'viewer' }); - db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack' }); + db.addRoleAssignment({ + user_id: id, + role: 'deployer', + resource_type: 'stack', + resource_id: 'my-stack', + node_id: defaultNodeId(), + }); const user = db.getUserById(id)!; const token = authToken('permcheck-community', 'viewer', user.token_version); @@ -689,53 +790,64 @@ describe('Atomic last-admin guard', () => { }); // ---- Orphaned Role Assignment Cleanup ---- +// Proxied remote stack DELETE clears hub grants only on 2xx in +// remoteNodeProxy (deleteRoleAssignmentsByStack). Non-2xx preserves rows. +// Orchestrated proxyRes coverage lives in proxy-scoped-stack-evidence.test.ts; +// these cases lock the DB helper isolation that the proxy calls. describe('Orphaned role assignment cleanup', () => { - it('deleting a node removes its role assignments', async () => { + it('deleting a node removes its node and stack role assignments', async () => { const db = DatabaseService.getInstance(); - // Create a test node - const nodeId = db.addNode({ name: 'test-cleanup-node', type: 'remote', api_url: 'http://test:1852', api_token: '', compose_dir: '/tmp', is_default: false }); - // Create a role assignment for this node + const nodeId = db.addNode({ + name: 'test-cleanup-node', type: 'remote', api_url: 'http://test:1852', + api_token: '', compose_dir: '/tmp', is_default: false, + }); const hash = await bcrypt.hash('password123', 1); const userId = db.addUser({ username: 'nodeorphan', password_hash: hash, role: 'viewer' }); - db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeId) }); + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeId), + }); + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'on-doomed', node_id: nodeId, + }); - // Verify assignment exists - const before = db.getAllRoleAssignments(userId); - expect(before.length).toBe(1); + expect(db.getAllRoleAssignments(userId)).toHaveLength(2); - // Delete the node db.deleteNode(nodeId); - // Assignments should be gone - const after = db.getAllRoleAssignments(userId); - expect(after.length).toBe(0); - - // Cleanup + expect(db.getAllRoleAssignments(userId)).toHaveLength(0); db.deleteUser(userId); }); - it('deleteRoleAssignmentsByResource removes only the matching resource tuple', async () => { + it('deleteRoleAssignmentsByStack clears only that node+stack tuple', async () => { const db = DatabaseService.getInstance(); const hash = await bcrypt.hash('password123', 1); const userId = db.addUser({ username: 'tupleorphan', password_hash: hash, role: 'viewer' }); - const nodeId = db.addNode({ - name: 'tuple-cleanup-node', type: 'remote', api_url: 'http://test:1852', + const nodeA = db.addNode({ + name: 'tuple-cleanup-node-a', type: 'remote', api_url: 'http://test-a:1852', api_token: '', compose_dir: '/tmp', is_default: false, }); - db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'target-stack' }); - db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'keep-stack' }); - db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeId) }); + const nodeB = db.addNode({ + name: 'tuple-cleanup-node-b', type: 'remote', api_url: 'http://test-b:1852', + api_token: '', compose_dir: '/tmp', is_default: false, + }); + db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'shared-name', node_id: nodeA }); + db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'shared-name', node_id: nodeB }); + db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'keep-stack', node_id: nodeA }); + db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeA) }); - db.deleteRoleAssignmentsByResource('stack', 'target-stack'); + db.deleteRoleAssignmentsByStack(nodeA, 'shared-name'); const after = db.getAllRoleAssignments(userId); - expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'target-stack')).toBe(false); - expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'keep-stack')).toBe(true); - expect(after.some((a) => a.resource_type === 'node' && a.resource_id === String(nodeId))).toBe(true); + expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'shared-name' && a.node_id === nodeA)).toBe(false); + expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'shared-name' && a.node_id === nodeB)).toBe(true); + expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'keep-stack' && a.node_id === nodeA)).toBe(true); + expect(after.some((a) => a.resource_type === 'node' && a.resource_id === String(nodeA))).toBe(true); db.deleteUser(userId); - db.deleteNode(nodeId); + db.deleteNode(nodeA); + db.deleteNode(nodeB); }); }); diff --git a/backend/src/helpers/assertStackExistsOnNode.ts b/backend/src/helpers/assertStackExistsOnNode.ts new file mode 100644 index 00000000..6d4f0d0b --- /dev/null +++ b/backend/src/helpers/assertStackExistsOnNode.ts @@ -0,0 +1,81 @@ +import axios from 'axios'; +import { DatabaseService } from '../services/DatabaseService'; +import { FileSystemService } from '../services/FileSystemService'; +import { NodeRegistry } from '../services/NodeRegistry'; +import { PROXY_TIER_HEADER } from '../services/license-headers'; +import { LicenseService } from '../services/LicenseService'; +import { isValidStackName } from '../utils/validation'; +import { getErrorMessage } from '../utils/errors'; + +const REMOTE_STACKS_TIMEOUT_MS = 30_000; + +export type AssertStackExistsResult = + | { ok: true } + | { ok: false; error: string }; + +/** + * Verify that `stackName` exists on `nodeId` before inserting a stack-scoped + * role assignment. Local nodes use FileSystemService; remotes use a machine + * GET /api/stacks via NodeRegistry.getProxyTarget. + */ +export async function assertStackExistsOnNode( + nodeId: number, + stackName: string, +): Promise { + if (!isValidStackName(stackName)) { + return { ok: false, error: 'Invalid stack name' }; + } + + const node = DatabaseService.getInstance().getNode(nodeId); + if (!node) { + return { ok: false, error: 'Node not found' }; + } + + if (node.type === 'local') { + try { + const stacks = await FileSystemService.getInstance(nodeId).getStacks(); + if (!stacks.includes(stackName)) { + return { ok: false, error: 'Stack not found on node' }; + } + return { ok: true }; + } catch (err) { + console.error('[assertStackExistsOnNode] Local stack list failed:', getErrorMessage(err, 'unknown')); + return { ok: false, error: 'Failed to verify stack on node' }; + } + } + + const target = NodeRegistry.getInstance().getProxyTarget(nodeId); + if (!target) { + return { ok: false, error: 'Remote node is unreachable' }; + } + + const baseUrl = target.apiUrl.replace(/\/$/, ''); + const headers: Record = { + [PROXY_TIER_HEADER]: LicenseService.getInstance().getProxyHeaders().tier, + }; + if (target.apiToken) { + headers.Authorization = `Bearer ${target.apiToken}`; + } + + try { + const res = await axios.get(`${baseUrl}/api/stacks`, { + headers, + timeout: REMOTE_STACKS_TIMEOUT_MS, + validateStatus: () => true, + }); + if (res.status < 200 || res.status >= 300) { + return { ok: false, error: 'Failed to verify stack on remote node' }; + } + if (!Array.isArray(res.data)) { + return { ok: false, error: 'Failed to verify stack on remote node' }; + } + const names = res.data.filter((n): n is string => typeof n === 'string'); + if (!names.includes(stackName)) { + return { ok: false, error: 'Stack not found on node' }; + } + return { ok: true }; + } catch (err) { + console.error('[assertStackExistsOnNode] Remote stack list failed:', getErrorMessage(err, 'unknown')); + return { ok: false, error: 'Failed to verify stack on remote node' }; + } +} diff --git a/backend/src/helpers/stackRouteAuth.ts b/backend/src/helpers/stackRouteAuth.ts new file mode 100644 index 00000000..76667df8 --- /dev/null +++ b/backend/src/helpers/stackRouteAuth.ts @@ -0,0 +1,224 @@ +import { isPermissionAction, type PermissionAction } from '../middleware/permissions'; +import { isValidStackName } from '../utils/validation'; + +export type StackRouteClassify = + | { kind: 'named-stack'; stackName: string; action: PermissionAction } + | { kind: 'static' } + | { kind: 'unknown-named' }; + +/** Static collection / create paths under /stacks (no stack-scoped resource). */ +const STATIC_STACK_PATHS = new Set([ + '/stacks', + '/stacks/', + '/stacks/statuses', + '/stacks/discovery', + '/stacks/import/scan', + '/stacks/import/move', + '/stacks/bulk', + '/stacks/from-git', +]); + +/** + * Exact relative suffixes under `/stacks/:name` mapped to the primary hub + * pre-check action. Service-name paths are matched separately via regex. + */ +type SuffixRule = { method: string; suffix: string; action: PermissionAction }; + +const EXACT_SUFFIX_RULES: readonly SuffixRule[] = [ + // Read + { method: 'GET', suffix: '', action: 'stack:read' }, + { method: 'GET', suffix: '/envs', action: 'stack:read' }, + { method: 'GET', suffix: '/env', action: 'stack:read' }, + { method: 'GET', suffix: '/project-env-files', action: 'stack:read' }, + { method: 'GET', suffix: '/project-env-files/candidates', action: 'stack:read' }, + { method: 'GET', suffix: '/dossier', action: 'stack:read' }, + { method: 'GET', suffix: '/containers', action: 'stack:read' }, + { method: 'GET', suffix: '/services', action: 'stack:read' }, + { method: 'GET', suffix: '/drift', action: 'stack:read' }, + { method: 'GET', suffix: '/preflight', action: 'stack:read' }, + { method: 'GET', suffix: '/missing-external-networks', action: 'stack:read' }, + { method: 'GET', suffix: '/preflight/acknowledgements', action: 'stack:read' }, + { method: 'GET', suffix: '/networking', action: 'stack:read' }, + { method: 'GET', suffix: '/storage', action: 'stack:read' }, + { method: 'GET', suffix: '/effective-anatomy', action: 'stack:read' }, + { method: 'GET', suffix: '/effective-services', action: 'stack:read' }, + { method: 'GET', suffix: '/env-inventory', action: 'stack:read' }, + { method: 'GET', suffix: '/label-inventory', action: 'stack:read' }, + { method: 'GET', suffix: '/exposure', action: 'stack:read' }, + { method: 'GET', suffix: '/update-readiness', action: 'stack:read' }, + { method: 'GET', suffix: '/rollback-readiness', action: 'stack:read' }, + { method: 'GET', suffix: '/health-gate', action: 'stack:read' }, + { method: 'GET', suffix: '/update-preview', action: 'stack:read' }, + { method: 'GET', suffix: '/backup', action: 'stack:read' }, + { method: 'GET', suffix: '/scan-status', action: 'stack:read' }, + { method: 'GET', suffix: '/file-roots', action: 'stack:read' }, + { method: 'GET', suffix: '/files', action: 'stack:read' }, + { method: 'GET', suffix: '/files/content', action: 'stack:read' }, + { method: 'GET', suffix: '/files/download', action: 'stack:read' }, + { method: 'GET', suffix: '/files/bulk-download', action: 'stack:read' }, + { method: 'GET', suffix: '/files/permissions', action: 'stack:read' }, + { method: 'GET', suffix: '/activity', action: 'stack:read' }, + { method: 'GET', suffix: '/git-source', action: 'stack:read' }, + + // Edit + { method: 'PUT', suffix: '', action: 'stack:edit' }, + { method: 'PUT', suffix: '/env', action: 'stack:edit' }, + { method: 'PUT', suffix: '/project-env-files', action: 'stack:edit' }, + { method: 'PUT', suffix: '/dossier', action: 'stack:edit' }, + { method: 'POST', suffix: '/drift/recheck', action: 'stack:read' }, + { method: 'POST', suffix: '/preflight/run', action: 'stack:read' }, + { method: 'POST', suffix: '/preflight/acknowledgements', action: 'stack:edit' }, + { method: 'PUT', suffix: '/exposure', action: 'stack:edit' }, + { method: 'POST', suffix: '/files/upload', action: 'stack:edit' }, + { method: 'PUT', suffix: '/files/content', action: 'stack:edit' }, + { method: 'DELETE', suffix: '/files', action: 'stack:edit' }, + { method: 'POST', suffix: '/files/folder', action: 'stack:edit' }, + { method: 'PATCH', suffix: '/files/rename', action: 'stack:edit' }, + { method: 'POST', suffix: '/files/copy', action: 'stack:edit' }, + { method: 'POST', suffix: '/files/bulk-delete', action: 'stack:edit' }, + { method: 'POST', suffix: '/files/bulk-move', action: 'stack:edit' }, + { method: 'PUT', suffix: '/files/permissions', action: 'stack:edit' }, + { method: 'PUT', suffix: '/labels', action: 'stack:edit' }, + { method: 'PUT', suffix: '/git-source', action: 'stack:edit' }, + { method: 'DELETE', suffix: '/git-source', action: 'stack:edit' }, + { method: 'POST', suffix: '/git-source/pull', action: 'stack:edit' }, + { method: 'POST', suffix: '/git-source/apply', action: 'stack:edit' }, + { method: 'POST', suffix: '/git-source/webhook-pull', action: 'stack:edit' }, + { method: 'POST', suffix: '/git-source/dismiss-pending', action: 'stack:edit' }, + { method: 'POST', suffix: '/git-source/browse', action: 'stack:edit' }, + + // Deploy + { method: 'POST', suffix: '/deploy', action: 'stack:deploy' }, + { method: 'POST', suffix: '/down', action: 'stack:deploy' }, + { method: 'POST', suffix: '/restart', action: 'stack:deploy' }, + { method: 'POST', suffix: '/stop', action: 'stack:deploy' }, + { method: 'POST', suffix: '/start', action: 'stack:deploy' }, + { method: 'POST', suffix: '/update-preview', action: 'stack:deploy' }, + { method: 'POST', suffix: '/update', action: 'stack:deploy' }, + { method: 'POST', suffix: '/rollback', action: 'stack:deploy' }, + { method: 'POST', suffix: '/backup', action: 'stack:deploy' }, + + // Delete + { method: 'DELETE', suffix: '', action: 'stack:delete' }, +]; + +const EXACT_SUFFIX_INDEX = new Map( + EXACT_SUFFIX_RULES.map((r) => [`${r.method} ${r.suffix}`, r.action]), +); + +/** `/services/:serviceName/{restart|stop|start|update|restore|recovery}` */ +const SERVICE_SUFFIX_RE = + /^\/services\/[^/]+\/(restart|stop|start|update|restore|recovery)$/; + +/** `/preflight/acknowledgements/:id` */ +const PREFLIGHT_ACK_DELETE_RE = /^\/preflight\/acknowledgements\/[^/]+$/; + +function normalizePath(pathAfterApiStrip: string): string { + const withoutQuery = pathAfterApiStrip.split('?')[0] ?? pathAfterApiStrip; + if (withoutQuery.length > 1 && withoutQuery.endsWith('/')) { + return withoutQuery.slice(0, -1); + } + return withoutQuery; +} + +function decodeStackSegment(raw: string): string | null { + let decoded: string; + try { + decoded = decodeURIComponent(raw); + } catch { + return null; + } + if (!isValidStackName(decoded)) return null; + return decoded; +} + +/** + * Classify a post-/api path for hub stack RBAC gating and evidence. + * Paths outside `/stacks` (and static `/stacks` collection routes) are + * `static`. Known named-stack families return the primary pre-check action. + * An unrecognized `/stacks//...` path fails closed as `unknown-named`. + */ +export function classifyStackApiPath(method: string, pathAfterApiStrip: string): StackRouteClassify { + const methodUpper = method.toUpperCase(); + const path = normalizePath(pathAfterApiStrip); + + if (!path.startsWith('/stacks')) { + return { kind: 'static' }; + } + + if (STATIC_STACK_PATHS.has(path) || (methodUpper === 'POST' && path === '/stacks')) { + return { kind: 'static' }; + } + + // Reserved first segments that look like names but are collection routes. + if ( + path === '/stacks/statuses' + || path === '/stacks/discovery' + || path.startsWith('/stacks/import/') + || path === '/stacks/bulk' + || path === '/stacks/from-git' + ) { + return { kind: 'static' }; + } + + const match = /^\/stacks\/([^/]+)(.*)$/.exec(path); + if (!match) { + return { kind: 'static' }; + } + + const stackName = decodeStackSegment(match[1]); + if (!stackName) { + return { kind: 'unknown-named' }; + } + + const suffix = match[2] ?? ''; + + const exact = EXACT_SUFFIX_INDEX.get(`${methodUpper} ${suffix}`); + if (exact) { + return { kind: 'named-stack', stackName, action: exact }; + } + + if (methodUpper === 'POST' && SERVICE_SUFFIX_RE.test(suffix)) { + const op = SERVICE_SUFFIX_RE.exec(suffix)?.[1]; + if (op === 'recovery') { + // recovery is GET-only in stacks.ts; POST recovery is unknown + return { kind: 'unknown-named' }; + } + return { kind: 'named-stack', stackName, action: 'stack:deploy' }; + } + + if (methodUpper === 'GET' && /^\/services\/[^/]+\/recovery$/.test(suffix)) { + return { kind: 'named-stack', stackName, action: 'stack:deploy' }; + } + + if (methodUpper === 'DELETE' && PREFLIGHT_ACK_DELETE_RE.test(suffix)) { + return { kind: 'named-stack', stackName, action: 'stack:edit' }; + } + + return { kind: 'unknown-named' }; +} + +/** + * Parse the comma-separated scoped-actions header. Returns null when empty + * or when any token is not a known PermissionAction. + */ +export function parseScopedStackActionsHeader(value: string): PermissionAction[] | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const parts = trimmed.split(',').map((p) => p.trim()).filter((p) => p.length > 0); + if (parts.length === 0) return null; + const actions: PermissionAction[] = []; + const seen = new Set(); + for (const part of parts) { + if (!isPermissionAction(part)) return null; + if (seen.has(part)) continue; + seen.add(part); + actions.push(part); + } + return actions; +} + +/** Serialize PermissionAction values for the scoped-actions proxy header. */ +export function formatScopedStackActionsHeader(actions: Iterable): string { + return [...new Set(actions)].join(','); +} diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 86434dc2..c23da8ed 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -8,10 +8,20 @@ import { type ApiTokenScope, } from '../services/DatabaseService'; import { getErrorMessage } from '../utils/errors'; -import { PROXY_TIER_HEADER, PROXY_ROLE_HEADER, PROXY_DEPLOY_SOURCE_HEADER, PROXY_DEPLOY_ACTOR_HEADER, isDeploySourceHeader } from '../services/license-headers'; +import { + PROXY_TIER_HEADER, + PROXY_ROLE_HEADER, + PROXY_DEPLOY_SOURCE_HEADER, + PROXY_DEPLOY_ACTOR_HEADER, + PROXY_SCOPED_STACK_NAME_HEADER, + PROXY_SCOPED_STACK_ACTIONS_HEADER, + isDeploySourceHeader, +} from '../services/license-headers'; import type { DeployInvocationContext } from '../services/network/missingExternalNetworksError'; import { isLicenseTier, normalizeTier } from '../services/license-normalize'; import { isDebugEnabled } from '../utils/debug'; +import { parseScopedStackActionsHeader } from '../helpers/stackRouteAuth'; +import { isValidStackName } from '../utils/validation'; import { COOKIE_NAME, MFA_PENDING_COOKIE_NAME, @@ -150,6 +160,19 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response req.deployContext = ctx; } + // Scoped stack auth evidence: only trust on this machine-auth path. + // Malformed or incomplete pairs are treated as absent (never as auth). + const scopedNameRaw = req.headers[PROXY_SCOPED_STACK_NAME_HEADER]; + const scopedActionsRaw = req.headers[PROXY_SCOPED_STACK_ACTIONS_HEADER]; + const scopedName = typeof scopedNameRaw === 'string' ? scopedNameRaw.trim() : ''; + const scopedActionsStr = typeof scopedActionsRaw === 'string' ? scopedActionsRaw : ''; + if (scopedName && isValidStackName(scopedName) && scopedActionsStr) { + const actions = parseScopedStackActionsHeader(scopedActionsStr); + if (actions && actions.length > 0) { + req.scopedStackEvidence = { stackName: scopedName, actions: new Set(actions) }; + } + } + next(); return; } diff --git a/backend/src/middleware/permissions.ts b/backend/src/middleware/permissions.ts index 3c558205..1ccbe215 100644 --- a/backend/src/middleware/permissions.ts +++ b/backend/src/middleware/permissions.ts @@ -34,6 +34,50 @@ export const ROLE_PERMISSIONS: Record = { ], }; +/** Canonical PermissionAction set (admin matrix covers every action). */ +export const ALL_PERMISSION_ACTIONS: readonly PermissionAction[] = ROLE_PERMISSIONS.admin; + +export function isPermissionAction(value: string): value is PermissionAction { + return (ALL_PERMISSION_ACTIONS as readonly string[]).includes(value); +} + +/** + * Collect stack:* actions from role matrices. Used for remote evidence so + * node:/system: never leave the hub on a machine-auth hop. + */ +function addStackActionsFromRole( + actions: Set, + role: UserRole, +): void { + for (const action of ROLE_PERMISSIONS[role] ?? []) { + if (action.startsWith('stack:')) { + actions.add(action); + } + } +} + +/** + * Union of PermissionAction values conferred by the user's exact stack + * grant for (nodeId, stackName), plus any node-scoped grant on that node + * (node-wide roles cover every stack on the node). Used when the hub + * builds bound evidence for a remote hop. + */ +export function scopedActionsForStack( + userId: number, + nodeId: number, + stackName: string, +): PermissionAction[] { + const db = DatabaseService.getInstance(); + const actions = new Set(); + for (const assignment of db.getRoleAssignments(userId, 'stack', stackName, nodeId)) { + addStackActionsFromRole(actions, assignment.role); + } + for (const assignment of db.getRoleAssignments(userId, 'node', String(nodeId))) { + addStackActionsFromRole(actions, assignment.role); + } + return [...actions]; +} + /** Core permission resolver. Admin bypasses all checks; scoped assignments only apply on the paid tier. */ export function checkPermission( req: Request, @@ -51,14 +95,49 @@ export function checkPermission( if (ROLE_PERMISSIONS[globalRole]?.includes(action)) return true; if (!resourceType || !resourceId) return false; + + // Bound machine evidence from the hub (node_proxy / pilot_tunnel only). + // Authorizes exact stack + action members of the evidenced set without a + // local role_assignments row (remote userId is 0). + const evidence = req.scopedStackEvidence; + if ( + evidence + && resourceType === 'stack' + && resourceId === evidence.stackName + && action.startsWith('stack:') + && evidence.actions.has(action) + ) { + return true; + } + if (effectiveTier(req) !== 'paid') return false; - const assignments = DatabaseService.getInstance().getRoleAssignments(req.user.userId, resourceType, resourceId); + const db = DatabaseService.getInstance(); + const nodeId = resourceType === 'stack' ? req.nodeId : null; + const assignments = db.getRoleAssignments( + req.user.userId, + resourceType, + resourceId, + nodeId, + ); if (isDebugEnabled()) console.log('[RBAC:diag] Scoped assignments found:', assignments.length, 'for user:', req.user.userId); for (const assignment of assignments) { if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true; } + // Node-scoped grants are node-wide: a Node Admin / Deployer / Admin on + // node N authorizes that role's stack actions for every stack on N. + if (resourceType === 'stack' && req.nodeId != null) { + const nodeAssignments = db.getRoleAssignments( + req.user.userId, + 'node', + String(req.nodeId), + ); + for (const assignment of nodeAssignments) { + if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true; + } + } + return false; } diff --git a/backend/src/proxy/remoteNodeProxy.ts b/backend/src/proxy/remoteNodeProxy.ts index abef7123..06f27a7b 100644 --- a/backend/src/proxy/remoteNodeProxy.ts +++ b/backend/src/proxy/remoteNodeProxy.ts @@ -1,17 +1,38 @@ import type { Request, Response, NextFunction, RequestHandler } from 'express'; import { createProxyMiddleware } from 'http-proxy-middleware'; import { NodeRegistry } from '../services/NodeRegistry'; -import { PROXY_TIER_HEADER, PROXY_ROLE_HEADER, PROXY_DEPLOY_SOURCE_HEADER, PROXY_DEPLOY_ACTOR_HEADER } from '../services/license-headers'; +import { + PROXY_TIER_HEADER, + PROXY_ROLE_HEADER, + PROXY_DEPLOY_SOURCE_HEADER, + PROXY_DEPLOY_ACTOR_HEADER, + PROXY_SCOPED_STACK_NAME_HEADER, + PROXY_SCOPED_STACK_ACTIONS_HEADER, +} from '../services/license-headers'; import { LicenseService } from '../services/LicenseService'; import { isProxyExemptPath } from '../helpers/proxyExemptPaths'; import { remoteSupportsCrossNodeRbac, remoteAdvertisesCapability } from '../helpers/remoteCapabilities'; -import { STACK_DOWN_REMOVE_VOLUMES_CAPABILITY, SERVICE_SCOPED_UPDATE_CAPABILITY, SERVICE_SCOPED_STACK_ALERT_CAPABILITY } from '../services/CapabilityRegistry'; +import { + STACK_DOWN_REMOVE_VOLUMES_CAPABILITY, + SERVICE_SCOPED_UPDATE_CAPABILITY, + SERVICE_SCOPED_STACK_ALERT_CAPABILITY, + SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY, +} from '../services/CapabilityRegistry'; import { getErrorMessage } from '../utils/errors'; import { DatabaseService } from '../services/DatabaseService'; import { redactSensitiveText } from '../utils/safeLog'; import { isDebugEnabled } from '../utils/debug'; import { logDebugTiming, templatizeHydrationPath } from '../utils/requestTiming'; import { invalidateFleetUpdateCache, isFullStackUpdatePath, isUpdatePreviewPath } from '../helpers/fleetUpdateCache'; +import { + classifyStackApiPath, + formatScopedStackActionsHeader, +} from '../helpers/stackRouteAuth'; +import { + checkPermission, + ROLE_PERMISSIONS, + scopedActionsForStack, +} from '../middleware/permissions'; /** * Per-request hop timing for the critical hydration GETs, kept off the Request @@ -132,6 +153,17 @@ export function createRemoteProxyMiddleware(): RequestHandler { if (req.user?.username) { proxyReq.setHeader(PROXY_DEPLOY_ACTOR_HEADER, req.user.username); } + // Scoped stack evidence: always strip client-supplied values, then + // attach hub-built evidence when the gate stashed elevation for this hop. + proxyReq.removeHeader(PROXY_SCOPED_STACK_NAME_HEADER); + proxyReq.removeHeader(PROXY_SCOPED_STACK_ACTIONS_HEADER); + if (req.proxyScopedStackEvidence) { + proxyReq.setHeader(PROXY_SCOPED_STACK_NAME_HEADER, req.proxyScopedStackEvidence.stackName); + proxyReq.setHeader( + PROXY_SCOPED_STACK_ACTIONS_HEADER, + formatScopedStackActionsHeader(req.proxyScopedStackEvidence.actions), + ); + } // Strip the ?nodeId= query param so the remote's nodeContextMiddleware // doesn't reject the request with 404 ("Node X not found") - the remote // has no record of the gateway's node IDs and should treat the request @@ -188,6 +220,23 @@ export function createRemoteProxyMiddleware(): RequestHandler { ) { invalidateFleetUpdateCache(); } + // Successful remote stack DELETE: clear hub grants for this (node, stack) + // only. Failed / non-2xx responses must preserve assignments. Use the + // gate-stashed classification: pathRewrite mutates req.url before this + // callback, so re-running classifyStackApiPath(req.path) would miss. + if (req.method === 'DELETE' && status >= 200 && status < 300) { + const route = req.proxyNamedStackRoute; + if (route?.action === 'stack:delete') { + try { + DatabaseService.getInstance().deleteRoleAssignmentsByStack(req.nodeId, route.stackName); + } catch (cleanupErr) { + console.warn( + '[Proxy] Failed to clear role assignments after remote stack delete:', + getErrorMessage(cleanupErr, 'unknown'), + ); + } + } + } }, error: (err, req, proxyRes) => { // Finalize the hop timing with an error outcome before the existing @@ -282,6 +331,49 @@ export function createRemoteProxyMiddleware(): RequestHandler { } } + // Named-stack hub pre-check + optional scoped-evidence elevation. + const classified = classifyStackApiPath(req.method, req.path); + if (classified.kind === 'unknown-named') { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + if (classified.kind === 'named-stack') { + if (!checkPermission(req, classified.action, 'stack', classified.stackName)) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + // Stash before pathRewrite so proxyRes DELETE cleanup can see the route. + req.proxyNamedStackRoute = { + stackName: classified.stackName, + action: classified.action, + }; + const globalRole = req.user?.role; + const globalGrantsPrimary = + globalRole === 'admin' + || (globalRole != null && (ROLE_PERMISSIONS[globalRole]?.includes(classified.action) ?? false)); + if (!globalGrantsPrimary) { + const evidenceSupported = await remoteAdvertisesCapability( + req.nodeId, + SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY, + ); + if (!evidenceSupported) { + res.status(403).json({ + error: `Remote node "${node.name}" does not support scoped stack authorization. Upgrade it before scoped users can act on it.`, + }); + return; + } + if (!req.user) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + const actions = scopedActionsForStack(req.user.userId, req.nodeId, classified.stackName); + req.proxyScopedStackEvidence = { + stackName: classified.stackName, + actions, + }; + } + } + // POST /alerts bodies are not on req.body for remote hops (JSON parsing // is skipped so the stream can be piped). Buffer once under the same // 100 KB cap as express.json(), gate on service_name, then rewrite diff --git a/backend/src/routes/permissions.ts b/backend/src/routes/permissions.ts index f2ef92a1..d229cb01 100644 --- a/backend/src/routes/permissions.ts +++ b/backend/src/routes/permissions.ts @@ -24,7 +24,9 @@ permissionsRouter.get('/me', authMiddleware, (req: Request, res: Response): void const scopedPermissions: Record = {}; if (effectiveTier(req) === 'paid') { for (const a of db.getAllRoleAssignments(req.user.userId)) { - const key = `${a.resource_type}:${a.resource_id}`; + const key = a.resource_type === 'stack' + ? `stack:${a.node_id}:${a.resource_id}` + : `node:${a.resource_id}`; const perms = ROLE_PERMISSIONS[a.role] || []; const existing = scopedPermissions[key] || []; scopedPermissions[key] = [...new Set([...existing, ...perms])]; diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 191e3a38..7321f3d9 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -10,6 +10,8 @@ import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors'; import { parseIntParam } from '../utils/parseIntParam'; import { sanitizeForLog } from '../utils/safeLog'; import { validateUsername } from '../helpers/validateUsername'; +import { assertStackExistsOnNode } from '../helpers/assertStackExistsOnNode'; +import { isValidStackName } from '../utils/validation'; const USERS_SCOPE_MESSAGE = 'API tokens cannot access user management.'; const VALID_USER_ROLES: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor']; @@ -247,13 +249,13 @@ usersRouter.get('/:id/roles', authMiddleware, (req: Request, res: Response): voi } }); -usersRouter.post('/:id/roles', authMiddleware, (req: Request, res: Response): void => { +usersRouter.post('/:id/roles', authMiddleware, async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return; if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; try { const userId = parseInt(req.params.id as string, 10); - const { role, resource_type, resource_id } = req.body; + const { role, resource_type, resource_id, node_id: rawNodeId } = req.body; if (!VALID_ASSIGNMENT_ROLES.includes(role)) { res.status(400).json({ error: 'Invalid role' }); @@ -274,10 +276,72 @@ usersRouter.post('/:id/roles', authMiddleware, (req: Request, res: Response): vo return; } + let nodeId: number | null = null; + + if (resource_type === 'stack') { + if (typeof rawNodeId !== 'number' || !Number.isInteger(rawNodeId)) { + res.status(400).json({ error: 'node_id is required for stack role assignments' }); + return; + } + if (!isValidStackName(resource_id)) { + res.status(400).json({ error: 'Invalid stack name' }); + return; + } + const exists = await assertStackExistsOnNode(rawNodeId, resource_id); + if (!exists.ok) { + res.status(400).json({ error: exists.error }); + return; + } + nodeId = rawNodeId; + } else { + // node resource: reject a stack-style node_id qualifier + if (rawNodeId !== undefined && rawNodeId !== null) { + res.status(400).json({ error: 'node_id must not be set for node role assignments' }); + return; + } + if (!/^\d+$/.test(resource_id)) { + res.status(400).json({ error: 'resource_id must be a numeric node id' }); + return; + } + const parsedNodeId = parseInt(resource_id, 10); + if (String(parsedNodeId) !== resource_id) { + res.status(400).json({ error: 'resource_id must be a canonical node id' }); + return; + } + if (!db.getNode(parsedNodeId)) { + res.status(400).json({ error: 'Node not found' }); + return; + } + } + try { - const id = db.addRoleAssignment({ user_id: userId, role, resource_type, resource_id }); - console.log('[Roles] Assigned', sanitizeForLog(role), 'on', sanitizeForLog(resource_type), sanitizeForLog(resource_id), 'to user', userId, 'by:', sanitizeForLog(req.user!.username)); - res.status(201).json({ id, user_id: userId, role, resource_type, resource_id }); + const id = db.addRoleAssignment({ + user_id: userId, + role, + resource_type, + resource_id, + node_id: nodeId, + }); + console.log( + '[Roles] Assigned', + sanitizeForLog(role), + 'on', + sanitizeForLog(resource_type), + sanitizeForLog(resource_id), + sanitizeForLog(nodeId != null ? `node ${nodeId}` : ''), + 'to user', + userId, + 'by:', + sanitizeForLog(req.user!.username), + ); + res.status(201).json({ + id, + user_id: userId, + role, + resource_type, + resource_id, + node_id: nodeId, + }); } catch (err: unknown) { if (isSqliteUniqueViolation(err)) { res.status(409).json({ error: 'This role assignment already exists' }); diff --git a/backend/src/services/BlueprintService.ts b/backend/src/services/BlueprintService.ts index adddbee8..73f4b161 100644 --- a/backend/src/services/BlueprintService.ts +++ b/backend/src/services/BlueprintService.ts @@ -657,6 +657,7 @@ export class BlueprintService { ); } if (res.status === 200) { + DatabaseService.getInstance().deleteRoleAssignmentsByStack(node.id, blueprint.name); return { status: 'withdrawn' }; } if (res.status === 409) { diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 1ef69b2f..3d385ce7 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -62,6 +62,7 @@ export const CAPABILITIES = [ 'guided-external-network-preflight', 'service-scoped-update', 'service-scoped-stack-alert', + 'scoped-stack-auth-evidence', ] as const; /** @@ -103,6 +104,15 @@ export const SERVICE_SCOPED_UPDATE_CAPABILITY = 'service-scoped-update' as const export const SERVICE_SCOPED_STACK_ALERT_CAPABILITY = 'service-scoped-stack-alert' as const satisfies Capability; +/** + * Remotes that consume hub-bound scoped stack auth evidence headers + * (`x-sencho-scoped-stack-name` / `x-sencho-scoped-stack-actions`) under + * machine auth. Hubs fail closed when scoped elevation is needed and the + * remote lacks this flag. + */ +export const SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY = + 'scoped-stack-auth-evidence' as const satisfies Capability; + /** Returns true when the string is a usable semver version. */ export function isValidVersion(v: string | null | undefined): v is string { return !!v && v !== 'unknown' && v !== '0.0.0-dev' && !!semver.valid(v); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 5eea339a..dad728f6 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -508,6 +508,8 @@ export interface RoleAssignment { role: UserRole; resource_type: ResourceType; resource_id: string; + /** Required for stack scopes; null for node scopes. */ + node_id: number | null; created_at: number; } @@ -2245,11 +2247,109 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_role_assignments_user ON role_assignments(user_id); CREATE INDEX IF NOT EXISTS idx_role_assignments_resource ON role_assignments(resource_type, resource_id); `); - try { - this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_role_assignments_unique ON role_assignments(user_id, role, resource_type, resource_id)'); - } catch (e) { - console.warn('[DatabaseService] Could not create role_assignments unique index:', (e as Error).message); - } + this.migrateRoleAssignmentsNodeQualified(); + } + + /** + * Rebuild role_assignments with Mesh-style stack identity (node_id, resource_id). + * Idempotent: probes sqlite_master for the final CHECK and both partial unique indexes. + */ + private migrateRoleAssignmentsNodeQualified(): void { + const tableSql = (this.db.prepare( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'role_assignments'" + ).get() as { sql: string } | undefined)?.sql ?? ''; + const indexRows = this.db.prepare( + "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'role_assignments'" + ).all() as Array<{ name: string; sql: string | null }>; + const hasNodeIdColumn = (this.db.prepare( + 'PRAGMA table_info(role_assignments)' + ).all() as Array<{ name: string }>).some((column) => column.name === 'node_id'); + const indexSqlByName = new Map(indexRows.map((r) => [r.name, r.sql ?? ''])); + const checkOk = + tableSql.includes("resource_type = 'stack' AND node_id IS NOT NULL") && + tableSql.includes("resource_type = 'node' AND node_id IS NULL"); + const stackUniqueSql = indexSqlByName.get('idx_role_assignments_stack_unique') ?? ''; + const nodeUniqueSql = indexSqlByName.get('idx_role_assignments_node_unique') ?? ''; + const stackUniqueOk = + stackUniqueSql.includes('user_id') && + stackUniqueSql.includes('role') && + stackUniqueSql.includes('resource_type') && + stackUniqueSql.includes('resource_id') && + stackUniqueSql.includes('node_id') && + /WHERE\s+resource_type\s*=\s*'stack'/i.test(stackUniqueSql); + const nodeUniqueOk = + nodeUniqueSql.includes('user_id') && + nodeUniqueSql.includes('role') && + nodeUniqueSql.includes('resource_type') && + nodeUniqueSql.includes('resource_id') && + /WHERE\s+resource_type\s*=\s*'node'/i.test(nodeUniqueSql) && + !/node_id/.test(nodeUniqueSql.replace(/WHERE[\s\S]*/i, '')); + if (checkOk && stackUniqueOk && nodeUniqueOk) return; + + this.db.exec('DROP TABLE IF EXISTS role_assignments_new'); + + // Do not call getDefaultNode(): NODE_COLUMNS may include columns not + // yet added when this migration runs early in the constructor chain. + const defaultNodeId = ( + this.db.prepare('SELECT id FROM nodes WHERE is_default = 1 LIMIT 1').get() as { id: number } | undefined + )?.id ?? null; + + this.db.transaction(() => { + this.db.exec(` + CREATE TABLE role_assignments_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + node_id INTEGER, + created_at INTEGER NOT NULL, + CHECK ( + (resource_type = 'stack' AND node_id IS NOT NULL) + OR (resource_type = 'node' AND node_id IS NULL) + ), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE + ); + `); + + this.db.exec(` + INSERT INTO role_assignments_new (id, user_id, role, resource_type, resource_id, node_id, created_at) + SELECT id, user_id, role, resource_type, resource_id, NULL, created_at + FROM role_assignments + WHERE resource_type = 'node'; + `); + + if (hasNodeIdColumn) { + this.db.exec(` + INSERT INTO role_assignments_new (id, user_id, role, resource_type, resource_id, node_id, created_at) + SELECT id, user_id, role, resource_type, resource_id, node_id, created_at + FROM role_assignments + WHERE resource_type = 'stack' AND node_id IS NOT NULL + `); + } else if (defaultNodeId !== null) { + this.db.prepare(` + INSERT INTO role_assignments_new (id, user_id, role, resource_type, resource_id, node_id, created_at) + SELECT id, user_id, role, resource_type, resource_id, ?, created_at + FROM role_assignments + WHERE resource_type = 'stack' + `).run(defaultNodeId); + } + // No default node: legacy stack rows are intentionally omitted (fail closed). + + this.db.exec(` + DROP TABLE role_assignments; + ALTER TABLE role_assignments_new RENAME TO role_assignments; + CREATE INDEX IF NOT EXISTS idx_role_assignments_user ON role_assignments(user_id); + CREATE INDEX IF NOT EXISTS idx_role_assignments_resource ON role_assignments(resource_type, resource_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_role_assignments_stack_unique + ON role_assignments(user_id, role, resource_type, resource_id, node_id) + WHERE resource_type = 'stack'; + CREATE UNIQUE INDEX IF NOT EXISTS idx_role_assignments_node_unique + ON role_assignments(user_id, role, resource_type, resource_id) + WHERE resource_type = 'node'; + `); + })(); } private migrateNotificationRoutes(): void { @@ -4723,6 +4823,7 @@ export class DatabaseService { this.db.prepare('DELETE FROM service_update_recovery WHERE node_id = ?').run(id); this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id); this.deleteRoleAssignmentsByResource('node', String(id)); + this.deleteRoleAssignmentsByStackNode(id); this.db.prepare('DELETE FROM fleet_sync_status WHERE node_id = ?').run(id); this.db.prepare('DELETE FROM node_update_skips WHERE node_id = ?').run(id); this.db.prepare( @@ -5405,23 +5506,44 @@ export class DatabaseService { // --- Role Assignments --- - public getRoleAssignments(userId: number, resourceType: ResourceType, resourceId: string): RoleAssignment[] { + public getRoleAssignments( + userId: number, + resourceType: ResourceType, + resourceId: string, + nodeId?: number | null, + ): RoleAssignment[] { + if (resourceType === 'stack') { + if (nodeId === undefined || nodeId === null) return []; + return this.db.prepare( + 'SELECT * FROM role_assignments WHERE user_id = ? AND resource_type = ? AND resource_id = ? AND node_id = ?' + ).all(userId, resourceType, resourceId, nodeId) as RoleAssignment[]; + } return this.db.prepare( - 'SELECT * FROM role_assignments WHERE user_id = ? AND resource_type = ? AND resource_id = ?' + 'SELECT * FROM role_assignments WHERE user_id = ? AND resource_type = ? AND resource_id = ? AND node_id IS NULL' ).all(userId, resourceType, resourceId) as RoleAssignment[]; } public getAllRoleAssignments(userId: number): RoleAssignment[] { return this.db.prepare( - 'SELECT * FROM role_assignments WHERE user_id = ? ORDER BY resource_type, resource_id' + 'SELECT * FROM role_assignments WHERE user_id = ? ORDER BY resource_type, resource_id, node_id' ).all(userId) as RoleAssignment[]; } - public addRoleAssignment(assignment: { user_id: number; role: UserRole; resource_type: ResourceType; resource_id: string }): number { + public addRoleAssignment(assignment: { + user_id: number; + role: UserRole; + resource_type: ResourceType; + resource_id: string; + node_id?: number | null; + }): number { const now = Date.now(); + const nodeId = assignment.resource_type === 'stack' ? assignment.node_id ?? null : null; + if (assignment.resource_type === 'stack' && (nodeId === null || nodeId === undefined)) { + throw new Error('node_id is required for stack role assignments'); + } const result = this.db.prepare( - 'INSERT INTO role_assignments (user_id, role, resource_type, resource_id, created_at) VALUES (?, ?, ?, ?, ?)' - ).run(assignment.user_id, assignment.role, assignment.resource_type, assignment.resource_id, now); + 'INSERT INTO role_assignments (user_id, role, resource_type, resource_id, node_id, created_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run(assignment.user_id, assignment.role, assignment.resource_type, assignment.resource_id, nodeId, now); return result.lastInsertRowid as number; } @@ -5441,6 +5563,20 @@ export class DatabaseService { this.db.prepare('DELETE FROM role_assignments WHERE resource_type = ? AND resource_id = ?').run(resourceType, resourceId); } + /** Clear stack-scoped grants for one (nodeId, stackName) tuple. */ + public deleteRoleAssignmentsByStack(nodeId: number, stackName: string): void { + this.db.prepare( + "DELETE FROM role_assignments WHERE resource_type = 'stack' AND node_id = ? AND resource_id = ?" + ).run(nodeId, stackName); + } + + /** Clear all stack-scoped grants for a node (explicit cleanup; FK CASCADE is not enforced). */ + public deleteRoleAssignmentsByStackNode(nodeId: number): void { + this.db.prepare( + "DELETE FROM role_assignments WHERE resource_type = 'stack' AND node_id = ?" + ).run(nodeId); + } + // --- SSO Config --- public getSSOConfigs(): SSOConfig[] { diff --git a/backend/src/services/DeployedStackDeletionService.ts b/backend/src/services/DeployedStackDeletionService.ts index b9ca7b92..340a6294 100644 --- a/backend/src/services/DeployedStackDeletionService.ts +++ b/backend/src/services/DeployedStackDeletionService.ts @@ -359,7 +359,7 @@ export class DeployedStackDeletionService { try { db.clearStackUpdateStatus(nodeId, stackName); db.clearStackScanAttempts(nodeId, stackName); - db.deleteRoleAssignmentsByResource('stack', stackName); + db.deleteRoleAssignmentsByStack(nodeId, stackName); db.deleteGitSource(stackName); db.deleteStackDossier(nodeId, stackName); db.deleteStackDriftFindings(nodeId, stackName); diff --git a/backend/src/services/license-headers.ts b/backend/src/services/license-headers.ts index e70ead58..45b14fb7 100644 --- a/backend/src/services/license-headers.ts +++ b/backend/src/services/license-headers.ts @@ -27,6 +27,16 @@ export const PROXY_ROLE_HEADER = 'x-sencho-actor-role'; export const PROXY_DEPLOY_SOURCE_HEADER = 'x-sencho-deploy-source'; export const PROXY_DEPLOY_ACTOR_HEADER = 'x-sencho-deploy-actor'; +/** + * Bound stack-scoped RBAC evidence for Proxy/Pilot hops. The hub strips any + * client-supplied values and, when scoped elevation is required, sets the + * exact stack name plus a comma-separated PermissionAction set conferred by + * that tuple's hub assignments. Remotes trust these only under node_proxy / + * pilot_tunnel machine auth. + */ +export const PROXY_SCOPED_STACK_NAME_HEADER = 'x-sencho-scoped-stack-name'; +export const PROXY_SCOPED_STACK_ACTIONS_HEADER = 'x-sencho-scoped-stack-actions'; + export const DEPLOY_SOURCES = [ 'manual', 'rollback', diff --git a/backend/src/types/express.ts b/backend/src/types/express.ts index 6007ef5d..a21b0c5c 100644 --- a/backend/src/types/express.ts +++ b/backend/src/types/express.ts @@ -1,5 +1,6 @@ import type { UserRole, ApiTokenScope, ApiToken } from '../services/DatabaseService'; import type { LicenseTier } from '../services/license-types'; +import type { PermissionAction } from '../middleware/permissions'; // Extend Express Request type for user and node context. // This file is imported for its side effects only (ambient declaration). @@ -27,6 +28,22 @@ declare global { deployContext?: import('../services/network/missingExternalNetworksError').DeployInvocationContext; /** Verified JWT scope for machine credentials (`node_proxy` / `pilot_tunnel`). */ machineAuthScope?: 'node_proxy' | 'pilot_tunnel'; + /** + * Hub-bound stack-scoped action evidence, trusted only when set under + * machine auth (`node_proxy` / `pilot_tunnel`). Never set from browser sessions. + */ + scopedStackEvidence?: { stackName: string; actions: ReadonlySet }; + /** + * Hub-side pending evidence to attach on the outbound proxy hop when + * the caller's global role alone would not grant the primary action. + */ + proxyScopedStackEvidence?: { stackName: string; actions: readonly PermissionAction[] }; + /** + * Named-stack classification from the hub gate. Stashed because + * http-proxy pathRewrite mutates req.url before proxyRes, so + * re-classifying req.path there would miss DELETE cleanup. + */ + proxyNamedStackRoute?: { stackName: string; action: PermissionAction }; } } } diff --git a/docs/features/rbac.mdx b/docs/features/rbac.mdx index ae047376..bd978f68 100644 --- a/docs/features/rbac.mdx +++ b/docs/features/rbac.mdx @@ -115,27 +115,28 @@ Click **Update user** to save. Changing the role takes effect on the next API re Scoped permissions let you grant a user a higher role on a specific stack or node without elevating them globally. A Viewer can be granted Deployer on one stack; a Deployer can be granted Node Admin on one server. +**Stack scopes are node-specific.** The same stack name on two different nodes is two independent grants. Assigning a stack scope means choosing the node first, then picking a stack that exists on that node. Display form conceptually: stack name @ node name (for example `frontend @ prod`). + +**Node scopes are node-wide.** The resource is the node itself. There is no separate node qualifier on a node assignment row. Granting Node Admin (or Deployer, or Admin) on `staging-server` authorizes that role's stack and node operations for every stack on that node, without a separate per-stack grant. + The box appears below the user form whenever you are editing a user on Admiral. - - Edit User form for the viewer account with a Scoped Permissions box below. The box contains an existing assignment row (a Deployer badge with the text on Stack: bazarr and a destructive trash icon on the right) and a three-column add-scope row underneath (Role combobox set to Deployer, Resource Type combobox set to Stack, Resource combobox showing Select..., and a disabled Add button). - - -The add-scope form has three controls and an **Add** button: +The add-scope form has these controls and an **Add** button: | Control | Options | |---------|---------| | **Role** | Deployer, Node Admin, or Admin. The scoped role picker is narrower than the global role picker. Viewer and Auditor cannot be scoped (they are floor-only roles). | | **Resource Type** | `Stack` or `Node`. | -| **Resource** | The picker shows stacks (when type is `Stack`) or remote nodes (when type is `Node`) the gateway knows about. Resource names match what you see in the sidebar. | +| **Node** | Shown when Resource Type is `Stack`. Choose the node that hosts the stack before the stack picker unlocks. | +| **Stack** or **Node** | When type is `Stack`, the picker lists stacks on the selected node. When type is `Node`, the picker lists nodes the gateway knows about. Names match what you see in the sidebar. | -Click **Add** to save the assignment. Existing scopes render as a row with the role badge, the line `on : `, and a trash icon for removal. Removing an assignment is instant; the user's effective permissions are recomputed on their next request. +Click **Add** to save the assignment. Existing scopes render as a row with the role badge, the line `on : `, and for stacks the node name after an `@`, plus a trash icon for removal. Removing an assignment is instant; the user's effective permissions are recomputed on their next request. -Scoped assignments are **additive only**. A Viewer with a scoped Deployer on `frontend` can deploy `frontend` but stays read-only on every other resource. Scopes never reduce the global role. +Scoped assignments are **additive only**. A Viewer with a scoped Deployer on `frontend` at a given node can deploy that stack on that node but stays read-only on every other resource. Scopes never reduce the global role. ### Example scenarios -- A **Viewer** with a scoped **Deployer** assignment on the `frontend` stack can deploy, restart, and stop only that stack. They cannot edit compose or delete it. +- A **Viewer** with a scoped **Deployer** assignment on the `frontend` stack at node `prod` can deploy, restart, and stop only that stack on `prod`. The same name on another node needs its own grant. They cannot edit compose or delete it. - A **Deployer** with a scoped **Node Admin** assignment on node `staging-server` can manage every stack and node operation on that server, while keeping plain Deployer rights on the rest of the fleet. - A **Node Admin** without any scoped assignments has full stack and node management across every node, but still cannot reach system settings, the user list, or the audit log. @@ -237,7 +238,7 @@ Entries include the acting user, IP address, HTTP method and path, response stat Check whether **Session policy > Keep active sessions alive** was turned off in **Settings > Users**. With it off, every session hits a strict, fixed 24-hour (or 30-day, with **Stay signed in**) ceiling regardless of activity. Turn it back on so an active session renews itself instead of hard-expiring, or have the user check **Stay signed in** at their next sign-in for a longer session between visits. - Two causes. **One,** the assignment was created on Admiral but the license has since dropped to Community. The permission resolver only consults scoped assignments when the effective tier is Admiral; on Community the scope is ignored and the user falls back to their global role. **Two,** the resource type or name on the assignment does not match the request's resource. Re-open the user in the edit form and check the existing-scope row matches the stack name (case-sensitive) exactly. + Three causes. **One,** the assignment was created on Admiral but the license has since dropped to Community. The permission resolver only consults scoped assignments when the effective tier is Admiral; on Community the scope is ignored and the user falls back to their global role. **Two,** the resource type or stack name on the assignment does not match the request's resource (names are case-sensitive). **Three,** the stack grant is tied to a different node than the one the user is acting on: the same stack name on another node is a separate grant. Re-open the user in the edit form and confirm the existing-scope row shows the expected stack name at the expected node. The icon only appears for users with a finished TOTP enrollment. If the user started enrollment but never confirmed their first code, the enrollment is incomplete and the icon stays hidden. Ask the user to finish enrollment from their account settings, or, if they cannot, leave the row alone: there is nothing to reset. diff --git a/docs/images/rbac/scoped-permissions.png b/docs/images/rbac/scoped-permissions.png deleted file mode 100644 index 214a136f88fdb5b47ea215d23cff6e1ae02d45d2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31916 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 { const stackName = stackNameOrFilename.replace(/\.(ya?ml)$/, ''); - return can('stack:edit', 'stack', stackName); + return can('stack:edit', 'stack', stackName, activeNode?.id); }, canOfferVolumeRemoval, onDeletedOpenStack: () => onDeletedOpenStackRef.current(), @@ -1059,6 +1059,7 @@ export default function EditorLayout() { can={can} selectedFile={selectedFile} stackName={stackName} + activeNodeId={activeNode?.id ?? null} gitSourceOpen={gitSourceOpen} setGitSourceOpen={setGitSourceOpen} canSelfUpdate={hasCapability('self-update')} diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index 777a83c0..7f14b579 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -311,7 +311,7 @@ export function EditorView(props: EditorViewProps) { hasUnsavedChanges, } = props; const monacoEditorRef = useRef(null); - const canEditCompose = can('stack:edit', 'stack', stackName); + const canEditCompose = can('stack:edit', 'stack', stackName, activeNode?.id); // Dispose the underlying Monaco model when EditorView unmounts. The // @monaco-editor/react wrapper reuses a single model per editor instance @@ -358,7 +358,7 @@ export function EditorView(props: EditorViewProps) { const safeContent = content || ''; const safeEnvContent = envContent || ''; const isRunning = safeContainers.some(c => c.State === 'running'); - const canRead = can('stack:read', 'stack', stackName); + const canRead = can('stack:read', 'stack', stackName, activeNode?.id); useEffect(() => { if (activeTab === 'files' && !canRead) { @@ -469,7 +469,7 @@ export function EditorView(props: EditorViewProps) { result={recoveryResult} activeNode={activeNode} backupInfo={backupInfo} - canDeploy={can('stack:deploy', 'stack', stackName)} + canDeploy={can('stack:deploy', 'stack', stackName, activeNode?.id)} onRetry={retryHandlerFor(recoveryResult.action, { deployStack, restartStack, updateStack, rollbackStack })} onRestart={restartStack} onRollback={rollbackStack} @@ -663,7 +663,7 @@ export function EditorView(props: EditorViewProps) { {activeTab === 'files' && canRead ? ( setActiveTab('compose')} onNavigateToEnv={() => setActiveTab('env')} @@ -732,7 +732,7 @@ export function EditorView(props: EditorViewProps) { onOpenGitSource={() => setGitSourceOpen(true)} onApplyUpdate={() => { void updateStack(); }} applying={loadingAction === 'update'} - canEdit={can('stack:edit', 'stack', stackName)} + canEdit={can('stack:edit', 'stack', stackName, activeNode?.id)} notifications={notifications} requestedTab={props.requestedAnatomyTab} /> diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.tsx index 1c4b81d4..7614b0ef 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.tsx @@ -93,7 +93,7 @@ export function MobileStackDetail(props: EditorViewProps) { const safeContainers = containers || []; const isMultiContainerLayout = safeContainers.length > 1 || effectiveServices.length > 1; const isRunning = safeContainers.some(c => c.State === 'running'); - const canEditStack = can('stack:edit', 'stack', stackName); + const canEditStack = can('stack:edit', 'stack', stackName, activeNode?.id); // The writable editor layer renders only for an editor; a stale editingCompose // while the user lacks stack:edit falls back to the read-only Compose segment. @@ -182,7 +182,7 @@ export function MobileStackDetail(props: EditorViewProps) { result={recoveryResult} activeNode={activeNode} backupInfo={backupInfo} - canDeploy={can('stack:deploy', 'stack', stackName)} + canDeploy={can('stack:deploy', 'stack', stackName, activeNode?.id)} onRetry={retryHandlerFor(recoveryResult.action, { deployStack, restartStack, updateStack, rollbackStack })} onRestart={restartStack} onRollback={rollbackStack} diff --git a/frontend/src/components/EditorLayout/ShellOverlays.tsx b/frontend/src/components/EditorLayout/ShellOverlays.tsx index 69be9161..61dd0afe 100644 --- a/frontend/src/components/EditorLayout/ShellOverlays.tsx +++ b/frontend/src/components/EditorLayout/ShellOverlays.tsx @@ -25,9 +25,10 @@ interface ShellOverlaysProps { stackActions: StackActionsHook; isDarkMode: boolean; isAdmin: boolean; - can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean; + can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean; selectedFile: string | null; stackName: string; + activeNodeId: number | null; gitSourceOpen: boolean; setGitSourceOpen: (open: boolean) => void; canSelfUpdate: boolean; @@ -45,6 +46,7 @@ export function ShellOverlays({ can, selectedFile, stackName, + activeNodeId, gitSourceOpen, setGitSourceOpen, canSelfUpdate, @@ -210,7 +212,7 @@ export function ShellOverlays({ open={gitSourceOpen} onOpenChange={setGitSourceOpen} stackName={stackName} - canEdit={can('stack:edit', 'stack', stackName)} + canEdit={can('stack:edit', 'stack', stackName, activeNodeId)} isDarkMode={isDarkMode} onSourceChanged={stackActions.refreshGitSourcePending} /> diff --git a/frontend/src/components/EditorLayout/editor-view-blocks.tsx b/frontend/src/components/EditorLayout/editor-view-blocks.tsx index d82fa62c..bfb2a97b 100644 --- a/frontend/src/components/EditorLayout/editor-view-blocks.tsx +++ b/frontend/src/components/EditorLayout/editor-view-blocks.tsx @@ -203,8 +203,8 @@ export function StackIdentityHeader({ backend permissions so a delete-only or deploy-only persona sees exactly what they can act on. */} {(() => { - const canDeploy = can('stack:deploy', 'stack', stackName); - const canDelete = can('stack:delete', 'stack', stackName); + const canDeploy = can('stack:deploy', 'stack', stackName, activeNode?.id); + const canDelete = can('stack:delete', 'stack', stackName, activeNode?.id); const canRollback = canDeploy && backupInfo.exists; const canScan = trivy.available && isAdmin; const canMute = stackMuteActions?.canMute ?? false; diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts index e9329318..11ac09a8 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts @@ -31,7 +31,7 @@ interface UseSidebarContextMenuOptions { stackActions: StackActionsHook; activeNode: Node | null | undefined; isAdmin: boolean; - can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean; + can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean; } export function useSidebarContextMenu({ @@ -61,9 +61,9 @@ export function useSidebarContextMenu({ canOpenApp: mainPort !== undefined && buildServiceUrl({ node: activeNode, publicPort: mainPort }) !== null, isBusy: stackListState.isStackBusy(file), isAdmin, - canDelete: can('stack:delete', 'stack', sName), - canDeploy: can('stack:deploy', 'stack', sName), - canEditLabels: can('stack:edit', 'stack', sName), + canDelete: can('stack:delete', 'stack', sName, nodeId), + canDeploy: can('stack:deploy', 'stack', sName, nodeId), + canEditLabels: can('stack:edit', 'stack', sName, nodeId), // POST /api/labels (the inline "New label" entry) is guarded by the // unscoped requirePermission('stack:edit'); a user with only per-stack // scoped edit can toggle existing labels but cannot create new ones. diff --git a/frontend/src/components/networking/NetworkingView.tsx b/frontend/src/components/networking/NetworkingView.tsx index 7810ed39..ba80f605 100644 --- a/frontend/src/components/networking/NetworkingView.tsx +++ b/frontend/src/components/networking/NetworkingView.tsx @@ -342,7 +342,7 @@ export function NetworkingView({ headerActions }: NetworkingViewProps) { {topFindings.map((finding) => { const primary = finding.recommendedActions.find((action) => - isNetworkingActionVisible(action, isAdmin, (stack) => can('stack:edit', 'stack', stack)), + isNetworkingActionVisible(action, isAdmin, (stack) => can('stack:edit', 'stack', stack, nodeId)), ); return ( diff --git a/frontend/src/components/settings/UsersSection.tsx b/frontend/src/components/settings/UsersSection.tsx index 0dfbb992..d7597f38 100644 --- a/frontend/src/components/settings/UsersSection.tsx +++ b/frontend/src/components/settings/UsersSection.tsx @@ -36,6 +36,7 @@ interface RoleAssignmentItem { role: UserRole; resource_type: 'stack' | 'node'; resource_id: string; + node_id: number | null; created_at: number; } @@ -209,6 +210,11 @@ export function UsersSection() { setFormRole('viewer'); setEditingUser(null); setShowForm(false); + setRoleAssignments([]); + setScopeResourceType('stack'); + setScopeNodeId(''); + setScopeResourceId(''); + setAvailableStacks([]); }; const handleSave = async () => { @@ -313,16 +319,18 @@ export function UsersSection() { setFormConfirmPassword(''); setShowForm(true); fetchRoleAssignments(u.id); - fetchScopeResources(); + void fetchAvailableNodes(); }; // --- Scoped Role Assignments --- const [roleAssignments, setRoleAssignments] = useState([]); const [scopeResourceType, setScopeResourceType] = useState<'stack' | 'node'>('stack'); + const [scopeNodeId, setScopeNodeId] = useState(''); const [scopeResourceId, setScopeResourceId] = useState(''); const [scopeRole, setScopeRole] = useState('deployer'); const [availableStacks, setAvailableStacks] = useState([]); const [availableNodes, setAvailableNodes] = useState<{ id: number; name: string }[]>([]); + const [loadingStacks, setLoadingStacks] = useState(false); const [addingScope, setAddingScope] = useState(false); const fetchRoleAssignments = async (userId: number) => { @@ -333,16 +341,9 @@ export function UsersSection() { } catch { setRoleAssignments([]); } }; - const fetchScopeResources = async () => { + const fetchAvailableNodes = async () => { try { - const [stacksRes, nodesRes] = await Promise.all([ - apiFetch('/stacks', { localOnly: true }), - apiFetch('/nodes', { localOnly: true }), - ]); - if (stacksRes.ok) { - const data = await stacksRes.json(); - setAvailableStacks(Array.isArray(data) ? data.filter((s: unknown): s is string => typeof s === 'string') : []); - } + const nodesRes = await apiFetch('/nodes', { localOnly: true }); if (nodesRes.ok) { const data = await nodesRes.json(); setAvailableNodes(Array.isArray(data) ? data.map((n: { id: number; name: string }) => ({ id: n.id, name: n.name })) : []); @@ -350,14 +351,51 @@ export function UsersSection() { } catch { /* ignore */ } }; + const fetchStacksForNode = async (nodeIdStr: string) => { + if (!nodeIdStr) { + setAvailableStacks([]); + return; + } + const nodeId = parseInt(nodeIdStr, 10); + if (!Number.isInteger(nodeId)) { + setAvailableStacks([]); + return; + } + setLoadingStacks(true); + try { + const stacksRes = await apiFetch('/stacks', { nodeId }); + if (stacksRes.ok) { + const data = await stacksRes.json(); + setAvailableStacks(Array.isArray(data) ? data.filter((s: unknown): s is string => typeof s === 'string') : []); + } else { + setAvailableStacks([]); + toast.error('Failed to load stacks for the selected node.'); + } + } catch { + setAvailableStacks([]); + toast.error('Failed to load stacks for the selected node.'); + } finally { + setLoadingStacks(false); + } + }; + const addRoleAssignment = async () => { if (!editingUser || !scopeResourceId) return; + if (scopeResourceType === 'stack' && !scopeNodeId) return; setAddingScope(true); try { + const body: Record = { + role: scopeRole, + resource_type: scopeResourceType, + resource_id: scopeResourceId, + }; + if (scopeResourceType === 'stack') { + body.node_id = parseInt(scopeNodeId, 10); + } const res = await apiFetch(`/users/${editingUser.id}/roles`, { method: 'POST', localOnly: true, - body: JSON.stringify({ role: scopeRole, resource_type: scopeResourceType, resource_id: scopeResourceId }), + body: JSON.stringify(body), }); if (!res.ok) { const err = await res.json(); @@ -476,21 +514,29 @@ export function UsersSection() { {roleAssignments.length > 0 && (
- {roleAssignments.map((a) => ( + {roleAssignments.map((a) => { + const nodeLabel = a.resource_type === 'stack' && a.node_id != null + ? (availableNodes.find((n) => n.id === a.node_id)?.name ?? `node ${a.node_id}`) + : null; + return (
{a.role} on {a.resource_type}: {a.resource_id} + {nodeLabel != null && ( + @ {nodeLabel} + )}
- ))} + ); + })}
)} -
+
{ setScopeResourceType(v as 'stack' | 'node'); setScopeResourceId(''); fetchScopeResources(); }} + onValueChange={(v) => { + setScopeResourceType(v as 'stack' | 'node'); + setScopeResourceId(''); + setScopeNodeId(''); + setAvailableStacks([]); + void fetchAvailableNodes(); + }} placeholder="Type..." className="h-8 text-xs w-[100px]" />
-
- + {scopeResourceType === 'stack' && ( +
+ + ({ value: String(n.id), label: n.name }))} + value={scopeNodeId} + onValueChange={(v) => { + setScopeNodeId(v); + setScopeResourceId(''); + void fetchStacksForNode(v); + }} + placeholder="Select node..." + className="h-8 text-xs w-[140px]" + /> +
+ )} +
+ ({ value: s, label: s })) @@ -527,11 +595,25 @@ export function UsersSection() { } value={scopeResourceId} onValueChange={setScopeResourceId} - placeholder="Select..." + placeholder={ + scopeResourceType === 'stack' + ? (loadingStacks ? 'Loading stacks...' : (!scopeNodeId ? 'Select a node first...' : 'Select stack...')) + : 'Select...' + } className="h-8 text-xs" + disabled={scopeResourceType === 'stack' && (!scopeNodeId || loadingStacks)} />
- diff --git a/frontend/src/components/stack/EnvironmentPanel.tsx b/frontend/src/components/stack/EnvironmentPanel.tsx index 78b4a32d..e33cbbc4 100644 --- a/frontend/src/components/stack/EnvironmentPanel.tsx +++ b/frontend/src/components/stack/EnvironmentPanel.tsx @@ -110,7 +110,7 @@ export default function EnvironmentPanel({ stackName }: { stackName: string }) { // Project env file selection const projectEnvCapable = hasCapability('project-env-files'); - const canEdit = can('stack:edit', 'stack', stackName); + const canEdit = can('stack:edit', 'stack', stackName, nodeId); const [projectEnvFiles, setProjectEnvFiles] = useState([]); const [candidates, setCandidates] = useState([]); const [savingProjectEnv, setSavingProjectEnv] = useState(false); diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 08ea7f46..b87e953b 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'; import { markMilestone } from '@/lib/hydrationTiming'; +import { resolveCan } from '@/lib/resolveCan'; type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | 'mfaChallenge' | 'authenticated'; @@ -33,7 +34,7 @@ interface AuthContextType { permissions: PermissionsData | null; permissionsStatus: PermissionsStatus; permissionsReady: boolean; - can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean; + can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean; login: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>; ssoLdapLogin: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>; submitMfa: (code: string, opts?: { isBackupCode?: boolean }) => Promise<{ success: boolean; error?: string; retryAfter?: number }>; @@ -128,20 +129,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { return () => window.removeEventListener('sencho-unauthorized', handleUnauthorized); }, []); - const can = useCallback((action: PermissionAction, resourceType?: string, resourceId?: string): boolean => { - if (!permissions) return false; - - if (permissions.globalRole === 'admin') return true; - - if (permissions.globalPermissions.includes(action)) return true; - - if (resourceType && resourceId) { - const key = `${resourceType}:${resourceId}`; - return permissions.scopedPermissions[key]?.includes(action) ?? false; - } - - return false; - }, [permissions]); + const can = useCallback(( + action: PermissionAction, + resourceType?: string, + resourceId?: string, + nodeId?: number | null, + ): boolean => resolveCan(permissions, action, resourceType, resourceId, nodeId), [permissions]); const login = async (username: string, password: string, remember = false): Promise<{ success: boolean; error?: string; mfaRequired?: boolean }> => { try { diff --git a/frontend/src/lib/__tests__/resolveCan.test.ts b/frontend/src/lib/__tests__/resolveCan.test.ts new file mode 100644 index 00000000..6b177746 --- /dev/null +++ b/frontend/src/lib/__tests__/resolveCan.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { resolveCan, type PermissionsSnapshot } from '../resolveCan'; + +const viewerBase: PermissionsSnapshot = { + globalRole: 'viewer', + globalPermissions: ['stack:read', 'node:read'], + scopedPermissions: {}, +}; + +describe('resolveCan', () => { + it('admin bypasses all checks', () => { + const perms: PermissionsSnapshot = { + globalRole: 'admin', + globalPermissions: [], + scopedPermissions: {}, + }; + expect(resolveCan(perms, 'system:users')).toBe(true); + expect(resolveCan(perms, 'stack:delete', 'stack', 'app', 1)).toBe(true); + }); + + it('grants from the global matrix without needing a resource', () => { + expect(resolveCan(viewerBase, 'stack:read')).toBe(true); + expect(resolveCan(viewerBase, 'stack:deploy')).toBe(false); + }); + + it('treats same stack name on different nodes as independent grants', () => { + const perms: PermissionsSnapshot = { + ...viewerBase, + scopedPermissions: { + 'stack:1:frontend': ['stack:read', 'stack:deploy'], + 'stack:2:frontend': ['stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:read', 'node:manage'], + }, + }; + expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', 1)).toBe(true); + expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 1)).toBe(false); + expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 2)).toBe(true); + expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', 2)).toBe(true); + }); + + it('fails closed for stack lookups when nodeId is missing', () => { + const perms: PermissionsSnapshot = { + ...viewerBase, + scopedPermissions: { + 'stack:1:frontend': ['stack:deploy'], + }, + }; + expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend')).toBe(false); + expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', null)).toBe(false); + expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', 1)).toBe(true); + }); + + it('keeps node scopes keyed as node:id without a nodeId argument', () => { + const perms: PermissionsSnapshot = { + ...viewerBase, + scopedPermissions: { + 'node:7': ['node:read', 'node:manage', 'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete'], + }, + }; + expect(resolveCan(perms, 'node:manage', 'node', '7')).toBe(true); + expect(resolveCan(perms, 'stack:deploy', 'node', '7')).toBe(true); + expect(resolveCan(perms, 'node:manage', 'node', '8')).toBe(false); + }); + + it('node-scoped grants authorize stack actions on that node only', () => { + const perms: PermissionsSnapshot = { + ...viewerBase, + scopedPermissions: { + 'node:7': ['node:read', 'node:manage', 'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete'], + }, + }; + expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 7)).toBe(true); + expect(resolveCan(perms, 'stack:deploy', 'stack', 'other', 7)).toBe(true); + expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 8)).toBe(false); + }); + + it('returns false when permissions are null', () => { + expect(resolveCan(null, 'stack:read')).toBe(false); + }); +}); diff --git a/frontend/src/lib/capabilities.ts b/frontend/src/lib/capabilities.ts index 97c5f20d..87bcb9c9 100644 --- a/frontend/src/lib/capabilities.ts +++ b/frontend/src/lib/capabilities.ts @@ -40,6 +40,7 @@ export const CAPABILITIES = [ 'guided-external-network-preflight', 'service-scoped-update', 'service-scoped-stack-alert', + 'scoped-stack-auth-evidence', ] as const; export type Capability = (typeof CAPABILITIES)[number]; @@ -54,3 +55,4 @@ export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes' export const GUIDED_EXTERNAL_NETWORK_PREFLIGHT_CAPABILITY = 'guided-external-network-preflight' as const satisfies Capability; export const SERVICE_SCOPED_UPDATE_CAPABILITY = 'service-scoped-update' as const satisfies Capability; export const SERVICE_SCOPED_STACK_ALERT_CAPABILITY = 'service-scoped-stack-alert' as const satisfies Capability; +export const SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY = 'scoped-stack-auth-evidence' as const satisfies Capability; diff --git a/frontend/src/lib/resolveCan.ts b/frontend/src/lib/resolveCan.ts new file mode 100644 index 00000000..667a4d74 --- /dev/null +++ b/frontend/src/lib/resolveCan.ts @@ -0,0 +1,48 @@ +/** Mirrors AuthContext PermissionAction / UserRole for the pure resolver (no circular import). */ +export type ResolveCanRole = 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor'; + +export type ResolveCanAction = + | 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete' + | 'node:read' | 'node:manage' + | 'system:settings' | 'system:users' | 'system:license' | 'system:webhooks' + | 'system:tokens' | 'system:console' | 'system:audit' | 'system:registries'; + +export interface PermissionsSnapshot { + globalRole: ResolveCanRole; + globalPermissions: ResolveCanAction[]; + scopedPermissions: Record; +} + +/** + * Pure permission resolver for AuthContext.can and unit tests. + * Stack scopes are keyed `stack:${nodeId}:${stackName}`; missing nodeId + * fails closed for stack lookups after the global matrix is checked. + * Node scopes stay `node:${id}` and also authorize that role's stack + * actions for every stack on the node (node-wide semantics). + */ +export function resolveCan( + permissions: PermissionsSnapshot | null, + action: ResolveCanAction, + resourceType?: string, + resourceId?: string, + nodeId?: number | null, +): boolean { + if (!permissions) return false; + + if (permissions.globalRole === 'admin') return true; + + if (permissions.globalPermissions.includes(action)) return true; + + if (!resourceType || !resourceId) return false; + + if (resourceType === 'stack') { + if (nodeId === undefined || nodeId === null) return false; + const stackKey = `stack:${nodeId}:${resourceId}`; + if (permissions.scopedPermissions[stackKey]?.includes(action)) return true; + const nodeKey = `node:${nodeId}`; + return permissions.scopedPermissions[nodeKey]?.includes(action) ?? false; + } + + const key = `${resourceType}:${resourceId}`; + return permissions.scopedPermissions[key]?.includes(action) ?? false; +}