Files
sencho/backend/src/__tests__/stackRouteAuth.test.ts
T
Anso 9922d8e765 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
2026-07-29 09:42:14 -04:00

156 lines
6.6 KiB
TypeScript

/**
* 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/<name>/... 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',
]);
});
});