forked from bai/curriculum-project-hub
feat(hub): capability connection admin API + UI + release v0.0.32 (#8)
Co-authored-by: Hong Jiarong <me@jrhim.com> Co-committed-by: Hong Jiarong <me@jrhim.com>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user