feat: secure organization provider credentials

This commit is contained in:
2026-07-10 22:04:43 +08:00
parent e049cb6880
commit 848f913597
51 changed files with 3280 additions and 230 deletions
@@ -0,0 +1,70 @@
-- ADR-0024: Organization-scoped provider connections and immutable encrypted
-- credential versions. No provider plaintext column exists in this schema.
CREATE TYPE "ProviderCredentialMode" AS ENUM ('BYOK', 'PLATFORM_MANAGED');
CREATE TYPE "OrganizationConnectionStatus" AS ENUM ('DRAFT', 'ACTIVE', 'DISABLED');
CREATE TABLE "OrganizationProviderConnection" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"mode" "ProviderCredentialMode" NOT NULL,
"status" "OrganizationConnectionStatus" NOT NULL DEFAULT 'DRAFT',
"activeSecretVersionId" TEXT,
"activatedAt" TIMESTAMP(3),
"disabledAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationProviderConnection_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "ProviderCredentialVersion" (
"id" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"envelopeVersion" INTEGER NOT NULL DEFAULT 1,
"keyId" TEXT NOT NULL,
"envelope" JSONB NOT NULL,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"retiredAt" TIMESTAMP(3),
CONSTRAINT "ProviderCredentialVersion_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "OrganizationProviderConnection_organizationId_providerId_key"
ON "OrganizationProviderConnection"("organizationId", "providerId");
CREATE UNIQUE INDEX "OrganizationProviderConnection_activeSecretVersionId_key"
ON "OrganizationProviderConnection"("activeSecretVersionId");
CREATE INDEX "OrganizationProviderConnection_organizationId_status_idx"
ON "OrganizationProviderConnection"("organizationId", "status");
CREATE INDEX "OrganizationProviderConnection_providerId_status_idx"
ON "OrganizationProviderConnection"("providerId", "status");
CREATE UNIQUE INDEX "ProviderCredentialVersion_connectionId_version_key"
ON "ProviderCredentialVersion"("connectionId", "version");
CREATE INDEX "ProviderCredentialVersion_connectionId_retiredAt_idx"
ON "ProviderCredentialVersion"("connectionId", "retiredAt");
CREATE INDEX "ProviderCredentialVersion_keyId_idx"
ON "ProviderCredentialVersion"("keyId");
CREATE INDEX "ProviderCredentialVersion_createdByUserId_idx"
ON "ProviderCredentialVersion"("createdByUserId");
ALTER TABLE "OrganizationProviderConnection"
ADD CONSTRAINT "OrganizationProviderConnection_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProviderCredentialVersion"
ADD CONSTRAINT "ProviderCredentialVersion_connectionId_fkey"
FOREIGN KEY ("connectionId") REFERENCES "OrganizationProviderConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProviderCredentialVersion"
ADD CONSTRAINT "ProviderCredentialVersion_createdByUserId_fkey"
FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "OrganizationProviderConnection"
ADD CONSTRAINT "OrganizationProviderConnection_activeSecretVersionId_fkey"
FOREIGN KEY ("activeSecretVersionId") REFERENCES "ProviderCredentialVersion"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "AuditEntry" ADD COLUMN "organizationId" TEXT;
ALTER TABLE "AuditEntry"
ADD CONSTRAINT "AuditEntry_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "AuditEntry_organizationId_createdAt_idx"
ON "AuditEntry"("organizationId", "createdAt");
+65
View File
@@ -41,6 +41,8 @@ model Organization {
projects Project[]
teams Team[]
externalDirectoryConnections ExternalDirectoryConnection[]
providerConnections OrganizationProviderConnection[]
auditEntries AuditEntry[] @relation("organizationAudit")
@@index([status])
}
@@ -106,6 +108,66 @@ model User {
permissionGrants PermissionGrant[] @relation("grantCreator")
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
auditEntries AuditEntry[] @relation("auditActor")
providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator")
}
/// ADR-0024: model-provider connection identity is stable and belongs to one
/// Organization. Both BYOK and platform-managed credentials are tenant-local;
/// only ACTIVE connections with an active immutable secret version resolve.
model OrganizationProviderConnection {
id String @id @default(cuid())
organizationId String
providerId String
mode ProviderCredentialMode
status OrganizationConnectionStatus @default(DRAFT)
activeSecretVersionId String? @unique
activatedAt DateTime?
disabledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
secretVersions ProviderCredentialVersion[] @relation("providerCredentialVersions")
activeSecretVersion ProviderCredentialVersion? @relation("activeProviderCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict)
@@unique([organizationId, providerId])
@@index([organizationId, status])
@@index([providerId, status])
}
enum ProviderCredentialMode {
BYOK
PLATFORM_MANAGED
}
enum OrganizationConnectionStatus {
DRAFT
ACTIVE
DISABLED
}
/// ADR-0024: write-only provider secret material is one authenticated envelope
/// per immutable version. keyId/envelopeVersion are redacted rotation metadata;
/// all provider URL/token/API-key fields remain inside envelope ciphertext.
model ProviderCredentialVersion {
id String @id @default(cuid())
connectionId String
version Int
envelopeVersion Int @default(1)
keyId String
envelope Json
createdByUserId String?
createdAt DateTime @default(now())
retiredAt DateTime?
connection OrganizationProviderConnection @relation("providerCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade)
activeFor OrganizationProviderConnection? @relation("activeProviderCredentialVersion")
createdBy User? @relation("providerCredentialVersionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@unique([connectionId, version])
@@index([connectionId, retiredAt])
@@index([keyId])
@@index([createdByUserId])
}
/// Platform-level role (admin/teacher). Distinct from ADR-0004 PermissionRole.
@@ -501,6 +563,7 @@ model AuditEntry {
id String @id @default(cuid())
runId String?
projectId String?
organizationId String?
actorUserId String?
action String
metadata Json
@@ -508,9 +571,11 @@ model AuditEntry {
actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull)
project Project? @relation("projectAudit", fields: [projectId], references: [id], onDelete: SetNull)
organization Organization? @relation("organizationAudit", fields: [organizationId], references: [id], onDelete: SetNull)
@@index([runId])
@@index([projectId, createdAt])
@@index([organizationId, createdAt])
@@index([actorUserId])
@@index([createdAt])
}