fix(security): service access is authoritative for service-scoped wss ops

When a container is opened from a service page (serviceId present), gate on
service access alone — matching application.readLogs — instead of requiring the
docker permission first. This unblocks a member who has the service but not the
canAccessToDocker permission (or the server it runs on) from reading its logs /
opening its terminal. Generic Docker-overview ops (no serviceId) still require
docker permission + server access.
This commit is contained in:
Mauricio Siu
2026-07-20 01:15:18 -06:00
parent 1a3c76d1f2
commit eb1f11b908
2 changed files with 21 additions and 10 deletions

View File

@@ -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();
});
});

View File

@@ -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,