Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
14 changes: 7 additions & 7 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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

Expand All @@ -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?
Expand Down Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions src/app/api/scim/v2/Groups/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Comment on lines +285 to +291
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Audit action scim.group_deleted is misleading

The handler intentionally preserves the team (to avoid cascading deletes of environments, pipelines, etc.) — it only removes memberships. But the audit entry fires "scim.group_deleted", which would lead an admin reviewing the audit trail to believe the team was removed when it wasn't.

Consider using an action name that accurately describes what occurred:

Suggested change
await writeAuditLog({
userId: null,
action: "scim.group_deleted",
entityType: "Team",
entityId: id,
metadata: { displayName: team.name },
await writeAuditLog({
userId: null,
action: "scim.group_memberships_cleared",
entityType: "Team",
entityId: id,
metadata: { displayName: team.name },
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/api/scim/v2/Groups/[id]/route.ts
Line: 285-291

Comment:
**Audit action `scim.group_deleted` is misleading**

The handler intentionally preserves the team (to avoid cascading deletes of environments, pipelines, etc.) — it only removes memberships. But the audit entry fires `"scim.group_deleted"`, which would lead an admin reviewing the audit trail to believe the team was removed when it wasn't.

Consider using an action name that accurately describes what occurred:

```suggestion
  await writeAuditLog({
    userId: null,
    action: "scim.group_memberships_cleared",
    entityType: "Team",
    entityId: id,
    metadata: { displayName: team.name },
  });
```

How can I resolve this? If you propose a fix, please make it concise.

});

return new NextResponse(null, { status: 204 });
}
73 changes: 73 additions & 0 deletions src/app/api/scim/v2/Groups/route.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 },
);
}
}
29 changes: 8 additions & 21 deletions src/app/api/scim/v2/Users/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
);
}
}
2 changes: 1 addition & 1 deletion src/components/flow/deploy-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ export function DeployDialog({ pipelineId, open, onOpenChange }: DeployDialogPro
</div>
<div className="text-xs text-muted-foreground space-y-1">
<p>
Requested by <span className="font-medium text-foreground">{pendingRequest.requestedBy.name ?? pendingRequest.requestedBy.email}</span>
Requested by <span className="font-medium text-foreground">{pendingRequest.requestedBy?.name ?? pendingRequest.requestedBy?.email ?? "Unknown"}</span>
{" "}{pendingRequestTimeAgo}
</p>
<p>
Expand Down
2 changes: 1 addition & 1 deletion src/server/routers/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ export const deployRouter = router({
try {
const result = await deployAgent(
request.pipelineId,
request.requestedById,
request.requestedById ?? ctx.session.user.id,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Admin misattributed as requester in PipelineVersion

When requestedById is null (the original requester was deleted), the fallback ctx.session.user.id is passed as the userId to deployAgent. Inside deployAgent, this value is used in two places:

  1. createVersion(pipelineId, ..., userId, ...) — the immutable PipelineVersion snapshot is permanently stamped with the approving admin's ID as the version creator.
  2. prisma.user.findUnique({ where: { id: userId } }) — the git sync commit is attributed to the admin.

So the admin who clicks "Approve" now permanently appears as the person who both requested and deployed the pipeline version — not just as the reviewer. The DeployRequest row correctly records reviewedById = ctx.session.user.id, but the PipelineVersion is a separate record that future audits and git history will show as authored by the admin.

Consider passing null (or a sentinel like "deleted-user") and updating createVersion/deployAgent to accept string | null for the userId, then treating null as "original requester deleted":

// Option: pass null and let deployAgent handle it
const result = await deployAgent(
  request.pipelineId,
  request.requestedById,   // may be null — deployAgent should handle
  request.changelog,
  request.configYaml,
);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/server/routers/deploy.ts
Line: 370

Comment:
**Admin misattributed as requester in PipelineVersion**

When `requestedById` is `null` (the original requester was deleted), the fallback `ctx.session.user.id` is passed as the `userId` to `deployAgent`. Inside `deployAgent`, this value is used in two places:

1. `createVersion(pipelineId, ..., userId, ...)` — the immutable `PipelineVersion` snapshot is permanently stamped with the approving admin's ID as the version creator.
2. `prisma.user.findUnique({ where: { id: userId } })` — the git sync commit is attributed to the admin.

So the admin who clicks "Approve" now permanently appears as the person who both requested and deployed the pipeline version — not just as the reviewer. The `DeployRequest` row correctly records `reviewedById = ctx.session.user.id`, but the `PipelineVersion` is a separate record that future audits and git history will show as authored by the admin.

Consider passing `null` (or a sentinel like `"deleted-user"`) and updating `createVersion`/`deployAgent` to accept `string | null` for the userId, then treating null as "original requester deleted":

```typescript
// Option: pass null and let deployAgent handle it
const result = await deployAgent(
  request.pipelineId,
  request.requestedById,   // may be null — deployAgent should handle
  request.changelog,
  request.configYaml,
);
```

How can I resolve this? If you propose a fix, please make it concise.

request.changelog,
request.configYaml,
);
Expand Down
29 changes: 16 additions & 13 deletions src/server/routers/fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
42 changes: 40 additions & 2 deletions src/server/services/scim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,52 @@ 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 =
scimUser.name?.formatted ??
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);
Expand All @@ -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<ScimUser>) {
Expand Down
Loading