New packages: - @lab/core: Resource types, Output<T> (Pulumi), audit event types, auth types, environment/account types, resource kind registry New Prisma schema (mcpctl pattern): - User (email/password/bcrypt), Session (bearer tokens), Group, GroupMember - ServiceAccount, RbacDefinition (JSON subjects + roleBindings) - AuditEvent (correlation IDs, causal chains, fire-and-forget batching) - Environment, Account (driver config, Infisical secret path), Binding - Resource (generic, kind/name/env unique, origin/managedBy tracking) - Secret, Fleet, FleetMember, GitSource - Keeps v1.0 models: Server, Agent, Bastion, Cluster, JoinToken New services: - AuthService: bearer token login, bootstrap (first login creates admin), session management with 30-day expiry - RbacService: environment-scoped permission checks, group membership, role hierarchy (admin > edit > view) - AuditService: fire-and-forget event collection, batch 50 / flush 5s, correlation IDs for causal chains - ResourceStore: CRUD with origin/managedBy, RBAC-enforced routes New routes: - POST /api/auth/login, POST /api/auth/logout (bearer token auth) - GET/POST/PUT/DELETE /api/resources (RBAC-enforced CRUD) - GET/POST /api/environments, GET/POST /api/accounts - POST /api/accounts/bind, GET /api/bindings - GET /api/events (audit query with --last, --kind, --env, --correlation) New middleware: - Bearer token auth (validates Authorization header, resolves user identity) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
124 lines
4.0 KiB
TypeScript
124 lines
4.0 KiB
TypeScript
// RBAC service: environment-scoped permission checks.
|
|
// Uses named RbacDefinition records with JSON subjects and roleBindings.
|
|
//
|
|
// Resolution flow:
|
|
// 1. Find all RbacDefinitions where subjects match the current user/groups
|
|
// 2. Collect all roleBindings from matching definitions
|
|
// 3. Check if any binding grants the requested action on the requested resource
|
|
|
|
import type { PrismaClient } from "@prisma/client";
|
|
import { logger } from "./logger.js";
|
|
|
|
export interface RbacCheck {
|
|
userId: string;
|
|
userEmail: string;
|
|
userRole: string;
|
|
action: string; // "view" | "edit" | "create" | "delete" | "run" | "admin"
|
|
resource?: string | undefined; // "servers" | "databases" | "clusters" | "*"
|
|
name?: string | undefined; // specific resource name
|
|
environment?: string | undefined; // specific environment name
|
|
}
|
|
|
|
export interface RbacResult {
|
|
allowed: boolean;
|
|
reason: string;
|
|
matchedDefinition?: string;
|
|
}
|
|
|
|
interface StoredSubject {
|
|
kind: string;
|
|
name: string;
|
|
}
|
|
|
|
interface StoredBinding {
|
|
role: string;
|
|
resource?: string;
|
|
name?: string;
|
|
environment?: string;
|
|
action?: string;
|
|
}
|
|
|
|
export class RbacService {
|
|
constructor(private readonly db: PrismaClient) {}
|
|
|
|
async check(req: RbacCheck): Promise<RbacResult> {
|
|
// Admin users bypass RBAC
|
|
if (req.userRole === "ADMIN") {
|
|
return { allowed: true, reason: "admin role" };
|
|
}
|
|
|
|
// Collect user's group memberships
|
|
const memberships = await this.db.groupMember.findMany({
|
|
where: { userId: req.userId },
|
|
include: { group: true },
|
|
});
|
|
const groupNames = memberships.map((m) => m.group.name);
|
|
|
|
// Find all RBAC definitions
|
|
const definitions = await this.db.rbacDefinition.findMany();
|
|
|
|
for (const def of definitions) {
|
|
const subjects = def.subjects as unknown as StoredSubject[];
|
|
const bindings = def.roleBindings as unknown as StoredBinding[];
|
|
|
|
// Check if this definition's subjects match the user
|
|
const subjectMatch = subjects.some((s) => {
|
|
if (s.kind === "User" && s.name === req.userEmail) return true;
|
|
if (s.kind === "Group" && groupNames.includes(s.name)) return true;
|
|
return false;
|
|
});
|
|
|
|
if (!subjectMatch) continue;
|
|
|
|
// Check if any binding grants the requested permission
|
|
for (const binding of bindings) {
|
|
if (this.bindingMatches(binding, req)) {
|
|
logger.info(`RBAC ALLOW: ${req.userEmail} ${req.action} ${req.resource ?? "*"}${req.name ? `/${req.name}` : ""} via ${def.name}`);
|
|
return {
|
|
allowed: true,
|
|
reason: `granted by ${def.name}`,
|
|
matchedDefinition: def.name,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.info(`RBAC DENY: ${req.userEmail} ${req.action} ${req.resource ?? "*"}${req.name ? `/${req.name}` : ""}`);
|
|
return {
|
|
allowed: false,
|
|
reason: `no matching role binding for ${req.action} on ${req.resource ?? "*"}`,
|
|
};
|
|
}
|
|
|
|
private bindingMatches(binding: StoredBinding, req: RbacCheck): boolean {
|
|
// Check role grants the action
|
|
if (!this.roleGrantsAction(binding.role, req.action)) return false;
|
|
|
|
// Check resource scope
|
|
if (binding.resource && binding.resource !== "*" && binding.resource !== req.resource) return false;
|
|
|
|
// Check name scope
|
|
if (binding.name && binding.name !== req.name) return false;
|
|
|
|
// Check environment scope
|
|
if (binding.environment && binding.environment !== req.environment) return false;
|
|
|
|
// Check operation scope (for "run" role with specific actions)
|
|
if (binding.action && binding.action !== "*" && binding.action !== req.action) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
private roleGrantsAction(role: string, action: string): boolean {
|
|
const grants: Record<string, string[]> = {
|
|
admin: ["view", "edit", "create", "delete", "run", "admin"],
|
|
edit: ["view", "edit", "create", "delete"],
|
|
create: ["create"],
|
|
delete: ["delete"],
|
|
view: ["view"],
|
|
run: ["run"],
|
|
};
|
|
return grants[role]?.includes(action) ?? false;
|
|
}
|
|
}
|