mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
9922d8e765
* 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
431 lines
16 KiB
TypeScript
431 lines
16 KiB
TypeScript
/**
|
|
* 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<string, string> {
|
|
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);
|
|
});
|
|
});
|