Merge 886db965c8 into 06289b36da
This commit is contained in:
commit
51a4a8267d
@ -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
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