From 50b977eb77cbdd0be0d5df40c3f93b33f0848deb Mon Sep 17 00:00:00 2001 From: haouarihk Date: Thu, 20 Aug 2026 21:57:05 +0100 Subject: [PATCH] fix: require ownership to change an existing GitLab source Assigned provider access can connect new deploys, but it must not let a member reassign someone else's source or register its webhook. --- .../git-provider/git-provider-access.test.ts | 60 +++++++++++++++++++ .../dokploy/server/api/routers/application.ts | 12 +++- apps/dokploy/server/api/routers/compose.ts | 11 +++- packages/server/src/services/git-provider.ts | 37 ++++++++++++ 4 files changed, 117 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/__test__/git-provider/git-provider-access.test.ts b/apps/dokploy/__test__/git-provider/git-provider-access.test.ts index 714607538..6105c89b2 100644 --- a/apps/dokploy/__test__/git-provider/git-provider-access.test.ts +++ b/apps/dokploy/__test__/git-provider/git-provider-access.test.ts @@ -1,6 +1,8 @@ import { + assertCanEditExistingDeployGitSource, canEditDeployGitSource, getAccessibleGitProviderIds, + getConnectedGitProviderId, } from "@dokploy/server/services/git-provider"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -367,3 +369,61 @@ describe("canEditDeployGitSource", () => { }); }); }); + +describe("getConnectedGitProviderId", () => { + it("returns the gitlab gitProviderId for a gitlab source", () => { + expect( + getConnectedGitProviderId({ + sourceType: "gitlab", + gitlab: { gitProviderId: "gp-gitlab" }, + }), + ).toBe("gp-gitlab"); + }); + + it("returns null when there is no connected git provider", () => { + expect(getConnectedGitProviderId({ sourceType: "docker" })).toBeNull(); + }); +}); + +describe("assertCanEditExistingDeployGitSource", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockHasValidLicense.mockResolvedValue(true); + }); + + it("allows first-time connects when no git source is attached", async () => { + await expect( + assertCanEditExistingDeployGitSource(null, session(USER_MEMBER)), + ).resolves.toBeUndefined(); + }); + + it("rejects existing-source edits that only have accessedGitProviders", async () => { + mockDb.query.member.findFirst.mockResolvedValue({ role: "member" }); + mockDb.query.gitProvider.findFirst.mockResolvedValue({ + userId: USER_OWNER, + sharedWithOrganization: false, + }); + + await expect( + assertCanEditExistingDeployGitSource( + providerPrivate.gitProviderId, + session(USER_MEMBER), + ), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("allows existing-source edits when the member owns the provider", async () => { + mockDb.query.member.findFirst.mockResolvedValue({ role: "member" }); + mockDb.query.gitProvider.findFirst.mockResolvedValue({ + userId: USER_MEMBER, + sharedWithOrganization: false, + }); + + await expect( + assertCanEditExistingDeployGitSource( + providerOwned.gitProviderId, + session(USER_MEMBER), + ), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 0b18c6696..97a663496 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -35,7 +35,11 @@ import { writeConfigRemote, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; -import { canEditDeployGitSource } from "@dokploy/server/services/git-provider"; +import { + assertCanEditExistingDeployGitSource, + canEditDeployGitSource, + getConnectedGitProviderId, +} from "@dokploy/server/services/git-provider"; import { addNewService, checkServiceAccess, @@ -461,6 +465,11 @@ export const applicationRouter = createTRPCRouter({ message: "GitLab provider is required", }); } + const application = await findApplicationById(input.applicationId); + await assertCanEditExistingDeployGitSource( + getConnectedGitProviderId(application), + ctx.session, + ); await assertGitlabProviderAccess(input.gitlabId, ctx.session); await updateApplication(input.applicationId, { gitlabRepository: input.gitlabRepository, @@ -475,7 +484,6 @@ export const applicationRouter = createTRPCRouter({ watchPaths: input.watchPaths, enableSubmodules: input.enableSubmodules, }); - const application = await findApplicationById(input.applicationId); if (application.autoDeploy) { const dokployUrl = await getDokployUrl(); await registerGitlabDeployWebhook({ diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 613e731eb..22770a1c1 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -36,7 +36,11 @@ import { updateDeploymentStatus, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; -import { canEditDeployGitSource } from "@dokploy/server/services/git-provider"; +import { + assertCanEditExistingDeployGitSource, + canEditDeployGitSource, + getConnectedGitProviderId, +} from "@dokploy/server/services/git-provider"; import { addNewService, checkServiceAccess, @@ -199,6 +203,11 @@ export const composeRouter = createTRPCRouter({ service: ["create"], }); if (input.gitlabId) { + const compose = await findComposeById(input.composeId); + await assertCanEditExistingDeployGitSource( + getConnectedGitProviderId(compose), + ctx.session, + ); await assertGitlabProviderAccess(input.gitlabId, ctx.session); } const updated = await updateCompose(input.composeId, input); diff --git a/packages/server/src/services/git-provider.ts b/packages/server/src/services/git-provider.ts index 5a7c6f438..a169e636e 100644 --- a/packages/server/src/services/git-provider.ts +++ b/packages/server/src/services/git-provider.ts @@ -75,6 +75,43 @@ export const canEditDeployGitSource = async ( return provider.userId === userId || provider.sharedWithOrganization; }; +export const getConnectedGitProviderId = (service: { + sourceType: string; + github?: { gitProviderId: string } | null; + gitlab?: { gitProviderId: string } | null; + bitbucket?: { gitProviderId: string } | null; + gitea?: { gitProviderId: string } | null; +}): string | null => { + switch (service.sourceType) { + case "github": + return service.github?.gitProviderId ?? null; + case "gitlab": + return service.gitlab?.gitProviderId ?? null; + case "bitbucket": + return service.bitbucket?.gitProviderId ?? null; + case "gitea": + return service.gitea?.gitProviderId ?? null; + default: + return null; + } +}; + +export const assertCanEditExistingDeployGitSource = async ( + gitProviderId: string | null | undefined, + session: { userId: string; activeOrganizationId: string }, +) => { + if (!gitProviderId) return; + + const canEdit = await canEditDeployGitSource(gitProviderId, session); + if (!canEdit) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "You are not authorized to change the git source of this service", + }); + } +}; + export const getAccessibleGitProviderIds = async (session: { userId: string; activeOrganizationId: string;