diff --git a/apps/dokploy/__test__/git-provider/git-provider-access.test.ts b/apps/dokploy/__test__/git-provider/git-provider-access.test.ts new file mode 100644 index 000000000..4ddf36244 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/git-provider-access.test.ts @@ -0,0 +1,369 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + canEditDeployGitSource, + getAccessibleGitProviderIds, +} from "@dokploy/server/services/git-provider"; + +const mockDb = vi.hoisted(() => ({ + query: { + gitProvider: { + findMany: vi.fn(), + findFirst: vi.fn(), + }, + member: { + findFirst: vi.fn(), + }, + }, +})); + +vi.mock("@dokploy/server/db", () => ({ db: mockDb })); + +const mockHasValidLicense = vi.hoisted(() => vi.fn()); +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: mockHasValidLicense, +})); + +const ORG_ID = "org-1"; +const USER_OWNER = "user-owner"; +const USER_ADMIN = "user-admin"; +const USER_MEMBER = "user-member"; +const USER_MEMBER_2 = "user-member-2"; + +const providerOwned = { + gitProviderId: "gp-owned", + userId: USER_MEMBER, + sharedWithOrganization: false, +}; +const providerShared = { + gitProviderId: "gp-shared", + userId: USER_OWNER, + sharedWithOrganization: true, +}; +const providerPrivate = { + gitProviderId: "gp-private", + userId: USER_OWNER, + sharedWithOrganization: false, +}; +const providerOtherMember = { + gitProviderId: "gp-other", + userId: USER_MEMBER_2, + sharedWithOrganization: false, +}; + +const allProviders = [ + providerOwned, + providerShared, + providerPrivate, + providerOtherMember, +]; + +function session(userId: string) { + return { userId, activeOrganizationId: ORG_ID }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockDb.query.gitProvider.findMany.mockResolvedValue(allProviders); + mockHasValidLicense.mockResolvedValue(false); +}); + +describe("getAccessibleGitProviderIds", () => { + describe("owner", () => { + beforeEach(() => { + mockDb.query.member.findFirst.mockResolvedValue({ + role: "owner", + accessedGitProviders: [], + }); + }); + + it("returns all org providers", async () => { + const ids = await getAccessibleGitProviderIds(session(USER_OWNER)); + expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId))); + }); + + it("includes providers owned by other members", async () => { + const ids = await getAccessibleGitProviderIds(session(USER_OWNER)); + expect(ids.has(providerOwned.gitProviderId)).toBe(true); + expect(ids.has(providerOtherMember.gitProviderId)).toBe(true); + }); + }); + + describe("admin", () => { + beforeEach(() => { + mockDb.query.member.findFirst.mockResolvedValue({ + role: "admin", + accessedGitProviders: [], + }); + }); + + it("returns all org providers", async () => { + const ids = await getAccessibleGitProviderIds(session(USER_ADMIN)); + expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId))); + }); + + it("includes providers owned by other members — fixes issue #4469", async () => { + const ids = await getAccessibleGitProviderIds(session(USER_ADMIN)); + expect(ids.has(providerPrivate.gitProviderId)).toBe(true); + expect(ids.has(providerOtherMember.gitProviderId)).toBe(true); + }); + }); + + describe("member without enterprise license", () => { + beforeEach(() => { + mockDb.query.member.findFirst.mockResolvedValue({ + role: "member", + accessedGitProviders: [providerPrivate.gitProviderId], + }); + mockHasValidLicense.mockResolvedValue(false); + }); + + it("can access their own provider", async () => { + const ids = await getAccessibleGitProviderIds(session(USER_MEMBER)); + expect(ids.has(providerOwned.gitProviderId)).toBe(true); + }); + + it("can access shared providers", async () => { + const ids = await getAccessibleGitProviderIds(session(USER_MEMBER)); + expect(ids.has(providerShared.gitProviderId)).toBe(true); + }); + + it("cannot access private providers of other users even if assigned (no license)", async () => { + const ids = await getAccessibleGitProviderIds(session(USER_MEMBER)); + expect(ids.has(providerPrivate.gitProviderId)).toBe(false); + }); + + it("cannot access providers of other members", async () => { + const ids = await getAccessibleGitProviderIds(session(USER_MEMBER)); + expect(ids.has(providerOtherMember.gitProviderId)).toBe(false); + }); + }); + + describe("member with enterprise license", () => { + beforeEach(() => { + mockHasValidLicense.mockResolvedValue(true); + }); + + it("can access provider explicitly assigned to them", async () => { + mockDb.query.member.findFirst.mockResolvedValue({ + role: "member", + accessedGitProviders: [providerPrivate.gitProviderId], + }); + const ids = await getAccessibleGitProviderIds(session(USER_MEMBER)); + expect(ids.has(providerPrivate.gitProviderId)).toBe(true); + }); + + it("cannot access provider not assigned and not shared", async () => { + mockDb.query.member.findFirst.mockResolvedValue({ + role: "member", + accessedGitProviders: [], + }); + const ids = await getAccessibleGitProviderIds(session(USER_MEMBER)); + expect(ids.has(providerPrivate.gitProviderId)).toBe(false); + expect(ids.has(providerOtherMember.gitProviderId)).toBe(false); + }); + + it("can access shared provider even without explicit assignment", async () => { + mockDb.query.member.findFirst.mockResolvedValue({ + role: "member", + accessedGitProviders: [], + }); + const ids = await getAccessibleGitProviderIds(session(USER_MEMBER)); + expect(ids.has(providerShared.gitProviderId)).toBe(true); + }); + + it("can access own provider regardless of assignments", async () => { + mockDb.query.member.findFirst.mockResolvedValue({ + role: "member", + accessedGitProviders: [], + }); + const ids = await getAccessibleGitProviderIds(session(USER_MEMBER)); + expect(ids.has(providerOwned.gitProviderId)).toBe(true); + }); + + it("cannot access provider of other member even with license but no assignment", async () => { + mockDb.query.member.findFirst.mockResolvedValue({ + role: "member", + accessedGitProviders: [], + }); + const ids = await getAccessibleGitProviderIds(session(USER_MEMBER)); + expect(ids.has(providerOtherMember.gitProviderId)).toBe(false); + }); + }); + + describe("member with no member record", () => { + beforeEach(() => { + mockDb.query.member.findFirst.mockResolvedValue(null); + mockHasValidLicense.mockResolvedValue(true); + }); + + it("only returns own providers and shared ones", async () => { + const ids = await getAccessibleGitProviderIds(session(USER_MEMBER)); + expect(ids.has(providerOwned.gitProviderId)).toBe(true); + expect(ids.has(providerShared.gitProviderId)).toBe(true); + expect(ids.has(providerPrivate.gitProviderId)).toBe(false); + }); + }); + + describe("enterprise license — member assigned to a provider they do not own", () => { + // getAccessibleGitProviderIds still returns the provider (member can connect NEW deploys) + it("member assigned to owner's private provider can USE the provider for new deploys", async () => { + mockHasValidLicense.mockResolvedValue(true); + mockDb.query.member.findFirst.mockResolvedValue({ + role: "member", + accessedGitProviders: [providerPrivate.gitProviderId], + }); + const ids = await getAccessibleGitProviderIds(session(USER_MEMBER)); + expect(ids.has(providerPrivate.gitProviderId)).toBe(true); + }); + + it("member NOT assigned to owner's private provider cannot use it at all", async () => { + mockHasValidLicense.mockResolvedValue(true); + mockDb.query.member.findFirst.mockResolvedValue({ + role: "member", + accessedGitProviders: [], + }); + const ids = await getAccessibleGitProviderIds(session(USER_MEMBER)); + expect(ids.has(providerPrivate.gitProviderId)).toBe(false); + }); + }); + + describe("empty org", () => { + beforeEach(() => { + mockDb.query.gitProvider.findMany.mockResolvedValue([]); + mockDb.query.member.findFirst.mockResolvedValue({ + role: "admin", + accessedGitProviders: [], + }); + }); + + it("returns empty set when org has no providers", async () => { + const ids = await getAccessibleGitProviderIds(session(USER_ADMIN)); + expect(ids.size).toBe(0); + }); + }); +}); + +describe("canEditDeployGitSource", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockHasValidLicense.mockResolvedValue(true); + }); + + describe("owner", () => { + it("can edit deploy using any provider", async () => { + mockDb.query.member.findFirst.mockResolvedValue({ role: "owner" }); + const result = await canEditDeployGitSource( + providerPrivate.gitProviderId, + session(USER_OWNER), + ); + expect(result).toBe(true); + }); + }); + + describe("admin", () => { + beforeEach(() => { + mockDb.query.member.findFirst.mockResolvedValue({ role: "admin" }); + }); + + it("cannot edit deploy using owner's private provider (not shared)", async () => { + mockDb.query.gitProvider.findFirst.mockResolvedValue({ + userId: USER_OWNER, + sharedWithOrganization: false, + }); + const result = await canEditDeployGitSource( + providerPrivate.gitProviderId, + session(USER_ADMIN), + ); + expect(result).toBe(false); + }); + + it("can edit deploy using a provider shared with the org", async () => { + mockDb.query.gitProvider.findFirst.mockResolvedValue({ + userId: USER_OWNER, + sharedWithOrganization: true, + }); + const result = await canEditDeployGitSource( + providerShared.gitProviderId, + session(USER_ADMIN), + ); + expect(result).toBe(true); + }); + + it("can edit deploy using their own provider", async () => { + mockDb.query.gitProvider.findFirst.mockResolvedValue({ + userId: USER_ADMIN, + sharedWithOrganization: false, + }); + const result = await canEditDeployGitSource( + "gp-admin-owned", + session(USER_ADMIN), + ); + expect(result).toBe(true); + }); + }); + + describe("member", () => { + beforeEach(() => { + mockDb.query.member.findFirst.mockResolvedValue({ role: "member" }); + }); + + it("can edit deploy using their own provider", async () => { + mockDb.query.gitProvider.findFirst.mockResolvedValue({ + userId: USER_MEMBER, + sharedWithOrganization: false, + }); + const result = await canEditDeployGitSource( + providerOwned.gitProviderId, + session(USER_MEMBER), + ); + expect(result).toBe(true); + }); + + it("can edit deploy using a provider shared with the org", async () => { + mockDb.query.gitProvider.findFirst.mockResolvedValue({ + userId: USER_OWNER, + sharedWithOrganization: true, + }); + const result = await canEditDeployGitSource( + providerShared.gitProviderId, + session(USER_MEMBER), + ); + expect(result).toBe(true); + }); + + it("cannot edit deploy using owner's private provider even with enterprise license and assignment", async () => { + // This is the key case: enterprise, provider del owner, no compartido, + // member tiene accessedGitProviders asignado — pero NO puede cambiar la branch del deploy del owner + mockDb.query.gitProvider.findFirst.mockResolvedValue({ + userId: USER_OWNER, + sharedWithOrganization: false, + }); + const result = await canEditDeployGitSource( + providerPrivate.gitProviderId, + session(USER_MEMBER), + ); + expect(result).toBe(false); + }); + + it("cannot edit deploy using another member's private provider", async () => { + mockDb.query.gitProvider.findFirst.mockResolvedValue({ + userId: USER_MEMBER_2, + sharedWithOrganization: false, + }); + const result = await canEditDeployGitSource( + providerOtherMember.gitProviderId, + session(USER_MEMBER), + ); + expect(result).toBe(false); + }); + + it("returns false if provider does not exist", async () => { + mockDb.query.gitProvider.findFirst.mockResolvedValue(null); + const result = await canEditDeployGitSource( + "nonexistent-id", + session(USER_MEMBER), + ); + expect(result).toBe(false); + }); + }); +}); diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index c7b1f8642..de847a301 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -4,7 +4,6 @@ import { deleteAllMiddlewares, findApplicationById, findEnvironmentById, - findGitProviderById, findProjectById, getAccessibleServerIds, getApplicationStats, @@ -31,6 +30,7 @@ import { writeConfigRemote, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; +import { canEditDeployGitSource } from "@dokploy/server/services/git-provider"; import { addNewService, checkServiceAccess, @@ -174,13 +174,11 @@ export const applicationRouter = createTRPCRouter({ const gitProviderId = getGitProviderId(); if (gitProviderId) { - try { - const gitProvider = await findGitProviderById(gitProviderId); - if (gitProvider.userId !== ctx.session.userId) { - hasGitProviderAccess = false; - unauthorizedProvider = application.sourceType; - } - } catch { + const canEdit = await canEditDeployGitSource( + gitProviderId, + ctx.session, + ); + if (!canEdit) { hasGitProviderAccess = false; unauthorizedProvider = application.sourceType; } diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 51e257ce6..126e80b1d 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -13,7 +13,6 @@ import { findComposeById, findDomainsByComposeId, findEnvironmentById, - findGitProviderById, findProjectById, findServerById, getAccessibleServerIds, @@ -34,6 +33,7 @@ import { updateDeploymentStatus, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; +import { canEditDeployGitSource } from "@dokploy/server/services/git-provider"; import { addNewService, checkServiceAccess, @@ -173,13 +173,11 @@ export const composeRouter = createTRPCRouter({ const gitProviderId = getGitProviderId(); if (gitProviderId) { - try { - const gitProvider = await findGitProviderById(gitProviderId); - if (gitProvider.userId !== ctx.session.userId) { - hasGitProviderAccess = false; - unauthorizedProvider = compose.sourceType; - } - } catch { + const canEdit = await canEditDeployGitSource( + gitProviderId, + ctx.session, + ); + if (!canEdit) { hasGitProviderAccess = false; unauthorizedProvider = compose.sourceType; } diff --git a/packages/server/src/services/git-provider.ts b/packages/server/src/services/git-provider.ts index aafc7e947..942f94ed2 100644 --- a/packages/server/src/services/git-provider.ts +++ b/packages/server/src/services/git-provider.ts @@ -43,6 +43,38 @@ export const updateGitProvider = async ( .then((response) => response[0]); }; +// Returns true if the user can edit the git source configuration of an existing +// deploy that is connected to the given provider. +// Owner/admin: always yes. +// Member: only if they own the provider or it's shared with the org. +// Being in accessedGitProviders only grants permission to connect NEW deploys, +// not to modify the git config of an existing deploy owned by someone else. +export const canEditDeployGitSource = async ( + gitProviderId: string, + session: { userId: string; activeOrganizationId: string }, +): Promise => { + const { userId, activeOrganizationId } = session; + + const memberRecord = await db.query.member.findFirst({ + where: and( + eq(member.userId, userId), + eq(member.organizationId, activeOrganizationId), + ), + columns: { role: true }, + }); + + if (memberRecord?.role === "owner") return true; + + const provider = await db.query.gitProvider.findFirst({ + where: eq(gitProvider.gitProviderId, gitProviderId), + columns: { userId: true, sharedWithOrganization: true }, + }); + + if (!provider) return false; + + return provider.userId === userId || provider.sharedWithOrganization; +}; + export const getAccessibleGitProviderIds = async (session: { userId: string; activeOrganizationId: string;