mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-26 02:06:49 +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:
@@ -24,7 +24,9 @@ permissionsRouter.get('/me', authMiddleware, (req: Request, res: Response): void
|
||||
const scopedPermissions: Record<string, PermissionAction[]> = {};
|
||||
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])];
|
||||
|
||||
@@ -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<void> => {
|
||||
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' });
|
||||
|
||||
Reference in New Issue
Block a user