feat(security): add role-based access control (RBAC)
This commit is contained in:
parent
5f4715acfc
commit
63278c4844
@ -22,6 +22,7 @@ Status: beta.
|
|||||||
- Docs: add Northflank one-click deployment guide. (#2167) Thanks @AdeboyeDN.
|
- Docs: add Northflank one-click deployment guide. (#2167) Thanks @AdeboyeDN.
|
||||||
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
|
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
|
||||||
- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
|
- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
|
||||||
|
- Security: add role-based access control (RBAC) with predefined roles and configurable permissions.
|
||||||
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
|
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
|
||||||
- Config: auto-migrate legacy state/config paths and keep config resolution consistent across legacy filenames.
|
- Config: auto-migrate legacy state/config paths and keep config resolution consistent across legacy filenames.
|
||||||
- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro.
|
- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro.
|
||||||
|
|||||||
383
src/security/rbac.test.ts
Normal file
383
src/security/rbac.test.ts
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
resolveRbacConfig,
|
||||||
|
resolveRole,
|
||||||
|
hasPermission,
|
||||||
|
checkPermission,
|
||||||
|
checkPermissions,
|
||||||
|
checkAnyPermission,
|
||||||
|
getRolePermissions,
|
||||||
|
getAvailableRoles,
|
||||||
|
getPredefinedRoles,
|
||||||
|
getAllPermissions,
|
||||||
|
createContextFromAuth,
|
||||||
|
type IdentityContext,
|
||||||
|
type Permission,
|
||||||
|
} from "./rbac.js";
|
||||||
|
|
||||||
|
describe("resolveRbacConfig", () => {
|
||||||
|
it("returns defaults when no config provided", () => {
|
||||||
|
const config = resolveRbacConfig();
|
||||||
|
expect(config.enabled).toBe(true);
|
||||||
|
expect(config.defaultRole).toBe("viewer");
|
||||||
|
expect(config.denyByDefault).toBe(true);
|
||||||
|
expect(config.roles.has("admin")).toBe(true);
|
||||||
|
expect(config.roles.has("operator")).toBe(true);
|
||||||
|
expect(config.roles.has("viewer")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges custom roles with predefined roles", () => {
|
||||||
|
const config = resolveRbacConfig({
|
||||||
|
roles: {
|
||||||
|
developer: {
|
||||||
|
permissions: ["exec.run", "agents.execute"],
|
||||||
|
description: "Developer access",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(config.roles.has("developer")).toBe(true);
|
||||||
|
expect(config.roles.has("admin")).toBe(true);
|
||||||
|
const dev = config.roles.get("developer");
|
||||||
|
expect(dev?.permissions).toContain("exec.run");
|
||||||
|
expect(dev?.permissions).toContain("agents.execute");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports role inheritance", () => {
|
||||||
|
const config = resolveRbacConfig({
|
||||||
|
roles: {
|
||||||
|
"super-viewer": {
|
||||||
|
inherits: "viewer",
|
||||||
|
permissions: ["sessions.export"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const superViewer = config.roles.get("super-viewer");
|
||||||
|
expect(superViewer?.permissions).toContain("sessions.export");
|
||||||
|
// Should also have inherited viewer permissions
|
||||||
|
expect(superViewer?.permissions).toContain("config.read");
|
||||||
|
expect(superViewer?.permissions).toContain("sessions.list");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles missing parent role gracefully", () => {
|
||||||
|
const config = resolveRbacConfig({
|
||||||
|
roles: {
|
||||||
|
orphan: {
|
||||||
|
inherits: "nonexistent",
|
||||||
|
permissions: ["config.read"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const orphan = config.roles.get("orphan");
|
||||||
|
expect(orphan?.permissions).toEqual(["config.read"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveRole", () => {
|
||||||
|
it("returns role from exact binding match", () => {
|
||||||
|
const config = resolveRbacConfig({
|
||||||
|
bindings: [{ identity: "alice@example.com", role: "admin" }],
|
||||||
|
});
|
||||||
|
const context: IdentityContext = { identity: "alice@example.com" };
|
||||||
|
const role = resolveRole(context, config);
|
||||||
|
expect(role?.name).toBe("admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns role from wildcard binding", () => {
|
||||||
|
const config = resolveRbacConfig({
|
||||||
|
bindings: [{ identity: "*@admin.com", role: "admin" }],
|
||||||
|
});
|
||||||
|
const context: IdentityContext = { identity: "bob@admin.com" };
|
||||||
|
const role = resolveRole(context, config);
|
||||||
|
expect(role?.name).toBe("admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns role from glob pattern", () => {
|
||||||
|
const config = resolveRbacConfig({
|
||||||
|
bindings: [{ identity: "service-*", role: "operator" }],
|
||||||
|
});
|
||||||
|
const context: IdentityContext = { identity: "service-worker-1" };
|
||||||
|
const role = resolveRole(context, config);
|
||||||
|
expect(role?.name).toBe("operator");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns default role when no binding matches", () => {
|
||||||
|
const config = resolveRbacConfig({
|
||||||
|
defaultRole: "viewer",
|
||||||
|
bindings: [{ identity: "admin@example.com", role: "admin" }],
|
||||||
|
});
|
||||||
|
const context: IdentityContext = { identity: "user@example.com" };
|
||||||
|
const role = resolveRole(context, config);
|
||||||
|
expect(role?.name).toBe("viewer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no role found and no default", () => {
|
||||||
|
const config = resolveRbacConfig({
|
||||||
|
defaultRole: undefined,
|
||||||
|
bindings: [],
|
||||||
|
});
|
||||||
|
// Override defaultRole to undefined
|
||||||
|
const resolved = { ...config, defaultRole: "" };
|
||||||
|
const context: IdentityContext = { identity: "unknown" };
|
||||||
|
const role = resolveRole(context, resolved);
|
||||||
|
expect(role).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects binding expiration", () => {
|
||||||
|
const pastTime = Date.now() - 60000; // 1 minute ago
|
||||||
|
const config = resolveRbacConfig({
|
||||||
|
defaultRole: "viewer",
|
||||||
|
bindings: [{ identity: "expired@example.com", role: "admin", expiresAt: pastTime }],
|
||||||
|
});
|
||||||
|
const context: IdentityContext = { identity: "expired@example.com" };
|
||||||
|
const role = resolveRole(context, config);
|
||||||
|
// Should fall back to default since binding expired
|
||||||
|
expect(role?.name).toBe("viewer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects IP range conditions", () => {
|
||||||
|
const config = resolveRbacConfig({
|
||||||
|
defaultRole: "viewer",
|
||||||
|
bindings: [
|
||||||
|
{
|
||||||
|
identity: "internal@example.com",
|
||||||
|
role: "admin",
|
||||||
|
conditions: { ipRange: ["192.168.*", "10.*"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Matching IP
|
||||||
|
const internalContext: IdentityContext = {
|
||||||
|
identity: "internal@example.com",
|
||||||
|
clientIp: "192.168.1.100",
|
||||||
|
};
|
||||||
|
expect(resolveRole(internalContext, config)?.name).toBe("admin");
|
||||||
|
|
||||||
|
// Non-matching IP
|
||||||
|
const externalContext: IdentityContext = {
|
||||||
|
identity: "internal@example.com",
|
||||||
|
clientIp: "8.8.8.8",
|
||||||
|
};
|
||||||
|
expect(resolveRole(externalContext, config)?.name).toBe("viewer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants admin when RBAC is disabled", () => {
|
||||||
|
const config = resolveRbacConfig({ enabled: false });
|
||||||
|
const context: IdentityContext = { identity: "anyone" };
|
||||||
|
const role = resolveRole(context, config);
|
||||||
|
expect(role?.name).toBe("admin");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasPermission", () => {
|
||||||
|
it("returns true for granted permission", () => {
|
||||||
|
const config = resolveRbacConfig();
|
||||||
|
const admin = config.roles.get("admin")!;
|
||||||
|
expect(hasPermission(admin, "config.write")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for denied permission", () => {
|
||||||
|
const config = resolveRbacConfig();
|
||||||
|
const viewer = config.roles.get("viewer")!;
|
||||||
|
expect(hasPermission(viewer, "config.write")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for null role", () => {
|
||||||
|
expect(hasPermission(null, "config.read")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkPermission", () => {
|
||||||
|
it("allows permission for matching role", () => {
|
||||||
|
const config = resolveRbacConfig({
|
||||||
|
bindings: [{ identity: "admin@test.com", role: "admin" }],
|
||||||
|
});
|
||||||
|
const context: IdentityContext = { identity: "admin@test.com" };
|
||||||
|
const result = checkPermission(context, "config.write", config);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.role).toBe("admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies permission for insufficient role", () => {
|
||||||
|
const config = resolveRbacConfig({
|
||||||
|
bindings: [{ identity: "viewer@test.com", role: "viewer" }],
|
||||||
|
});
|
||||||
|
const context: IdentityContext = { identity: "viewer@test.com" };
|
||||||
|
const result = checkPermission(context, "config.write", config);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.role).toBe("viewer");
|
||||||
|
expect(result.reason).toContain("lacks permission");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies when no role and denyByDefault=true", () => {
|
||||||
|
const config = resolveRbacConfig({
|
||||||
|
defaultRole: "",
|
||||||
|
denyByDefault: true,
|
||||||
|
});
|
||||||
|
const resolved = { ...config, defaultRole: "" };
|
||||||
|
const context: IdentityContext = { identity: "unknown" };
|
||||||
|
const result = checkPermission(context, "config.read", resolved);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe("No role assigned");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows all when RBAC disabled", () => {
|
||||||
|
const config = resolveRbacConfig({ enabled: false });
|
||||||
|
const context: IdentityContext = { identity: "anyone" };
|
||||||
|
const result = checkPermission(context, "gateway.shutdown", config);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.reason).toBe("RBAC disabled");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkPermissions", () => {
|
||||||
|
it("allows when all permissions granted", () => {
|
||||||
|
const config = resolveRbacConfig({
|
||||||
|
bindings: [{ identity: "admin@test.com", role: "admin" }],
|
||||||
|
});
|
||||||
|
const context: IdentityContext = { identity: "admin@test.com" };
|
||||||
|
const result = checkPermissions(context, ["config.read", "config.write"], config);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies when any permission missing", () => {
|
||||||
|
const config = resolveRbacConfig({
|
||||||
|
bindings: [{ identity: "viewer@test.com", role: "viewer" }],
|
||||||
|
});
|
||||||
|
const context: IdentityContext = { identity: "viewer@test.com" };
|
||||||
|
const result = checkPermissions(context, ["config.read", "config.write"], config);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toContain("config.write");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkAnyPermission", () => {
|
||||||
|
it("allows when any permission granted", () => {
|
||||||
|
const config = resolveRbacConfig({
|
||||||
|
bindings: [{ identity: "viewer@test.com", role: "viewer" }],
|
||||||
|
});
|
||||||
|
const context: IdentityContext = { identity: "viewer@test.com" };
|
||||||
|
const result = checkAnyPermission(context, ["config.read", "config.write"], config);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies when no permission granted", () => {
|
||||||
|
const config = resolveRbacConfig({
|
||||||
|
bindings: [{ identity: "viewer@test.com", role: "viewer" }],
|
||||||
|
});
|
||||||
|
const context: IdentityContext = { identity: "viewer@test.com" };
|
||||||
|
const result = checkAnyPermission(context, ["config.write", "gateway.shutdown"], config);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toContain("lacks any of");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getRolePermissions", () => {
|
||||||
|
it("returns permissions for existing role", () => {
|
||||||
|
const config = resolveRbacConfig();
|
||||||
|
const permissions = getRolePermissions("admin", config);
|
||||||
|
expect(permissions).toContain("config.write");
|
||||||
|
expect(permissions).toContain("gateway.shutdown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for non-existent role", () => {
|
||||||
|
const config = resolveRbacConfig();
|
||||||
|
const permissions = getRolePermissions("nonexistent", config);
|
||||||
|
expect(permissions).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAvailableRoles", () => {
|
||||||
|
it("includes predefined roles", () => {
|
||||||
|
const config = resolveRbacConfig();
|
||||||
|
const roles = getAvailableRoles(config);
|
||||||
|
expect(roles).toContain("admin");
|
||||||
|
expect(roles).toContain("operator");
|
||||||
|
expect(roles).toContain("viewer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes custom roles", () => {
|
||||||
|
const config = resolveRbacConfig({
|
||||||
|
roles: { custom: { permissions: ["config.read"] } },
|
||||||
|
});
|
||||||
|
const roles = getAvailableRoles(config);
|
||||||
|
expect(roles).toContain("custom");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPredefinedRoles", () => {
|
||||||
|
it("returns predefined role names", () => {
|
||||||
|
const roles = getPredefinedRoles();
|
||||||
|
expect(roles).toEqual(["admin", "operator", "viewer"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAllPermissions", () => {
|
||||||
|
it("returns all available permissions", () => {
|
||||||
|
const permissions = getAllPermissions();
|
||||||
|
expect(permissions.length).toBeGreaterThan(0);
|
||||||
|
expect(permissions).toContain("config.read");
|
||||||
|
expect(permissions).toContain("config.write");
|
||||||
|
expect(permissions).toContain("gateway.shutdown");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createContextFromAuth", () => {
|
||||||
|
it("creates context from auth params", () => {
|
||||||
|
const context = createContextFromAuth({
|
||||||
|
user: "alice@example.com",
|
||||||
|
method: "token",
|
||||||
|
clientIp: "192.168.1.1",
|
||||||
|
});
|
||||||
|
expect(context.identity).toBe("alice@example.com");
|
||||||
|
expect(context.authMethod).toBe("token");
|
||||||
|
expect(context.clientIp).toBe("192.168.1.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to anonymous when no user", () => {
|
||||||
|
const context = createContextFromAuth({});
|
||||||
|
expect(context.identity).toBe("anonymous");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("predefined role permissions", () => {
|
||||||
|
it("admin has all permissions", () => {
|
||||||
|
const config = resolveRbacConfig();
|
||||||
|
const admin = config.roles.get("admin")!;
|
||||||
|
const allPerms = getAllPermissions();
|
||||||
|
for (const perm of allPerms) {
|
||||||
|
expect(hasPermission(admin, perm)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("viewer cannot write or modify", () => {
|
||||||
|
const config = resolveRbacConfig();
|
||||||
|
const viewer = config.roles.get("viewer")!;
|
||||||
|
const writePerms: Permission[] = [
|
||||||
|
"config.write",
|
||||||
|
"sessions.delete",
|
||||||
|
"agents.create",
|
||||||
|
"agents.delete",
|
||||||
|
"channels.configure",
|
||||||
|
"exec.elevated",
|
||||||
|
"gateway.restart",
|
||||||
|
"gateway.shutdown",
|
||||||
|
"nodes.register",
|
||||||
|
"nodes.remove",
|
||||||
|
"users.manage",
|
||||||
|
"roles.manage",
|
||||||
|
];
|
||||||
|
for (const perm of writePerms) {
|
||||||
|
expect(hasPermission(viewer, perm)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("operator can execute but not configure", () => {
|
||||||
|
const config = resolveRbacConfig();
|
||||||
|
const operator = config.roles.get("operator")!;
|
||||||
|
expect(hasPermission(operator, "exec.run")).toBe(true);
|
||||||
|
expect(hasPermission(operator, "agents.execute")).toBe(true);
|
||||||
|
expect(hasPermission(operator, "config.write")).toBe(false);
|
||||||
|
expect(hasPermission(operator, "gateway.shutdown")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
581
src/security/rbac.ts
Normal file
581
src/security/rbac.ts
Normal file
@ -0,0 +1,581 @@
|
|||||||
|
/**
|
||||||
|
* Role-Based Access Control (RBAC)
|
||||||
|
*
|
||||||
|
* Provides authorization layer on top of authentication:
|
||||||
|
* - Predefined roles: admin, operator, viewer
|
||||||
|
* - Custom roles with configurable permissions
|
||||||
|
* - Permission checking for gateway operations
|
||||||
|
* - Identity-to-role mapping
|
||||||
|
*
|
||||||
|
* This module complements src/gateway/auth.ts which handles authentication.
|
||||||
|
* RBAC determines what authenticated users can do.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
|
|
||||||
|
const log = createSubsystemLogger("rbac");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available permissions in the system.
|
||||||
|
*/
|
||||||
|
export type Permission =
|
||||||
|
// Configuration management
|
||||||
|
| "config.read"
|
||||||
|
| "config.write"
|
||||||
|
| "config.reload"
|
||||||
|
// Session management
|
||||||
|
| "sessions.list"
|
||||||
|
| "sessions.read"
|
||||||
|
| "sessions.delete"
|
||||||
|
| "sessions.export"
|
||||||
|
// Agent operations
|
||||||
|
| "agents.list"
|
||||||
|
| "agents.create"
|
||||||
|
| "agents.delete"
|
||||||
|
| "agents.execute"
|
||||||
|
// Channel management
|
||||||
|
| "channels.list"
|
||||||
|
| "channels.connect"
|
||||||
|
| "channels.disconnect"
|
||||||
|
| "channels.configure"
|
||||||
|
// Command execution
|
||||||
|
| "exec.run"
|
||||||
|
| "exec.elevated"
|
||||||
|
| "exec.approve"
|
||||||
|
// Gateway administration
|
||||||
|
| "gateway.status"
|
||||||
|
| "gateway.restart"
|
||||||
|
| "gateway.shutdown"
|
||||||
|
// Node management
|
||||||
|
| "nodes.list"
|
||||||
|
| "nodes.register"
|
||||||
|
| "nodes.remove"
|
||||||
|
| "nodes.invoke"
|
||||||
|
// Audit and monitoring
|
||||||
|
| "audit.read"
|
||||||
|
| "audit.export"
|
||||||
|
| "metrics.read"
|
||||||
|
// User management (for future multi-user scenarios)
|
||||||
|
| "users.list"
|
||||||
|
| "users.manage"
|
||||||
|
| "roles.manage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Predefined role types.
|
||||||
|
*/
|
||||||
|
export type PredefinedRole = "admin" | "operator" | "viewer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role definition with associated permissions.
|
||||||
|
*/
|
||||||
|
export type Role = {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
permissions: Permission[];
|
||||||
|
/** Inherit permissions from another role */
|
||||||
|
inherits?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity-to-role mapping.
|
||||||
|
*/
|
||||||
|
export type RoleBinding = {
|
||||||
|
/** Identity pattern (exact match or glob) */
|
||||||
|
identity: string;
|
||||||
|
/** Role name */
|
||||||
|
role: string;
|
||||||
|
/** Optional expiration timestamp */
|
||||||
|
expiresAt?: number;
|
||||||
|
/** Optional conditions for the binding */
|
||||||
|
conditions?: {
|
||||||
|
/** Require specific IP range */
|
||||||
|
ipRange?: string[];
|
||||||
|
/** Require specific time window (cron expression) */
|
||||||
|
timeWindow?: string;
|
||||||
|
/** Require MFA verification */
|
||||||
|
requireMfa?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RBAC configuration.
|
||||||
|
*/
|
||||||
|
export type RbacConfig = {
|
||||||
|
/** Enable RBAC (default: true). */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Default role for authenticated users without explicit binding (default: viewer). */
|
||||||
|
defaultRole?: string;
|
||||||
|
/** Custom role definitions */
|
||||||
|
roles?: Record<string, Omit<Role, "name">>;
|
||||||
|
/** Identity-to-role bindings */
|
||||||
|
bindings?: RoleBinding[];
|
||||||
|
/** Log permission checks (default: false). */
|
||||||
|
logChecks?: boolean;
|
||||||
|
/** Deny by default when no role matches (default: true). */
|
||||||
|
denyByDefault?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedRbacConfig = Required<Omit<RbacConfig, "roles" | "bindings">> & {
|
||||||
|
roles: Map<string, Role>;
|
||||||
|
bindings: RoleBinding[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Predefined role permissions.
|
||||||
|
*/
|
||||||
|
const PREDEFINED_ROLES: Record<PredefinedRole, Role> = {
|
||||||
|
admin: {
|
||||||
|
name: "admin",
|
||||||
|
description: "Full administrative access",
|
||||||
|
permissions: [
|
||||||
|
"config.read",
|
||||||
|
"config.write",
|
||||||
|
"config.reload",
|
||||||
|
"sessions.list",
|
||||||
|
"sessions.read",
|
||||||
|
"sessions.delete",
|
||||||
|
"sessions.export",
|
||||||
|
"agents.list",
|
||||||
|
"agents.create",
|
||||||
|
"agents.delete",
|
||||||
|
"agents.execute",
|
||||||
|
"channels.list",
|
||||||
|
"channels.connect",
|
||||||
|
"channels.disconnect",
|
||||||
|
"channels.configure",
|
||||||
|
"exec.run",
|
||||||
|
"exec.elevated",
|
||||||
|
"exec.approve",
|
||||||
|
"gateway.status",
|
||||||
|
"gateway.restart",
|
||||||
|
"gateway.shutdown",
|
||||||
|
"nodes.list",
|
||||||
|
"nodes.register",
|
||||||
|
"nodes.remove",
|
||||||
|
"nodes.invoke",
|
||||||
|
"audit.read",
|
||||||
|
"audit.export",
|
||||||
|
"metrics.read",
|
||||||
|
"users.list",
|
||||||
|
"users.manage",
|
||||||
|
"roles.manage",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
operator: {
|
||||||
|
name: "operator",
|
||||||
|
description: "Operational access without admin privileges",
|
||||||
|
permissions: [
|
||||||
|
"config.read",
|
||||||
|
"sessions.list",
|
||||||
|
"sessions.read",
|
||||||
|
"sessions.export",
|
||||||
|
"agents.list",
|
||||||
|
"agents.execute",
|
||||||
|
"channels.list",
|
||||||
|
"channels.connect",
|
||||||
|
"channels.disconnect",
|
||||||
|
"exec.run",
|
||||||
|
"gateway.status",
|
||||||
|
"nodes.list",
|
||||||
|
"nodes.invoke",
|
||||||
|
"audit.read",
|
||||||
|
"metrics.read",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
viewer: {
|
||||||
|
name: "viewer",
|
||||||
|
description: "Read-only access",
|
||||||
|
permissions: [
|
||||||
|
"config.read",
|
||||||
|
"sessions.list",
|
||||||
|
"sessions.read",
|
||||||
|
"agents.list",
|
||||||
|
"channels.list",
|
||||||
|
"gateway.status",
|
||||||
|
"nodes.list",
|
||||||
|
"audit.read",
|
||||||
|
"metrics.read",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: ResolvedRbacConfig = {
|
||||||
|
enabled: true,
|
||||||
|
defaultRole: "viewer",
|
||||||
|
roles: new Map(Object.entries(PREDEFINED_ROLES)),
|
||||||
|
bindings: [],
|
||||||
|
logChecks: false,
|
||||||
|
denyByDefault: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve RBAC configuration with defaults.
|
||||||
|
*/
|
||||||
|
export function resolveRbacConfig(config?: Partial<RbacConfig>): ResolvedRbacConfig {
|
||||||
|
const roles = new Map<string, Role>(Object.entries(PREDEFINED_ROLES));
|
||||||
|
|
||||||
|
// Add custom roles
|
||||||
|
if (config?.roles) {
|
||||||
|
for (const [name, def] of Object.entries(config.roles)) {
|
||||||
|
const role: Role = {
|
||||||
|
name,
|
||||||
|
description: def.description,
|
||||||
|
permissions: [...def.permissions],
|
||||||
|
inherits: def.inherits,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve inheritance
|
||||||
|
if (def.inherits) {
|
||||||
|
const parent = roles.get(def.inherits);
|
||||||
|
if (parent) {
|
||||||
|
const combined = new Set([...parent.permissions, ...def.permissions]);
|
||||||
|
role.permissions = [...combined];
|
||||||
|
} else {
|
||||||
|
log.warn("Role inheritance failed: parent not found", {
|
||||||
|
role: name,
|
||||||
|
inherits: def.inherits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
roles.set(name, role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: config?.enabled ?? DEFAULT_CONFIG.enabled,
|
||||||
|
defaultRole: config?.defaultRole ?? DEFAULT_CONFIG.defaultRole,
|
||||||
|
roles,
|
||||||
|
bindings: config?.bindings ?? DEFAULT_CONFIG.bindings,
|
||||||
|
logChecks: config?.logChecks ?? DEFAULT_CONFIG.logChecks,
|
||||||
|
denyByDefault: config?.denyByDefault ?? DEFAULT_CONFIG.denyByDefault,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity matching context.
|
||||||
|
*/
|
||||||
|
export type IdentityContext = {
|
||||||
|
/** Primary identity (e.g., email, username, token hash) */
|
||||||
|
identity: string;
|
||||||
|
/** Authentication method used */
|
||||||
|
authMethod?: "token" | "password" | "tailscale" | "device-token";
|
||||||
|
/** Client IP address */
|
||||||
|
clientIp?: string;
|
||||||
|
/** Additional identity attributes */
|
||||||
|
attributes?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an identity pattern matches a given identity.
|
||||||
|
*/
|
||||||
|
function matchesIdentity(pattern: string, identity: string): boolean {
|
||||||
|
// Exact match
|
||||||
|
if (pattern === identity) return true;
|
||||||
|
|
||||||
|
// Wildcard match
|
||||||
|
if (pattern === "*") return true;
|
||||||
|
|
||||||
|
// Glob pattern (simple implementation)
|
||||||
|
if (pattern.includes("*")) {
|
||||||
|
const regex = new RegExp(
|
||||||
|
"^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$",
|
||||||
|
"i",
|
||||||
|
);
|
||||||
|
return regex.test(identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if conditions are satisfied.
|
||||||
|
*/
|
||||||
|
function checkConditions(
|
||||||
|
binding: RoleBinding,
|
||||||
|
context: IdentityContext,
|
||||||
|
now: number = Date.now(),
|
||||||
|
): boolean {
|
||||||
|
// Check expiration
|
||||||
|
if (binding.expiresAt && now > binding.expiresAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditions = binding.conditions;
|
||||||
|
if (!conditions) return true;
|
||||||
|
|
||||||
|
// Check IP range
|
||||||
|
if (conditions.ipRange && context.clientIp) {
|
||||||
|
const ipMatches = conditions.ipRange.some((range) => {
|
||||||
|
// Simple prefix matching for now
|
||||||
|
if (range.endsWith("*")) {
|
||||||
|
return context.clientIp?.startsWith(range.slice(0, -1));
|
||||||
|
}
|
||||||
|
return context.clientIp === range;
|
||||||
|
});
|
||||||
|
if (!ipMatches) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: MFA and time window checks would require additional context
|
||||||
|
// These are placeholders for enterprise features
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the effective role for an identity.
|
||||||
|
*/
|
||||||
|
export function resolveRole(context: IdentityContext, config: ResolvedRbacConfig): Role | null {
|
||||||
|
if (!config.enabled) {
|
||||||
|
// When RBAC is disabled, grant admin role
|
||||||
|
return config.roles.get("admin") ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find matching binding
|
||||||
|
for (const binding of config.bindings) {
|
||||||
|
if (matchesIdentity(binding.identity, context.identity)) {
|
||||||
|
if (checkConditions(binding, context)) {
|
||||||
|
const role = config.roles.get(binding.role);
|
||||||
|
if (role) {
|
||||||
|
if (config.logChecks) {
|
||||||
|
log.debug("Role resolved from binding", {
|
||||||
|
identity: context.identity,
|
||||||
|
role: role.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to default role
|
||||||
|
if (config.defaultRole) {
|
||||||
|
const defaultRole = config.roles.get(config.defaultRole);
|
||||||
|
if (defaultRole) {
|
||||||
|
if (config.logChecks) {
|
||||||
|
log.debug("Using default role", {
|
||||||
|
identity: context.identity,
|
||||||
|
role: defaultRole.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return defaultRole;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No role found
|
||||||
|
if (config.logChecks) {
|
||||||
|
log.debug("No role found for identity", { identity: context.identity });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a role has a specific permission.
|
||||||
|
*/
|
||||||
|
export function hasPermission(role: Role | null, permission: Permission): boolean {
|
||||||
|
if (!role) return false;
|
||||||
|
return role.permissions.includes(permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission check result.
|
||||||
|
*/
|
||||||
|
export type PermissionCheckResult = {
|
||||||
|
allowed: boolean;
|
||||||
|
role?: string;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an identity has a specific permission.
|
||||||
|
*/
|
||||||
|
export function checkPermission(
|
||||||
|
context: IdentityContext,
|
||||||
|
permission: Permission,
|
||||||
|
config: ResolvedRbacConfig,
|
||||||
|
): PermissionCheckResult {
|
||||||
|
if (!config.enabled) {
|
||||||
|
return { allowed: true, reason: "RBAC disabled" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = resolveRole(context, config);
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
const allowed = !config.denyByDefault;
|
||||||
|
if (config.logChecks) {
|
||||||
|
log.debug("Permission check (no role)", {
|
||||||
|
identity: context.identity,
|
||||||
|
permission,
|
||||||
|
allowed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
allowed,
|
||||||
|
reason: allowed ? "No role, deny-by-default disabled" : "No role assigned",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = hasPermission(role, permission);
|
||||||
|
|
||||||
|
if (config.logChecks) {
|
||||||
|
log.debug("Permission check", {
|
||||||
|
identity: context.identity,
|
||||||
|
role: role.name,
|
||||||
|
permission,
|
||||||
|
allowed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed,
|
||||||
|
role: role.name,
|
||||||
|
reason: allowed ? undefined : `Role '${role.name}' lacks permission '${permission}'`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check multiple permissions (all must pass).
|
||||||
|
*/
|
||||||
|
export function checkPermissions(
|
||||||
|
context: IdentityContext,
|
||||||
|
permissions: Permission[],
|
||||||
|
config: ResolvedRbacConfig,
|
||||||
|
): PermissionCheckResult {
|
||||||
|
if (!config.enabled) {
|
||||||
|
return { allowed: true, reason: "RBAC disabled" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = resolveRole(context, config);
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
const allowed = !config.denyByDefault;
|
||||||
|
return {
|
||||||
|
allowed,
|
||||||
|
reason: allowed ? "No role, deny-by-default disabled" : "No role assigned",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = permissions.filter((p) => !hasPermission(role, p));
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
role: role.name,
|
||||||
|
reason: `Role '${role.name}' lacks permissions: ${missing.join(", ")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true, role: role.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check any of multiple permissions (at least one must pass).
|
||||||
|
*/
|
||||||
|
export function checkAnyPermission(
|
||||||
|
context: IdentityContext,
|
||||||
|
permissions: Permission[],
|
||||||
|
config: ResolvedRbacConfig,
|
||||||
|
): PermissionCheckResult {
|
||||||
|
if (!config.enabled) {
|
||||||
|
return { allowed: true, reason: "RBAC disabled" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = resolveRole(context, config);
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
const allowed = !config.denyByDefault;
|
||||||
|
return {
|
||||||
|
allowed,
|
||||||
|
reason: allowed ? "No role, deny-by-default disabled" : "No role assigned",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAny = permissions.some((p) => hasPermission(role, p));
|
||||||
|
|
||||||
|
if (!hasAny) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
role: role.name,
|
||||||
|
reason: `Role '${role.name}' lacks any of: ${permissions.join(", ")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true, role: role.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all permissions for a role.
|
||||||
|
*/
|
||||||
|
export function getRolePermissions(roleName: string, config: ResolvedRbacConfig): Permission[] {
|
||||||
|
const role = config.roles.get(roleName);
|
||||||
|
return role ? [...role.permissions] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available roles.
|
||||||
|
*/
|
||||||
|
export function getAvailableRoles(config: ResolvedRbacConfig): string[] {
|
||||||
|
return [...config.roles.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get predefined roles (always available).
|
||||||
|
*/
|
||||||
|
export function getPredefinedRoles(): PredefinedRole[] {
|
||||||
|
return ["admin", "operator", "viewer"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available permissions.
|
||||||
|
*/
|
||||||
|
export function getAllPermissions(): Permission[] {
|
||||||
|
return [
|
||||||
|
"config.read",
|
||||||
|
"config.write",
|
||||||
|
"config.reload",
|
||||||
|
"sessions.list",
|
||||||
|
"sessions.read",
|
||||||
|
"sessions.delete",
|
||||||
|
"sessions.export",
|
||||||
|
"agents.list",
|
||||||
|
"agents.create",
|
||||||
|
"agents.delete",
|
||||||
|
"agents.execute",
|
||||||
|
"channels.list",
|
||||||
|
"channels.connect",
|
||||||
|
"channels.disconnect",
|
||||||
|
"channels.configure",
|
||||||
|
"exec.run",
|
||||||
|
"exec.elevated",
|
||||||
|
"exec.approve",
|
||||||
|
"gateway.status",
|
||||||
|
"gateway.restart",
|
||||||
|
"gateway.shutdown",
|
||||||
|
"nodes.list",
|
||||||
|
"nodes.register",
|
||||||
|
"nodes.remove",
|
||||||
|
"nodes.invoke",
|
||||||
|
"audit.read",
|
||||||
|
"audit.export",
|
||||||
|
"metrics.read",
|
||||||
|
"users.list",
|
||||||
|
"users.manage",
|
||||||
|
"roles.manage",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an RBAC context from gateway auth result.
|
||||||
|
*/
|
||||||
|
export function createContextFromAuth(params: {
|
||||||
|
user?: string;
|
||||||
|
method?: "token" | "password" | "tailscale" | "device-token";
|
||||||
|
clientIp?: string;
|
||||||
|
}): IdentityContext {
|
||||||
|
return {
|
||||||
|
identity: params.user ?? "anonymous",
|
||||||
|
authMethod: params.method,
|
||||||
|
clientIp: params.clientIp,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user