This commit is contained in:
Ronit Chidara 2026-01-29 18:39:20 +02:00 committed by GitHub
commit 51a4a8267d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 965 additions and 0 deletions

View File

@ -23,6 +23,7 @@ Status: beta.
- 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: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
- Security: add role-based access control (RBAC) with predefined roles and configurable permissions. (#3964)
- 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.
- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro.

383
src/security/rbac.test.ts Normal file
View 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
View 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,
};
}