diff --git a/apps/dokploy/__test__/wss/authorize.test.ts b/apps/dokploy/__test__/wss/authorize.test.ts index cb83d1e1e..24677330d 100644 --- a/apps/dokploy/__test__/wss/authorize.test.ts +++ b/apps/dokploy/__test__/wss/authorize.test.ts @@ -56,19 +56,25 @@ describe("canAccessDockerOverWss", () => { }); it("denies when the container belongs to a service the caller cannot access", async () => { - mockHasPermission.mockResolvedValue(true); mockCheckServiceAccess.mockRejectedValue(new Error("no access")); expect(await canAccessDockerOverWss(USER, SESSION, null, "svc-1")).toBe( false, ); }); - it("allows when the caller has access to the container's service", async () => { - mockHasPermission.mockResolvedValue(true); + it("allows service access even without docker permission or server access", async () => { + // A member granted the service but without canAccessToDocker, whose + // service runs on a server they were not individually granted, must still + // read its logs — matches application.readLogs (service access only). + mockHasPermission.mockResolvedValue(false); + mockGetAccessibleServerIds.mockResolvedValue(new Set()); mockCheckServiceAccess.mockResolvedValue(undefined); - expect(await canAccessDockerOverWss(USER, SESSION, null, "svc-1")).toBe( + expect(await canAccessDockerOverWss(USER, SESSION, "srv-remote", "svc-1")).toBe( true, ); + // Service path is authoritative — it must not fall through to docker/server. + expect(mockHasPermission).not.toHaveBeenCalled(); + expect(mockGetAccessibleServerIds).not.toHaveBeenCalled(); }); }); diff --git a/apps/dokploy/server/wss/authorize.ts b/apps/dokploy/server/wss/authorize.ts index ceff47b2c..8f22c56f8 100644 --- a/apps/dokploy/server/wss/authorize.ts +++ b/apps/dokploy/server/wss/authorize.ts @@ -28,21 +28,26 @@ export const canAccessDockerOverWss = async ( if (!user || !session?.activeOrganizationId) return false; const ctx = buildCtx(user, session.activeOrganizationId); - if (!(await hasPermission(ctx, { docker: ["read"] }))) return false; - // When the container belongs to a known Dokploy service (opened from a - // service page), enforce service-level access. Checked before the server - // gate so the service grant is the primary authorization signal. Container - // terminals opened from the generic Docker overview have no serviceId and - // fall back to the docker-permission + server checks. + // When the container belongs to a specific Dokploy service (opened from a + // service page, so serviceId is present), access to that service is the + // authoritative gate — matching the service tRPC endpoints (e.g. + // application.readLogs, which check service access only). A member granted + // the service can read its logs / open its terminal even without the broad + // "docker" permission or explicit access to the server it runs on. if (serviceId) { try { await checkServiceAccess(ctx, serviceId, "read"); + return true; } catch { return false; } } + // Generic Docker overview (no service context): mirror the docker tRPC router + // — require the docker permission and access to the target server. + if (!(await hasPermission(ctx, { docker: ["read"] }))) return false; + if (serverId && serverId !== "local") { const accessible = await getAccessibleServerIds({ userId: user.id,