From 63278c484490ca0ecb9941a3e1dc192f8c5d7dbf Mon Sep 17 00:00:00 2001 From: ronitchidara Date: Thu, 29 Jan 2026 18:08:23 +0530 Subject: [PATCH] feat(security): add role-based access control (RBAC) --- CHANGELOG.md | 1 + src/security/rbac.test.ts | 383 +++++++++++++++++++++++++ src/security/rbac.ts | 581 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 965 insertions(+) create mode 100644 src/security/rbac.test.ts create mode 100644 src/security/rbac.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c5321870..850b14aa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,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. - 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. diff --git a/src/security/rbac.test.ts b/src/security/rbac.test.ts new file mode 100644 index 000000000..8a1684f2b --- /dev/null +++ b/src/security/rbac.test.ts @@ -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); + }); +}); diff --git a/src/security/rbac.ts b/src/security/rbac.ts new file mode 100644 index 000000000..1f5951857 --- /dev/null +++ b/src/security/rbac.ts @@ -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>; + /** 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> & { + roles: Map; + bindings: RoleBinding[]; +}; + +/** + * Predefined role permissions. + */ +const PREDEFINED_ROLES: Record = { + 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): ResolvedRbacConfig { + const roles = new Map(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; +}; + +/** + * 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, + }; +}