forked from bai/curriculum-project-hub
ef96f8d33d
Co-authored-by: Hong Jiarong <me@jrhim.com> Co-committed-by: Hong Jiarong <me@jrhim.com>
267 lines
9.6 KiB
TypeScript
267 lines
9.6 KiB
TypeScript
/**
|
|
* ADR-0027: Organization-scoped capability connection service. Manages the
|
|
* lifecycle (rotate / read / disable) of capability credentials stored in
|
|
* ADR-0024 encrypted envelopes with purpose="capability".
|
|
*
|
|
* Mirrors FeishuApplicationConnectionService, but keyed by (organizationId,
|
|
* capabilityId) instead of 1:1 — an org may have multiple capabilities.
|
|
*/
|
|
import { randomUUID } from "node:crypto";
|
|
import type { Prisma, PrismaClient } from "@prisma/client";
|
|
import { lockActiveOrganization } from "../org/status.js";
|
|
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
|
import { probeDocmindCredential, type CapabilityReadinessProbe } from "./capabilityReadiness.js";
|
|
import type { CapabilitySecretPayload } from "./types.js";
|
|
|
|
const CAPABILITY_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
const KNOWN_CAPABILITY_IDS = new Set(["pdf_to_md_bundle", "audio_video_to_text"]);
|
|
|
|
export interface CapabilityCredentialInput {
|
|
readonly accessKeyId: string;
|
|
readonly accessKeySecret: string;
|
|
readonly endpoint: string;
|
|
}
|
|
|
|
export interface RotateCapabilityInput extends CapabilityCredentialInput {
|
|
readonly organizationId: string;
|
|
readonly capabilityId: string;
|
|
readonly actorUserId: string;
|
|
}
|
|
|
|
export interface CapabilityConnectionMetadata {
|
|
readonly id: string;
|
|
readonly capabilityId: string;
|
|
readonly status: "DRAFT" | "ACTIVE" | "DISABLED";
|
|
readonly activeVersion: number | null;
|
|
readonly keyId: string | null;
|
|
readonly createdAt: Date;
|
|
readonly updatedAt: Date;
|
|
}
|
|
|
|
export interface CapabilityConnectionWriteResult extends CapabilityConnectionMetadata {
|
|
readonly created: boolean;
|
|
}
|
|
|
|
export type CapabilitySecretPayloadV1 = CapabilitySecretPayload;
|
|
|
|
export class CapabilityConnectionService {
|
|
constructor(
|
|
private readonly prisma: PrismaClient,
|
|
private readonly secrets: LocalSecretEnvelope,
|
|
private readonly readinessProbe: CapabilityReadinessProbe = probeDocmindCredential,
|
|
) {}
|
|
|
|
async rotate(input: RotateCapabilityInput): Promise<CapabilityConnectionWriteResult> {
|
|
if (!CAPABILITY_ID_PATTERN.test(input.capabilityId)) {
|
|
throw new Error(`invalid capabilityId: ${input.capabilityId}`);
|
|
}
|
|
const payload = validateCredential(input);
|
|
await this.prisma.$transaction(async (tx) => {
|
|
await requireCapabilityAdmin(tx, input);
|
|
});
|
|
await this.readinessProbe({
|
|
endpoint: payload.endpoint,
|
|
accessKeyId: payload.accessKeyId,
|
|
accessKeySecret: payload.accessKeySecret,
|
|
});
|
|
|
|
return this.prisma.$transaction(async (tx) => {
|
|
await requireCapabilityAdmin(tx, input);
|
|
const connection = await tx.organizationCapabilityConnection.upsert({
|
|
where: {
|
|
organizationId_capabilityId: {
|
|
organizationId: input.organizationId,
|
|
capabilityId: input.capabilityId,
|
|
},
|
|
},
|
|
update: {},
|
|
create: {
|
|
id: randomUUID(),
|
|
organizationId: input.organizationId,
|
|
capabilityId: input.capabilityId,
|
|
status: "DRAFT",
|
|
},
|
|
});
|
|
await tx.$queryRaw`SELECT "id" FROM "OrganizationCapabilityConnection" WHERE "id" = ${connection.id} FOR UPDATE`;
|
|
const locked = await tx.organizationCapabilityConnection.findUniqueOrThrow({
|
|
where: { id: connection.id },
|
|
include: {
|
|
activeSecretVersion: true,
|
|
secretVersions: { orderBy: { version: "desc" }, take: 1, select: { version: true } },
|
|
},
|
|
});
|
|
if (locked.organizationId !== input.organizationId) {
|
|
throw new Error("Capability Connection scope changed during rotation");
|
|
}
|
|
const version = (locked.secretVersions[0]?.version ?? 0) + 1;
|
|
const secretVersionId = randomUUID();
|
|
const envelope = this.secrets.encryptJson(
|
|
{
|
|
purpose: "capability",
|
|
organizationId: input.organizationId,
|
|
connectionId: locked.id,
|
|
secretVersionId,
|
|
},
|
|
payload,
|
|
);
|
|
const now = new Date();
|
|
const secretVersion = await tx.capabilityCredentialVersion.create({
|
|
data: {
|
|
id: secretVersionId,
|
|
connectionId: locked.id,
|
|
version,
|
|
envelopeVersion: envelope.version,
|
|
keyId: envelope.keyId,
|
|
envelope: envelope as unknown as Prisma.InputJsonValue,
|
|
createdByUserId: input.actorUserId,
|
|
},
|
|
});
|
|
if (locked.activeSecretVersion !== null) {
|
|
await tx.capabilityCredentialVersion.update({
|
|
where: { id: locked.activeSecretVersion.id },
|
|
data: { retiredAt: now },
|
|
});
|
|
}
|
|
const activated = await tx.organizationCapabilityConnection.update({
|
|
where: { id: locked.id },
|
|
data: {
|
|
status: "ACTIVE",
|
|
activeSecretVersionId: secretVersion.id,
|
|
activatedAt: now,
|
|
disabledAt: null,
|
|
},
|
|
});
|
|
await tx.auditEntry.create({
|
|
data: {
|
|
organizationId: input.organizationId,
|
|
actorUserId: input.actorUserId,
|
|
action: version === 1 ? "capability.created" : "capability.rotated",
|
|
metadata: {
|
|
connectionId: locked.id,
|
|
capabilityId: input.capabilityId,
|
|
status: "ACTIVE",
|
|
secretVersion: version,
|
|
keyId: envelope.keyId,
|
|
},
|
|
},
|
|
});
|
|
return {
|
|
...toMetadata(activated, { version, keyId: secretVersion.keyId }),
|
|
created: version === 1,
|
|
};
|
|
});
|
|
}
|
|
|
|
async list(organizationId: string): Promise<CapabilityConnectionMetadata[]> {
|
|
const connections = await this.prisma.organizationCapabilityConnection.findMany({
|
|
where: { organizationId },
|
|
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
|
|
orderBy: { capabilityId: "asc" },
|
|
});
|
|
return connections.map((c) => toMetadata(c, c.activeSecretVersion));
|
|
}
|
|
|
|
async read(organizationId: string, capabilityId: string): Promise<CapabilityConnectionMetadata | null> {
|
|
const connection = await this.prisma.organizationCapabilityConnection.findFirst({
|
|
where: { organizationId, capabilityId },
|
|
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
|
|
});
|
|
return connection === null ? null : toMetadata(connection, connection.activeSecretVersion);
|
|
}
|
|
|
|
async disable(input: {
|
|
readonly organizationId: string;
|
|
readonly capabilityId: string;
|
|
readonly actorUserId: string;
|
|
}): Promise<CapabilityConnectionMetadata> {
|
|
return this.prisma.$transaction(async (tx) => {
|
|
await requireCapabilityAdmin(tx, input);
|
|
const connection = await tx.organizationCapabilityConnection.findFirst({
|
|
where: { organizationId: input.organizationId, capabilityId: input.capabilityId },
|
|
select: { id: true },
|
|
});
|
|
if (connection === null) throw new Error("Capability Connection not found");
|
|
await tx.$queryRaw`SELECT "id" FROM "OrganizationCapabilityConnection" WHERE "id" = ${connection.id} FOR UPDATE`;
|
|
const locked = await tx.organizationCapabilityConnection.findUniqueOrThrow({
|
|
where: { id: connection.id },
|
|
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
|
|
});
|
|
if (locked.status === "DISABLED") return toMetadata(locked, locked.activeSecretVersion);
|
|
const disabled = await tx.organizationCapabilityConnection.update({
|
|
where: { id: locked.id },
|
|
data: { status: "DISABLED", disabledAt: new Date() },
|
|
});
|
|
await tx.auditEntry.create({
|
|
data: {
|
|
organizationId: input.organizationId,
|
|
actorUserId: input.actorUserId,
|
|
action: "capability.disabled",
|
|
metadata: {
|
|
connectionId: locked.id,
|
|
capabilityId: input.capabilityId,
|
|
previousStatus: locked.status,
|
|
status: "DISABLED",
|
|
},
|
|
},
|
|
});
|
|
return toMetadata(disabled, locked.activeSecretVersion);
|
|
});
|
|
}
|
|
}
|
|
|
|
function validateCredential(input: RotateCapabilityInput): CapabilitySecretPayloadV1 {
|
|
if (!KNOWN_CAPABILITY_IDS.has(input.capabilityId)) {
|
|
throw new Error(`unsupported capabilityId: ${input.capabilityId}`);
|
|
}
|
|
const accessKeyId = nonEmpty(input.accessKeyId, "accessKeyId");
|
|
const accessKeySecret = nonEmpty(input.accessKeySecret, "accessKeySecret");
|
|
const endpoint = nonEmpty(input.endpoint, "endpoint");
|
|
return { schemaVersion: 1, accessKeyId, accessKeySecret, endpoint };
|
|
}
|
|
|
|
function toMetadata(
|
|
connection: {
|
|
readonly id: string;
|
|
readonly capabilityId: string;
|
|
readonly status: string;
|
|
readonly createdAt: Date;
|
|
readonly updatedAt: Date;
|
|
},
|
|
secret: { readonly version: number; readonly keyId: string } | null,
|
|
): CapabilityConnectionMetadata {
|
|
return {
|
|
id: connection.id,
|
|
capabilityId: connection.capabilityId,
|
|
status: connection.status as CapabilityConnectionMetadata["status"],
|
|
activeVersion: secret?.version ?? null,
|
|
keyId: secret?.keyId ?? null,
|
|
createdAt: connection.createdAt,
|
|
updatedAt: connection.updatedAt,
|
|
};
|
|
}
|
|
|
|
async function requireCapabilityAdmin(
|
|
tx: Prisma.TransactionClient,
|
|
input: { readonly organizationId: string; readonly actorUserId: string },
|
|
): Promise<void> {
|
|
await lockActiveOrganization(tx, input.organizationId);
|
|
const membership = await tx.organizationMembership.findFirst({
|
|
where: {
|
|
organizationId: input.organizationId,
|
|
userId: input.actorUserId,
|
|
role: { in: ["OWNER", "ADMIN"] },
|
|
revokedAt: null,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
if (membership === null) {
|
|
throw new Error("only Organization OWNER or ADMIN may manage capability connections");
|
|
}
|
|
}
|
|
|
|
function nonEmpty(value: string, label: string): string {
|
|
const trimmed = value.trim();
|
|
if (trimmed === "") throw new Error(`${label} must not be empty`);
|
|
return trimmed;
|
|
}
|