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>
109 lines
3.7 KiB
TypeScript
109 lines
3.7 KiB
TypeScript
// Resource store: CRUD for generic resources with origin/managedBy tracking.
|
|
// All mutations go through this service so RBAC and audit are applied consistently.
|
|
|
|
import type { PrismaClient, Resource as PrismaResource, Prisma } from "@prisma/client";
|
|
import { logger } from "./logger.js";
|
|
|
|
export interface CreateResourceInput {
|
|
kind: string;
|
|
name: string;
|
|
environmentId: string;
|
|
accountId: string;
|
|
origin?: string;
|
|
managedBy?: string;
|
|
sourceRef?: string;
|
|
desiredSpec: Record<string, unknown>;
|
|
}
|
|
|
|
export interface UpdateResourceInput {
|
|
desiredSpec?: Record<string, unknown>;
|
|
status?: string;
|
|
statusMessage?: string;
|
|
actualSpec?: Record<string, unknown>;
|
|
platformRef?: string;
|
|
}
|
|
|
|
export interface ListResourcesFilter {
|
|
kind?: string | undefined;
|
|
environmentId?: string | undefined;
|
|
accountId?: string | undefined;
|
|
status?: string | undefined;
|
|
}
|
|
|
|
export class ResourceStore {
|
|
constructor(private readonly db: PrismaClient) {}
|
|
|
|
async create(input: CreateResourceInput): Promise<PrismaResource> {
|
|
const resource = await this.db.resource.create({
|
|
data: {
|
|
kind: input.kind,
|
|
name: input.name,
|
|
environmentId: input.environmentId,
|
|
accountId: input.accountId,
|
|
origin: input.origin ?? "cli",
|
|
managedBy: input.managedBy ?? "manual",
|
|
sourceRef: input.sourceRef ?? null,
|
|
desiredSpec: input.desiredSpec as Prisma.InputJsonValue,
|
|
status: "pending",
|
|
},
|
|
});
|
|
|
|
logger.info(`RESOURCE CREATED: ${input.kind}/${input.name} in env ${input.environmentId.slice(0, 8)}...`);
|
|
return resource;
|
|
}
|
|
|
|
async get(id: string): Promise<PrismaResource | null> {
|
|
return this.db.resource.findUnique({ where: { id } });
|
|
}
|
|
|
|
async getByKindNameEnv(kind: string, name: string, environmentId: string): Promise<PrismaResource | null> {
|
|
return this.db.resource.findUnique({
|
|
where: { kind_name_environmentId: { kind, name, environmentId } },
|
|
});
|
|
}
|
|
|
|
async list(filter: ListResourcesFilter = {}): Promise<PrismaResource[]> {
|
|
return this.db.resource.findMany({
|
|
where: {
|
|
...(filter.kind ? { kind: filter.kind } : {}),
|
|
...(filter.environmentId ? { environmentId: filter.environmentId } : {}),
|
|
...(filter.accountId ? { accountId: filter.accountId } : {}),
|
|
...(filter.status ? { status: filter.status } : {}),
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
}
|
|
|
|
async update(id: string, input: UpdateResourceInput): Promise<PrismaResource> {
|
|
const data: Prisma.ResourceUpdateInput = {};
|
|
if (input.desiredSpec !== undefined) data.desiredSpec = input.desiredSpec as Prisma.InputJsonValue;
|
|
if (input.status !== undefined) data.status = input.status;
|
|
if (input.statusMessage !== undefined) data.statusMessage = input.statusMessage;
|
|
if (input.actualSpec !== undefined) data.actualSpec = input.actualSpec as Prisma.InputJsonValue;
|
|
if (input.platformRef !== undefined) data.platformRef = input.platformRef;
|
|
if (input.status === "ready") data.lastReconciled = new Date();
|
|
|
|
const resource = await this.db.resource.update({ where: { id }, data });
|
|
|
|
logger.info(`RESOURCE UPDATED: ${resource.kind}/${resource.name} -> ${input.status ?? "spec change"}`);
|
|
return resource;
|
|
}
|
|
|
|
async delete(id: string): Promise<void> {
|
|
const resource = await this.db.resource.findUnique({ where: { id } });
|
|
if (!resource) return;
|
|
|
|
// Mark as deleting first (driver handles actual deletion)
|
|
await this.db.resource.update({
|
|
where: { id },
|
|
data: { status: "deleting" },
|
|
});
|
|
|
|
logger.info(`RESOURCE DELETING: ${resource.kind}/${resource.name}`);
|
|
}
|
|
|
|
async hardDelete(id: string): Promise<void> {
|
|
await this.db.resource.delete({ where: { id } });
|
|
}
|
|
}
|