feat: v2.0 Phase 1 foundation — @lab/core, auth, RBAC, audit, resource store

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>
This commit is contained in:
Michal
2026-04-02 01:42:28 +01:00
parent 95c99cb4d5
commit 04faa079e2
20 changed files with 3346 additions and 100 deletions

View File

@@ -0,0 +1,108 @@
// 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 } });
}
}