diff --git a/apps/dokploy/__test__/security/database-environment-reveal.test.ts b/apps/dokploy/__test__/security/database-environment-reveal.test.ts index 08390417c23..fa298fe746f 100644 --- a/apps/dokploy/__test__/security/database-environment-reveal.test.ts +++ b/apps/dokploy/__test__/security/database-environment-reveal.test.ts @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({ findMySqlById: vi.fn(), findPostgresById: vi.fn(), findRedisById: vi.fn(), + hasPermission: vi.fn(), getServiceContainerCommand: vi.fn( (appName: string) => `docker ps -q --filter "label=com.docker.swarm.service.name=${appName}" | head -n 1`, @@ -96,6 +97,7 @@ vi.mock("@dokploy/server/services/permission", () => ({ checkServiceAccess: mocks.checkServiceAccess, checkServicePermissionAndAccess: mocks.checkServicePermissionAndAccess, findMemberByUserId: mocks.noop, + hasPermission: mocks.hasPermission, })); vi.mock("@/server/api/utils/audit", () => ({ @@ -141,7 +143,9 @@ const databaseService = ( databasePassword: `${id}-password`, databaseRootPassword: `${id}-root-password`, environment: { + env: "ENVIRONMENT_SHARED=secret", project: { + env: "PROJECT_SHARED=secret", organizationId, }, }, @@ -191,6 +195,7 @@ describe("database environment reveal boundary", () => { vi.clearAllMocks(); mocks.checkServiceAccess.mockResolvedValue(undefined); mocks.checkServicePermissionAndAccess.mockResolvedValue(undefined); + mocks.hasPermission.mockResolvedValue(false); for (const testCase of cases) { testCase.find.mockResolvedValue( databaseService(testCase.idField, testCase.id), @@ -221,4 +226,39 @@ describe("database environment reveal boundary", () => { ); }, ); + + it.each(cases)( + "hides shared env in $id reads without shared env read permissions", + async (testCase) => { + const normalRead = await testCase.router + .createCaller(createContext()) + .one({ [testCase.idField]: testCase.id } as never); + + expect(normalRead.environment).toEqual({ + env: REDACTED_SECRET_VALUE, + project: { env: REDACTED_SECRET_VALUE, organizationId: "org-1" }, + }); + }, + ); + + it.each(cases)( + "keeps shared env in $id reads for callers that can read it", + async (testCase) => { + mocks.hasPermission.mockImplementation( + async (_ctx: unknown, permissions: Record) => + "environmentEnvVars" in permissions || + "projectEnvVars" in permissions, + ); + + const normalRead = await testCase.router + .createCaller(createContext()) + .one({ [testCase.idField]: testCase.id } as never); + + expect(normalRead.env).toBe(REDACTED_SECRET_VALUE); + expect(normalRead.environment).toEqual({ + env: "ENVIRONMENT_SHARED=secret", + project: { env: "PROJECT_SHARED=secret", organizationId: "org-1" }, + }); + }, + ); }); diff --git a/apps/dokploy/__test__/security/secret-redaction.test.ts b/apps/dokploy/__test__/security/secret-redaction.test.ts index 1cd816292d2..d8b17c204d1 100644 --- a/apps/dokploy/__test__/security/secret-redaction.test.ts +++ b/apps/dokploy/__test__/security/secret-redaction.test.ts @@ -5,7 +5,9 @@ import { REDACTED_SECRET_VALUE, redactBackupScheduleSecrets, redactDatabaseServiceSecrets, + redactDatabaseServiceSecretsFor, redactDeployableServiceSecrets, + redactDeployableServiceSecretsFor, redactProjectNestedSecrets, redactRollbackFullContextSecrets, redactSecretFields, @@ -157,6 +159,77 @@ describe("shared secret redaction helpers", () => { }); }); + it("redacts git url credentials and shared env from deployable service reads", () => { + const redacted = redactDeployableServiceSecrets({ + customGitUrl: "https://user:token@example.com/org/repo.git", + environment: { + env: "ENVIRONMENT_SHARED=secret", + project: { env: "PROJECT_SHARED=secret", organizationId: "org-1" }, + }, + }); + + expect(redacted).toEqual({ + customGitUrl: `https://${REDACTED_SECRET_VALUE}@example.com/org/repo.git`, + environment: { + env: REDACTED_SECRET_VALUE, + project: { env: REDACTED_SECRET_VALUE, organizationId: "org-1" }, + }, + }); + }); + + it("keeps only the shared env a reader may read", () => { + const service = { + env: "TOKEN=secret", + environment: { + env: "ENVIRONMENT_SHARED=secret", + project: { env: "PROJECT_SHARED=secret" }, + }, + }; + + expect( + redactDeployableServiceSecretsFor(service, { + environmentEnv: true, + projectEnv: false, + }), + ).toEqual({ + env: REDACTED_SECRET_VALUE, + environment: { + env: "ENVIRONMENT_SHARED=secret", + project: { env: REDACTED_SECRET_VALUE }, + }, + }); + expect( + redactDatabaseServiceSecretsFor(service, { + environmentEnv: false, + projectEnv: true, + }), + ).toEqual({ + env: REDACTED_SECRET_VALUE, + environment: { + env: REDACTED_SECRET_VALUE, + project: { env: "PROJECT_SHARED=secret" }, + }, + }); + }); + + it("redacts shared env from database service reads", () => { + expect( + redactDatabaseServiceSecrets({ + databasePassword: "secret", + environment: { + env: "ENVIRONMENT_SHARED=secret", + project: { env: "PROJECT_SHARED=secret" }, + }, + }), + ).toEqual({ + databasePassword: REDACTED_SECRET_VALUE, + environment: { + env: REDACTED_SECRET_VALUE, + project: { env: REDACTED_SECRET_VALUE }, + }, + }); + }); + it("redacts database service read credentials", () => { const redacted = redactDatabaseServiceSecrets({ env: "PGSSLMODE=require", diff --git a/apps/dokploy/__test__/security/service-environment-reveal.test.ts b/apps/dokploy/__test__/security/service-environment-reveal.test.ts index edfaf567de0..88f2ae7cce3 100644 --- a/apps/dokploy/__test__/security/service-environment-reveal.test.ts +++ b/apps/dokploy/__test__/security/service-environment-reveal.test.ts @@ -225,8 +225,11 @@ const application = (organizationId = "org-1") => ({ env: "APP_SECRET=secret", buildArgs: "BUILD_ARG_SECRET=secret", buildSecrets: "BUILD_SECRET=secret", + customGitUrl: "https://app-token@example.com/org/private.git", environment: { + env: "ENVIRONMENT_SHARED=secret", project: { + env: "PROJECT_SHARED=secret", organizationId, }, }, @@ -242,7 +245,9 @@ const compose = (organizationId = "org-1") => ({ "services:\n api:\n environment:\n TOKEN: compose-secret", customGitUrl: "https://compose-token@example.com/org/private.git", environment: { + env: "ENVIRONMENT_SHARED=secret", project: { + env: "PROJECT_SHARED=secret", organizationId, }, }, @@ -250,6 +255,19 @@ const compose = (organizationId = "org-1") => ({ const redactedCustomGitUrl = `https://${REDACTED_SECRET_VALUE}@example.com/org/private.git`; +const redactedSharedEnvironment = { + env: REDACTED_SECRET_VALUE, + project: { + env: REDACTED_SECRET_VALUE, + organizationId: "org-1", + }, +}; + +const canReadSharedEnv = async ( + _ctx: unknown, + permissions: Record, +) => "environmentEnvVars" in permissions || "projectEnvVars" in permissions; + describe("service environment reveal boundary", () => { beforeEach(() => { vi.clearAllMocks(); @@ -347,6 +365,71 @@ describe("service environment reveal boundary", () => { }); }); + it("hides shared env and git url credentials in application reads", async () => { + const normalRead = await applicationRouter + .createCaller(createContext()) + .one({ applicationId: "app-1" }); + + expect(normalRead).toMatchObject({ + customGitUrl: redactedCustomGitUrl, + environment: redactedSharedEnvironment, + }); + expect(permissionMocks.hasPermission).toHaveBeenCalledWith( + expect.anything(), + { environmentEnvVars: ["read"] }, + ); + expect(permissionMocks.hasPermission).toHaveBeenCalledWith( + expect.anything(), + { projectEnvVars: ["read"] }, + ); + }); + + it("keeps shared env in application and compose reads for callers that can read it", async () => { + permissionMocks.hasPermission.mockImplementation(canReadSharedEnv); + + const applicationRead = await applicationRouter + .createCaller(createContext()) + .one({ applicationId: "app-1" }); + const composeRead = await composeRouter + .createCaller(createContext()) + .one({ composeId: "compose-1" }); + + for (const read of [applicationRead, composeRead]) { + expect(read).toMatchObject({ + env: REDACTED_SECRET_VALUE, + customGitUrl: redactedCustomGitUrl, + environment: { + env: "ENVIRONMENT_SHARED=secret", + project: { env: "PROJECT_SHARED=secret" }, + }, + }); + } + }); + + it("hides shared env in compose reads", async () => { + const normalRead = await composeRouter + .createCaller(createContext()) + .one({ composeId: "compose-1" }); + + expect(normalRead).toMatchObject({ + environment: redactedSharedEnvironment, + }); + }); + + it("hides shared env and git url credentials in application action responses", async () => { + permissionMocks.hasPermission.mockImplementation(canReadSharedEnv); + + const stopped = await applicationRouter + .createCaller(createContext()) + .stop({ applicationId: "app-1" }); + + expect(stopped).toMatchObject({ + env: REDACTED_SECRET_VALUE, + customGitUrl: redactedCustomGitUrl, + environment: redactedSharedEnvironment, + }); + }); + it("denies reveal when the service belongs to another organization", async () => { serverMocks.findApplicationById.mockResolvedValueOnce(application("org-2")); diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 4d10c36bc0e..4db0c8e8f0a 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -51,6 +51,7 @@ import { assertCustomGitUrlAllowed } from "@dokploy/server/utils/providers/git"; import { preserveSecretPlaceholderFields, redactDeployableServiceSecrets, + redactDeployableServiceSecretsFor, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; @@ -66,7 +67,10 @@ import { audit } from "@/server/api/utils/audit"; import { assertDeploySourceCredentialAccess } from "@/server/api/utils/deploy-source-access"; import { assertContainerMetricsServiceAccess } from "@/server/api/utils/monitoring-access"; import { assertTargetEnvironmentAccess } from "@/server/api/utils/placement-access"; -import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; +import { + assertServiceEnvironmentReadAccess, + getSharedEnvReadAccess, +} from "@/server/api/utils/service-environment"; import { apiCreateApplication, apiDeployApplication, @@ -445,8 +449,9 @@ export const applicationRouter = createTRPCRouter({ } return { - ...redactDeployableServiceSecrets( + ...redactDeployableServiceSecretsFor( redactGitProviderSecrets(application), + await getSharedEnvReadAccess(ctx), ), hasGitProviderAccess, unauthorizedProvider, diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index bf4f82983c3..1669b4f3652 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -60,9 +60,9 @@ import { processTemplate } from "@dokploy/server/templates/processors"; import { assertCustomGitUrlAllowed } from "@dokploy/server/utils/providers/git"; import { preserveSecretPlaceholderFields, - redactDeployableServiceSecrets, + redactDeployableServiceSecretsFor, redactSecretFields, - redactSensitiveText, + type SharedEnvReadAccess, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; @@ -102,46 +102,37 @@ import { createTRPCRouter, protectedProcedure } from "../trpc"; import { audit } from "../utils/audit"; import { assertDeploySourceCredentialAccess } from "../utils/deploy-source-access"; import { assertTargetEnvironmentAccess } from "../utils/placement-access"; -import { assertServiceEnvironmentReadAccess } from "../utils/service-environment"; +import { + assertServiceEnvironmentReadAccess, + getSharedEnvReadAccess, +} from "../utils/service-environment"; type SecretRecord = Record; -const redactCustomGitUrl = ( - record: T, -) => { - if (!record) { - return record; - } - - const redacted = { ...record }; - if ("customGitUrl" in redacted) { - redacted.customGitUrl = redactSensitiveText( - redacted.customGitUrl as string | null | undefined, - ); - } - - return redacted as T; -}; - const redactComposeSecrets = ( record: T, - { redactComposeFile = true }: { redactComposeFile?: boolean } = {}, + { + redactComposeFile = true, + sharedEnvAccess, + }: { + redactComposeFile?: boolean; + sharedEnvAccess?: SharedEnvReadAccess; + } = {}, ) => { if (!record) { return record; } - const redacted = redactCustomGitUrl( - redactDeployableServiceSecrets( - redactGitProviderSecrets( - record as T & { - bitbucket?: object | null; - gitea?: object | null; - github?: object | null; - gitlab?: object | null; - }, - ), + const redacted = redactDeployableServiceSecretsFor( + redactGitProviderSecrets( + record as T & { + bitbucket?: object | null; + gitea?: object | null; + github?: object | null; + gitlab?: object | null; + }, ), + sharedEnvAccess, ); return redactComposeFile @@ -360,11 +351,13 @@ export const composeRouter = createTRPCRouter({ } const canReadEnvVars = await hasPermission(ctx, { envVars: ["read"] }); + const sharedEnvAccess = await getSharedEnvReadAccess(ctx); return { // The compose file editor loads composeFile from this response. ...redactComposeSecrets(compose, { redactComposeFile: !canReadEnvVars, + sharedEnvAccess, }), hasGitProviderAccess, unauthorizedProvider, diff --git a/apps/dokploy/server/api/routers/environment.ts b/apps/dokploy/server/api/routers/environment.ts index 004121b2df7..1345ed0e2dd 100644 --- a/apps/dokploy/server/api/routers/environment.ts +++ b/apps/dokploy/server/api/routers/environment.ts @@ -23,7 +23,6 @@ import { redactDatabaseServiceSecrets, redactDeployableServiceSecrets, redactSecretFields, - redactSensitiveText, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; @@ -44,24 +43,6 @@ import { type SecretRecord = Record; -const redactCustomGitUrl = ( - record: T, -) => { - if (!record) { - return record; - } - - const redacted = { ...record }; - - if ("customGitUrl" in redacted) { - redacted.customGitUrl = redactSensitiveText( - redacted.customGitUrl as string | null | undefined, - ); - } - - return redacted as T; -}; - const redactDeployableEnvironmentServiceSecrets = < T extends SecretRecord | null | undefined, >( @@ -71,16 +52,14 @@ const redactDeployableEnvironmentServiceSecrets = < return record; } - return redactCustomGitUrl( - redactDeployableServiceSecrets( - redactGitProviderSecrets( - record as T & { - bitbucket?: object | null; - gitea?: object | null; - github?: object | null; - gitlab?: object | null; - }, - ), + return redactDeployableServiceSecrets( + redactGitProviderSecrets( + record as T & { + bitbucket?: object | null; + gitea?: object | null; + github?: object | null; + gitlab?: object | null; + }, ), ); }; diff --git a/apps/dokploy/server/api/routers/libsql.ts b/apps/dokploy/server/api/routers/libsql.ts index 50e88ab3f59..1b41b017bd3 100644 --- a/apps/dokploy/server/api/routers/libsql.ts +++ b/apps/dokploy/server/api/routers/libsql.ts @@ -27,6 +27,7 @@ import { import { preserveSecretPlaceholderFields, redactDatabaseServiceSecrets, + redactDatabaseServiceSecretsFor, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; @@ -34,7 +35,10 @@ import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; import { assertTargetEnvironmentAccess } from "@/server/api/utils/placement-access"; -import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; +import { + assertServiceEnvironmentReadAccess, + getSharedEnvReadAccess, +} from "@/server/api/utils/service-environment"; import { db } from "@/server/db"; import { apiChangeLibsqlStatus, @@ -125,7 +129,10 @@ export const libsqlRouter = createTRPCRouter({ message: "You are not authorized to access this Libsql", }); } - return redactDatabaseServiceSecrets(libsql); + return redactDatabaseServiceSecretsFor( + libsql, + await getSharedEnvReadAccess(ctx), + ); }), revealEnvironment: protectedProcedure diff --git a/apps/dokploy/server/api/routers/mariadb.ts b/apps/dokploy/server/api/routers/mariadb.ts index 5108a35ae54..ada5a8008d5 100644 --- a/apps/dokploy/server/api/routers/mariadb.ts +++ b/apps/dokploy/server/api/routers/mariadb.ts @@ -32,6 +32,7 @@ import { getServiceContainerCommand } from "@dokploy/server/utils/backups/utils" import { preserveSecretPlaceholderFields, redactDatabaseServiceSecrets, + redactDatabaseServiceSecretsFor, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; @@ -41,7 +42,10 @@ import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; import { buildMysqlPasswordChangeCommand } from "@/server/api/utils/database-password"; import { assertTargetEnvironmentAccess } from "@/server/api/utils/placement-access"; -import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; +import { + assertServiceEnvironmentReadAccess, + getSharedEnvReadAccess, +} from "@/server/api/utils/service-environment"; import { apiChangeMariaDBStatus, apiCreateMariaDB, @@ -141,7 +145,10 @@ export const mariadbRouter = createTRPCRouter({ message: "You are not authorized to access this Mariadb", }); } - return redactDatabaseServiceSecrets(mariadb); + return redactDatabaseServiceSecretsFor( + mariadb, + await getSharedEnvReadAccess(ctx), + ); }), revealEnvironment: protectedProcedure diff --git a/apps/dokploy/server/api/routers/mongo.ts b/apps/dokploy/server/api/routers/mongo.ts index 0010c9cde2d..87f821127ef 100644 --- a/apps/dokploy/server/api/routers/mongo.ts +++ b/apps/dokploy/server/api/routers/mongo.ts @@ -31,6 +31,7 @@ import { getServiceContainerCommand } from "@dokploy/server/utils/backups/utils" import { preserveSecretPlaceholderFields, redactDatabaseServiceSecrets, + redactDatabaseServiceSecretsFor, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; @@ -39,7 +40,10 @@ import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; import { buildMongoPasswordChangeCommand } from "@/server/api/utils/database-password"; import { assertTargetEnvironmentAccess } from "@/server/api/utils/placement-access"; -import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; +import { + assertServiceEnvironmentReadAccess, + getSharedEnvReadAccess, +} from "@/server/api/utils/service-environment"; import { apiChangeMongoStatus, apiCreateMongo, @@ -144,7 +148,10 @@ export const mongoRouter = createTRPCRouter({ message: "You are not authorized to access this mongo", }); } - return redactDatabaseServiceSecrets(mongo); + return redactDatabaseServiceSecretsFor( + mongo, + await getSharedEnvReadAccess(ctx), + ); }), revealEnvironment: protectedProcedure diff --git a/apps/dokploy/server/api/routers/mysql.ts b/apps/dokploy/server/api/routers/mysql.ts index 2f7f2854462..df2c93d6d7d 100644 --- a/apps/dokploy/server/api/routers/mysql.ts +++ b/apps/dokploy/server/api/routers/mysql.ts @@ -32,6 +32,7 @@ import { getServiceContainerCommand } from "@dokploy/server/utils/backups/utils" import { preserveSecretPlaceholderFields, redactDatabaseServiceSecrets, + redactDatabaseServiceSecretsFor, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; @@ -40,7 +41,10 @@ import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; import { buildMysqlPasswordChangeCommand } from "@/server/api/utils/database-password"; import { assertTargetEnvironmentAccess } from "@/server/api/utils/placement-access"; -import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; +import { + assertServiceEnvironmentReadAccess, + getSharedEnvReadAccess, +} from "@/server/api/utils/service-environment"; import { apiChangeMySqlStatus, apiCreateMySql, @@ -145,7 +149,10 @@ export const mysqlRouter = createTRPCRouter({ message: "You are not authorized to access this MySQL", }); } - return redactDatabaseServiceSecrets(mysql); + return redactDatabaseServiceSecretsFor( + mysql, + await getSharedEnvReadAccess(ctx), + ); }), revealEnvironment: protectedProcedure diff --git a/apps/dokploy/server/api/routers/postgres.ts b/apps/dokploy/server/api/routers/postgres.ts index 1bab1548ec0..31bc6549245 100644 --- a/apps/dokploy/server/api/routers/postgres.ts +++ b/apps/dokploy/server/api/routers/postgres.ts @@ -32,6 +32,7 @@ import { getServiceContainerCommand } from "@dokploy/server/utils/backups/utils" import { preserveSecretPlaceholderFields, redactDatabaseServiceSecrets, + redactDatabaseServiceSecretsFor, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; @@ -40,7 +41,10 @@ import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; import { buildPostgresPasswordChangeCommand } from "@/server/api/utils/database-password"; import { assertTargetEnvironmentAccess } from "@/server/api/utils/placement-access"; -import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; +import { + assertServiceEnvironmentReadAccess, + getSharedEnvReadAccess, +} from "@/server/api/utils/service-environment"; import { apiChangePostgresStatus, apiCreatePostgres, @@ -148,7 +152,10 @@ export const postgresRouter = createTRPCRouter({ message: "You are not authorized to access this Postgres", }); } - return redactDatabaseServiceSecrets(postgres); + return redactDatabaseServiceSecretsFor( + postgres, + await getSharedEnvReadAccess(ctx), + ); }), revealEnvironment: protectedProcedure diff --git a/apps/dokploy/server/api/routers/redis.ts b/apps/dokploy/server/api/routers/redis.ts index c02cfeda98f..99059bbf5cf 100644 --- a/apps/dokploy/server/api/routers/redis.ts +++ b/apps/dokploy/server/api/routers/redis.ts @@ -30,6 +30,7 @@ import { getServiceContainerCommand } from "@dokploy/server/utils/backups/utils" import { preserveSecretPlaceholderFields, redactDatabaseServiceSecrets, + redactDatabaseServiceSecretsFor, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; @@ -38,7 +39,10 @@ import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; import { buildRedisPasswordChangeCommand } from "@/server/api/utils/database-password"; import { assertTargetEnvironmentAccess } from "@/server/api/utils/placement-access"; -import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; +import { + assertServiceEnvironmentReadAccess, + getSharedEnvReadAccess, +} from "@/server/api/utils/service-environment"; import { apiChangeRedisStatus, apiCreateRedis, @@ -135,7 +139,10 @@ export const redisRouter = createTRPCRouter({ message: "You are not authorized to access this Redis", }); } - return redactDatabaseServiceSecrets(redis); + return redactDatabaseServiceSecretsFor( + redis, + await getSharedEnvReadAccess(ctx), + ); }), revealEnvironment: protectedProcedure diff --git a/apps/dokploy/server/api/utils/service-environment.ts b/apps/dokploy/server/api/utils/service-environment.ts index 76b01bec359..2d4edc36c2d 100644 --- a/apps/dokploy/server/api/utils/service-environment.ts +++ b/apps/dokploy/server/api/utils/service-environment.ts @@ -1,4 +1,8 @@ -import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission"; +import { + checkServicePermissionAndAccess, + hasPermission, +} from "@dokploy/server/services/permission"; +import type { SharedEnvReadAccess } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; type ServiceEnvironmentContext = Parameters< @@ -43,3 +47,10 @@ export const assertServiceEnvironmentReadAccess = async < return service; }; + +export const getSharedEnvReadAccess = async ( + ctx: Parameters[0], +): Promise => ({ + environmentEnv: await hasPermission(ctx, { environmentEnvVars: ["read"] }), + projectEnv: await hasPermission(ctx, { projectEnvVars: ["read"] }), +}); diff --git a/packages/server/src/utils/security/redaction.ts b/packages/server/src/utils/security/redaction.ts index a55be3f9ef4..11b16e277ce 100644 --- a/packages/server/src/utils/security/redaction.ts +++ b/packages/server/src/utils/security/redaction.ts @@ -74,10 +74,56 @@ const redactNestedServerSecrets = (server: T): T => { return redacted as T; }; +export type SharedEnvReadAccess = { + environmentEnv: boolean; + projectEnv: boolean; +}; + +const NO_SHARED_ENV_READ_ACCESS: SharedEnvReadAccess = { + environmentEnv: false, + projectEnv: false, +}; + +// Service reads load environment.project for organization checks. The env of +// those relations is shared by every service in them, so it is only returned to +// callers with the matching environmentEnvVars/projectEnvVars read permission. +const redactServiceSharedEnv = ( + record: T, + access: SharedEnvReadAccess, +) => { + if (!record?.environment || typeof record.environment !== "object") { + return record; + } + + let environment = record.environment as SecretRecord; + if (!access.environmentEnv) { + environment = redactSecretFields(environment, ["env"]); + } + if ( + !access.projectEnv && + environment.project && + typeof environment.project === "object" + ) { + environment = { + ...environment, + project: redactSecretFields(environment.project as SecretRecord, ["env"]), + }; + } + + return { ...record, environment } as T; +}; + export const redactDeployableServiceSecrets = < T extends SecretRecord | null | undefined, >( record: T, +) => redactDeployableServiceSecretsFor(record); + +export const redactDeployableServiceSecretsFor = < + T extends SecretRecord | null | undefined, +>( + record: T, + sharedEnvAccess: SharedEnvReadAccess = NO_SHARED_ENV_READ_ACCESS, ) => { const redacted = redactSecretFields(record, [ "env", @@ -94,6 +140,11 @@ export const redactDeployableServiceSecrets = < } const withRelations = { ...redacted }; + if ("customGitUrl" in withRelations) { + withRelations.customGitUrl = redactSensitiveText( + withRelations.customGitUrl as string | null | undefined, + ); + } if (withRelations.security && typeof withRelations.security === "object") { withRelations.security = Array.isArray(withRelations.security) ? withRelations.security.map(redactSecuritySecrets) @@ -104,13 +155,20 @@ export const redactDeployableServiceSecrets = < withRelations[key] = redactNestedServerSecrets(withRelations[key]); } } - return withRelations as T; + return redactServiceSharedEnv(withRelations as T, sharedEnvAccess); }; export const redactDatabaseServiceSecrets = < T extends SecretRecord | null | undefined, >( record: T, +) => redactDatabaseServiceSecretsFor(record); + +export const redactDatabaseServiceSecretsFor = < + T extends SecretRecord | null | undefined, +>( + record: T, + sharedEnvAccess: SharedEnvReadAccess = NO_SHARED_ENV_READ_ACCESS, ) => { const redacted = redactSecretFields(record, [ "env", @@ -125,7 +183,7 @@ export const redactDatabaseServiceSecrets = < if ("server" in withRelations) { withRelations.server = redactNestedServerSecrets(withRelations.server); } - return withRelations as T; + return redactServiceSharedEnv(withRelations as T, sharedEnvAccess); }; export const redactAiSettingsSecrets = <