mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +00:00
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
This commit is contained in:
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user