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:
Anso
2026-07-29 09:42:14 -04:00
committed by GitHub
parent d698eb46f9
commit 9922d8e765
37 changed files with 2487 additions and 143 deletions
+1
View File
@@ -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) {
@@ -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);
+147 -11
View File
@@ -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[] {
@@ -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);
+10
View File
@@ -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',