diff --git a/prisma/migrations/20260308030000_fix_user_deletion_fk_constraints/migration.sql b/prisma/migrations/20260308030000_fix_user_deletion_fk_constraints/migration.sql new file mode 100644 index 00000000..e3f5c5eb --- /dev/null +++ b/prisma/migrations/20260308030000_fix_user_deletion_fk_constraints/migration.sql @@ -0,0 +1,16 @@ +-- DropForeignKey +ALTER TABLE "TeamMember" DROP CONSTRAINT "TeamMember_userId_fkey"; +ALTER TABLE "VrlSnippet" DROP CONSTRAINT "VrlSnippet_createdBy_fkey"; +ALTER TABLE "DeployRequest" DROP CONSTRAINT "DeployRequest_requestedById_fkey"; +ALTER TABLE "ServiceAccount" DROP CONSTRAINT "ServiceAccount_createdById_fkey"; + +-- AlterTable (make columns nullable where needed) +ALTER TABLE "VrlSnippet" ALTER COLUMN "createdBy" DROP NOT NULL; +ALTER TABLE "DeployRequest" ALTER COLUMN "requestedById" DROP NOT NULL; +ALTER TABLE "ServiceAccount" ALTER COLUMN "createdById" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "VrlSnippet" ADD CONSTRAINT "VrlSnippet_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "DeployRequest" ADD CONSTRAINT "DeployRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "ServiceAccount" ADD CONSTRAINT "ServiceAccount_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 47df67e3..9f6b48fc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -58,7 +58,7 @@ model TeamMember { userId String teamId String role Role - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) team Team @relation(fields: [teamId], references: [id]) @@unique([userId, teamId]) @@ -497,8 +497,8 @@ model VrlSnippet { description String? category String code String - createdBy String - creator User @relation(fields: [createdBy], references: [id]) + createdBy String? + creator User? @relation(fields: [createdBy], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -512,8 +512,8 @@ model DeployRequest { pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade) environmentId String environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) - requestedById String - requestedBy User @relation("deployRequester", fields: [requestedById], references: [id]) + requestedById String? + requestedBy User? @relation("deployRequester", fields: [requestedById], references: [id], onDelete: SetNull) configYaml String changelog String nodeSelector Json? @@ -653,8 +653,8 @@ model ServiceAccount { environmentId String environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) permissions Json // string[] of permission names - createdById String - createdBy User @relation(fields: [createdById], references: [id]) + createdById String? + createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) lastUsedAt DateTime? expiresAt DateTime? enabled Boolean @default(true) diff --git a/src/app/api/scim/v2/Groups/[id]/route.ts b/src/app/api/scim/v2/Groups/[id]/route.ts index eb2dcdf1..044ec581 100644 --- a/src/app/api/scim/v2/Groups/[id]/route.ts +++ b/src/app/api/scim/v2/Groups/[id]/route.ts @@ -263,3 +263,33 @@ export async function PUT( return scimError(message, 400); } } + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + if (!(await authenticateScim(req))) { + return scimError("Unauthorized", 401); + } + + const { id } = await params; + + const team = await prisma.team.findUnique({ where: { id } }); + if (!team) { + return scimError("Group not found", 404); + } + + // Remove all memberships but keep the team (soft approach — avoids + // cascading deletes of environments, pipelines, etc.) + await prisma.teamMember.deleteMany({ where: { teamId: id } }); + + await writeAuditLog({ + userId: null, + action: "scim.group_deleted", + entityType: "Team", + entityId: id, + metadata: { displayName: team.name }, + }); + + return new NextResponse(null, { status: 204 }); +} diff --git a/src/app/api/scim/v2/Groups/route.ts b/src/app/api/scim/v2/Groups/route.ts index ad646ef5..59472140 100644 --- a/src/app/api/scim/v2/Groups/route.ts +++ b/src/app/api/scim/v2/Groups/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +import { writeAuditLog } from "@/server/services/audit"; import { authenticateScim } from "../auth"; interface ScimGroup { @@ -78,3 +79,75 @@ export async function GET(req: NextRequest) { Resources: teams.map(toScimGroup), }); } + +export async function POST(req: NextRequest) { + if (!(await authenticateScim(req))) { + return NextResponse.json( + { + schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], + detail: "Unauthorized", + status: "401", + }, + { status: 401 }, + ); + } + + try { + const body = await req.json(); + const displayName = body.displayName; + if (!displayName || typeof displayName !== "string") { + return NextResponse.json( + { + schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], + detail: "displayName is required", + status: "400", + }, + { status: 400 }, + ); + } + + // Check if a team with this name already exists — adopt it + const existing = await prisma.team.findFirst({ + where: { name: displayName }, + include: { members: { include: { user: { select: { email: true } } } } }, + }); + + if (existing) { + await writeAuditLog({ + userId: null, + action: "scim.group_adopted", + entityType: "Team", + entityId: existing.id, + metadata: { displayName }, + }); + + return NextResponse.json(toScimGroup(existing), { status: 200 }); + } + + const team = await prisma.team.create({ + data: { name: displayName }, + include: { members: { include: { user: { select: { email: true } } } } }, + }); + + await writeAuditLog({ + userId: null, + action: "scim.group_created", + entityType: "Team", + entityId: team.id, + metadata: { displayName }, + }); + + return NextResponse.json(toScimGroup(team), { status: 201 }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to create group"; + return NextResponse.json( + { + schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], + detail: message, + status: "400", + }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/scim/v2/Users/route.ts b/src/app/api/scim/v2/Users/route.ts index 2745bdb3..69326beb 100644 --- a/src/app/api/scim/v2/Users/route.ts +++ b/src/app/api/scim/v2/Users/route.ts @@ -43,35 +43,22 @@ export async function POST(req: NextRequest) { try { const body = await req.json(); - const user = await scimCreateUser(body); - return NextResponse.json(user, { status: 201 }); + const { user, adopted } = await scimCreateUser(body); + // Return 200 for adopted (existing) users, 201 for newly created + return NextResponse.json(user, { status: adopted ? 200 : 201 }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to create user"; - // Handle unique constraint violation (duplicate email or externalId) - if (message.includes("Unique constraint")) { - let detail = "User already exists"; - if (message.includes("User_email_key")) - detail = "A user with this email already exists"; - else if (message.includes("User_scimExternalId_key")) - detail = "A user with this external ID already exists"; - return NextResponse.json( - { - schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], - detail, - status: "409", - scimType: "uniqueness", - }, - { status: 409 }, - ); - } + // RFC 7644 §3.3: uniqueness conflicts use 409 + const isConflict = error instanceof Error && (error as Error & { scimConflict?: boolean }).scimConflict === true; + const status = isConflict ? 409 : 400; return NextResponse.json( { schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], detail: message, - status: "400", + status: String(status), }, - { status: 400 }, + { status }, ); } } diff --git a/src/components/flow/deploy-dialog.tsx b/src/components/flow/deploy-dialog.tsx index adb1d270..88946249 100644 --- a/src/components/flow/deploy-dialog.tsx +++ b/src/components/flow/deploy-dialog.tsx @@ -279,7 +279,7 @@ export function DeployDialog({ pipelineId, open, onOpenChange }: DeployDialogPro
- Requested by {pendingRequest.requestedBy.name ?? pendingRequest.requestedBy.email} + Requested by {pendingRequest.requestedBy?.name ?? pendingRequest.requestedBy?.email ?? "Unknown"} {" "}{pendingRequestTimeAgo}
diff --git a/src/server/routers/deploy.ts b/src/server/routers/deploy.ts
index c2395f4c..46633868 100644
--- a/src/server/routers/deploy.ts
+++ b/src/server/routers/deploy.ts
@@ -367,7 +367,7 @@ export const deployRouter = router({
try {
const result = await deployAgent(
request.pipelineId,
- request.requestedById,
+ request.requestedById ?? ctx.session.user.id,
request.changelog,
request.configYaml,
);
diff --git a/src/server/routers/fleet.ts b/src/server/routers/fleet.ts
index ae014058..0c4225c3 100644
--- a/src/server/routers/fleet.ts
+++ b/src/server/routers/fleet.ts
@@ -253,27 +253,30 @@ export const fleetRouter = router({
});
}
- let { targetVersion, checksum } = input;
const { downloadUrl } = input;
+ let { targetVersion, checksum } = input;
// Dev releases are rolling — the binary at the download URL may have been
// replaced since the UI cached the version/checksum. Force-refresh to get
// the current release data and avoid checksum mismatch on the agent.
if (targetVersion.startsWith("dev-")) {
const fresh = await checkDevAgentVersion(true);
- if (fresh.latestVersion && fresh.latestVersion !== targetVersion) {
- const binaryName = downloadUrl.split("/").pop() ?? "vf-agent-linux-amd64";
- const freshChecksum = fresh.checksums[binaryName];
- if (!freshChecksum) {
- throw new TRPCError({
- code: "INTERNAL_SERVER_ERROR",
- message:
- "Dev release has been updated but fresh checksum could not be retrieved. Please retry.",
- });
- }
- targetVersion = fresh.latestVersion;
- checksum = `sha256:${freshChecksum}`;
+ if (!fresh.latestVersion) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Unable to fetch current dev release info — retry the update",
+ });
+ }
+ const binaryName = downloadUrl.split("/").pop() ?? "vf-agent-linux-amd64";
+ const freshChecksum = fresh.checksums[binaryName];
+ if (!freshChecksum) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: `Failed to retrieve fresh checksum for ${binaryName} — retry the update`,
+ });
}
+ targetVersion = fresh.latestVersion;
+ checksum = `sha256:${freshChecksum}`;
}
return prisma.vectorNode.update({
diff --git a/src/server/services/scim.ts b/src/server/services/scim.ts
index c3866e63..82a10f6d 100644
--- a/src/server/services/scim.ts
+++ b/src/server/services/scim.ts
@@ -90,7 +90,7 @@ export async function scimGetUser(id: string) {
return toScimUser(user);
}
-export async function scimCreateUser(scimUser: ScimUser) {
+export async function scimCreateUser(scimUser: ScimUser): Promise<{ user: ScimUser; adopted: boolean }> {
const email =
scimUser.emails?.[0]?.value ?? scimUser.userName;
const name =
@@ -98,6 +98,44 @@ export async function scimCreateUser(scimUser: ScimUser) {
scimUser.name?.givenName ??
email.split("@")[0];
+ // Check if user already exists (e.g. created via OIDC login before SCIM provisioning)
+ const existing = await prisma.user.findUnique({
+ where: { email },
+ select: { ...USER_SELECT, authMethod: true },
+ });
+
+ if (existing) {
+ // Only adopt users already created via SSO or previously SCIM-linked.
+ // Local-credential accounts require explicit admin action to link.
+ if (existing.authMethod !== "OIDC" && !existing.scimExternalId) {
+ const err = new Error(
+ `User ${email} exists as a local account and cannot be adopted via SCIM. ` +
+ "An administrator must link or convert the account first.",
+ );
+ (err as Error & { scimConflict: boolean }).scimConflict = true;
+ throw err;
+ }
+
+ // Adopt: link the SCIM externalId to the existing SSO user
+ const updated = await prisma.user.update({
+ where: { id: existing.id },
+ data: {
+ scimExternalId: scimUser.externalId ?? existing.scimExternalId,
+ },
+ select: USER_SELECT,
+ });
+
+ await writeAuditLog({
+ userId: null,
+ action: "scim.user_adopted",
+ entityType: "User",
+ entityId: updated.id,
+ metadata: { email, scimExternalId: scimUser.externalId },
+ });
+
+ return { user: toScimUser(updated), adopted: true };
+ }
+
// Generate random password (SCIM users authenticate via SSO, not local credentials)
const tempPassword = crypto.randomBytes(32).toString("hex");
const passwordHash = await bcrypt.hash(tempPassword, 12);
@@ -122,7 +160,7 @@ export async function scimCreateUser(scimUser: ScimUser) {
metadata: { email, scimExternalId: scimUser.externalId },
});
- return toScimUser(user);
+ return { user: toScimUser(user), adopted: false };
}
export async function scimUpdateUser(id: string, scimUser: Partial