diff --git a/.env.example b/.env.example index 059ec124f..5220d5a03 100644 --- a/.env.example +++ b/.env.example @@ -122,7 +122,7 @@ INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET= #gcp app connection INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL= -# azure app connection +# azure app connections INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID= INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET= @@ -135,6 +135,10 @@ INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET= INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID= INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET= +# heroku app connection +INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID= +INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET= + # datadog SHOULD_USE_DATADOG_TRACER= DATADOG_PROFILING_ENABLED= diff --git a/.github/workflows/release-standalone-docker-img-postgres-offical.yml b/.github/workflows/release-standalone-docker-img-postgres-offical.yml index 5c8809eb4..486ff12b2 100644 --- a/.github/workflows/release-standalone-docker-img-postgres-offical.yml +++ b/.github/workflows/release-standalone-docker-img-postgres-offical.yml @@ -63,6 +63,8 @@ jobs: build-args: | POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }} INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }} + DD_GIT_REPOSITORY_URL=${{ github.server_url }}/${{ github.repository }} + DD_GIT_COMMIT_SHA=${{ github.sha }} infisical-fips-standalone: name: Build infisical standalone image postgres @@ -115,3 +117,28 @@ jobs: build-args: | POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }} INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }} + trigger-binary-release: + runs-on: ubuntu-latest + needs: [infisical-standalone, infisical-fips-standalone] + steps: + - name: Create tag if it doesn't exist + run: | + TAG_NAME="${{ github.ref_name }}" + echo "Checking for tag: $TAG_NAME" + + if gh api repos/Infisical/infisical-omnibus/git/refs/tags/$TAG_NAME --silent 2>/dev/null; then + echo "Tag $TAG_NAME already exists, skipping..." + else + echo "Creating tag in Infisical/infisical-omnibus: $TAG_NAME" + LATEST_SHA=$(gh api repos/Infisical/infisical-omnibus/git/refs/heads/main --jq '.object.sha') + echo "Latest SHA: $LATEST_SHA" + + gh api repos/Infisical/infisical-omnibus/git/refs \ + --method POST \ + --field ref="refs/tags/$TAG_NAME" \ + --field sha="$LATEST_SHA" + + echo "Successfully created tag $TAG_NAME" + fi + env: + GH_TOKEN: ${{ secrets.OMNIBUS_RELEASE_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release_helm_gateway.yaml b/.github/workflows/release_helm_gateway.yaml index 85f61c4c6..7fd0eb03a 100644 --- a/.github/workflows/release_helm_gateway.yaml +++ b/.github/workflows/release_helm_gateway.yaml @@ -35,7 +35,7 @@ jobs: run: kubectl create namespace infisical-gateway - name: Create gateway secret - run: kubectl create secret generic infisical-gateway-environment --from-literal=TOKEN=my-test-token -n infisical-gateway + run: kubectl create secret generic infisical-gateway-environment --from-literal=TOKEN=my-test-token --from-literal=INFISICAL_RELAY_NAME=my-test-relay -n infisical-gateway - name: Run chart-testing (install) run: | diff --git a/.github/workflows/validate-upgrade-path.yml b/.github/workflows/validate-upgrade-path.yml new file mode 100644 index 000000000..dabdccae0 --- /dev/null +++ b/.github/workflows/validate-upgrade-path.yml @@ -0,0 +1,39 @@ +name: "Validate Upgrade Path Configuration" + +on: + pull_request: + types: [opened, synchronize] + paths: + - "backend/upgrade-path.yaml" + - "backend/scripts/validate-upgrade-path-file.ts" + - "backend/src/services/upgrade-path/upgrade-path-schemas.ts" + + workflow_call: + +jobs: + validate-upgrade-path: + name: Validate upgrade-path.yaml + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout source + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: 'backend/package-lock.json' + + - name: Install minimal dependencies + working-directory: backend + run: | + npm install --no-package-lock js-yaml@^4.1.0 zod@^3.22.0 tsx@^4.0.0 @types/js-yaml@^4.0.0 re2@^1.20.0 + + - name: Validate upgrade-path.yaml format + working-directory: backend + run: npx tsx ./scripts/validate-upgrade-path-file.ts \ No newline at end of file diff --git a/.infisicalignore b/.infisicalignore index 66e2fb635..b935763c8 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -51,3 +51,4 @@ docs/integrations/app-connections/bitbucket.mdx:generic-api-key:123 docs/integrations/app-connections/railway.mdx:generic-api-key:156 .github/workflows/validate-db-schemas.yml:generic-api-key:21 k8-operator/config/samples/universalAuthIdentitySecret.yaml:generic-api-key:8 +docs/integrations/app-connections/redis.mdx:generic-api-key:80 diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index a4e5c150a..9ca5e1dea 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -173,6 +173,12 @@ COPY --from=frontend-runner /app ./backend/frontend-build ARG INFISICAL_PLATFORM_VERSION ENV INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION +ARG DD_GIT_REPOSITORY_URL +ENV DD_GIT_REPOSITORY_URL $DD_GIT_REPOSITORY_URL + +ARG DD_GIT_COMMIT_SHA +ENV DD_GIT_COMMIT_SHA $DD_GIT_COMMIT_SHA + ENV PORT 8080 ENV HOST=0.0.0.0 ENV HTTPS_ENABLED false diff --git a/backend/e2e-test/routes/v2/secret-folder.spec.ts b/backend/e2e-test/routes/v2/secret-folder.spec.ts new file mode 100644 index 000000000..a2bed759a --- /dev/null +++ b/backend/e2e-test/routes/v2/secret-folder.spec.ts @@ -0,0 +1,165 @@ +import { seedData1 } from "@app/db/seed-data"; + +const createFolder = async (dto: { path: string; name: string }) => { + const res = await testServer.inject({ + method: "POST", + url: `/api/v2/folders`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + projectId: seedData1.project.id, + environment: seedData1.environment.slug, + name: dto.name, + path: dto.path + } + }); + expect(res.statusCode).toBe(200); + return res.json().folder; +}; + +const deleteFolder = async (dto: { path: string; id: string }) => { + const res = await testServer.inject({ + method: "DELETE", + url: `/api/v2/folders/${dto.id}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + projectId: seedData1.project.id, + environment: seedData1.environment.slug, + path: dto.path + } + }); + expect(res.statusCode).toBe(200); + return res.json().folder; +}; + +describe("Secret Folder Router", async () => { + test.each([ + { name: "folder1", path: "/" }, // one in root + { name: "folder1", path: "/level1/level2" }, // then create a deep one creating intermediate ones + { name: "folder2", path: "/" }, + { name: "folder1", path: "/level1/level2" } // this should not create folder return same thing + ])("Create folder $name in $path", async ({ name, path }) => { + const createdFolder = await createFolder({ path, name }); + // check for default environments + expect(createdFolder).toEqual( + expect.objectContaining({ + name, + id: expect.any(String) + }) + ); + await deleteFolder({ path, id: createdFolder.id }); + }); + + test.each([ + { + path: "/", + expected: { + folders: [{ name: "folder1" }, { name: "level1" }, { name: "folder2" }], + length: 3 + } + }, + { path: "/level1/level2", expected: { folders: [{ name: "folder1" }], length: 1 } } + ])("Get folders $path", async ({ path, expected }) => { + const newFolders = await Promise.all(expected.folders.map(({ name }) => createFolder({ name, path }))); + + const res = await testServer.inject({ + method: "GET", + url: `/api/v2/folders`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + query: { + projectId: seedData1.project.id, + environment: seedData1.environment.slug, + path + } + }); + + expect(res.statusCode).toBe(200); + const payload = JSON.parse(res.payload); + expect(payload).toHaveProperty("folders"); + expect(payload.folders.length >= expected.folders.length).toBeTruthy(); + expect(payload).toEqual({ + folders: expect.arrayContaining(expected.folders.map((el) => expect.objectContaining(el))) + }); + + await Promise.all(newFolders.map(({ id }) => deleteFolder({ path, id }))); + }); + + test("Update a deep folder", async () => { + const newFolder = await createFolder({ name: "folder-updated", path: "/level1/level2" }); + expect(newFolder).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "folder-updated" + }) + ); + + const resUpdatedFolders = await testServer.inject({ + method: "GET", + url: `/api/v2/folders`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + query: { + projectId: seedData1.project.id, + environment: seedData1.environment.slug, + path: "/level1/level2" + } + }); + + expect(resUpdatedFolders.statusCode).toBe(200); + const updatedFolderList = JSON.parse(resUpdatedFolders.payload); + expect(updatedFolderList).toHaveProperty("folders"); + expect(updatedFolderList.folders[0].name).toEqual("folder-updated"); + + await deleteFolder({ path: "/level1/level2", id: newFolder.id }); + }); + + test("Delete a deep folder", async () => { + const newFolder = await createFolder({ name: "folder-updated", path: "/level1/level2" }); + const res = await testServer.inject({ + method: "DELETE", + url: `/api/v2/folders/${newFolder.id}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + projectId: seedData1.project.id, + environment: seedData1.environment.slug, + path: "/level1/level2" + } + }); + + expect(res.statusCode).toBe(200); + const payload = JSON.parse(res.payload); + expect(payload).toHaveProperty("folder"); + expect(payload.folder).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "folder-updated" + }) + ); + + const resUpdatedFolders = await testServer.inject({ + method: "GET", + url: `/api/v2/folders`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + query: { + projectId: seedData1.project.id, + environment: seedData1.environment.slug, + path: "/level1/level2" + } + }); + + expect(resUpdatedFolders.statusCode).toBe(200); + const updatedFolderList = JSON.parse(resUpdatedFolders.payload); + expect(updatedFolderList).toHaveProperty("folders"); + expect(updatedFolderList.folders.length).toEqual(0); + }); +}); diff --git a/backend/e2e-test/routes/v2/service-token.spec.ts b/backend/e2e-test/routes/v2/service-token.spec.ts index 025d9796f..4f72987cb 100644 --- a/backend/e2e-test/routes/v2/service-token.spec.ts +++ b/backend/e2e-test/routes/v2/service-token.spec.ts @@ -70,7 +70,7 @@ const createServiceToken = async ( const deleteServiceToken = async () => { const serviceTokenListRes = await testServer.inject({ method: "GET", - url: `/api/v1/workspace/${seedData1.project.id}/service-token-data`, + url: `/api/v1/projects/${seedData1.project.id}/service-token-data`, headers: { authorization: `Bearer ${jwtAuthToken}` } diff --git a/backend/e2e-test/routes/v4/secrets.spec.ts b/backend/e2e-test/routes/v4/secrets.spec.ts new file mode 100644 index 000000000..979adddf8 --- /dev/null +++ b/backend/e2e-test/routes/v4/secrets.spec.ts @@ -0,0 +1,678 @@ +import { SecretType } from "@app/db/schemas"; +import { seedData1 } from "@app/db/seed-data"; +import { AuthMode } from "@app/services/auth/auth-type"; + +type TRawSecret = { + secretKey: string; + secretValue: string; + secretComment?: string; + version: number; +}; + +const createSecret = async (dto: { path: string; key: string; value: string; comment: string; type?: SecretType }) => { + const createSecretReqBody = { + projectId: seedData1.projectV3.id, + environment: seedData1.environment.slug, + type: dto.type || SecretType.Shared, + secretPath: dto.path, + secretKey: dto.key, + secretValue: dto.value, + secretComment: dto.comment + }; + const createSecRes = await testServer.inject({ + method: "POST", + url: `/api/v4/secrets/${dto.key}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: createSecretReqBody + }); + expect(createSecRes.statusCode).toBe(200); + const createdSecretPayload = JSON.parse(createSecRes.payload); + expect(createdSecretPayload).toHaveProperty("secret"); + return createdSecretPayload.secret as TRawSecret; +}; + +const deleteSecret = async (dto: { path: string; key: string }) => { + const deleteSecRes = await testServer.inject({ + method: "DELETE", + url: `/api/v4/secrets/${dto.key}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + projectId: seedData1.projectV3.id, + environment: seedData1.environment.slug, + secretPath: dto.path + } + }); + expect(deleteSecRes.statusCode).toBe(200); + const updatedSecretPayload = JSON.parse(deleteSecRes.payload); + expect(updatedSecretPayload).toHaveProperty("secret"); + return updatedSecretPayload.secret as TRawSecret; +}; + +describe.each([{ auth: AuthMode.JWT }, { auth: AuthMode.IDENTITY_ACCESS_TOKEN }])( + "Secret V4 - $auth mode", + async ({ auth }) => { + let folderId = ""; + let authToken = ""; + const secretTestCases = [ + { + path: "/", + secret: { + key: "SEC1", + value: "something-secret", + comment: "some comment" + } + }, + { + path: "/nested1/nested2/folder", + secret: { + key: "NESTED-SEC1", + value: "something-secret", + comment: "some comment" + } + }, + { + path: "/", + secret: { + key: "secret-key-2", + value: `-----BEGIN PRIVATE KEY----- + MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCa6eeFk+cMVqFn + hoVQDYgn2Ptp5Azysr2UPq6P73pCL9BzUtOXKZROqDyGehzzfg3wE2KdYU1Jk5Uq + fP0ZOWDIlM2SaVCSI3FW32o5+ZiggjpqcVdLFc/PS0S/ZdSmpPd8h11iO2brtIAI + ugTW8fcKlGSNUwx9aFmE7A6JnTRliTxB1l6QaC+YAwTK39VgeVH2gDSWC407aS15 + QobAkaBKKmFkzB5D7i2ZJwt+uXJV/rbLmyDmtnw0lubciGn7NX9wbYef180fisqT + aPNAz0nPKk0fFH2Wd5MZixNGbrrpDA+FCYvI5doThZyT2hpj08qWP07oXXCAqw46 + IEupNSILAgMBAAECggEBAIJb5KzeaiZS3B3O8G4OBQ5rJB3WfyLYUHnoSWLsBbie + nc392/ovThLmtZAAQE6SO85Tsb93+t64Z2TKqv1H8G658UeMgfWIB78v4CcLJ2mi + TN/3opqXrzjkQOTDHzBgT7al/mpETHZ6fOdbCemK0fVALGFUioUZg4M8VXtuI4Jw + q28jAyoRKrCrzda4BeQ553NZ4G5RvwhX3O2I8B8upTbt5hLcisBKy8MPLYY5LUFj + YKAP+raf6QLliP6KYHuVxUlgzxjLTxVG41etcyqqZF+foyiKBO3PU3n8oh++tgQP + ExOxiR0JSkBG5b+oOBD0zxcvo3/SjBHn0dJOZCSU2SkCgYEAyCe676XnNyBZMRD7 + 6trsaoiCWBpA6M8H44+x3w4cQFtqV38RyLy60D+iMKjIaLqeBbnay61VMzo24Bz3 + EuF2n4+9k/MetLJ0NCw8HmN5k0WSMD2BFsJWG8glVbzaqzehP4tIclwDTYc1jQVt + IoV2/iL7HGT+x2daUwbU5kN5hK0CgYEAxiLB+fmjxJW7VY4SHDLqPdpIW0q/kv4K + d/yZBrCX799vjmFb9vLh7PkQUfJhMJ/ttJOd7EtT3xh4mfkBeLfHwVU0d/ahbmSH + UJu/E9ZGxAW3PP0kxHZtPrLKQwBnfq8AxBauIhR3rPSorQTIOKtwz1jMlHFSUpuL + 3KeK2YfDYJcCgYEAkQnJOlNcAuRb/WQzSHIvktssqK8NjiZHryy3Vc0hx7j2jES2 + HGI2dSVHYD9OSiXA0KFm3OTTsnViwm/60iGzFdjRJV6tR39xGUVcoyCuPnvRfUd0 + PYvBXgxgkYpyYlPDcwp5CvWGJy3tLi1acgOIwIuUr3S38sL//t4adGk8q1kCgYB8 + Jbs1Tl53BvrimKpwUNbE+sjrquJu0A7vL68SqgQJoQ7dP9PH4Ff/i+/V6PFM7mib + BQOm02wyFbs7fvKVGVJoqWK+6CIucX732x7W5yRgHtS5ukQXdbzt1Ek3wkEW98Cb + HTruz7RNAt/NyXlLSODeit1lBbx3Vk9EaxZtRsv88QKBgGn7JwXgez9NOyobsNIo + QVO80rpUeenSjuFi+R0VmbLKe/wgAQbYJ0xTAsQ0btqViMzB27D6mJyC+KUIwWNX + MN8a+m46v4kqvZkKL2c4gmDibyURNe/vCtCHFuanJS/1mo2tr4XDyEeiuK52eTd9 + omQDpP86RX/hIIQ+JyLSaWYa + -----END PRIVATE KEY-----`, + comment: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation" + } + }, + { + path: "/nested1/nested2/folder", + secret: { + key: "secret-key-3", + value: `-----BEGIN PRIVATE KEY----- + MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCa6eeFk+cMVqFn + hoVQDYgn2Ptp5Azysr2UPq6P73pCL9BzUtOXKZROqDyGehzzfg3wE2KdYU1Jk5Uq + fP0ZOWDIlM2SaVCSI3FW32o5+ZiggjpqcVdLFc/PS0S/ZdSmpPd8h11iO2brtIAI + ugTW8fcKlGSNUwx9aFmE7A6JnTRliTxB1l6QaC+YAwTK39VgeVH2gDSWC407aS15 + QobAkaBKKmFkzB5D7i2ZJwt+uXJV/rbLmyDmtnw0lubciGn7NX9wbYef180fisqT + aPNAz0nPKk0fFH2Wd5MZixNGbrrpDA+FCYvI5doThZyT2hpj08qWP07oXXCAqw46 + IEupNSILAgMBAAECggEBAIJb5KzeaiZS3B3O8G4OBQ5rJB3WfyLYUHnoSWLsBbie + nc392/ovThLmtZAAQE6SO85Tsb93+t64Z2TKqv1H8G658UeMgfWIB78v4CcLJ2mi + TN/3opqXrzjkQOTDHzBgT7al/mpETHZ6fOdbCemK0fVALGFUioUZg4M8VXtuI4Jw + q28jAyoRKrCrzda4BeQ553NZ4G5RvwhX3O2I8B8upTbt5hLcisBKy8MPLYY5LUFj + YKAP+raf6QLliP6KYHuVxUlgzxjLTxVG41etcyqqZF+foyiKBO3PU3n8oh++tgQP + ExOxiR0JSkBG5b+oOBD0zxcvo3/SjBHn0dJOZCSU2SkCgYEAyCe676XnNyBZMRD7 + 6trsaoiCWBpA6M8H44+x3w4cQFtqV38RyLy60D+iMKjIaLqeBbnay61VMzo24Bz3 + EuF2n4+9k/MetLJ0NCw8HmN5k0WSMD2BFsJWG8glVbzaqzehP4tIclwDTYc1jQVt + IoV2/iL7HGT+x2daUwbU5kN5hK0CgYEAxiLB+fmjxJW7VY4SHDLqPdpIW0q/kv4K + d/yZBrCX799vjmFb9vLh7PkQUfJhMJ/ttJOd7EtT3xh4mfkBeLfHwVU0d/ahbmSH + UJu/E9ZGxAW3PP0kxHZtPrLKQwBnfq8AxBauIhR3rPSorQTIOKtwz1jMlHFSUpuL + 3KeK2YfDYJcCgYEAkQnJOlNcAuRb/WQzSHIvktssqK8NjiZHryy3Vc0hx7j2jES2 + HGI2dSVHYD9OSiXA0KFm3OTTsnViwm/60iGzFdjRJV6tR39xGUVcoyCuPnvRfUd0 + PYvBXgxgkYpyYlPDcwp5CvWGJy3tLi1acgOIwIuUr3S38sL//t4adGk8q1kCgYB8 + Jbs1Tl53BvrimKpwUNbE+sjrquJu0A7vL68SqgQJoQ7dP9PH4Ff/i+/V6PFM7mib + BQOm02wyFbs7fvKVGVJoqWK+6CIucX732x7W5yRgHtS5ukQXdbzt1Ek3wkEW98Cb + HTruz7RNAt/NyXlLSODeit1lBbx3Vk9EaxZtRsv88QKBgGn7JwXgez9NOyobsNIo + QVO80rpUeenSjuFi+R0VmbLKe/wgAQbYJ0xTAsQ0btqViMzB27D6mJyC+KUIwWNX + MN8a+m46v4kqvZkKL2c4gmDibyURNe/vCtCHFuanJS/1mo2tr4XDyEeiuK52eTd9 + omQDpP86RX/hIIQ+JyLSaWYa + -----END PRIVATE KEY-----`, + comment: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation" + } + }, + { + path: "/nested1/nested2/folder", + secret: { + key: "secret-key-3", + value: + "TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdC4gU2VkIGRvIGVpdXNtb2QgdGVtcG9yIGluY2lkaWR1bnQgdXQgbGFib3JlIGV0IGRvbG9yZSBtYWduYSBhbGlxdWEuIFV0IGVuaW0gYWQgbWluaW0gdmVuaWFtLCBxdWlzIG5vc3RydWQgZXhlcmNpdGF0aW9uCg==", + comment: "" + } + } + ]; + + beforeAll(async () => { + if (auth === AuthMode.JWT) { + authToken = jwtAuthToken; + } else if (auth === AuthMode.IDENTITY_ACCESS_TOKEN) { + const identityLogin = await testServer.inject({ + method: "POST", + url: "/api/v1/auth/universal-auth/login", + body: { + clientSecret: seedData1.machineIdentity.clientCredentials.secret, + clientId: seedData1.machineIdentity.clientCredentials.id + } + }); + expect(identityLogin.statusCode).toBe(200); + authToken = identityLogin.json().accessToken; + } + // create a deep folder + const folderCreate = await testServer.inject({ + method: "POST", + url: `/api/v2/folders`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + projectId: seedData1.projectV3.id, + environment: seedData1.environment.slug, + name: "folder", + path: "/nested1/nested2" + } + }); + expect(folderCreate.statusCode).toBe(200); + folderId = folderCreate.json().folder.id; + }); + + afterAll(async () => { + const deleteFolder = await testServer.inject({ + method: "DELETE", + url: `/api/v2/folders/${folderId}`, + headers: { + authorization: `Bearer ${authToken}` + }, + body: { + projectId: seedData1.projectV3.id, + environment: seedData1.environment.slug, + path: "/nested1/nested2" + } + }); + expect(deleteFolder.statusCode).toBe(200); + }); + + const getSecrets = async (environment: string, secretPath = "/") => { + const res = await testServer.inject({ + method: "GET", + url: `/api/v4/secrets`, + headers: { + authorization: `Bearer ${authToken}` + }, + query: { + secretPath, + environment, + projectId: seedData1.projectV3.id + } + }); + const secrets: TRawSecret[] = JSON.parse(res.payload).secrets || []; + return secrets; + }; + + test.each(secretTestCases)("Create secret in path $path", async ({ secret, path }) => { + const createdSecret = await createSecret({ path, ...secret }); + expect(createdSecret.secretKey).toEqual(secret.key); + expect(createdSecret.secretValue).toEqual(secret.value); + expect(createdSecret.secretComment || "").toEqual(secret.comment); + expect(createdSecret.version).toEqual(1); + + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + secretKey: secret.key, + secretValue: secret.value, + type: SecretType.Shared + }) + ]) + ); + await deleteSecret({ path, key: secret.key }); + }); + + test.each(secretTestCases)("Get secret by name in path $path", async ({ secret, path }) => { + await createSecret({ path, ...secret }); + + const getSecByNameRes = await testServer.inject({ + method: "GET", + url: `/api/v4/secrets/${secret.key}`, + headers: { + authorization: `Bearer ${authToken}` + }, + query: { + secretPath: path, + projectId: seedData1.projectV3.id, + environment: seedData1.environment.slug + } + }); + expect(getSecByNameRes.statusCode).toBe(200); + const getSecretByNamePayload = JSON.parse(getSecByNameRes.payload); + expect(getSecretByNamePayload).toHaveProperty("secret"); + const decryptedSecret = getSecretByNamePayload.secret as TRawSecret; + expect(decryptedSecret.secretKey).toEqual(secret.key); + expect(decryptedSecret.secretValue).toEqual(secret.value); + expect(decryptedSecret.secretComment || "").toEqual(secret.comment); + + await deleteSecret({ path, key: secret.key }); + }); + + if (auth === AuthMode.JWT) { + test.each(secretTestCases)( + "Creating personal secret without shared throw error in path $path", + async ({ secret }) => { + const createSecretReqBody = { + projectId: seedData1.projectV3.id, + environment: seedData1.environment.slug, + type: SecretType.Personal, + secretKey: secret.key, + secretValue: secret.value, + secretComment: secret.comment + }; + const createSecRes = await testServer.inject({ + method: "POST", + url: `/api/v4/secrets/SEC2`, + headers: { + authorization: `Bearer ${authToken}` + }, + body: createSecretReqBody + }); + const payload = JSON.parse(createSecRes.payload); + expect(createSecRes.statusCode).toBe(400); + expect(payload.error).toEqual("BadRequest"); + } + ); + + test.each(secretTestCases)("Creating personal secret in path $path", async ({ secret, path }) => { + await createSecret({ path, ...secret }); + + const createSecretReqBody = { + projectId: seedData1.projectV3.id, + environment: seedData1.environment.slug, + type: SecretType.Personal, + secretPath: path, + secretKey: secret.key, + secretValue: "personal-value", + secretComment: secret.comment + }; + const createSecRes = await testServer.inject({ + method: "POST", + url: `/api/v4/secrets/${secret.key}`, + headers: { + authorization: `Bearer ${authToken}` + }, + body: createSecretReqBody + }); + expect(createSecRes.statusCode).toBe(200); + + // list secrets should contain personal one and shared one + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + secretKey: secret.key, + secretValue: secret.value, + type: SecretType.Shared + }), + expect.objectContaining({ + secretKey: secret.key, + secretValue: "personal-value", + type: SecretType.Personal + }) + ]) + ); + + await deleteSecret({ path, key: secret.key }); + }); + + test.each(secretTestCases)( + "Deleting personal one should not delete shared secret in path $path", + async ({ secret, path }) => { + await createSecret({ path, ...secret }); // shared one + await createSecret({ path, ...secret, type: SecretType.Personal }); + + // shared secret deletion should delete personal ones also + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + secretKey: secret.key, + type: SecretType.Shared + }), + expect.not.objectContaining({ + secretKey: secret.key, + type: SecretType.Personal + }) + ]) + ); + await deleteSecret({ path, key: secret.key }); + } + ); + } + + test.each(secretTestCases)("Update secret in path $path", async ({ path, secret }) => { + await createSecret({ path, ...secret }); + const updateSecretReqBody = { + projectId: seedData1.projectV3.id, + environment: seedData1.environment.slug, + type: SecretType.Shared, + secretPath: path, + secretKey: secret.key, + secretValue: "new-value", + secretComment: secret.comment + }; + const updateSecRes = await testServer.inject({ + method: "PATCH", + url: `/api/v4/secrets/${secret.key}`, + headers: { + authorization: `Bearer ${authToken}` + }, + body: updateSecretReqBody + }); + expect(updateSecRes.statusCode).toBe(200); + const updatedSecretPayload = JSON.parse(updateSecRes.payload); + expect(updatedSecretPayload).toHaveProperty("secret"); + const decryptedSecret = updatedSecretPayload.secret; + expect(decryptedSecret.secretKey).toEqual(secret.key); + expect(decryptedSecret.secretValue).toEqual("new-value"); + expect(decryptedSecret.secretComment || "").toEqual(secret.comment); + + // list secret should have updated value + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + secretKey: secret.key, + secretValue: "new-value", + type: SecretType.Shared + }) + ]) + ); + + await deleteSecret({ path, key: secret.key }); + }); + + test.each(secretTestCases)("Delete secret in path $path", async ({ secret, path }) => { + await createSecret({ path, ...secret }); + const deletedSecret = await deleteSecret({ path, key: secret.key }); + expect(deletedSecret.secretKey).toEqual(secret.key); + + // shared secret deletion should delete personal ones also + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.not.arrayContaining([ + expect.objectContaining({ + secretKey: secret.key, + type: SecretType.Shared + }), + expect.objectContaining({ + secretKey: secret.key, + type: SecretType.Personal + }) + ]) + ); + }); + + test.each(secretTestCases)("Bulk create secrets in path $path", async ({ secret, path }) => { + const createSharedSecRes = await testServer.inject({ + method: "POST", + url: `/api/v4/secrets/batch`, + headers: { + authorization: `Bearer ${authToken}` + }, + body: { + projectId: seedData1.projectV3.id, + environment: seedData1.environment.slug, + secretPath: path, + secrets: Array.from(Array(5)).map((_e, i) => ({ + secretKey: `BULK-${secret.key}-${i + 1}`, + secretValue: secret.value, + secretComment: secret.comment + })) + } + }); + expect(createSharedSecRes.statusCode).toBe(200); + const createSharedSecPayload = JSON.parse(createSharedSecRes.payload); + expect(createSharedSecPayload).toHaveProperty("secrets"); + + // bulk ones should exist + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining( + Array.from(Array(5)).map((_e, i) => + expect.objectContaining({ + secretKey: `BULK-${secret.key}-${i + 1}`, + secretValue: secret.value, + type: SecretType.Shared + }) + ) + ) + ); + + await Promise.all( + Array.from(Array(5)).map((_e, i) => deleteSecret({ path, key: `BULK-${secret.key}-${i + 1}` })) + ); + }); + + test.each(secretTestCases)("Bulk create fail on existing secret in path $path", async ({ secret, path }) => { + await createSecret({ ...secret, key: `BULK-${secret.key}-1`, path }); + + const createSharedSecRes = await testServer.inject({ + method: "POST", + url: `/api/v4/secrets/batch`, + headers: { + authorization: `Bearer ${authToken}` + }, + body: { + projectId: seedData1.projectV3.id, + environment: seedData1.environment.slug, + secretPath: path, + secrets: Array.from(Array(5)).map((_e, i) => ({ + secretKey: `BULK-${secret.key}-${i + 1}`, + secretValue: secret.value, + secretComment: secret.comment + })) + } + }); + expect(createSharedSecRes.statusCode).toBe(400); + + await deleteSecret({ path, key: `BULK-${secret.key}-1` }); + }); + + test.each(secretTestCases)("Bulk update secrets in path $path", async ({ secret, path }) => { + await Promise.all( + Array.from(Array(5)).map((_e, i) => createSecret({ ...secret, key: `BULK-${secret.key}-${i + 1}`, path })) + ); + + const updateSharedSecRes = await testServer.inject({ + method: "PATCH", + url: `/api/v4/secrets/batch`, + headers: { + authorization: `Bearer ${authToken}` + }, + body: { + projectId: seedData1.projectV3.id, + environment: seedData1.environment.slug, + secretPath: path, + secrets: Array.from(Array(5)).map((_e, i) => ({ + secretKey: `BULK-${secret.key}-${i + 1}`, + secretValue: "update-value", + secretComment: secret.comment + })) + } + }); + expect(updateSharedSecRes.statusCode).toBe(200); + const updateSharedSecPayload = JSON.parse(updateSharedSecRes.payload); + expect(updateSharedSecPayload).toHaveProperty("secrets"); + + // bulk ones should exist + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining( + Array.from(Array(5)).map((_e, i) => + expect.objectContaining({ + secretKey: `BULK-${secret.key}-${i + 1}`, + secretValue: "update-value", + type: SecretType.Shared + }) + ) + ) + ); + await Promise.all( + Array.from(Array(5)).map((_e, i) => deleteSecret({ path, key: `BULK-${secret.key}-${i + 1}` })) + ); + }); + + test.each(secretTestCases)("Bulk upsert secrets in path $path", async ({ secret, path }) => { + const updateSharedSecRes = await testServer.inject({ + method: "PATCH", + url: `/api/v4/secrets/batch`, + headers: { + authorization: `Bearer ${authToken}` + }, + body: { + projectId: seedData1.projectV3.id, + environment: seedData1.environment.slug, + secretPath: path, + mode: "upsert", + secrets: Array.from(Array(5)).map((_e, i) => ({ + secretKey: `BULK-${secret.key}-${i + 1}`, + secretValue: "update-value", + secretComment: secret.comment + })) + } + }); + expect(updateSharedSecRes.statusCode).toBe(200); + const updateSharedSecPayload = JSON.parse(updateSharedSecRes.payload); + expect(updateSharedSecPayload).toHaveProperty("secrets"); + + // bulk ones should exist + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining( + Array.from(Array(5)).map((_e, i) => + expect.objectContaining({ + secretKey: `BULK-${secret.key}-${i + 1}`, + secretValue: "update-value", + type: SecretType.Shared + }) + ) + ) + ); + await Promise.all( + Array.from(Array(5)).map((_e, i) => deleteSecret({ path, key: `BULK-${secret.key}-${i + 1}` })) + ); + }); + + test("Bulk upsert secrets in path multiple paths", async () => { + const firstBatchSecrets = Array.from(Array(5)).map((_e, i) => ({ + secretKey: `BULK-KEY-${secretTestCases[0].secret.key}-${i + 1}`, + secretValue: "update-value", + secretComment: "comment", + secretPath: secretTestCases[0].path + })); + const secondBatchSecrets = Array.from(Array(5)).map((_e, i) => ({ + secretKey: `BULK-KEY-${secretTestCases[1].secret.key}-${i + 1}`, + secretValue: "update-value", + secretComment: "comment", + secretPath: secretTestCases[1].path + })); + const testSecrets = [...firstBatchSecrets, ...secondBatchSecrets]; + + const updateSharedSecRes = await testServer.inject({ + method: "PATCH", + url: `/api/v4/secrets/batch`, + headers: { + authorization: `Bearer ${authToken}` + }, + body: { + projectId: seedData1.projectV3.id, + environment: seedData1.environment.slug, + mode: "upsert", + secrets: testSecrets + } + }); + expect(updateSharedSecRes.statusCode).toBe(200); + const updateSharedSecPayload = JSON.parse(updateSharedSecRes.payload); + expect(updateSharedSecPayload).toHaveProperty("secrets"); + + // bulk ones should exist + const firstBatchSecretsOnInfisical = await getSecrets(seedData1.environment.slug, secretTestCases[0].path); + expect(firstBatchSecretsOnInfisical).toEqual( + expect.arrayContaining( + firstBatchSecrets.map((el) => + expect.objectContaining({ + secretKey: el.secretKey, + secretValue: "update-value", + type: SecretType.Shared + }) + ) + ) + ); + const secondBatchSecretsOnInfisical = await getSecrets(seedData1.environment.slug, secretTestCases[1].path); + expect(secondBatchSecretsOnInfisical).toEqual( + expect.arrayContaining( + secondBatchSecrets.map((el) => + expect.objectContaining({ + secretKey: el.secretKey, + secretValue: "update-value", + type: SecretType.Shared + }) + ) + ) + ); + await Promise.all(testSecrets.map((el) => deleteSecret({ path: el.secretPath, key: el.secretKey }))); + }); + + test.each(secretTestCases)("Bulk delete secrets in path $path", async ({ secret, path }) => { + await Promise.all( + Array.from(Array(5)).map((_e, i) => createSecret({ ...secret, key: `BULK-${secret.key}-${i + 1}`, path })) + ); + + const deletedSharedSecRes = await testServer.inject({ + method: "DELETE", + url: `/api/v4/secrets/batch`, + headers: { + authorization: `Bearer ${authToken}` + }, + body: { + projectId: seedData1.projectV3.id, + environment: seedData1.environment.slug, + secretPath: path, + secrets: Array.from(Array(5)).map((_e, i) => ({ + secretKey: `BULK-${secret.key}-${i + 1}` + })) + } + }); + + expect(deletedSharedSecRes.statusCode).toBe(200); + const deletedSecretPayload = JSON.parse(deletedSharedSecRes.payload); + expect(deletedSecretPayload).toHaveProperty("secrets"); + + // bulk ones should exist + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.not.arrayContaining( + Array.from(Array(5)).map((_e, i) => + expect.objectContaining({ + secretKey: `BULK-${secret.value}-${i + 1}`, + type: SecretType.Shared + }) + ) + ) + ); + }); + } +); diff --git a/backend/package-lock.json b/backend/package-lock.json index c6ac0b147..db4e9e7dd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -83,6 +83,7 @@ "ioredis": "^5.3.2", "isomorphic-dompurify": "^2.22.0", "jmespath": "^0.16.0", + "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", "jsrp": "^0.2.4", "jwks-rsa": "^3.1.0", @@ -143,6 +144,7 @@ "@smithy/types": "^4.3.1", "@types/bcrypt": "^5.0.2", "@types/jmespath": "^0.15.2", + "@types/js-yaml": "^4.0.9", "@types/jsonwebtoken": "^9.0.5", "@types/jsrp": "^0.2.6", "@types/libsodium-wrappers": "^0.7.13", @@ -13160,6 +13162,13 @@ "integrity": "sha512-pegh49FtNsC389Flyo9y8AfkVIZn9MMPE9yJrO9svhq6Fks2MwymULWjZqySuxmctd3ZH4/n7Mr98D+1Qo5vGA==", "dev": true }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -20452,6 +20461,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, diff --git a/backend/package.json b/backend/package.json index 0c8464eaf..f06c1d69f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -73,7 +73,8 @@ "seed": "knex --knexfile ./dist/db/knexfile.ts --client pg seed:run", "seed-dev": "knex --knexfile ./src/db/knexfile.ts --client pg seed:run", "db:reset": "npm run migration:rollback -- --all && npm run migration:latest", - "email:dev": "email dev --dir src/services/smtp/emails" + "email:dev": "email dev --dir src/services/smtp/emails", + "validate-upgrade-path": "tsx ./scripts/validate-upgrade-path-file.ts" }, "keywords": [], "author": "", @@ -87,6 +88,7 @@ "@smithy/types": "^4.3.1", "@types/bcrypt": "^5.0.2", "@types/jmespath": "^0.15.2", + "@types/js-yaml": "^4.0.9", "@types/jsonwebtoken": "^9.0.5", "@types/jsrp": "^0.2.6", "@types/libsodium-wrappers": "^0.7.13", @@ -203,6 +205,7 @@ "ioredis": "^5.3.2", "isomorphic-dompurify": "^2.22.0", "jmespath": "^0.16.0", + "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", "jsrp": "^0.2.4", "jwks-rsa": "^3.1.0", diff --git a/backend/scripts/validate-upgrade-path-file.ts b/backend/scripts/validate-upgrade-path-file.ts new file mode 100644 index 000000000..566300cde --- /dev/null +++ b/backend/scripts/validate-upgrade-path-file.ts @@ -0,0 +1,107 @@ +/* eslint-disable no-console */ +import { readFile } from "fs/promises"; +import * as yaml from "js-yaml"; +import * as path from "path"; +import { z } from "zod"; + +import { upgradePathConfigSchema } from "../src/services/upgrade-path/upgrade-path-schemas"; + +async function validateUpgradePathConfig(): Promise { + try { + const yamlPath = path.join(__dirname, "..", "upgrade-path.yaml"); + const resolvedPath = path.resolve(yamlPath); + const expectedBaseDir = path.resolve(__dirname, ".."); + + if (!resolvedPath.startsWith(expectedBaseDir)) { + throw new Error("Invalid configuration file path"); + } + + try { + await readFile(yamlPath, "utf8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + console.log("Warning: No upgrade-path.yaml file found"); + return; + } + throw error; + } + + const yamlContent = await readFile(yamlPath, "utf8"); + + if (yamlContent.length > 1024 * 1024) { + throw new Error("Config file too large (>1MB)"); + } + + let config: unknown; + try { + config = yaml.load(yamlContent, { + schema: yaml.FAILSAFE_SCHEMA, + filename: yamlPath, + onWarning: (warning) => { + console.log(`YAML Warning: ${warning.message}`); + } + }); + } catch (yamlError) { + if (yamlError instanceof yaml.YAMLException) { + throw new Error( + `YAML parsing failed: ${yamlError.message} at line ${yamlError.mark?.line}, column ${yamlError.mark?.column}` + ); + } + throw new Error(`YAML parsing failed: ${yamlError instanceof Error ? yamlError.message : "Unknown YAML error"}`); + } + + if (!config) { + console.log("Warning: Empty configuration file"); + return; + } + + if (typeof config !== "object" || config === null) { + throw new Error("Configuration must be a valid YAML object"); + } + + const result = upgradePathConfigSchema.safeParse(config); + + if (!result.success) { + console.log("Validation failed with the following errors:"); + result.error.issues.forEach((issue: z.ZodIssue) => { + const issuePath = issue.path.length > 0 ? `[${issue.path.join(".")}]` : ""; + console.log(` - ${issuePath}: ${issue.message}`); + }); + throw new Error("Schema validation failed"); + } + + const validatedConfig = result.data; + const versions = validatedConfig?.versions || {}; + const versionCount = Object.keys(versions).length; + + if (versionCount === 0) { + console.log("Warning: No versions found in the configuration"); + } else { + console.log(`Validated ${versionCount} version configuration(s)`); + + const commonPatterns = [ + /^v?\d+\.\d+\.\d+$/, + /^v?\d+\.\d+\.\d+\.\d+$/, + /^infisical\/v?\d+\.\d+\.\d+$/, + /^infisical\/v?\d+\.\d+\.\d+-\w+$/ + ]; + + for (const versionKey of Object.keys(versions)) { + const isCommonPattern = commonPatterns.some((pattern) => pattern.test(versionKey)); + if (!isCommonPattern) { + console.log(`Warning: Version key '${versionKey}' doesn't match common patterns. This may be intentional.`); + } + } + } + + console.log("upgrade-path.yaml format is valid"); + } catch (error) { + console.error(`Validation failed: ${error instanceof Error ? error.message : "Unknown error"}`); + process.exit(1); + } +} + +validateUpgradePathConfig().catch((error) => { + console.error("Unexpected error:", error); + process.exit(1); +}); diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 8ca4288b5..c814ab9d8 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -93,6 +93,7 @@ import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-servi import { TPkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; import { TPkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service"; import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service"; +import { TPkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service"; import { TPkiTemplatesServiceFactory } from "@app/services/pki-templates/pki-templates-service"; import { TProjectServiceFactory } from "@app/services/project/project-service"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; @@ -114,6 +115,7 @@ import { TSlackServiceFactory } from "@app/services/slack/slack-service"; import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin-service"; import { TTelemetryServiceFactory } from "@app/services/telemetry/telemetry-service"; import { TTotpServiceFactory } from "@app/services/totp/totp-service"; +import { TUpgradePathService } from "@app/services/upgrade-path/upgrade-path-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserServiceFactory } from "@app/services/user/user-service"; import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; @@ -267,6 +269,7 @@ declare module "fastify" { certificateEst: TCertificateEstServiceFactory; pkiCollection: TPkiCollectionServiceFactory; pkiSubscriber: TPkiSubscriberServiceFactory; + pkiSync: TPkiSyncServiceFactory; secretScanning: TSecretScanningServiceFactory; license: TLicenseServiceFactory; trustedIp: TTrustedIpServiceFactory; @@ -312,6 +315,7 @@ declare module "fastify" { identityAuthTemplate: TIdentityAuthTemplateServiceFactory; notification: TNotificationServiceFactory; offlineUsageReport: TOfflineUsageReportServiceFactory; + upgradePath: TUpgradePathService; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 75a358341..b6bde44bc 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -263,6 +263,9 @@ import { TPkiSubscribers, TPkiSubscribersInsert, TPkiSubscribersUpdate, + TPkiSyncs, + TPkiSyncsInsert, + TPkiSyncsUpdate, TProjectBots, TProjectBotsInsert, TProjectBotsUpdate, @@ -680,6 +683,7 @@ declare module "knex/types/tables" { TPkiSubscribersInsert, TPkiSubscribersUpdate >; + [TableName.PkiSync]: KnexOriginal.CompositeTableType; [TableName.UserGroupMembership]: KnexOriginal.CompositeTableType< TUserGroupMembership, TUserGroupMembershipInsert, diff --git a/backend/src/db/migrations/20250204025010_app-connections-and-secret-syncs-unique-constraint.ts b/backend/src/db/migrations/20250204025010_app-connections-and-secret-syncs-unique-constraint.ts index 348694ae7..f9b46559d 100644 --- a/backend/src/db/migrations/20250204025010_app-connections-and-secret-syncs-unique-constraint.ts +++ b/backend/src/db/migrations/20250204025010_app-connections-and-secret-syncs-unique-constraint.ts @@ -1,5 +1,6 @@ import { Knex } from "knex"; +import { dropConstraintIfExists } from "@app/db/migrations/utils/dropConstraintIfExists"; import { TableName } from "@app/db/schemas"; export async function up(knex: Knex): Promise { @@ -13,9 +14,7 @@ export async function up(knex: Knex): Promise { } export async function down(knex: Knex): Promise { - await knex.schema.alterTable(TableName.AppConnection, (t) => { - t.dropUnique(["orgId", "name"]); - }); + await dropConstraintIfExists(TableName.AppConnection, "app_connections_orgid_name_unique", knex); await knex.schema.alterTable(TableName.SecretSync, (t) => { t.dropUnique(["projectId", "name"]); diff --git a/backend/src/db/migrations/20250602155452_pit-projects-commits-initialization.ts b/backend/src/db/migrations/20250602155452_pit-projects-commits-initialization.ts index dd4034c57..f382db0d6 100644 --- a/backend/src/db/migrations/20250602155452_pit-projects-commits-initialization.ts +++ b/backend/src/db/migrations/20250602155452_pit-projects-commits-initialization.ts @@ -226,7 +226,7 @@ export async function up(knex: Knex): Promise { // Insert New Commits in batches of 9000 const newCommits = foldersCommitsList.map((folderCommit) => folderCommit.commit); const commitBatches = chunkArray(newCommits, 9000); - + let pendingDeepTreeCommitResources: TFolderCommits[] = []; let j = 0; for (const commitBatch of commitBatches) { j += 1; @@ -265,12 +265,14 @@ export async function up(knex: Knex): Promise { }); // Create folder commit changes + const currentBatchFolderIds = new Set(newCommitsInserted.map((commit) => commit.folderId)); // eslint-disable-next-line no-await-in-loop await knex.batchInsert( TableName.FolderCommitChanges, foldersCommitsList .map((folderCommit) => folderCommit.changes) .flat() + .filter((change) => currentBatchFolderIds.has(change.folderId)) .map((change) => ({ folderCommitId: newCommitsMap[change.folderId], changeType: change.changeType, @@ -289,6 +291,7 @@ export async function up(knex: Knex): Promise { foldersCommitsList .map((folderCommit) => folderCommit.changes) .flat() + .filter((change) => currentBatchFolderIds.has(change.folderId)) .map((change) => ({ folderCheckpointId: newCheckpointsMap[change.folderId], folderVersionId: change.folderVersionId, @@ -303,9 +306,11 @@ export async function up(knex: Knex): Promise { const newTreeCheckpoints = (await knex .batchInsert( TableName.FolderTreeCheckpoint, - Object.keys(rootFoldersMap).map((folderId) => ({ - folderCommitId: newCommitsMap[folderId] - })) + Object.keys(rootFoldersMap) + .filter((folderId) => currentBatchFolderIds.has(folderId)) + .map((folderId) => ({ + folderCommitId: newCommitsMap[folderId] + })) ) .returning("*")) as TFolderTreeCheckpoints[]; @@ -317,18 +322,31 @@ export async function up(knex: Knex): Promise { }); // Create Folder Tree Checkpoint Resources - // eslint-disable-next-line no-await-in-loop - await knex - .batchInsert( - TableName.FolderTreeCheckpointResources, - newCommitsInserted.map((folderCommit) => ({ - folderTreeCheckpointId: newTreeCheckpointsMap[folderCommit.envId], - folderId: folderCommit.folderId, - folderCommitId: folderCommit.id - })) - ) - .returning("*"); + const commitsToProcess = pendingDeepTreeCommitResources.concat(newCommitsInserted); + const unprocessableCommits: TFolderCommits[] = []; + const processableCommits = commitsToProcess.filter((folderCommit) => { + const isProcessable = newTreeCheckpointsMap[folderCommit.envId]; + if (!isProcessable) { + unprocessableCommits.push(folderCommit); + } + return isProcessable; + }); + if (processableCommits.length > 0) { + // eslint-disable-next-line no-await-in-loop + await knex + .batchInsert( + TableName.FolderTreeCheckpointResources, + processableCommits.map((folderCommit) => ({ + folderTreeCheckpointId: newTreeCheckpointsMap[folderCommit.envId], + folderId: folderCommit.folderId, + folderCommitId: folderCommit.id + })) + ) + .returning("*"); + } + + pendingDeepTreeCommitResources = unprocessableCommits; logger.info(`Finished inserting folder tree checkpoint resources - batch ${j} of ${commitBatches.length}`); } } diff --git a/backend/src/db/migrations/20250819081226_identity-lockouts-ldap.ts b/backend/src/db/migrations/20250819081226_identity-lockouts-ldap.ts new file mode 100644 index 000000000..6cc851368 --- /dev/null +++ b/backend/src/db/migrations/20250819081226_identity-lockouts-ldap.ts @@ -0,0 +1,57 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.IdentityLdapAuth)) { + const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutEnabled"); + const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutThreshold"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutDurationSeconds"); + const hasLockoutCounterReset = await knex.schema.hasColumn( + TableName.IdentityLdapAuth, + "lockoutCounterResetSeconds" + ); + + await knex.schema.alterTable(TableName.IdentityLdapAuth, (t) => { + if (!hasLockoutEnabled) { + t.boolean("lockoutEnabled").notNullable().defaultTo(true); + } + if (!hasLockoutThreshold) { + t.integer("lockoutThreshold").notNullable().defaultTo(3); + } + if (!hasLockoutDuration) { + t.integer("lockoutDurationSeconds").notNullable().defaultTo(300); // 5 minutes + } + if (!hasLockoutCounterReset) { + t.integer("lockoutCounterResetSeconds").notNullable().defaultTo(30); // 30 seconds + } + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.IdentityLdapAuth)) { + const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutEnabled"); + const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutThreshold"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutDurationSeconds"); + const hasLockoutCounterReset = await knex.schema.hasColumn( + TableName.IdentityLdapAuth, + "lockoutCounterResetSeconds" + ); + + await knex.schema.alterTable(TableName.IdentityLdapAuth, (t) => { + if (hasLockoutEnabled) { + t.dropColumn("lockoutEnabled"); + } + if (hasLockoutThreshold) { + t.dropColumn("lockoutThreshold"); + } + if (hasLockoutDuration) { + t.dropColumn("lockoutDurationSeconds"); + } + if (hasLockoutCounterReset) { + t.dropColumn("lockoutCounterResetSeconds"); + } + }); + } +} diff --git a/backend/src/db/migrations/20250910193000_pki-sync.ts b/backend/src/db/migrations/20250910193000_pki-sync.ts new file mode 100644 index 000000000..73dc620d9 --- /dev/null +++ b/backend/src/db/migrations/20250910193000_pki-sync.ts @@ -0,0 +1,47 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.PkiSync))) { + await knex.schema.createTable(TableName.PkiSync, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name", 32).notNullable(); + t.string("description"); + t.string("destination").notNullable(); + t.boolean("isAutoSyncEnabled").notNullable().defaultTo(true); + t.integer("version").defaultTo(1).notNullable(); + t.jsonb("destinationConfig").notNullable(); + t.jsonb("syncOptions").notNullable(); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("subscriberId"); + t.foreign("subscriberId").references("id").inTable(TableName.PkiSubscriber).onDelete("SET NULL"); + t.uuid("connectionId").notNullable(); + t.foreign("connectionId").references("id").inTable(TableName.AppConnection); + t.timestamps(true, true, true); + t.string("syncStatus"); + t.string("lastSyncJobId"); + t.string("lastSyncMessage"); + t.datetime("lastSyncedAt"); + t.string("importStatus"); + t.string("lastImportJobId"); + t.string("lastImportMessage"); + t.datetime("lastImportedAt"); + t.string("removeStatus"); + t.string("lastRemoveJobId"); + t.string("lastRemoveMessage"); + t.datetime("lastRemovedAt"); + + t.unique(["name", "projectId"], { indexName: "pki_syncs_name_project_id_unique" }); + }); + + await createOnUpdateTrigger(knex, TableName.PkiSync); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.PkiSync); + await dropOnUpdateTrigger(knex, TableName.PkiSync); +} diff --git a/backend/src/db/migrations/20250912011133_app-connection-project-id-col.ts b/backend/src/db/migrations/20250912011133_app-connection-project-id-col.ts new file mode 100644 index 000000000..5c846a739 --- /dev/null +++ b/backend/src/db/migrations/20250912011133_app-connection-project-id-col.ts @@ -0,0 +1,41 @@ +import { Knex } from "knex"; + +import { dropConstraintIfExists } from "@app/db/migrations/utils/dropConstraintIfExists"; +import { TableName } from "@app/db/schemas"; + +const UNIQUE_NAME_ORG_CONNECTION_INDEX = "unique_name_org_app_connection"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.AppConnection)) { + // we can't add the constraint back after up since there may be conflicting names so we do if exists + await dropConstraintIfExists(TableName.AppConnection, "app_connections_orgid_name_unique", knex); + + if (!(await knex.schema.hasColumn(TableName.AppConnection, "projectId"))) { + await knex.schema.alterTable(TableName.AppConnection, (t) => { + t.string("projectId").nullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + // unique name for project-level connections + t.unique(["name", "projectId", "orgId"]); + }); + + // unique name for org-level connections + await knex.raw(` + CREATE UNIQUE INDEX ${UNIQUE_NAME_ORG_CONNECTION_INDEX} + ON ${TableName.AppConnection} ("name", "orgId") + WHERE "projectId" IS NULL + `); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.AppConnection)) { + if (await knex.schema.hasColumn(TableName.AppConnection, "projectId")) { + await knex.schema.alterTable(TableName.AppConnection, (t) => { + t.dropUnique(["name", "projectId", "orgId"]); + t.dropColumn("projectId"); + }); + await dropConstraintIfExists(TableName.AppConnection, UNIQUE_NAME_ORG_CONNECTION_INDEX, knex); + } + } +} diff --git a/backend/src/db/migrations/20250921120821_k8-auth-name-increase.ts b/backend/src/db/migrations/20250921120821_k8-auth-name-increase.ts new file mode 100644 index 000000000..9cd8a8d4f --- /dev/null +++ b/backend/src/db/migrations/20250921120821_k8-auth-name-increase.ts @@ -0,0 +1,31 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasAllowedNamespaces = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "allowedNamespaces"); + const hasAllowedNames = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "allowedNames"); + const hasAllowedAudience = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "allowedAudience"); + + if (hasAllowedNamespaces || hasAllowedNames || hasAllowedAudience) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (t) => { + if (hasAllowedNames) t.string("allowedNames", 1000).notNullable().alter(); + if (hasAllowedNamespaces) t.string("allowedNamespaces", 1000).notNullable().alter(); + if (hasAllowedAudience) t.string("allowedAudience", 1000).notNullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasAllowedNamespaces = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "allowedNamespaces"); + const hasAllowedNames = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "allowedNames"); + const hasAllowedAudience = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "allowedAudience"); + + if (hasAllowedNamespaces || hasAllowedNames || hasAllowedAudience) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (t) => { + if (hasAllowedNames) t.string("allowedNames", 255).notNullable().alter(); + if (hasAllowedNamespaces) t.string("allowedNamespaces", 255).notNullable().alter(); + if (hasAllowedAudience) t.string("allowedAudience", 255).notNullable().alter(); + }); + } +} diff --git a/backend/src/db/migrations/20250922222724_remove-secret-approval-multiline-encoding-default.ts b/backend/src/db/migrations/20250922222724_remove-secret-approval-multiline-encoding-default.ts new file mode 100644 index 000000000..a6b9e624f --- /dev/null +++ b/backend/src/db/migrations/20250922222724_remove-secret-approval-multiline-encoding-default.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretApprovalRequestSecretV2)) { + await knex.schema.alterTable(TableName.SecretApprovalRequestSecretV2, (t) => { + t.boolean("skipMultilineEncoding").alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretApprovalRequestSecretV2)) { + await knex.schema.alterTable(TableName.SecretApprovalRequestSecretV2, (t) => { + t.boolean("skipMultilineEncoding").defaultTo(false).alter(); + }); + } +} diff --git a/backend/src/db/migrations/20250926000000_saml-configs-group-sync-fields.ts b/backend/src/db/migrations/20250926000000_saml-configs-group-sync-fields.ts new file mode 100644 index 000000000..4b637e809 --- /dev/null +++ b/backend/src/db/migrations/20250926000000_saml-configs-group-sync-fields.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasEnableGroupSyncCol = await knex.schema.hasColumn(TableName.SamlConfig, "enableGroupSync"); + + if (!hasEnableGroupSyncCol) { + await knex.schema.alterTable(TableName.SamlConfig, (tb) => { + tb.boolean("enableGroupSync").notNullable().defaultTo(false); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasEnableGroupSyncCol = await knex.schema.hasColumn(TableName.SamlConfig, "enableGroupSync"); + + if (hasEnableGroupSyncCol) { + await knex.schema.alterTable(TableName.SamlConfig, (t) => { + t.dropColumn("enableGroupSync"); + }); + } +} diff --git a/backend/src/db/schemas/app-connections.ts b/backend/src/db/schemas/app-connections.ts index 2218b75ce..41d1df17f 100644 --- a/backend/src/db/schemas/app-connections.ts +++ b/backend/src/db/schemas/app-connections.ts @@ -21,7 +21,8 @@ export const AppConnectionsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), isPlatformManagedCredentials: z.boolean().default(false).nullable().optional(), - gatewayId: z.string().uuid().nullable().optional() + gatewayId: z.string().uuid().nullable().optional(), + projectId: z.string().nullable().optional() }); export type TAppConnections = z.infer; diff --git a/backend/src/db/schemas/identity-ldap-auths.ts b/backend/src/db/schemas/identity-ldap-auths.ts index 3e4d88649..87c7f1608 100644 --- a/backend/src/db/schemas/identity-ldap-auths.ts +++ b/backend/src/db/schemas/identity-ldap-auths.ts @@ -26,7 +26,11 @@ export const IdentityLdapAuthsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), accessTokenPeriod: z.coerce.number().default(0), - templateId: z.string().uuid().nullable().optional() + templateId: z.string().uuid().nullable().optional(), + lockoutEnabled: z.boolean().default(true), + lockoutThreshold: z.number().default(3), + lockoutDurationSeconds: z.number().default(300), + lockoutCounterResetSeconds: z.number().default(30) }); export type TIdentityLdapAuths = z.infer; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index f09e3c263..8cbaa00bb 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -87,6 +87,7 @@ export * from "./pki-alerts"; export * from "./pki-collection-items"; export * from "./pki-collections"; export * from "./pki-subscribers"; +export * from "./pki-syncs"; export * from "./project-bots"; export * from "./project-environments"; export * from "./project-gateways"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index a4585972f..385a328b6 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -156,6 +156,7 @@ export enum TableName { ProjectSlackConfigs = "project_slack_configs", AppConnection = "app_connections", SecretSync = "secret_syncs", + PkiSync = "pki_syncs", KmipClient = "kmip_clients", KmipOrgConfig = "kmip_org_configs", KmipOrgServerCertificates = "kmip_org_server_certificates", diff --git a/backend/src/db/schemas/pki-syncs.ts b/backend/src/db/schemas/pki-syncs.ts new file mode 100644 index 000000000..d4ef2ed6d --- /dev/null +++ b/backend/src/db/schemas/pki-syncs.ts @@ -0,0 +1,40 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiSyncsSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable().optional(), + destination: z.string(), + isAutoSyncEnabled: z.boolean().default(true), + version: z.number().default(1), + destinationConfig: z.unknown(), + syncOptions: z.unknown(), + projectId: z.string(), + subscriberId: z.string().uuid().nullable().optional(), + connectionId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + syncStatus: z.string().nullable().optional(), + lastSyncJobId: z.string().nullable().optional(), + lastSyncMessage: z.string().nullable().optional(), + lastSyncedAt: z.date().nullable().optional(), + importStatus: z.string().nullable().optional(), + lastImportJobId: z.string().nullable().optional(), + lastImportMessage: z.string().nullable().optional(), + lastImportedAt: z.date().nullable().optional(), + removeStatus: z.string().nullable().optional(), + lastRemoveJobId: z.string().nullable().optional(), + lastRemoveMessage: z.string().nullable().optional(), + lastRemovedAt: z.date().nullable().optional() +}); + +export type TPkiSyncs = z.infer; +export type TPkiSyncsInsert = Omit, TImmutableDBKeys>; +export type TPkiSyncsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/saml-configs.ts b/backend/src/db/schemas/saml-configs.ts index 350e84492..de85653c2 100644 --- a/backend/src/db/schemas/saml-configs.ts +++ b/backend/src/db/schemas/saml-configs.ts @@ -28,7 +28,8 @@ export const SamlConfigsSchema = z.object({ lastUsed: z.date().nullable().optional(), encryptedSamlEntryPoint: zodBuffer, encryptedSamlIssuer: zodBuffer, - encryptedSamlCertificate: zodBuffer + encryptedSamlCertificate: zodBuffer, + enableGroupSync: z.boolean().default(false) }); export type TSamlConfigs = z.infer; diff --git a/backend/src/db/schemas/secret-approval-requests-secrets-v2.ts b/backend/src/db/schemas/secret-approval-requests-secrets-v2.ts index 298985fed..d0f1a7c6d 100644 --- a/backend/src/db/schemas/secret-approval-requests-secrets-v2.ts +++ b/backend/src/db/schemas/secret-approval-requests-secrets-v2.ts @@ -17,7 +17,7 @@ export const SecretApprovalRequestsSecretsV2Schema = z.object({ encryptedComment: zodBuffer.nullable().optional(), reminderNote: z.string().nullable().optional(), reminderRepeatDays: z.number().nullable().optional(), - skipMultilineEncoding: z.boolean().default(false).nullable().optional(), + skipMultilineEncoding: z.boolean().nullable().optional(), metadata: z.unknown().nullable().optional(), createdAt: z.date(), updatedAt: z.date(), diff --git a/backend/src/ee/routes/v1/deprecated-project-role-router.ts b/backend/src/ee/routes/v1/deprecated-project-role-router.ts new file mode 100644 index 000000000..dc361626d --- /dev/null +++ b/backend/src/ee/routes/v1/deprecated-project-role-router.ts @@ -0,0 +1,342 @@ +import { packRules } from "@casl/ability/extra"; +import { z } from "zod"; + +import { ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { + backfillPermissionV1SchemaToV2Schema, + ProjectPermissionV1Schema +} from "@app/ee/services/permission/project-permission"; +import { PROJECT_ROLE } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { SanitizedRoleSchemaV1 } from "@app/server/routes/sanitizedSchemas"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { ProjectRoleServiceIdentifierType } from "@app/services/project-role/project-role-types"; + +export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/:projectSlug/roles", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Create a project role", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectSlug: z.string().trim().describe(PROJECT_ROLE.CREATE.projectSlug) + }), + body: z.object({ + slug: slugSchema({ max: 64 }) + .refine( + (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), + "Please choose a different slug, the slug you have entered is reserved" + ) + .describe(PROJECT_ROLE.CREATE.slug), + name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name), + description: z.string().trim().nullish().describe(PROJECT_ROLE.CREATE.description), + permissions: ProjectPermissionV1Schema.array().describe(PROJECT_ROLE.CREATE.permissions) + }), + response: { + 200: z.object({ + role: SanitizedRoleSchemaV1 + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const stringifiedPermissions = JSON.stringify( + packRules(backfillPermissionV1SchemaToV2Schema(req.body.permissions, true)) + ); + + const role = await server.services.projectRole.createRole({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + filter: { + type: ProjectRoleServiceIdentifierType.SLUG, + projectSlug: req.params.projectSlug + }, + data: { + ...req.body, + permissions: stringifiedPermissions + } + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: role.projectId, + event: { + type: EventType.CREATE_PROJECT_ROLE, + metadata: { + roleId: role.id, + slug: req.body.slug, + name: req.body.name, + description: req.body.description, + permissions: stringifiedPermissions + } + } + }); + + return { role }; + } + }); + + server.route({ + method: "PATCH", + url: "/:projectSlug/roles/:roleId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Update a project role", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectSlug: z.string().trim().describe(PROJECT_ROLE.UPDATE.projectSlug), + roleId: z.string().trim().describe(PROJECT_ROLE.UPDATE.roleId) + }), + body: z.object({ + slug: slugSchema({ max: 64 }) + .refine( + (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), + "Please choose a different slug, the slug you have entered is reserved" + ) + .describe(PROJECT_ROLE.UPDATE.slug) + .optional(), + name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), + description: z.string().trim().nullish().describe(PROJECT_ROLE.UPDATE.description), + permissions: ProjectPermissionV1Schema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional() + }), + response: { + 200: z.object({ + role: SanitizedRoleSchemaV1 + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const stringifiedPermissions = req.body.permissions + ? JSON.stringify(packRules(backfillPermissionV1SchemaToV2Schema(req.body.permissions, true))) + : undefined; + + const role = await server.services.projectRole.updateRole({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + roleId: req.params.roleId, + data: { + ...req.body, + permissions: stringifiedPermissions + } + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: role.projectId, + event: { + type: EventType.UPDATE_PROJECT_ROLE, + metadata: { + roleId: role.id, + slug: req.body.slug, + name: req.body.name, + description: req.body.description, + permissions: stringifiedPermissions + } + } + }); + + return { role }; + } + }); + + server.route({ + method: "DELETE", + url: "/:projectSlug/roles/:roleId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Delete a project role", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectSlug: z.string().trim().describe(PROJECT_ROLE.DELETE.projectSlug), + roleId: z.string().trim().describe(PROJECT_ROLE.DELETE.roleId) + }), + response: { + 200: z.object({ + role: SanitizedRoleSchemaV1 + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const role = await server.services.projectRole.deleteRole({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + roleId: req.params.roleId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: role.projectId, + event: { + type: EventType.DELETE_PROJECT_ROLE, + metadata: { + roleId: role.id, + slug: role.slug, + name: role.name + } + } + }); + + return { role }; + } + }); + + server.route({ + method: "GET", + url: "/:projectSlug/roles", + config: { + rateLimit: readLimit + }, + schema: { + description: "List project role", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectSlug: z.string().trim().describe(PROJECT_ROLE.LIST.projectSlug) + }), + response: { + 200: z.object({ + roles: ProjectRolesSchema.omit({ permissions: true, version: true }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const roles = await server.services.projectRole.listRoles({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + filter: { + type: ProjectRoleServiceIdentifierType.SLUG, + projectSlug: req.params.projectSlug + } + }); + return { roles }; + } + }); + + server.route({ + method: "GET", + url: "/:projectSlug/roles/slug/:slug", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectSlug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.projectSlug), + slug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.roleSlug) + }), + response: { + 200: z.object({ + role: SanitizedRoleSchemaV1.omit({ version: true }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const role = await server.services.projectRole.getRoleBySlug({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + filter: { + type: ProjectRoleServiceIdentifierType.SLUG, + projectSlug: req.params.projectSlug + }, + roleSlug: req.params.slug + }); + + return { role }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/permissions", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim() + }), + response: { + 200: z.object({ + data: z.object({ + membership: z.object({ + id: z.string(), + roles: z + .object({ + role: z.string() + }) + .array() + }), + assumedPrivilegeDetails: z + .object({ + actorId: z.string(), + actorType: z.string(), + actorName: z.string(), + actorEmail: z.string().optional() + }) + .optional(), + permissions: z.any().array() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { permissions, membership, assumedPrivilegeDetails } = await server.services.projectRole.getUserPermission( + req.permission.id, + req.params.projectId, + req.permission.authMethod, + req.permission.orgId + ); + + return { + data: { + permissions, + membership, + assumedPrivilegeDetails + } + }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/deprecated-project-router.ts b/backend/src/ee/routes/v1/deprecated-project-router.ts new file mode 100644 index 000000000..6c4175f84 --- /dev/null +++ b/backend/src/ee/routes/v1/deprecated-project-router.ts @@ -0,0 +1,195 @@ +import { z } from "zod"; + +import { AuditLogsSchema, SecretSnapshotsSchema } from "@app/db/schemas"; +import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, AUDIT_LOGS, PROJECTS } from "@app/lib/api-docs"; +import { getLastMidnightDateISO, removeTrailingSlash } from "@app/lib/fn"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:workspaceId/secret-snapshots", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Projects], + description: "Return project secret snapshots ids", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + workspaceId: z.string().trim().describe(PROJECTS.GET_SNAPSHOTS.projectId) + }), + querystring: z.object({ + environment: z.string().trim().describe(PROJECTS.GET_SNAPSHOTS.environment), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(PROJECTS.GET_SNAPSHOTS.path), + offset: z.coerce.number().default(0).describe(PROJECTS.GET_SNAPSHOTS.offset), + limit: z.coerce.number().default(20).describe(PROJECTS.GET_SNAPSHOTS.limit) + }), + response: { + 200: z.object({ + secretSnapshots: SecretSnapshotsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const secretSnapshots = await server.services.snapshot.listSnapshots({ + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + ...req.query + }); + return { secretSnapshots }; + } + }); + + server.route({ + method: "GET", + url: "/:workspaceId/secret-snapshots/count", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + querystring: z.object({ + environment: z.string().trim(), + path: z.string().trim().default("/").transform(removeTrailingSlash) + }), + response: { + 200: z.object({ + count: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const count = await server.services.snapshot.projectSecretSnapshotCount({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + environment: req.query.environment, + path: req.query.path + }); + return { count }; + } + }); + + /* + * Daniel: This endpoint is no longer is use. + * We are keeping it for now because it has been exposed in our public api docs for a while, so by removing it we are likely to break users workflows. + * + * Please refer to the new endpoint, GET /api/v1/organization/audit-logs, for the same (and more) functionality. + */ + server.route({ + method: "GET", + url: "/:workspaceId/audit-logs", + config: { + rateLimit: readLimit + }, + schema: { + description: "Return audit logs", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + workspaceId: z.string().trim().describe(AUDIT_LOGS.EXPORT.projectId) + }), + querystring: z + .object({ + eventType: z.nativeEnum(EventType).optional().describe(AUDIT_LOGS.EXPORT.eventType), + userAgentType: z.nativeEnum(UserAgentType).optional().describe(AUDIT_LOGS.EXPORT.userAgentType), + startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate), + endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate), + offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset), + limit: z.coerce.number().max(1000).default(20).describe(AUDIT_LOGS.EXPORT.limit), + actor: z.string().optional().describe(AUDIT_LOGS.EXPORT.actor) + }) + .superRefine((el, ctx) => { + if (el.endDate && el.startDate) { + const startDate = new Date(el.startDate); + const endDate = new Date(el.endDate); + const maxAllowedDate = new Date(startDate); + maxAllowedDate.setMonth(maxAllowedDate.getMonth() + 3); + if (endDate < startDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["endDate"], + message: "End date cannot be before start date" + }); + } + if (endDate > maxAllowedDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["endDate"], + message: "Dates must be within 3 months" + }); + } + } + }), + response: { + 200: z.object({ + auditLogs: AuditLogsSchema.omit({ + eventMetadata: true, + eventType: true, + actor: true, + actorMetadata: true + }) + .merge( + z.object({ + project: z + .object({ + name: z.string(), + slug: z.string() + }) + .optional(), + event: z.object({ + type: z.string(), + metadata: z.any() + }), + actor: z.object({ + type: z.string(), + metadata: z.any() + }) + }) + ) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const auditLogs = await server.services.auditLog.listAuditLogs({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + + filter: { + ...req.query, + projectId: req.params.workspaceId, + endDate: req.query.endDate || new Date().toISOString(), + startDate: req.query.startDate || getLastMidnightDateISO(), + auditLogActorId: req.query.actor, + eventType: req.query.eventType ? [req.query.eventType] : undefined + } + }); + return { auditLogs }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/deprecated-secret-approval-policy-router.ts b/backend/src/ee/routes/v1/deprecated-secret-approval-policy-router.ts new file mode 100644 index 000000000..42f6f9c12 --- /dev/null +++ b/backend/src/ee/routes/v1/deprecated-secret-approval-policy-router.ts @@ -0,0 +1,293 @@ +import { nanoid } from "nanoid"; +import { z } from "zod"; + +import { ApproverType, BypasserType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { EnforcementLevel } from "@app/lib/types"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { sapPubSchema } from "@app/server/routes/sanitizedSchemas"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerDeprecatedSecretApprovalPolicyRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + config: { + rateLimit: writeLimit + }, + schema: { + body: z + .object({ + workspaceId: z.string(), + name: z.string().optional(), + environment: z.string().optional(), + environments: z.string().array().optional(), + secretPath: z + .string() + .min(1, { message: "Secret path cannot be empty" }) + .transform((val) => removeTrailingSlash(val)), + approvers: z + .discriminatedUnion("type", [ + z.object({ type: z.literal(ApproverType.Group), id: z.string() }), + z.object({ + type: z.literal(ApproverType.User), + id: z.string().optional(), + username: z.string().optional() + }) + ]) + .array() + .min(1, { message: "At least one approver should be provided" }) + .max(100, "Cannot have more than 100 approvers"), + bypassers: z + .discriminatedUnion("type", [ + z.object({ type: z.literal(BypasserType.Group), id: z.string() }), + z.object({ + type: z.literal(BypasserType.User), + id: z.string().optional(), + username: z.string().optional() + }) + ]) + .array() + .max(100, "Cannot have more than 100 bypassers") + .optional(), + approvals: z.number().min(1).default(1), + enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), + allowedSelfApprovals: z.boolean().default(true) + }) + .refine((data) => data.environment || data.environments, "At least one environment should be provided"), + response: { + 200: z.object({ + approval: sapPubSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const approval = await server.services.secretApprovalPolicy.createSecretApprovalPolicy({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.body.workspaceId, + ...req.body, + name: req.body.name ?? `${req.body.environment || req.body.environments?.join(",")}-${nanoid(3)}`, + enforcementLevel: req.body.enforcementLevel + }); + return { approval }; + } + }); + + server.route({ + url: "/:sapId", + method: "PATCH", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + sapId: z.string() + }), + body: z.object({ + name: z.string().optional(), + approvers: z + .discriminatedUnion("type", [ + z.object({ type: z.literal(ApproverType.Group), id: z.string() }), + z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), username: z.string().optional() }) + ]) + .array() + .min(1, { message: "At least one approver should be provided" }) + .max(100, "Cannot have more than 100 approvers"), + bypassers: z + .discriminatedUnion("type", [ + z.object({ type: z.literal(BypasserType.Group), id: z.string() }), + z.object({ type: z.literal(BypasserType.User), id: z.string().optional(), username: z.string().optional() }) + ]) + .array() + .max(100, "Cannot have more than 100 bypassers") + .optional(), + approvals: z.number().min(1).default(1), + secretPath: z + .string() + .trim() + .min(1, { message: "Secret path cannot be empty" }) + .optional() + .transform((val) => (val ? removeTrailingSlash(val) : undefined)), + enforcementLevel: z.nativeEnum(EnforcementLevel).optional(), + allowedSelfApprovals: z.boolean().default(true), + environments: z.array(z.string()).optional() + }), + response: { + 200: z.object({ + approval: sapPubSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const approval = await server.services.secretApprovalPolicy.updateSecretApprovalPolicy({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + secretPolicyId: req.params.sapId + }); + return { approval }; + } + }); + + server.route({ + url: "/:sapId", + method: "DELETE", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + sapId: z.string() + }), + response: { + 200: z.object({ + approval: sapPubSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const approval = await server.services.secretApprovalPolicy.deleteSecretApprovalPolicy({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + secretPolicyId: req.params.sapId + }); + return { approval }; + } + }); + + server.route({ + url: "/", + method: "GET", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + workspaceId: z.string().trim() + }), + response: { + 200: z.object({ + approvals: sapPubSchema + .extend({ + approvers: z + .object({ + id: z.string().nullable().optional(), + type: z.nativeEnum(ApproverType) + }) + .array(), + bypassers: z + .object({ + id: z.string().nullable().optional(), + type: z.nativeEnum(BypasserType) + }) + .array() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const approvals = await server.services.secretApprovalPolicy.getSecretApprovalPolicyByProjectId({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.query.workspaceId + }); + return { approvals }; + } + }); + + server.route({ + url: "/:sapId", + method: "GET", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + sapId: z.string() + }), + response: { + 200: z.object({ + approval: sapPubSchema.extend({ + approvers: z + .object({ + id: z.string().nullable().optional(), + type: z.nativeEnum(ApproverType), + username: z.string().nullable().optional() + }) + .array(), + bypassers: z + .object({ + id: z.string().nullable().optional(), + type: z.nativeEnum(BypasserType), + username: z.string().nullable().optional() + }) + .array() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const approval = await server.services.secretApprovalPolicy.getSecretApprovalPolicyById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.params + }); + + return { approval }; + } + }); + + server.route({ + url: "/board", + method: "GET", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().transform(removeTrailingSlash) + }), + response: { + 200: z.object({ + policy: sapPubSchema + .extend({ + userApprovers: z.object({ userId: z.string().nullable().optional() }).array() + }) + .optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const policy = await server.services.secretApprovalPolicy.getSecretApprovalPolicyOfFolder({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.query.workspaceId, + ...req.query + }); + return { policy }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 5a43de381..56d450df3 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -5,6 +5,9 @@ import { registerAccessApprovalRequestRouter } from "./access-approval-request-r import { registerAssumePrivilegeRouter } from "./assume-privilege-router"; import { AUDIT_LOG_STREAM_REGISTER_ROUTER_MAP, registerAuditLogStreamRouter } from "./audit-log-stream-routers"; import { registerCaCrlRouter } from "./certificate-authority-crl-router"; +import { registerDeprecatedProjectRoleRouter } from "./deprecated-project-role-router"; +import { registerDeprecatedProjectRouter } from "./deprecated-project-router"; +import { registerDeprecatedSecretApprovalPolicyRouter } from "./deprecated-secret-approval-policy-router"; import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; import { registerKubernetesDynamicSecretLeaseRouter } from "./dynamic-secret-lease-routers/kubernetes-lease-router"; import { registerDynamicSecretRouter } from "./dynamic-secret-router"; @@ -27,7 +30,6 @@ import { registerRateLimitRouter } from "./rate-limit-router"; import { registerRelayRouter } from "./relay-router"; import { registerSamlRouter } from "./saml-router"; import { registerScimRouter } from "./scim-router"; -import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router"; import { registerSecretApprovalRequestRouter } from "./secret-approval-request-router"; import { registerSecretRotationProviderRouter } from "./secret-rotation-provider-router"; import { registerSecretRotationRouter } from "./secret-rotation-router"; @@ -47,18 +49,29 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { // org role starts with organization await server.register(registerOrgRoleRouter, { prefix: "/organization" }); await server.register(registerLicenseRouter, { prefix: "/organizations" }); + + // depreciated in favour of infisical workspace await server.register( async (projectRouter) => { - await projectRouter.register(registerProjectRoleRouter); - await projectRouter.register(registerProjectRouter); - await projectRouter.register(registerTrustedIpRouter); - await projectRouter.register(registerAssumePrivilegeRouter); + await projectRouter.register(registerDeprecatedProjectRoleRouter); + await projectRouter.register(registerDeprecatedProjectRouter); }, { prefix: "/workspace" } ); + + await server.register( + async (projectRouter) => { + await projectRouter.register(registerProjectRoleRouter); + await projectRouter.register(registerTrustedIpRouter); + await projectRouter.register(registerAssumePrivilegeRouter); + await projectRouter.register(registerProjectRouter); + }, + { prefix: "/projects" } + ); + await server.register(registerSnapshotRouter, { prefix: "/secret-snapshot" }); await server.register(registerPITRouter, { prefix: "/pit" }); - await server.register(registerSecretApprovalPolicyRouter, { prefix: "/secret-approvals" }); + await server.register(registerDeprecatedSecretApprovalPolicyRouter, { prefix: "/secret-approvals" }); await server.register(registerSecretApprovalRequestRouter, { prefix: "/secret-approval-requests" }); diff --git a/backend/src/ee/routes/v1/org-role-router.ts b/backend/src/ee/routes/v1/org-role-router.ts index c8ee03a99..070462d47 100644 --- a/backend/src/ee/routes/v1/org-role-router.ts +++ b/backend/src/ee/routes/v1/org-role-router.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { OrgMembershipRole, OrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -42,6 +43,22 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { req.permission.authMethod, req.permission.orgId ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.CREATE_ORG_ROLE, + metadata: { + roleId: role.id, + slug: req.body.slug, + name: req.body.name, + description: req.body.description, + permissions: JSON.stringify(req.body.permissions) + } + } + }); + return { role }; } }); @@ -116,6 +133,22 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { req.permission.authMethod, req.permission.orgId ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.UPDATE_ORG_ROLE, + metadata: { + roleId: role.id, + slug: req.body.slug, + name: req.body.name, + description: req.body.description, + permissions: req.body.permissions ? JSON.stringify(req.body.permissions) : undefined + } + } + }); + return { role }; } }); @@ -146,6 +179,16 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { req.permission.authMethod, req.permission.orgId ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.DELETE_ORG_ROLE, + metadata: { roleId: role.id, slug: role.slug, name: role.name } + } + }); + return { role }; } }); diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index 949d4cf7e..5a20ad893 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -2,26 +2,27 @@ import { packRules } from "@casl/ability/extra"; import { z } from "zod"; import { ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas"; -import { - backfillPermissionV1SchemaToV2Schema, - ProjectPermissionV1Schema -} from "@app/ee/services/permission/project-permission"; -import { PROJECT_ROLE } from "@app/lib/api-docs"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { checkForInvalidPermissionCombination } from "@app/ee/services/permission/permission-fns"; +import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission"; +import { ApiDocsTags, PROJECT_ROLE } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { SanitizedRoleSchemaV1 } from "@app/server/routes/sanitizedSchemas"; +import { SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; import { ProjectRoleServiceIdentifierType } from "@app/services/project-role/project-role-types"; export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/:projectSlug/roles", + url: "/:projectId/roles", config: { rateLimit: writeLimit }, schema: { + hide: false, + tags: [ApiDocsTags.ProjectRoles], description: "Create a project role", security: [ { @@ -29,10 +30,10 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - projectSlug: z.string().trim().describe(PROJECT_ROLE.CREATE.projectSlug) + projectId: z.string().trim().describe(PROJECT_ROLE.CREATE.projectId) }), body: z.object({ - slug: slugSchema({ max: 64 }) + slug: slugSchema({ min: 1, max: 64 }) .refine( (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), "Please choose a different slug, the slug you have entered is reserved" @@ -40,28 +41,48 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { .describe(PROJECT_ROLE.CREATE.slug), name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name), description: z.string().trim().nullish().describe(PROJECT_ROLE.CREATE.description), - permissions: ProjectPermissionV1Schema.array().describe(PROJECT_ROLE.CREATE.permissions) + permissions: ProjectPermissionV2Schema.array() + .describe(PROJECT_ROLE.CREATE.permissions) + .refine(checkForInvalidPermissionCombination) }), response: { 200: z.object({ - role: SanitizedRoleSchemaV1 + role: SanitizedRoleSchema }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const stringifiedPermissions = JSON.stringify(packRules(req.body.permissions)); + const role = await server.services.projectRole.createRole({ actorAuthMethod: req.permission.authMethod, actorId: req.permission.id, actorOrgId: req.permission.orgId, actor: req.permission.type, filter: { - type: ProjectRoleServiceIdentifierType.SLUG, - projectSlug: req.params.projectSlug + type: ProjectRoleServiceIdentifierType.ID, + projectId: req.params.projectId }, data: { ...req.body, - permissions: JSON.stringify(packRules(backfillPermissionV1SchemaToV2Schema(req.body.permissions, true))) + permissions: stringifiedPermissions + } + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: role.projectId, + event: { + type: EventType.CREATE_PROJECT_ROLE, + metadata: { + roleId: role.id, + slug: req.body.slug, + name: req.body.name, + description: req.body.description, + permissions: stringifiedPermissions + } } }); @@ -71,11 +92,13 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "PATCH", - url: "/:projectSlug/roles/:roleId", + url: "/:projectId/roles/:roleId", config: { rateLimit: writeLimit }, schema: { + hide: false, + tags: [ApiDocsTags.ProjectRoles], description: "Update a project role", security: [ { @@ -83,29 +106,33 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - projectSlug: z.string().trim().describe(PROJECT_ROLE.UPDATE.projectSlug), + projectId: z.string().trim().describe(PROJECT_ROLE.UPDATE.projectId), roleId: z.string().trim().describe(PROJECT_ROLE.UPDATE.roleId) }), body: z.object({ - slug: slugSchema({ max: 64 }) + slug: slugSchema({ min: 1, max: 64 }) .refine( (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), "Please choose a different slug, the slug you have entered is reserved" ) - .describe(PROJECT_ROLE.UPDATE.slug) - .optional(), + .optional() + .describe(PROJECT_ROLE.UPDATE.slug), name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), description: z.string().trim().nullish().describe(PROJECT_ROLE.UPDATE.description), - permissions: ProjectPermissionV1Schema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional() + permissions: ProjectPermissionV2Schema.array() + .describe(PROJECT_ROLE.UPDATE.permissions) + .optional() + .superRefine(checkForInvalidPermissionCombination) }), response: { 200: z.object({ - role: SanitizedRoleSchemaV1 + role: SanitizedRoleSchema }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const stringifiedPermissions = req.body.permissions ? JSON.stringify(packRules(req.body.permissions)) : undefined; const role = await server.services.projectRole.updateRole({ actorAuthMethod: req.permission.authMethod, actorId: req.permission.id, @@ -114,22 +141,39 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { roleId: req.params.roleId, data: { ...req.body, - permissions: req.body.permissions - ? JSON.stringify(packRules(backfillPermissionV1SchemaToV2Schema(req.body.permissions, true))) - : undefined + permissions: stringifiedPermissions } }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: role.projectId, + event: { + type: EventType.UPDATE_PROJECT_ROLE, + metadata: { + roleId: role.id, + slug: req.body.slug, + name: req.body.name, + description: req.body.description, + permissions: stringifiedPermissions + } + } + }); + return { role }; } }); server.route({ method: "DELETE", - url: "/:projectSlug/roles/:roleId", + url: "/:projectId/roles/:roleId", config: { rateLimit: writeLimit }, schema: { + hide: false, + tags: [ApiDocsTags.ProjectRoles], description: "Delete a project role", security: [ { @@ -137,12 +181,12 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - projectSlug: z.string().trim().describe(PROJECT_ROLE.DELETE.projectSlug), + projectId: z.string().trim().describe(PROJECT_ROLE.DELETE.projectId), roleId: z.string().trim().describe(PROJECT_ROLE.DELETE.roleId) }), response: { 200: z.object({ - role: SanitizedRoleSchemaV1 + role: SanitizedRoleSchema }) } }, @@ -155,17 +199,34 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { actor: req.permission.type, roleId: req.params.roleId }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: role.projectId, + event: { + type: EventType.DELETE_PROJECT_ROLE, + metadata: { + roleId: role.id, + slug: role.slug, + name: role.name + } + } + }); + return { role }; } }); server.route({ method: "GET", - url: "/:projectSlug/roles", + url: "/:projectId/roles", config: { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.ProjectRoles], description: "List project role", security: [ { @@ -173,7 +234,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - projectSlug: z.string().trim().describe(PROJECT_ROLE.LIST.projectSlug) + projectId: z.string().trim().describe(PROJECT_ROLE.LIST.projectId) }), response: { 200: z.object({ @@ -189,8 +250,8 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { actorOrgId: req.permission.orgId, actor: req.permission.type, filter: { - type: ProjectRoleServiceIdentifierType.SLUG, - projectSlug: req.params.projectSlug + type: ProjectRoleServiceIdentifierType.ID, + projectId: req.params.projectId } }); return { roles }; @@ -199,18 +260,20 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/:projectSlug/roles/slug/:slug", + url: "/:projectId/roles/slug/:roleSlug", config: { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.ProjectRoles], params: z.object({ - projectSlug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.projectSlug), - slug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.roleSlug) + projectId: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.projectId), + roleSlug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.roleSlug) }), response: { 200: z.object({ - role: SanitizedRoleSchemaV1.omit({ version: true }) + role: SanitizedRoleSchema.omit({ version: true }) }) } }, @@ -222,12 +285,11 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { actorOrgId: req.permission.orgId, actor: req.permission.type, filter: { - type: ProjectRoleServiceIdentifierType.SLUG, - projectSlug: req.params.projectSlug + type: ProjectRoleServiceIdentifierType.ID, + projectId: req.params.projectId }, - roleSlug: req.params.slug + roleSlug: req.params.roleSlug }); - return { role }; } }); diff --git a/backend/src/ee/routes/v1/project-router.ts b/backend/src/ee/routes/v1/project-router.ts index 8d8cc4817..4988565f8 100644 --- a/backend/src/ee/routes/v1/project-router.ts +++ b/backend/src/ee/routes/v1/project-router.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -import { AuditLogsSchema, SecretSnapshotsSchema } from "@app/db/schemas"; -import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; -import { ApiDocsTags, AUDIT_LOGS, PROJECTS } from "@app/lib/api-docs"; -import { getLastMidnightDateISO, removeTrailingSlash } from "@app/lib/fn"; +import { SecretSnapshotsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, PROJECTS } from "@app/lib/api-docs"; +import { removeTrailingSlash } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -12,7 +12,7 @@ import { KmsType } from "@app/services/kms/kms-types"; export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/:workspaceId/secret-snapshots", + url: "/:projectId/secret-snapshots", config: { rateLimit: readLimit }, @@ -26,7 +26,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - workspaceId: z.string().trim().describe(PROJECTS.GET_SNAPSHOTS.workspaceId) + projectId: z.string().trim().describe(PROJECTS.GET_SNAPSHOTS.projectId) }), querystring: z.object({ environment: z.string().trim().describe(PROJECTS.GET_SNAPSHOTS.environment), @@ -47,7 +47,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorAuthMethod: req.permission.authMethod, actorId: req.permission.id, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, + projectId: req.params.projectId, ...req.query }); return { secretSnapshots }; @@ -56,13 +56,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/:workspaceId/secret-snapshots/count", + url: "/:projectId/secret-snapshots/count", config: { rateLimit: readLimit }, schema: { params: z.object({ - workspaceId: z.string().trim() + projectId: z.string().trim() }), querystring: z.object({ environment: z.string().trim(), @@ -81,7 +81,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, + projectId: req.params.projectId, environment: req.query.environment, path: req.query.path }); @@ -89,140 +89,15 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); - /* - * Daniel: This endpoint is no longer is use. - * We are keeping it for now because it has been exposed in our public api docs for a while, so by removing it we are likely to break users workflows. - * - * Please refer to the new endpoint, GET /api/v1/organization/audit-logs, for the same (and more) functionality. - */ server.route({ method: "GET", - url: "/:workspaceId/audit-logs", - config: { - rateLimit: readLimit - }, - schema: { - description: "Return audit logs", - security: [ - { - bearerAuth: [] - } - ], - params: z.object({ - workspaceId: z.string().trim().describe(AUDIT_LOGS.EXPORT.projectId) - }), - querystring: z - .object({ - eventType: z.nativeEnum(EventType).optional().describe(AUDIT_LOGS.EXPORT.eventType), - userAgentType: z.nativeEnum(UserAgentType).optional().describe(AUDIT_LOGS.EXPORT.userAgentType), - startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate), - endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate), - offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset), - limit: z.coerce.number().max(1000).default(20).describe(AUDIT_LOGS.EXPORT.limit), - actor: z.string().optional().describe(AUDIT_LOGS.EXPORT.actor) - }) - .superRefine((el, ctx) => { - if (el.endDate && el.startDate) { - const startDate = new Date(el.startDate); - const endDate = new Date(el.endDate); - const maxAllowedDate = new Date(startDate); - maxAllowedDate.setMonth(maxAllowedDate.getMonth() + 3); - if (endDate < startDate) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["endDate"], - message: "End date cannot be before start date" - }); - } - if (endDate > maxAllowedDate) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["endDate"], - message: "Dates must be within 3 months" - }); - } - } - }), - response: { - 200: z.object({ - auditLogs: AuditLogsSchema.omit({ - eventMetadata: true, - eventType: true, - actor: true, - actorMetadata: true - }) - .merge( - z.object({ - project: z - .object({ - name: z.string(), - slug: z.string() - }) - .optional(), - event: z.object({ - type: z.string(), - metadata: z.any() - }), - actor: z.object({ - type: z.string(), - metadata: z.any() - }) - }) - ) - .array() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const auditLogs = await server.services.auditLog.listAuditLogs({ - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - actor: req.permission.type, - - filter: { - ...req.query, - projectId: req.params.workspaceId, - endDate: req.query.endDate || new Date().toISOString(), - startDate: req.query.startDate || getLastMidnightDateISO(), - auditLogActorId: req.query.actor, - eventType: req.query.eventType ? [req.query.eventType] : undefined - } - }); - return { auditLogs }; - } - }); - - server.route({ - method: "GET", - url: "/:workspaceId/audit-logs/filters/actors", + url: "/:projectId/kms", config: { rateLimit: readLimit }, schema: { params: z.object({ - workspaceId: z.string().trim() - }), - response: { - 200: z.object({ - actors: z.string().array() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async () => ({ actors: [] }) - }); - - server.route({ - method: "GET", - url: "/:workspaceId/kms", - config: { - rateLimit: readLimit - }, - schema: { - params: z.object({ - workspaceId: z.string().trim() + projectId: z.string().trim() }), response: { 200: z.object({ @@ -241,7 +116,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId + projectId: req.params.projectId }); return kmsKey; @@ -250,13 +125,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "PATCH", - url: "/:workspaceId/kms", + url: "/:projectId/kms", config: { rateLimit: writeLimit }, schema: { params: z.object({ - workspaceId: z.string().trim() + projectId: z.string().trim() }), body: z.object({ kms: z.discriminatedUnion("type", [ @@ -281,13 +156,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, + projectId: req.params.projectId, ...req.body }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.params.workspaceId, + projectId: req.params.projectId, event: { type: EventType.UPDATE_PROJECT_KMS, metadata: { @@ -307,13 +182,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/:workspaceId/kms/backup", + url: "/:projectId/kms/backup", config: { rateLimit: readLimit }, schema: { params: z.object({ - workspaceId: z.string().trim() + projectId: z.string().trim() }), response: { 200: z.object({ @@ -328,12 +203,12 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId + projectId: req.params.projectId }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.params.workspaceId, + projectId: req.params.projectId, event: { type: EventType.GET_PROJECT_KMS_BACKUP, metadata: {} @@ -346,13 +221,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/:workspaceId/kms/backup", + url: "/:projectId/kms/backup", config: { rateLimit: writeLimit }, schema: { params: z.object({ - workspaceId: z.string().trim() + projectId: z.string().trim() }), body: z.object({ backup: z.string().min(1) @@ -374,13 +249,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, + projectId: req.params.projectId, backup: req.body.backup }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.params.workspaceId, + projectId: req.params.projectId, event: { type: EventType.LOAD_PROJECT_KMS_BACKUP, metadata: {} @@ -393,13 +268,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/:workspaceId/migrate-v3", + url: "/:projectId/migrate-v3", config: { rateLimit: writeLimit }, schema: { params: z.object({ - workspaceId: z.string().trim() + projectId: z.string().trim() }), response: { @@ -415,7 +290,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId + projectId: req.params.projectId }); return migration; diff --git a/backend/src/ee/routes/v1/relay-router.ts b/backend/src/ee/routes/v1/relay-router.ts index e20480088..f3d006b10 100644 --- a/backend/src/ee/routes/v1/relay-router.ts +++ b/backend/src/ee/routes/v1/relay-router.ts @@ -1,9 +1,10 @@ import { z } from "zod"; +import { RelaysSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; -import { writeLimit } from "@app/server/config/rateLimiter"; +import { UnauthorizedError } from "@app/lib/errors"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -89,14 +90,59 @@ export const registerRelayRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - throw new BadRequestError({ - message: "Org relay registration is not yet supported" - }); - return server.services.relay.registerRelay({ ...req.body, identityId: req.permission.id, - orgId: req.permission.orgId + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + } + }); + + server.route({ + method: "GET", + url: "/", + schema: { + response: { + 200: RelaysSchema.array() + } + }, + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + return server.services.relay.getRelays({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + } + }); + + server.route({ + method: "DELETE", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + id: z.string() + }), + response: { + 200: RelaysSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + return server.services.relay.deleteRelay({ + id: req.params.id, + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId }); } }); diff --git a/backend/src/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index f8e371d01..76bff60e8 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -286,7 +286,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { entryPoint: z.string(), issuer: z.string(), cert: z.string(), - lastUsed: z.date().nullable().optional() + lastUsed: z.date().nullable().optional(), + enableGroupSync: z.boolean().optional() }) } }, @@ -325,14 +326,15 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { isActive: z.boolean().describe(SamlSso.CREATE_CONFIG.isActive), entryPoint: z.string().trim().describe(SamlSso.CREATE_CONFIG.entryPoint), issuer: z.string().trim().describe(SamlSso.CREATE_CONFIG.issuer), - cert: z.string().trim().describe(SamlSso.CREATE_CONFIG.cert) + cert: z.string().trim().describe(SamlSso.CREATE_CONFIG.cert), + enableGroupSync: z.boolean().optional().describe(SamlSso.CREATE_CONFIG.enableGroupSync) }), response: { 200: SanitizedSamlConfigSchema } }, handler: async (req) => { - const { isActive, authProvider, issuer, entryPoint, cert } = req.body; + const { isActive, authProvider, issuer, entryPoint, cert, enableGroupSync } = req.body; const { permission } = req; return server.services.saml.createSamlCfg({ @@ -341,6 +343,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { issuer, entryPoint, idpCert: cert, + enableGroupSync, actor: permission.type, actorId: permission.id, actorAuthMethod: permission.authMethod, @@ -372,7 +375,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { isActive: z.boolean().describe(SamlSso.UPDATE_CONFIG.isActive), entryPoint: z.string().trim().describe(SamlSso.UPDATE_CONFIG.entryPoint), issuer: z.string().trim().describe(SamlSso.UPDATE_CONFIG.issuer), - cert: z.string().trim().describe(SamlSso.UPDATE_CONFIG.cert) + cert: z.string().trim().describe(SamlSso.UPDATE_CONFIG.cert), + enableGroupSync: z.boolean().optional().describe(SamlSso.UPDATE_CONFIG.enableGroupSync) }) .partial() .merge(z.object({ organizationId: z.string().trim().describe(SamlSso.UPDATE_CONFIG.organizationId) })), @@ -381,7 +385,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { isActive, authProvider, issuer, entryPoint, cert } = req.body; + const { isActive, authProvider, issuer, entryPoint, cert, enableGroupSync } = req.body; const { permission } = req; return server.services.saml.updateSamlCfg({ @@ -390,6 +394,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { issuer, entryPoint, idpCert: cert, + enableGroupSync, actor: permission.type, actorId: permission.id, actorAuthMethod: permission.authMethod, diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index bdc9c2dcd..78d9aeddc 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -27,7 +27,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv }, schema: { querystring: z.object({ - workspaceId: z.string().trim(), + projectId: z.string().trim(), environment: z.string().trim().optional(), committer: z.string().trim().optional(), search: z.string().trim().optional(), @@ -80,7 +80,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, ...req.query, - projectId: req.query.workspaceId + projectId: req.query.projectId }); return { approvals, totalCount }; } @@ -94,7 +94,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv }, schema: { querystring: z.object({ - workspaceId: z.string().trim(), + projectId: z.string().trim(), policyId: z.string().trim().optional() }), response: { @@ -113,7 +113,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.query.workspaceId, + projectId: req.query.projectId, policyId: req.query.policyId }); return { approvals }; @@ -320,10 +320,20 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv .array(), secretPath: z.string(), commits: secretRawSchema - .omit({ _id: true, environment: true, workspace: true, type: true, version: true, secretValue: true }) + .omit({ + _id: true, + environment: true, + workspace: true, + type: true, + version: true, + secretValue: true, + secretComment: true + }) .extend({ secretValueHidden: z.boolean(), secretValue: z.string().optional(), + secretComment: z.string().optional(), + skipMultilineEncoding: z.boolean().nullish(), isRotatedSecret: z.boolean().optional(), op: z.string(), tags: SanitizedTagSchema.array().optional(), @@ -348,7 +358,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv secretValueHidden: z.boolean(), secretComment: z.string().optional(), tags: SanitizedTagSchema.array().optional(), - secretMetadata: ResourceMetadataSchema.nullish() + secretMetadata: ResourceMetadataSchema.nullish(), + skipMultilineEncoding: z.boolean().nullish() }) .optional() }) diff --git a/backend/src/ee/routes/v1/secret-router.ts b/backend/src/ee/routes/v1/secret-router.ts index a964eb1b8..b9eeb7729 100644 --- a/backend/src/ee/routes/v1/secret-router.ts +++ b/backend/src/ee/routes/v1/secret-router.ts @@ -34,7 +34,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretName: z.string().trim().describe(RAW_SECRETS.GET_ACCESS_LIST.secretName) }), querystring: z.object({ - workspaceId: z.string().trim().describe(RAW_SECRETS.GET_ACCESS_LIST.workspaceId), + projectId: z.string().trim().describe(RAW_SECRETS.GET_ACCESS_LIST.projectId), environment: z.string().trim().describe(RAW_SECRETS.GET_ACCESS_LIST.environment), secretPath: z .string() @@ -54,7 +54,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { const { secretName } = req.params; - const { secretPath, environment, workspaceId: projectId } = req.query; + const { secretPath, environment, projectId } = req.query; return server.services.secret.getSecretAccessList({ actorId: req.permission.id, diff --git a/backend/src/ee/routes/v1/trusted-ip-router.ts b/backend/src/ee/routes/v1/trusted-ip-router.ts index b6fc3cc90..5416e8613 100644 --- a/backend/src/ee/routes/v1/trusted-ip-router.ts +++ b/backend/src/ee/routes/v1/trusted-ip-router.ts @@ -9,13 +9,13 @@ import { AuthMode } from "@app/services/auth/auth-type"; export const registerTrustedIpRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/:workspaceId/trusted-ips", + url: "/:projectId/trusted-ips", config: { rateLimit: readLimit }, schema: { params: z.object({ - workspaceId: z.string().trim() + projectId: z.string().trim() }), response: { 200: z.object({ @@ -27,7 +27,7 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => { handler: async (req) => { const trustedIps = await server.services.trustedIp.listIpsByProjectId({ actorAuthMethod: req.permission.authMethod, - projectId: req.params.workspaceId, + projectId: req.params.projectId, actor: req.permission.type, actorId: req.permission.id, actorOrgId: req.permission.orgId @@ -38,13 +38,13 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/:workspaceId/trusted-ips", + url: "/:projectId/trusted-ips", config: { rateLimit: writeLimit }, schema: { params: z.object({ - workspaceId: z.string().trim() + projectId: z.string().trim() }), body: z.object({ ipAddress: z.string().trim(), @@ -61,7 +61,7 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => { handler: async (req) => { const { trustedIp, project } = await server.services.trustedIp.addProjectIp({ actorAuthMethod: req.permission.authMethod, - projectId: req.params.workspaceId, + projectId: req.params.projectId, actor: req.permission.type, actorId: req.permission.id, actorOrgId: req.permission.orgId, @@ -86,13 +86,13 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => { server.route({ method: "PATCH", - url: "/:workspaceId/trusted-ips/:trustedIpId", + url: "/:projectId/trusted-ips/:trustedIpId", config: { rateLimit: writeLimit }, schema: { params: z.object({ - workspaceId: z.string().trim(), + projectId: z.string().trim(), trustedIpId: z.string().trim() }), body: z.object({ @@ -108,7 +108,7 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { const { trustedIp, project } = await server.services.trustedIp.updateProjectIp({ - projectId: req.params.workspaceId, + projectId: req.params.projectId, actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -135,13 +135,13 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => { server.route({ method: "DELETE", - url: "/:workspaceId/trusted-ips/:trustedIpId", + url: "/:projectId/trusted-ips/:trustedIpId", config: { rateLimit: writeLimit }, schema: { params: z.object({ - workspaceId: z.string().trim(), + projectId: z.string().trim(), trustedIpId: z.string().trim() }), response: { @@ -153,7 +153,7 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { const { trustedIp, project } = await server.services.trustedIp.deleteProjectIp({ - projectId: req.params.workspaceId, + projectId: req.params.projectId, actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, diff --git a/backend/src/ee/routes/v2/project-role-router.ts b/backend/src/ee/routes/v2/deprecated-project-role-router.ts similarity index 81% rename from backend/src/ee/routes/v2/project-role-router.ts rename to backend/src/ee/routes/v2/deprecated-project-role-router.ts index 538929316..326bda06a 100644 --- a/backend/src/ee/routes/v2/project-role-router.ts +++ b/backend/src/ee/routes/v2/deprecated-project-role-router.ts @@ -2,6 +2,7 @@ import { packRules } from "@casl/ability/extra"; import { z } from "zod"; import { ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { checkForInvalidPermissionCombination } from "@app/ee/services/permission/permission-fns"; import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission"; import { ApiDocsTags, PROJECT_ROLE } from "@app/lib/api-docs"; @@ -12,7 +13,7 @@ import { SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; import { ProjectRoleServiceIdentifierType } from "@app/services/project-role/project-role-types"; -export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { +export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", url: "/:projectId/roles", @@ -52,6 +53,8 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const stringifiedPermissions = JSON.stringify(packRules(req.body.permissions)); + const role = await server.services.projectRole.createRole({ actorAuthMethod: req.permission.authMethod, actorId: req.permission.id, @@ -63,9 +66,26 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { }, data: { ...req.body, - permissions: JSON.stringify(packRules(req.body.permissions)) + permissions: stringifiedPermissions } }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: role.projectId, + event: { + type: EventType.CREATE_PROJECT_ROLE, + metadata: { + roleId: role.id, + slug: req.body.slug, + name: req.body.name, + description: req.body.description, + permissions: stringifiedPermissions + } + } + }); + return { role }; } }); @@ -112,6 +132,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const stringifiedPermissions = req.body.permissions ? JSON.stringify(packRules(req.body.permissions)) : undefined; const role = await server.services.projectRole.updateRole({ actorAuthMethod: req.permission.authMethod, actorId: req.permission.id, @@ -120,9 +141,26 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { roleId: req.params.roleId, data: { ...req.body, - permissions: req.body.permissions ? JSON.stringify(packRules(req.body.permissions)) : undefined + permissions: stringifiedPermissions } }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: role.projectId, + event: { + type: EventType.UPDATE_PROJECT_ROLE, + metadata: { + roleId: role.id, + slug: req.body.slug, + name: req.body.name, + description: req.body.description, + permissions: stringifiedPermissions + } + } + }); + return { role }; } }); @@ -161,6 +199,21 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { actor: req.permission.type, roleId: req.params.roleId }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: role.projectId, + event: { + type: EventType.DELETE_PROJECT_ROLE, + metadata: { + roleId: role.id, + slug: role.slug, + name: role.name + } + } + }); + return { role }; } }); diff --git a/backend/src/ee/routes/v2/index.ts b/backend/src/ee/routes/v2/index.ts index e082773dd..c402ab00a 100644 --- a/backend/src/ee/routes/v2/index.ts +++ b/backend/src/ee/routes/v2/index.ts @@ -7,15 +7,16 @@ import { SECRET_SCANNING_REGISTER_ROUTER_MAP } from "@app/ee/routes/v2/secret-scanning-v2-routers"; +import { registerDeprecatedProjectRoleRouter } from "./deprecated-project-role-router"; import { registerGatewayV2Router } from "./gateway-router"; import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router"; -import { registerProjectRoleRouter } from "./project-role-router"; +import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router"; export const registerV2EERoutes = async (server: FastifyZodProvider) => { - // org role starts with organization await server.register( async (projectRouter) => { - await projectRouter.register(registerProjectRoleRouter); + // this has been depreciated and moved to /api/v1/projects + await projectRouter.register(registerDeprecatedProjectRoleRouter); }, { prefix: "/workspace" } ); @@ -26,6 +27,8 @@ export const registerV2EERoutes = async (server: FastifyZodProvider) => { await server.register(registerGatewayV2Router, { prefix: "/gateways" }); + await server.register(registerSecretApprovalPolicyRouter, { prefix: "/secret-approvals" }); + await server.register( async (secretRotationV2Router) => { // register generic secret rotation endpoints diff --git a/backend/src/ee/routes/v1/secret-approval-policy-router.ts b/backend/src/ee/routes/v2/secret-approval-policy-router.ts similarity index 97% rename from backend/src/ee/routes/v1/secret-approval-policy-router.ts rename to backend/src/ee/routes/v2/secret-approval-policy-router.ts index dc87b83f2..f7f770197 100644 --- a/backend/src/ee/routes/v1/secret-approval-policy-router.ts +++ b/backend/src/ee/routes/v2/secret-approval-policy-router.ts @@ -19,7 +19,7 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi schema: { body: z .object({ - workspaceId: z.string(), + projectId: z.string(), name: z.string().optional(), environment: z.string().optional(), environments: z.string().array().optional(), @@ -69,7 +69,6 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.body.workspaceId, ...req.body, name: req.body.name ?? `${req.body.environment || req.body.environments?.join(",")}-${nanoid(3)}`, enforcementLevel: req.body.enforcementLevel @@ -174,7 +173,7 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi }, schema: { querystring: z.object({ - workspaceId: z.string().trim() + projectId: z.string().trim() }), response: { 200: z.object({ @@ -204,7 +203,7 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.query.workspaceId + projectId: req.query.projectId }); return { approvals }; } @@ -263,7 +262,7 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi }, schema: { querystring: z.object({ - workspaceId: z.string().trim(), + projectId: z.string().trim(), environment: z.string().trim(), secretPath: z.string().trim().transform(removeTrailingSlash) }), @@ -284,7 +283,6 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.query.workspaceId, ...req.query }); return { policy }; diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts index 5f8dea5d7..8d1702850 100644 --- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts @@ -9,6 +9,7 @@ import { registerMySqlCredentialsRotationRouter } from "./mysql-credentials-rota import { registerOktaClientSecretRotationRouter } from "./okta-client-secret-rotation-router"; import { registerOracleDBCredentialsRotationRouter } from "./oracledb-credentials-rotation-router"; import { registerPostgresCredentialsRotationRouter } from "./postgres-credentials-rotation-router"; +import { registerRedisCredentialsRotationRouter } from "./redis-credentials-rotation-router"; export * from "./secret-rotation-v2-router"; @@ -24,5 +25,6 @@ export const SECRET_ROTATION_REGISTER_ROUTER_MAP: Record< [SecretRotation.AzureClientSecret]: registerAzureClientSecretRotationRouter, [SecretRotation.AwsIamUserSecret]: registerAwsIamUserSecretRotationRouter, [SecretRotation.LdapPassword]: registerLdapPasswordRotationRouter, - [SecretRotation.OktaClientSecret]: registerOktaClientSecretRotationRouter + [SecretRotation.OktaClientSecret]: registerOktaClientSecretRotationRouter, + [SecretRotation.RedisCredentials]: registerRedisCredentialsRotationRouter }; diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/redis-credentials-rotation-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/redis-credentials-rotation-router.ts new file mode 100644 index 000000000..b83cec52c --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/redis-credentials-rotation-router.ts @@ -0,0 +1,19 @@ +import { + CreateRedisCredentialsRotationSchema, + RedisCredentialsRotationGeneratedCredentialsSchema, + RedisCredentialsRotationSchema, + UpdateRedisCredentialsRotationSchema +} from "@app/ee/services/secret-rotation-v2/redis-credentials"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; + +import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints"; + +export const registerRedisCredentialsRotationRouter = async (server: FastifyZodProvider) => + registerSecretRotationEndpoints({ + type: SecretRotation.RedisCredentials, + server, + responseSchema: RedisCredentialsRotationSchema, + createSchema: CreateRedisCredentialsRotationSchema, + updateSchema: UpdateRedisCredentialsRotationSchema, + generatedCredentialsSchema: RedisCredentialsRotationGeneratedCredentialsSchema + }); diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts index 7db99c8c4..6ea6497e4 100644 --- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts @@ -10,6 +10,7 @@ import { MySqlCredentialsRotationListItemSchema } from "@app/ee/services/secret- import { OktaClientSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/okta-client-secret"; import { OracleDBCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/oracledb-credentials"; import { PostgresCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; +import { RedisCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/redis-credentials"; import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema"; import { ApiDocsTags, SecretRotations } from "@app/lib/api-docs"; import { readLimit } from "@app/server/config/rateLimiter"; @@ -25,7 +26,8 @@ const SecretRotationV2OptionsSchema = z.discriminatedUnion("type", [ AzureClientSecretRotationListItemSchema, AwsIamUserSecretRotationListItemSchema, LdapPasswordRotationListItemSchema, - OktaClientSecretRotationListItemSchema + OktaClientSecretRotationListItemSchema, + RedisCredentialsRotationListItemSchema ]); export const registerSecretRotationV2Router = async (server: FastifyZodProvider) => { diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 008b61919..d69c6da79 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -35,7 +35,7 @@ import { ApprovalStatus, TAccessApprovalRequestServiceFactory } from "./access-a type TSecretApprovalRequestServiceFactoryDep = { additionalPrivilegeDAL: Pick; - permissionService: Pick; + permissionService: Pick; accessApprovalPolicyApproverDAL: Pick; projectEnvDAL: Pick; projectDAL: Pick< @@ -758,6 +758,8 @@ export const accessApprovalRequestServiceFactory = ({ { privilegeId: privilegeIdToSet, status: ApprovalStatus.APPROVED }, tx ); + + await permissionService.invalidateProjectPermissionCache(accessApprovalRequest.projectId, tx); } } @@ -777,6 +779,20 @@ export const accessApprovalRequestServiceFactory = ({ .map((appUser) => appUser.email) .filter((email): email is string => !!email); + const approvalPath = `/projects/secret-management/${project.id}/approval`; + const approvalUrl = `${cfg.SITE_URL}${approvalPath}`; + + await notificationService.createUserNotifications( + approverUsersForEmail.map((approver) => ({ + userId: approver.id, + orgId: actorOrgId, + type: NotificationType.ACCESS_POLICY_BYPASSED, + title: "Secret Access Policy Bypassed", + body: `**${actingUser.firstName} ${actingUser.lastName}** (${actingUser.email}) has accessed a secret in **${policy.secretPath || "/"}** in the **${environment?.name || permissionEnvironment}** environment for project **${project.name}** without obtaining the required approval.`, + link: approvalPath + })) + ); + if (recipientEmails.length > 0) { await smtpService.sendMail({ recipients: recipientEmails, @@ -788,7 +804,7 @@ export const accessApprovalRequestServiceFactory = ({ bypassReason: bypassReason || "No reason provided", secretPath: policy.secretPath || "/", environment: environment?.name || permissionEnvironment, - approvalUrl: `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval`, + approvalUrl, requestType: "access" }, template: SmtpTemplates.AccessSecretRequestBypassed diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 10c84cc4a..ab72dbc65 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -146,7 +146,7 @@ export enum EventType { MOVE_SECRETS = "move-secrets", DELETE_SECRET = "delete-secret", DELETE_SECRETS = "delete-secrets", - GET_WORKSPACE_KEY = "get-workspace-key", + GET_PROJECT_KEY = "get-project-key", AUTHORIZE_INTEGRATION = "authorize-integration", UPDATE_INTEGRATION_AUTH = "update-integration-auth", UNAUTHORIZE_INTEGRATION = "unauthorize-integration", @@ -199,6 +199,7 @@ export enum EventType { CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts", + CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS = "clear-identity-ldap-auth-lockouts", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID = "get-identity-universal-auth-client-secret-by-id", @@ -249,9 +250,9 @@ export enum EventType { UPDATE_ENVIRONMENT = "update-environment", DELETE_ENVIRONMENT = "delete-environment", GET_ENVIRONMENT = "get-environment", - ADD_WORKSPACE_MEMBER = "add-workspace-member", - ADD_BATCH_WORKSPACE_MEMBER = "add-workspace-members", - REMOVE_WORKSPACE_MEMBER = "remove-workspace-member", + ADD_PROJECT_MEMBER = "add-project-member", + ADD_BATCH_PROJECT_MEMBER = "add-project-members", + REMOVE_PROJECT_MEMBER = "remove-project-member", CREATE_FOLDER = "create-folder", UPDATE_FOLDER = "update-folder", DELETE_FOLDER = "delete-folder", @@ -264,8 +265,8 @@ export enum EventType { CREATE_SECRET_IMPORT = "create-secret-import", UPDATE_SECRET_IMPORT = "update-secret-import", DELETE_SECRET_IMPORT = "delete-secret-import", - UPDATE_USER_WORKSPACE_ROLE = "update-user-workspace-role", - UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions", + UPDATE_USER_PROJECT_ROLE = "update-user-project-role", + UPDATE_USER_PROJECT_DENIED_PERMISSIONS = "update-user-project-denied-permissions", SECRET_APPROVAL_MERGED = "secret-approval-merged", SECRET_APPROVAL_REQUEST = "secret-approval-request", SECRET_APPROVAL_CLOSED = "secret-approval-closed", @@ -392,6 +393,8 @@ export enum EventType { CREATE_APP_CONNECTION = "create-app-connection", UPDATE_APP_CONNECTION = "update-app-connection", DELETE_APP_CONNECTION = "delete-app-connection", + GET_APP_CONNECTION_USAGE = "get-app-connection-usage", + MIGRATE_APP_CONNECTION = "migrate-app-connection", CREATE_SHARED_SECRET = "create-shared-secret", CREATE_SECRET_REQUEST = "create-secret-request", DELETE_SHARED_SECRET = "delete-shared-secret", @@ -404,6 +407,14 @@ export enum EventType { SECRET_SYNC_SYNC_SECRETS = "secret-sync-sync-secrets", SECRET_SYNC_IMPORT_SECRETS = "secret-sync-import-secrets", SECRET_SYNC_REMOVE_SECRETS = "secret-sync-remove-secrets", + GET_PKI_SYNCS = "get-pki-syncs", + GET_PKI_SYNC = "get-pki-sync", + CREATE_PKI_SYNC = "create-pki-sync", + UPDATE_PKI_SYNC = "update-pki-sync", + DELETE_PKI_SYNC = "delete-pki-sync", + PKI_SYNC_SYNC_CERTIFICATES = "pki-sync-sync-certificates", + PKI_SYNC_IMPORT_CERTIFICATES = "pki-sync-import-certificates", + PKI_SYNC_REMOVE_CERTIFICATES = "pki-sync-remove-certificates", OIDC_GROUP_MEMBERSHIP_MAPPING_ASSIGN_USER = "oidc-group-membership-mapping-assign-user", OIDC_GROUP_MEMBERSHIP_MAPPING_REMOVE_USER = "oidc-group-membership-mapping-remove-user", CREATE_KMIP_CLIENT = "create-kmip-client", @@ -475,9 +486,21 @@ export enum EventType { UPDATE_PROJECT = "update-project", DELETE_PROJECT = "delete-project", + CREATE_PROJECT_ROLE = "create-project-role", + UPDATE_PROJECT_ROLE = "update-project-role", + DELETE_PROJECT_ROLE = "delete-project-role", + + CREATE_ORG_ROLE = "create-org-role", + UPDATE_ORG_ROLE = "update-org-role", + DELETE_ORG_ROLE = "delete-org-role", + CREATE_SECRET_REMINDER = "create-secret-reminder", GET_SECRET_REMINDER = "get-secret-reminder", - DELETE_SECRET_REMINDER = "delete-secret-reminder" + DELETE_SECRET_REMINDER = "delete-secret-reminder", + + DASHBOARD_LIST_SECRETS = "dashboard-list-secrets", + DASHBOARD_GET_SECRET_VALUE = "dashboard-get-secret-value", + DASHBOARD_GET_SECRET_VERSION_VALUE = "dashboard-get-secret-version-value" } export const filterableSecretEvents: EventType[] = [ @@ -588,6 +611,7 @@ interface CreateSecretEvent { secretKey: string; secretVersion: number; secretMetadata?: TSecretMetadata; + secretTags?: string[]; }; } @@ -602,6 +626,7 @@ interface CreateSecretBatchEvent { secretPath?: string; secretVersion: number; secretMetadata?: TSecretMetadata; + secretTags?: string[]; }>; }; } @@ -615,6 +640,7 @@ interface UpdateSecretEvent { secretKey: string; secretVersion: number; secretMetadata?: TSecretMetadata; + secretTags?: string[]; }; } @@ -629,6 +655,7 @@ interface UpdateSecretBatchEvent { secretVersion: number; secretMetadata?: TSecretMetadata; secretPath?: string; + secretTags?: string[]; }>; }; } @@ -664,8 +691,8 @@ interface DeleteSecretBatchEvent { }; } -interface GetWorkspaceKeyEvent { - type: EventType.GET_WORKSPACE_KEY; +interface GetProjectKeyEvent { + type: EventType.GET_PROJECT_KEY; metadata: { keyId: string; }; @@ -1370,6 +1397,10 @@ interface AddIdentityLdapAuthEvent { allowedFields?: TAllowedFields[]; url: string; templateId?: string | null; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; }; } @@ -1384,6 +1415,10 @@ interface UpdateIdentityLdapAuthEvent { allowedFields?: TAllowedFields[]; url?: string; templateId?: string | null; + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDurationSeconds?: number; + lockoutCounterResetSeconds?: number; }; } @@ -1401,6 +1436,13 @@ interface RevokeIdentityLdapAuthEvent { }; } +interface ClearIdentityLdapAuthLockoutsEvent { + type: EventType.CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS; + metadata: { + identityId: string; + }; +} + interface LoginIdentityOidcAuthEvent { type: EventType.LOGIN_IDENTITY_OIDC_AUTH; metadata: { @@ -1557,24 +1599,24 @@ interface DeleteEnvironmentEvent { }; } -interface AddWorkspaceMemberEvent { - type: EventType.ADD_WORKSPACE_MEMBER; +interface AddProjectMemberEvent { + type: EventType.ADD_PROJECT_MEMBER; metadata: { userId: string; email: string; }; } -interface AddBatchWorkspaceMemberEvent { - type: EventType.ADD_BATCH_WORKSPACE_MEMBER; +interface AddBatchProjectMemberEvent { + type: EventType.ADD_BATCH_PROJECT_MEMBER; metadata: Array<{ userId: string; email: string; }>; } -interface RemoveWorkspaceMemberEvent { - type: EventType.REMOVE_WORKSPACE_MEMBER; +interface RemoveProjectMemberEvent { + type: EventType.REMOVE_PROJECT_MEMBER; metadata: { userId: string; email: string; @@ -1713,7 +1755,7 @@ interface DeleteSecretImportEvent { } interface UpdateUserRole { - type: EventType.UPDATE_USER_WORKSPACE_ROLE; + type: EventType.UPDATE_USER_PROJECT_ROLE; metadata: { userId: string; email: string; @@ -1723,7 +1765,7 @@ interface UpdateUserRole { } interface UpdateUserDeniedPermissions { - type: EventType.UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS; + type: EventType.UPDATE_USER_PROJECT_DENIED_PERMISSIONS; metadata: { userId: string; email: string; @@ -2781,14 +2823,31 @@ interface GetAppConnectionEvent { }; } +interface GetAppConnectionUsageEvent { + type: EventType.GET_APP_CONNECTION_USAGE; + metadata: { + connectionId: string; + }; +} + +interface MigrateAppConnectionEvent { + type: EventType.MIGRATE_APP_CONNECTION; + metadata: { + connectionId: string; + }; +} + interface CreateAppConnectionEvent { type: EventType.CREATE_APP_CONNECTION; - metadata: Omit & { connectionId: string }; + metadata: Omit & { connectionId: string }; } interface UpdateAppConnectionEvent { type: EventType.UPDATE_APP_CONNECTION; - metadata: Omit & { connectionId: string; credentialsUpdated: boolean }; + metadata: Omit & { + connectionId: string; + credentialsUpdated: boolean; + }; } interface DeleteAppConnectionEvent { @@ -2908,6 +2967,77 @@ interface SecretSyncRemoveSecretsEvent { }; } +interface GetPkiSyncsEvent { + type: EventType.GET_PKI_SYNCS; + metadata: { + projectId: string; + }; +} + +interface GetPkiSyncEvent { + type: EventType.GET_PKI_SYNC; + metadata: { + destination: string; + syncId: string; + }; +} + +interface CreatePkiSyncEvent { + type: EventType.CREATE_PKI_SYNC; + metadata: { + pkiSyncId: string; + name: string; + destination: string; + }; +} + +interface UpdatePkiSyncEvent { + type: EventType.UPDATE_PKI_SYNC; + metadata: { + pkiSyncId: string; + name: string; + }; +} + +interface DeletePkiSyncEvent { + type: EventType.DELETE_PKI_SYNC; + metadata: { + pkiSyncId: string; + name: string; + destination: string; + }; +} + +interface PkiSyncSyncCertificatesEvent { + type: EventType.PKI_SYNC_SYNC_CERTIFICATES; + metadata: { + syncId: string; + syncMessage: string | null; + jobId: string; + jobRanAt: Date; + }; +} + +interface PkiSyncImportCertificatesEvent { + type: EventType.PKI_SYNC_IMPORT_CERTIFICATES; + metadata: { + syncId: string; + importMessage: string | null; + jobId: string; + jobRanAt: Date; + }; +} + +interface PkiSyncRemoveCertificatesEvent { + type: EventType.PKI_SYNC_REMOVE_CERTIFICATES; + metadata: { + syncId: string; + removeMessage: string | null; + jobId: string; + jobRanAt: Date; + }; +} + interface OidcGroupMembershipMappingAssignUserEvent { type: EventType.OIDC_GROUP_MEMBERSHIP_MAPPING_ASSIGN_USER; metadata: { @@ -3467,6 +3597,96 @@ interface ProjectDeleteEvent { }; } +interface DashboardListSecretsEvent { + type: EventType.DASHBOARD_LIST_SECRETS; + metadata: { + environment: string; + secretPath: string; + numberOfSecrets: number; + secretIds: string[]; + }; +} + +interface DashboardGetSecretValueEvent { + type: EventType.DASHBOARD_GET_SECRET_VALUE; + metadata: { + secretId: string; + secretKey: string; + environment: string; + secretPath: string; + }; +} + +interface DashboardGetSecretVersionValueEvent { + type: EventType.DASHBOARD_GET_SECRET_VERSION_VALUE; + metadata: { + secretId: string; + version: string; + }; +} + +interface ProjectRoleCreateEvent { + type: EventType.CREATE_PROJECT_ROLE; + metadata: { + roleId: string; + slug: string; + name: string; + description?: string | null; + permissions: string; + }; +} + +interface ProjectRoleUpdateEvent { + type: EventType.UPDATE_PROJECT_ROLE; + metadata: { + roleId: string; + slug?: string; + name?: string; + description?: string | null; + permissions?: string; + }; +} + +interface ProjectRoleDeleteEvent { + type: EventType.DELETE_PROJECT_ROLE; + metadata: { + roleId: string; + slug: string; + name: string; + }; +} + +interface OrgRoleCreateEvent { + type: EventType.CREATE_ORG_ROLE; + metadata: { + roleId: string; + slug: string; + name: string; + description?: string | null; + permissions: string; + }; +} + +interface OrgRoleUpdateEvent { + type: EventType.UPDATE_ORG_ROLE; + metadata: { + roleId: string; + slug?: string; + name?: string; + description?: string | null; + permissions?: string; + }; +} + +interface OrgRoleDeleteEvent { + type: EventType.DELETE_ORG_ROLE; + metadata: { + roleId: string; + slug: string; + name: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -3477,7 +3697,7 @@ export type Event = | MoveSecretsEvent | DeleteSecretEvent | DeleteSecretBatchEvent - | GetWorkspaceKeyEvent + | GetProjectKeyEvent | AuthorizeIntegrationEvent | UpdateIntegrationAuthEvent | UnauthorizeIntegrationEvent @@ -3562,13 +3782,14 @@ export type Event = | UpdateIdentityLdapAuthEvent | GetIdentityLdapAuthEvent | RevokeIdentityLdapAuthEvent + | ClearIdentityLdapAuthLockoutsEvent | CreateEnvironmentEvent | GetEnvironmentEvent | UpdateEnvironmentEvent | DeleteEnvironmentEvent - | AddWorkspaceMemberEvent - | AddBatchWorkspaceMemberEvent - | RemoveWorkspaceMemberEvent + | AddProjectMemberEvent + | AddBatchProjectMemberEvent + | RemoveProjectMemberEvent | CreateFolderEvent | UpdateFolderEvent | DeleteFolderEvent @@ -3697,6 +3918,8 @@ export type Event = | CreateAppConnectionEvent | UpdateAppConnectionEvent | DeleteAppConnectionEvent + | GetAppConnectionUsageEvent + | MigrateAppConnectionEvent | GetSshHostGroupEvent | CreateSshHostGroupEvent | UpdateSshHostGroupEvent @@ -3715,6 +3938,14 @@ export type Event = | SecretSyncSyncSecretsEvent | SecretSyncImportSecretsEvent | SecretSyncRemoveSecretsEvent + | GetPkiSyncsEvent + | GetPkiSyncEvent + | CreatePkiSyncEvent + | UpdatePkiSyncEvent + | DeletePkiSyncEvent + | PkiSyncSyncCertificatesEvent + | PkiSyncImportCertificatesEvent + | PkiSyncRemoveCertificatesEvent | OidcGroupMembershipMappingAssignUserEvent | OidcGroupMembershipMappingRemoveUserEvent | CreateKmipClientEvent @@ -3780,4 +4011,13 @@ export type Event = | ProjectDeleteEvent | SecretReminderCreateEvent | SecretReminderGetEvent - | SecretReminderDeleteEvent; + | SecretReminderDeleteEvent + | DashboardListSecretsEvent + | DashboardGetSecretValueEvent + | DashboardGetSecretVersionValueEvent + | ProjectRoleCreateEvent + | ProjectRoleUpdateEvent + | ProjectRoleDeleteEvent + | OrgRoleCreateEvent + | OrgRoleUpdateEvent + | OrgRoleDeleteEvent; diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts index d4fceb674..08686ffc4 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts @@ -16,7 +16,7 @@ import { PutUserPolicyCommand, RemoveUserFromGroupCommand } from "@aws-sdk/client-iam"; -import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; +import { AssumeRoleCommand, GetSessionTokenCommand, STSClient } from "@aws-sdk/client-sts"; import { z } from "zod"; import { CustomAWSHasher } from "@app/lib/aws/hashing"; @@ -26,9 +26,12 @@ import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { sanitizeString } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; -import { AwsIamAuthType, DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models"; +import { AwsIamAuthType, AwsIamCredentialType, DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models"; import { compileUsernameTemplate } from "./templateUtils"; +// AWS STS duration constants (in seconds) +const AWS_STS_MIN_DURATION = 900; + const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { const randomUsername = alphaNumericNanoId(32); if (!usernameTemplate) return randomUsername; @@ -120,6 +123,58 @@ export const AwsIamProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown, { projectId }: { projectId: string }) => { const providerInputs = await validateProviderInputs(inputs); try { + if (providerInputs.credentialType === AwsIamCredentialType.TemporaryCredentials) { + if (providerInputs.method === AwsIamAuthType.AccessKey) { + const stsClient = new STSClient({ + region: providerInputs.region, + useFipsEndpoint: crypto.isFipsModeEnabled(), + sha256: CustomAWSHasher, + credentials: { + accessKeyId: providerInputs.accessKey, + secretAccessKey: providerInputs.secretAccessKey + } + }); + + await stsClient.send(new GetSessionTokenCommand({ DurationSeconds: AWS_STS_MIN_DURATION })); + return true; + } + if (providerInputs.method === AwsIamAuthType.AssumeRole) { + const appCfg = getConfig(); + const stsClient = new STSClient({ + region: providerInputs.region, + useFipsEndpoint: crypto.isFipsModeEnabled(), + sha256: CustomAWSHasher, + credentials: + appCfg.DYNAMIC_SECRET_AWS_ACCESS_KEY_ID && appCfg.DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY + ? { + accessKeyId: appCfg.DYNAMIC_SECRET_AWS_ACCESS_KEY_ID, + secretAccessKey: appCfg.DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY + } + : undefined + }); + + await stsClient.send( + new AssumeRoleCommand({ + RoleArn: providerInputs.roleArn, + RoleSessionName: `infisical-validation-${crypto.nativeCrypto.randomUUID()}`, + DurationSeconds: AWS_STS_MIN_DURATION, + ExternalId: projectId + }) + ); + return true; + } + if (providerInputs.method === AwsIamAuthType.IRSA) { + const stsClient = new STSClient({ + region: providerInputs.region, + useFipsEndpoint: crypto.isFipsModeEnabled(), + sha256: CustomAWSHasher + }); + + await stsClient.send(new GetSessionTokenCommand({ DurationSeconds: AWS_STS_MIN_DURATION })); + return true; + } + } + const client = await $getClient(providerInputs, projectId); const isConnected = await client .send(new GetUserCommand({})) @@ -137,7 +192,7 @@ export const AwsIamProvider = (): TDynamicProviderFns => { }); return isConnected; } catch (err) { - const sensitiveTokens = []; + const sensitiveTokens: string[] = []; if (providerInputs.method === AwsIamAuthType.AccessKey) { sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey); } @@ -163,102 +218,269 @@ export const AwsIamProvider = (): TDynamicProviderFns => { }; metadata: { projectId: string }; }) => { - const { inputs, usernameTemplate, metadata, identity } = data; + const { inputs, usernameTemplate, metadata, identity, expireAt } = data; const providerInputs = await validateProviderInputs(inputs); - const client = await $getClient(providerInputs, metadata.projectId); - const username = generateUsername(usernameTemplate, identity); - const { policyArns, userGroups, policyDocument, awsPath, permissionBoundaryPolicyArn } = providerInputs; - const awsTags = [{ Key: "createdBy", Value: "infisical-dynamic-secret" }]; + if (providerInputs.credentialType === AwsIamCredentialType.TemporaryCredentials) { + try { + let stsClient: STSClient; + let entityId: string; - if (providerInputs.tags && Array.isArray(providerInputs.tags)) { - const additionalTags = providerInputs.tags.map((tag) => ({ - Key: tag.key, - Value: tag.value - })); - awsTags.push(...additionalTags); + const currentTime = Date.now(); + const requestedDuration = Math.floor((expireAt - currentTime) / 1000); + + if (requestedDuration <= 0) { + throw new BadRequestError({ message: "Expiration time must be in the future" }); + } + + let durationSeconds: number; + + if (providerInputs.method === AwsIamAuthType.AssumeRole) { + durationSeconds = requestedDuration; + const appCfg = getConfig(); + stsClient = new STSClient({ + region: providerInputs.region, + useFipsEndpoint: crypto.isFipsModeEnabled(), + sha256: CustomAWSHasher, + credentials: + appCfg.DYNAMIC_SECRET_AWS_ACCESS_KEY_ID && appCfg.DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY + ? { + accessKeyId: appCfg.DYNAMIC_SECRET_AWS_ACCESS_KEY_ID, + secretAccessKey: appCfg.DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY + } + : undefined + }); + + const assumeRoleRes = await stsClient.send( + new AssumeRoleCommand({ + RoleArn: providerInputs.roleArn, + RoleSessionName: `infisical-temp-cred-${crypto.nativeCrypto.randomUUID()}`, + DurationSeconds: durationSeconds, + ExternalId: metadata.projectId + }) + ); + + if ( + !assumeRoleRes.Credentials?.AccessKeyId || + !assumeRoleRes.Credentials?.SecretAccessKey || + !assumeRoleRes.Credentials?.SessionToken + ) { + throw new BadRequestError({ message: "Failed to assume role - verify credentials and role configuration" }); + } + + entityId = `assume-role-${alphaNumericNanoId(8)}`; + return { + entityId, + data: { + ACCESS_KEY: assumeRoleRes.Credentials.AccessKeyId, + SECRET_ACCESS_KEY: assumeRoleRes.Credentials.SecretAccessKey, + SESSION_TOKEN: assumeRoleRes.Credentials.SessionToken + } + }; + } + if (providerInputs.method === AwsIamAuthType.AccessKey) { + durationSeconds = requestedDuration; + stsClient = new STSClient({ + region: providerInputs.region, + useFipsEndpoint: crypto.isFipsModeEnabled(), + sha256: CustomAWSHasher, + credentials: { + accessKeyId: providerInputs.accessKey, + secretAccessKey: providerInputs.secretAccessKey + } + }); + + const sessionTokenRes = await stsClient.send( + new GetSessionTokenCommand({ + DurationSeconds: durationSeconds + }) + ); + + if ( + !sessionTokenRes.Credentials?.AccessKeyId || + !sessionTokenRes.Credentials?.SecretAccessKey || + !sessionTokenRes.Credentials?.SessionToken + ) { + throw new BadRequestError({ message: "Failed to get session token - verify credentials and permissions" }); + } + + entityId = `session-token-${alphaNumericNanoId(8)}`; + return { + entityId, + data: { + ACCESS_KEY: sessionTokenRes.Credentials.AccessKeyId, + SECRET_ACCESS_KEY: sessionTokenRes.Credentials.SecretAccessKey, + SESSION_TOKEN: sessionTokenRes.Credentials.SessionToken + } + }; + } + if (providerInputs.method === AwsIamAuthType.IRSA) { + durationSeconds = requestedDuration; + stsClient = new STSClient({ + region: providerInputs.region, + useFipsEndpoint: crypto.isFipsModeEnabled(), + sha256: CustomAWSHasher + }); + + const sessionTokenRes = await stsClient.send( + new GetSessionTokenCommand({ + DurationSeconds: durationSeconds + }) + ); + + if ( + !sessionTokenRes.Credentials?.AccessKeyId || + !sessionTokenRes.Credentials?.SecretAccessKey || + !sessionTokenRes.Credentials?.SessionToken + ) { + throw new BadRequestError({ + message: "Failed to get session token - verify IRSA credentials and permissions" + }); + } + + entityId = `irsa-session-${alphaNumericNanoId(8)}`; + return { + entityId, + data: { + ACCESS_KEY: sessionTokenRes.Credentials.AccessKeyId, + SECRET_ACCESS_KEY: sessionTokenRes.Credentials.SecretAccessKey, + SESSION_TOKEN: sessionTokenRes.Credentials.SessionToken + } + }; + } + + throw new BadRequestError({ message: "Unsupported authentication method for temporary credentials" }); + } catch (err) { + const sensitiveTokens: string[] = []; + if (providerInputs.method === AwsIamAuthType.AccessKey) { + sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey); + } + if (providerInputs.method === AwsIamAuthType.AssumeRole) { + sensitiveTokens.push(providerInputs.roleArn); + } + + let errorMessage = (err as Error)?.message || "Unknown error"; + + if (err && typeof err === "object" && "name" in err && "$metadata" in err) { + const awsError = err as { name?: string; message?: string; $metadata?: object }; + if (awsError.name) { + errorMessage = `${awsError.name}: ${errorMessage}`; + } + } + + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: errorMessage, + tokens: sensitiveTokens + }); + throw new BadRequestError({ + message: `Failed to create temporary credentials: ${sanitizedErrorMessage}` + }); + } } - try { - const createUserRes = await client.send( - new CreateUserCommand({ - Path: awsPath, - PermissionsBoundary: permissionBoundaryPolicyArn || undefined, - Tags: awsTags, - UserName: username - }) - ); + if (providerInputs.credentialType === AwsIamCredentialType.IamUser) { + const client = await $getClient(providerInputs, metadata.projectId); - if (!createUserRes.User) throw new BadRequestError({ message: "Failed to create AWS IAM User" }); - if (userGroups) { - await Promise.all( - userGroups - .split(",") - .filter(Boolean) - .map((group) => - client.send(new AddUserToGroupCommand({ UserName: createUserRes?.User?.UserName, GroupName: group })) - ) - ); + const username = generateUsername(usernameTemplate, identity); + const { policyArns, userGroups, policyDocument, awsPath, permissionBoundaryPolicyArn } = providerInputs; + const awsTags = [{ Key: "createdBy", Value: "infisical-dynamic-secret" }]; + + if (providerInputs.tags && Array.isArray(providerInputs.tags)) { + const additionalTags = providerInputs.tags.map((tag) => ({ + Key: tag.key, + Value: tag.value + })); + awsTags.push(...additionalTags); } - if (policyArns) { - await Promise.all( - policyArns - .split(",") - .filter(Boolean) - .map((policyArn) => - client.send( - new AttachUserPolicyCommand({ UserName: createUserRes?.User?.UserName, PolicyArn: policyArn }) - ) - ) - ); - } - if (policyDocument) { - await client.send( - new PutUserPolicyCommand({ - UserName: createUserRes.User.UserName, - PolicyName: `infisical-dynamic-policy-${alphaNumericNanoId(4)}`, - PolicyDocument: policyDocument + + try { + const createUserRes = await client.send( + new CreateUserCommand({ + Path: awsPath, + PermissionsBoundary: permissionBoundaryPolicyArn || undefined, + Tags: awsTags, + UserName: username }) ); - } - const createAccessKeyRes = await client.send( - new CreateAccessKeyCommand({ - UserName: createUserRes.User.UserName - }) - ); - if (!createAccessKeyRes.AccessKey) - throw new BadRequestError({ message: "Failed to create AWS IAM User access key" }); - - return { - entityId: username, - data: { - ACCESS_KEY: createAccessKeyRes.AccessKey.AccessKeyId, - SECRET_ACCESS_KEY: createAccessKeyRes.AccessKey.SecretAccessKey, - USERNAME: username + if (!createUserRes.User) throw new BadRequestError({ message: "Failed to create AWS IAM User" }); + if (userGroups) { + await Promise.all( + userGroups + .split(",") + .filter(Boolean) + .map((group) => + client.send(new AddUserToGroupCommand({ UserName: createUserRes?.User?.UserName, GroupName: group })) + ) + ); } - }; - } catch (err) { - const sensitiveTokens = [username]; - if (providerInputs.method === AwsIamAuthType.AccessKey) { - sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey); + if (policyArns) { + await Promise.all( + policyArns + .split(",") + .filter(Boolean) + .map((policyArn) => + client.send( + new AttachUserPolicyCommand({ UserName: createUserRes?.User?.UserName, PolicyArn: policyArn }) + ) + ) + ); + } + if (policyDocument) { + await client.send( + new PutUserPolicyCommand({ + UserName: createUserRes.User.UserName, + PolicyName: `infisical-dynamic-policy-${alphaNumericNanoId(4)}`, + PolicyDocument: policyDocument + }) + ); + } + + const createAccessKeyRes = await client.send( + new CreateAccessKeyCommand({ + UserName: createUserRes.User.UserName + }) + ); + if (!createAccessKeyRes.AccessKey) + throw new BadRequestError({ message: "Failed to create AWS IAM User access key" }); + + return { + entityId: username, + data: { + ACCESS_KEY: createAccessKeyRes.AccessKey.AccessKeyId, + SECRET_ACCESS_KEY: createAccessKeyRes.AccessKey.SecretAccessKey, + USERNAME: username + } + }; + } catch (err) { + const sensitiveTokens = [username]; + if (providerInputs.method === AwsIamAuthType.AccessKey) { + sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey); + } + if (providerInputs.method === AwsIamAuthType.AssumeRole) { + sensitiveTokens.push(providerInputs.roleArn); + } + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: sensitiveTokens + }); + throw new BadRequestError({ + message: `Failed to create lease from provider: ${sanitizedErrorMessage}` + }); } - if (providerInputs.method === AwsIamAuthType.AssumeRole) { - sensitiveTokens.push(providerInputs.roleArn); - } - const sanitizedErrorMessage = sanitizeString({ - unsanitizedString: (err as Error)?.message, - tokens: sensitiveTokens - }); - throw new BadRequestError({ - message: `Failed to create lease from provider: ${sanitizedErrorMessage}` - }); } + + throw new BadRequestError({ message: "Invalid credential type specified" }); }; const revoke = async (inputs: unknown, entityId: string, metadata: { projectId: string }) => { const providerInputs = await validateProviderInputs(inputs); + + if (providerInputs.credentialType === AwsIamCredentialType.TemporaryCredentials) { + return { entityId }; + } + const client = await $getClient(providerInputs, metadata.projectId); const username = entityId; diff --git a/backend/src/ee/services/dynamic-secret/providers/azure-sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/azure-sql-database.ts new file mode 100644 index 000000000..965964883 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/azure-sql-database.ts @@ -0,0 +1,541 @@ +import handlebars from "handlebars"; +import knex from "knex"; +import RE2 from "re2"; +import { z } from "zod"; + +import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; + +import { TGatewayServiceFactory } from "../../gateway/gateway-service"; +import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service"; +import { verifyHostInputValidity } from "../dynamic-secret-fns"; +import { DynamicSecretAzureSqlDBSchema, PasswordRequirements, SqlProviders, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +const DEFAULT_PASSWORD_REQUIREMENTS = { + length: 48, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 0 + }, + allowedSymbols: "-_.~!*" +}; + +const generatePassword = (requirements?: PasswordRequirements) => { + const finalReqs = requirements || DEFAULT_PASSWORD_REQUIREMENTS; + + try { + const { length, required, allowedSymbols } = finalReqs; + + const chars = { + lowercase: "abcdefghijklmnopqrstuvwxyz", + uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + digits: "0123456789", + symbols: allowedSymbols || "-_.~!*" + }; + + const parts: string[] = []; + + if (required.lowercase > 0) { + parts.push( + ...Array(required.lowercase) + .fill(0) + .map(() => chars.lowercase[crypto.randomInt(chars.lowercase.length)]) + ); + } + + if (required.uppercase > 0) { + parts.push( + ...Array(required.uppercase) + .fill(0) + .map(() => chars.uppercase[crypto.randomInt(chars.uppercase.length)]) + ); + } + + if (required.digits > 0) { + parts.push( + ...Array(required.digits) + .fill(0) + .map(() => chars.digits[crypto.randomInt(chars.digits.length)]) + ); + } + + if (required.symbols > 0) { + parts.push( + ...Array(required.symbols) + .fill(0) + .map(() => chars.symbols[crypto.randomInt(chars.symbols.length)]) + ); + } + + const requiredTotal = Object.values(required).reduce((a, b) => a + b, 0); + const remainingLength = Math.max(length - requiredTotal, 0); + + const allowedChars = Object.entries(chars) + .filter(([key]) => required[key as keyof typeof required] > 0) + .map(([, value]) => value) + .join(""); + + parts.push( + ...Array(remainingLength) + .fill(0) + .map(() => allowedChars[crypto.randomInt(allowedChars.length)]) + ); + + // shuffle the array to mix up the characters + for (let i = parts.length - 1; i > 0; i -= 1) { + const j = crypto.randomInt(i + 1); + [parts[i], parts[j]] = [parts[j], parts[i]]; + } + + return parts.join(""); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + throw new Error(`Failed to generate password: ${message}`); + } +}; + +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { + const randomUsername = alphaNumericNanoId(32); + if (!usernameTemplate) return randomUsername; + return compileUsernameTemplate({ + usernameTemplate, + randomUsername, + identity + }); +}; + +type TAzureSqlDatabaseProviderDTO = { + gatewayService: Pick; + gatewayV2Service: Pick; +}; + +export const AzureSqlDatabaseProvider = ({ + gatewayService, + gatewayV2Service +}: TAzureSqlDatabaseProviderDTO): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretAzureSqlDBSchema.parseAsync(inputs); + + const [hostIp] = await verifyHostInputValidity(providerInputs.host, Boolean(providerInputs.gatewayId)); + validateHandlebarTemplate("Azure SQL master creation", providerInputs.masterCreationStatement, { + allowedExpressions: (val) => ["username", "password", "expiration", "database"].includes(val) + }); + validateHandlebarTemplate("Azure SQL creation", providerInputs.creationStatement, { + allowedExpressions: (val) => ["username", "password", "expiration", "database"].includes(val) + }); + if (providerInputs.renewStatement) { + validateHandlebarTemplate("Azure SQL renew", providerInputs.renewStatement, { + allowedExpressions: (val) => ["username", "expiration", "database"].includes(val) + }); + } + validateHandlebarTemplate("Azure SQL revoke", providerInputs.revocationStatement, { + allowedExpressions: (val) => ["username", "database"].includes(val) + }); + + return { ...providerInputs, hostIp }; + }; + + const $getClient = async ( + providerInputs: z.infer & { hostIp: string; originalHost: string }, + targetDatabase?: string + ) => { + const ssl = providerInputs.ca + ? { rejectUnauthorized: false, ca: providerInputs.ca, servername: providerInputs.host } + : undefined; + + /* + We route through the gateway by setting connection.host = "localhost". + Azure SQL identifies the logical server from the TDS login name when the host + isn't the Azure FQDN. Therefore, when using the gateway, ensure username is + "user@" so Azure opens the correct logical server. + Direct connections to the Azure FQDN usually don't require this suffix. + */ + const isAzureSql = new RE2(/\.database\.windows\.net$/i).test(providerInputs.originalHost); + const azureServerLabel = + isAzureSql && providerInputs.gatewayId ? providerInputs.originalHost?.split(".")[0] : undefined; + const effectiveUser = + isAzureSql && !providerInputs.username.includes("@") && azureServerLabel + ? `${providerInputs.username}@${azureServerLabel}` + : providerInputs.username; + + const db = knex({ + client: SqlProviders.MsSQL, + connection: { + database: targetDatabase || providerInputs.database, + port: providerInputs.port, + host: providerInputs.host, + user: effectiveUser, + password: providerInputs.password, + ssl, + // @ts-expect-error this is because of knexjs type signature issue. This is directly passed to driver + // https://github.com/knex/knex/blob/b6507a7129d2b9fafebf5f831494431e64c6a8a0/lib/dialects/mssql/index.js#L66 + // https://github.com/tediousjs/tedious/blob/ebb023ed90969a7ec0e4b036533ad52739d921f7/test/config.ci.ts#L19 + options: { + ...(providerInputs.sslEnabled !== undefined ? { encrypt: providerInputs.sslEnabled } : {}), + trustServerCertificate: !providerInputs.ca, + cryptoCredentialsDetails: providerInputs.ca ? { ca: providerInputs.ca } : {} + } + }, + acquireConnectionTimeout: EXTERNAL_REQUEST_TIMEOUT, + pool: { min: 0, max: 7 } + }); + return db; + }; + + const gatewayProxyWrapper = async ( + providerInputs: z.infer, + gatewayCallback: (host: string, port: number) => Promise + ) => { + const gatewayV2ConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId: providerInputs.gatewayId as string, + targetHost: providerInputs.host, + targetPort: providerInputs.port + }); + + if (gatewayV2ConnectionDetails) { + return withGatewayV2Proxy( + async (port) => { + await gatewayCallback("localhost", port); + }, + { + relayHost: gatewayV2ConnectionDetails.relayHost, + gateway: gatewayV2ConnectionDetails.gateway, + relay: gatewayV2ConnectionDetails.relay, + protocol: GatewayProxyProtocol.Tcp + } + ); + } + + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(providerInputs.gatewayId as string); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + await withGatewayProxy( + async (port) => { + await gatewayCallback("localhost", port); + }, + { + protocol: GatewayProxyProtocol.Tcp, + targetHost: providerInputs.host, + targetPort: providerInputs.port, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + let isConnected = false; + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + const db = await $getClient({ + ...providerInputs, + port, + host, + hostIp: providerInputs.hostIp, + originalHost: providerInputs.host + }); + + try { + isConnected = await db.raw("SELECT 1").then(() => true); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [providerInputs.username] + }); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } finally { + await db.destroy(); + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + return isConnected; + }; + + const create = async (data: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + identity?: { name: string }; + }) => { + const { inputs, expireAt, usernameTemplate, identity } = data; + + const providerInputs = await validateProviderInputs(inputs); + const { database, masterDatabase } = providerInputs; + const username = generateUsername(usernameTemplate, identity); + const password = generatePassword(providerInputs.passwordRequirements); + + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + const expiration = new Date(expireAt).toISOString(); + + const masterDb = await $getClient( + { + ...providerInputs, + port, + host, + originalHost: providerInputs.host + }, + masterDatabase + ); + + try { + const masterCreationStatement = handlebars.compile(providerInputs.masterCreationStatement, { noEscape: true })({ + username, + password, + expiration, + database + }); + + const masterQueries = masterCreationStatement.toString().split(";").filter(Boolean); + await masterDb.transaction(async (tx) => { + for (const query of masterQueries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, password, database] + }); + throw new BadRequestError({ + message: `Failed to create login in master database: ${sanitizedErrorMessage}` + }); + } finally { + await masterDb.destroy(); + } + + const targetDb = await $getClient({ + ...providerInputs, + port, + host, + originalHost: providerInputs.host + }); + + try { + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password, + expiration, + database + }); + + const queries = creationStatement.toString().split(";").filter(Boolean); + await targetDb.transaction(async (tx) => { + for (const query of queries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, password, database] + }); + throw new BadRequestError({ + message: `Failed to create user in target database: ${sanitizedErrorMessage}` + }); + } finally { + await targetDb.destroy(); + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + }; + + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs); + const username = entityId; + const { database, masterDatabase } = providerInputs; + + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, database }); + const queries = revokeStatement.toString().split(";").filter(Boolean); + + const userDropQueries = queries.filter((query) => query.toLowerCase().includes("drop user")); + const loginDropQueries = queries.filter((query) => query.toLowerCase().includes("drop login")); + + if (userDropQueries.length > 0) { + const targetDb = await $getClient({ + ...providerInputs, + port, + host, + originalHost: providerInputs.host + }); + + try { + await targetDb.transaction(async (tx) => { + for (const query of userDropQueries) { + // eslint-disable-next-line + await tx.raw(query.trim()); + } + }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, database] + }); + throw new BadRequestError({ + message: `Failed to drop user from target database: ${sanitizedErrorMessage}` + }); + } finally { + await targetDb.destroy(); + } + } + + if (loginDropQueries.length > 0) { + const masterDb = await $getClient( + { + ...providerInputs, + port, + host, + originalHost: providerInputs.host + }, + masterDatabase + ); + + try { + await masterDb.transaction(async (tx) => { + for (const query of loginDropQueries) { + // eslint-disable-next-line + await tx.raw(query.trim()); + } + }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, database] + }); + throw new BadRequestError({ + message: `Failed to drop login from master database: ${sanitizedErrorMessage}` + }); + } finally { + await masterDb.destroy(); + } + } + + const otherQueries = queries.filter( + (query) => !query.toLowerCase().includes("drop user") && !query.toLowerCase().includes("drop login") + ); + + if (otherQueries.length > 0) { + const targetDb = await $getClient({ + ...providerInputs, + port, + host, + originalHost: providerInputs.host + }); + + try { + await targetDb.transaction(async (tx) => { + for (const query of otherQueries) { + // eslint-disable-next-line + await tx.raw(query.trim()); + } + }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, database] + }); + throw new BadRequestError({ + message: `Failed to execute revocation statement: ${sanitizedErrorMessage}` + }); + } finally { + await targetDb.destroy(); + } + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + return { entityId: username }; + }; + + const renew = async (inputs: unknown, entityId: string, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + if (!providerInputs.renewStatement) return { entityId }; + + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + const db = await $getClient({ + ...providerInputs, + port, + host, + originalHost: providerInputs.host + }); + const expiration = new Date(expireAt).toISOString(); + const { database } = providerInputs; + + const renewStatement = handlebars.compile(providerInputs.renewStatement)({ + username: entityId, + expiration, + database + }); + try { + if (renewStatement) { + const queries = renewStatement.toString().split(";").filter(Boolean); + await db.transaction(async (tx) => { + for (const query of queries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); + } + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [database] + }); + throw new BadRequestError({ + message: `Failed to renew lease from provider: ${sanitizedErrorMessage}` + }); + } finally { + await db.destroy(); + } + }; + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + return { entityId }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 3ec0f795e..0259f2d2d 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -5,6 +5,7 @@ import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service"; import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache"; import { AwsIamProvider } from "./aws-iam"; import { AzureEntraIDProvider } from "./azure-entra-id"; +import { AzureSqlDatabaseProvider } from "./azure-sql-database"; import { CassandraProvider } from "./cassandra"; import { CouchbaseProvider } from "./couchbase"; import { ElasticSearchProvider } from "./elastic-search"; @@ -42,6 +43,7 @@ export const buildDynamicSecretProviders = ({ [DynamicSecretProviders.ElasticSearch]: ElasticSearchProvider(), [DynamicSecretProviders.RabbitMq]: RabbitMqProvider(), [DynamicSecretProviders.AzureEntraID]: AzureEntraIDProvider(), + [DynamicSecretProviders.AzureSqlDatabase]: AzureSqlDatabaseProvider({ gatewayService, gatewayV2Service }), [DynamicSecretProviders.Ldap]: LdapProvider(), [DynamicSecretProviders.SapHana]: SapHanaProvider(), [DynamicSecretProviders.Snowflake]: SnowflakeProvider(), diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index f076bf883..8baf178a9 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -32,6 +32,11 @@ export enum AwsIamAuthType { IRSA = "irsa" } +export enum AwsIamCredentialType { + IamUser = "iam-user", + TemporaryCredentials = "temporary-credentials" +} + export enum ElasticSearchAuthTypes { User = "user", ApiKey = "api-key" @@ -203,6 +208,7 @@ export const DynamicSecretAwsIamSchema = z.preprocess( z.discriminatedUnion("method", [ z.object({ method: z.literal(AwsIamAuthType.AccessKey), + credentialType: z.nativeEnum(AwsIamCredentialType).default(AwsIamCredentialType.IamUser), accessKey: z.string().trim().min(1), secretAccessKey: z.string().trim().min(1), region: z.string().trim().min(1), @@ -215,6 +221,7 @@ export const DynamicSecretAwsIamSchema = z.preprocess( }), z.object({ method: z.literal(AwsIamAuthType.AssumeRole), + credentialType: z.nativeEnum(AwsIamCredentialType).default(AwsIamCredentialType.IamUser), roleArn: z.string().trim().min(1, "Role ARN required"), region: z.string().trim().min(1), awsPath: z.string().trim().optional(), @@ -226,6 +233,7 @@ export const DynamicSecretAwsIamSchema = z.preprocess( }), z.object({ method: z.literal(AwsIamAuthType.IRSA), + credentialType: z.nativeEnum(AwsIamCredentialType).default(AwsIamCredentialType.IamUser), region: z.string().trim().min(1), awsPath: z.string().trim().optional(), permissionBoundaryPolicyArn: z.string().trim().optional(), @@ -319,6 +327,44 @@ export const AzureEntraIDSchema = z.object({ clientSecret: z.string().trim().min(1) }); +export const DynamicSecretAzureSqlDBSchema = z.object({ + host: z.string().trim().toLowerCase(), + port: z.number(), + database: z.string().trim(), + masterDatabase: z.string().trim().optional().default("master"), + username: z.string().trim(), + password: z.string().trim(), + passwordRequirements: z + .object({ + length: z.number().min(1).max(250), + required: z + .object({ + lowercase: z.number().min(0), + uppercase: z.number().min(0), + digits: z.number().min(0), + symbols: z.number().min(0) + }) + .refine((data) => { + const total = Object.values(data).reduce((sum, count) => sum + count, 0); + return total <= 250; + }, "Sum of required characters cannot exceed 250"), + allowedSymbols: z.string().optional() + }) + .refine((data) => { + const total = Object.values(data.required).reduce((sum, count) => sum + count, 0); + return total <= data.length; + }, "Sum of required characters cannot exceed the total length") + .optional() + .describe("Password generation requirements"), + masterCreationStatement: z.string().trim(), + creationStatement: z.string().trim(), + revocationStatement: z.string().trim(), + renewStatement: z.string().trim().optional(), + ca: z.string().optional(), + sslEnabled: z.boolean().optional(), + gatewayId: z.string().nullable().optional() +}); + export const LdapSchema = z.union([ z.object({ url: z.string().trim().min(1), @@ -602,6 +648,7 @@ export enum DynamicSecretProviders { MongoDB = "mongo-db", RabbitMq = "rabbit-mq", AzureEntraID = "azure-entra-id", + AzureSqlDatabase = "azure-sql-database", Ldap = "ldap", SapHana = "sap-hana", Snowflake = "snowflake", @@ -627,6 +674,7 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.MongoDB), inputs: DynamicSecretMongoDBSchema }), z.object({ type: z.literal(DynamicSecretProviders.RabbitMq), inputs: DynamicSecretRabbitMqSchema }), z.object({ type: z.literal(DynamicSecretProviders.AzureEntraID), inputs: AzureEntraIDSchema }), + z.object({ type: z.literal(DynamicSecretProviders.AzureSqlDatabase), inputs: DynamicSecretAzureSqlDBSchema }), z.object({ type: z.literal(DynamicSecretProviders.Ldap), inputs: LdapSchema }), z.object({ type: z.literal(DynamicSecretProviders.Snowflake), inputs: DynamicSecretSnowflakeSchema }), z.object({ type: z.literal(DynamicSecretProviders.Totp), inputs: DynamicSecretTotpSchema }), diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts index 317e5da6d..e8ccc8a5e 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -395,7 +395,8 @@ export const gatewayV2ServiceFactory = ({ relayId: gateway.relayId, orgId: gateway.orgId, orgName: gateway.orgName, - gatewayId + gatewayId, + gatewayName: gateway.name }); return { @@ -508,7 +509,8 @@ export const gatewayV2ServiceFactory = ({ const relayCredentials = await relayService.getCredentialsForGateway({ relayName, orgId, - gatewayId: gateway.id + gatewayId: gateway.id, + gatewayName: gateway.name }); return { diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 46c9831b0..d4d30b5ae 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -44,7 +44,10 @@ type TGroupServiceFactoryDep = { projectDAL: Pick; projectBotDAL: Pick; projectKeyDAL: Pick; - permissionService: Pick; + permissionService: Pick< + TPermissionServiceFactory, + "getOrgPermission" | "getOrgPermissionByRole" | "invalidateProjectPermissionCache" + >; licenseService: Pick; oidcConfigDAL: Pick; }; @@ -225,6 +228,15 @@ export const groupServiceFactory = ({ return updated; }); + if (role) { + const groupProjects = await groupProjectDAL.find({ groupId: group.id }); + await Promise.allSettled([ + ...groupProjects.map((groupProject) => + permissionService.invalidateProjectPermissionCache(groupProject.projectId) + ) + ]); + } + return updatedGroup; }; @@ -247,11 +259,17 @@ export const groupServiceFactory = ({ message: "Failed to delete group due to plan restriction. Upgrade plan to delete group." }); + const groupProjects = await groupProjectDAL.find({ groupId: id }); + const [group] = await groupDAL.delete({ id, orgId: actorOrgId }); + await Promise.allSettled([ + ...groupProjects.map((groupProject) => permissionService.invalidateProjectPermissionCache(groupProject.projectId)) + ]); + return group; }; @@ -398,6 +416,11 @@ export const groupServiceFactory = ({ projectBotDAL }); + const groupProjects = await groupProjectDAL.find({ groupId: group.id }); + await Promise.allSettled([ + ...groupProjects.map((groupProject) => permissionService.invalidateProjectPermissionCache(groupProject.projectId)) + ]); + return users[0]; }; @@ -479,6 +502,11 @@ export const groupServiceFactory = ({ projectKeyDAL }); + const groupProjects = await groupProjectDAL.find({ groupId: group.id }); + await Promise.allSettled([ + ...groupProjects.map((groupProject) => permissionService.invalidateProjectPermissionCache(groupProject.projectId)) + ]); + return users[0]; }; diff --git a/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts b/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts index 64da588f8..27b67367e 100644 --- a/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts +++ b/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts @@ -28,7 +28,7 @@ type TIdentityProjectAdditionalPrivilegeV2ServiceFactoryDep = { identityProjectAdditionalPrivilegeDAL: TIdentityProjectAdditionalPrivilegeV2DALFactory; identityProjectDAL: Pick; projectDAL: Pick; - permissionService: Pick; + permissionService: Pick; }; export type TIdentityProjectAdditionalPrivilegeV2ServiceFactory = ReturnType< @@ -115,6 +115,8 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ permissions: packedPermission }); + await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); + return { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) @@ -132,6 +134,9 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime), temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs) }); + + await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); + return { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) @@ -224,6 +229,9 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""), temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || "")) }); + + await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); + return { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) @@ -239,6 +247,9 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ temporaryRange: null, temporaryMode: null }); + + await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); + return { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) @@ -294,6 +305,9 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ }); const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id); + + await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); + return { ...deletedPrivilege, permissions: unpackPermissions(deletedPrivilege.permissions) diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts index 828cf43a3..ddba76920 100644 --- a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts +++ b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts @@ -31,7 +31,7 @@ type TIdentityProjectAdditionalPrivilegeServiceFactoryDep = { identityProjectAdditionalPrivilegeDAL: TIdentityProjectAdditionalPrivilegeDALFactory; identityProjectDAL: Pick; projectDAL: Pick; - permissionService: Pick; + permissionService: Pick; }; export type TIdentityProjectAdditionalPrivilegeServiceFactory = ReturnType< @@ -129,6 +129,9 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ slug, permissions: packedPermission }); + + await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); + return { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) @@ -146,6 +149,9 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime), temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs) }); + + await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); + return { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) @@ -250,6 +256,9 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""), temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || "")) }); + + await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); + return { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) @@ -265,6 +274,9 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ temporaryRange: null, temporaryMode: null }); + + await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); + return { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) @@ -338,9 +350,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ } const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id); + + await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); + return { ...deletedPrivilege, - permissions: unpackPermissions(deletedPrivilege.permissions) }; }; diff --git a/backend/src/ee/services/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts index 729696baa..f139ff2c1 100644 --- a/backend/src/ee/services/license/__mocks__/license-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -31,6 +31,7 @@ export const getDefaultOnPremFeatures = () => { caCrl: false, sshHostGroups: false, enterpriseSecretSyncs: false, + enterpriseCertificateSyncs: false, enterpriseAppConnections: true, machineIdentityAuthTemplates: false }; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index a302e956a..cbe194f31 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -62,6 +62,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ sshHostGroups: false, secretScanning: false, enterpriseSecretSyncs: false, + enterpriseCertificateSyncs: false, enterpriseAppConnections: false, fips: false, eventSubscriptions: false, diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index a5353b7e5..544eeae37 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -160,7 +160,10 @@ export const licenseServiceFactory = ({ } if (isValidOfflineLicense) { - onPremFeatures = contents.license.features; + onPremFeatures = { + ...contents.license.features, + slug: "enterprise" + }; instanceType = InstanceType.EnterpriseOnPremOffline; logger.info(`Instance type: ${InstanceType.EnterpriseOnPremOffline}`); isValidLicense = true; @@ -458,17 +461,16 @@ export const licenseServiceFactory = ({ }; const fetchPlanTableFromServer = async (customerId: string | null | undefined) => { - if (!customerId) { - throw new NotFoundError({ message: "Organization customer ID is required for plan table retrieval" }); - } - - const baseUrl = `/api/license-server/v1/customers/${customerId}`; + const baseUrl = `/api/license-server/v1/customers`; if (instanceType === InstanceType.Cloud) { + if (!customerId) { + throw new NotFoundError({ message: "Organization customer ID is required for plan table retrieval" }); + } const { data } = await licenseServerCloudApi.request.get<{ head: { name: string }[]; rows: { name: string; allowed: boolean }[]; - }>(`${baseUrl}/cloud-plan/table`); + }>(`${baseUrl}/${customerId}/cloud-plan/table`); return data; } diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 2ccd3ac8f..266794074 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -24,7 +24,7 @@ export type TOfflineLicense = { export type TFeatureSet = { _id: null; - slug: null; + slug: string | null; tier: -1; workspaceLimit: null; workspacesUsed: number; @@ -75,6 +75,7 @@ export type TFeatureSet = { sshHostGroups: false; secretScanning: false; enterpriseSecretSyncs: false; + enterpriseCertificateSyncs: false; enterpriseAppConnections: false; machineIdentityAuthTemplates: false; fips: false; diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 9329c3c7f..83d1c44d9 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -2,6 +2,7 @@ import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability" import { ProjectPermissionActions, + ProjectPermissionAppConnectionActions, ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, @@ -12,6 +13,7 @@ import { ProjectPermissionKmipActions, ProjectPermissionMemberActions, ProjectPermissionPkiSubscriberActions, + ProjectPermissionPkiSyncActions, ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, ProjectPermissionSecretEventActions, @@ -208,6 +210,19 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.SecretSyncs ); + can( + [ + ProjectPermissionPkiSyncActions.Create, + ProjectPermissionPkiSyncActions.Edit, + ProjectPermissionPkiSyncActions.Delete, + ProjectPermissionPkiSyncActions.Read, + ProjectPermissionPkiSyncActions.SyncCertificates, + ProjectPermissionPkiSyncActions.ImportCertificates, + ProjectPermissionPkiSyncActions.RemoveCertificates + ], + ProjectPermissionSub.PkiSyncs + ); + can( [ ProjectPermissionKmipActions.CreateClients, @@ -264,6 +279,17 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.SecretEvents ); + can( + [ + ProjectPermissionAppConnectionActions.Create, + ProjectPermissionAppConnectionActions.Edit, + ProjectPermissionAppConnectionActions.Delete, + ProjectPermissionAppConnectionActions.Read, + ProjectPermissionAppConnectionActions.Connect + ], + ProjectPermissionSub.AppConnections + ); + return rules; }; @@ -450,6 +476,19 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.SecretSyncs ); + can( + [ + ProjectPermissionPkiSyncActions.Create, + ProjectPermissionPkiSyncActions.Edit, + ProjectPermissionPkiSyncActions.Delete, + ProjectPermissionPkiSyncActions.Read, + ProjectPermissionPkiSyncActions.SyncCertificates, + ProjectPermissionPkiSyncActions.ImportCertificates, + ProjectPermissionPkiSyncActions.RemoveCertificates + ], + ProjectPermissionSub.PkiSyncs + ); + can( [ ProjectPermissionSecretScanningDataSourceActions.Read, @@ -477,6 +516,8 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.SecretEvents ); + can(ProjectPermissionAppConnectionActions.Connect, ProjectPermissionSub.AppConnections); + return rules; }; @@ -512,6 +553,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates); can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs); + can(ProjectPermissionPkiSyncActions.Read, ProjectPermissionSub.PkiSyncs); can(ProjectPermissionCommitsActions.Read, ProjectPermissionSub.Commits); can( diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index d4155d02c..0548911e8 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -58,6 +58,13 @@ export enum OrgPermissionGatewayActions { AttachGateways = "attach-gateways" } +export enum OrgPermissionRelayActions { + CreateRelays = "create-relays", + ListRelays = "list-relays", + EditRelays = "edit-relays", + DeleteRelays = "delete-relays" +} + export enum OrgPermissionIdentityActions { Read = "read", Create = "create", @@ -87,6 +94,7 @@ export enum OrgPermissionBillingActions { export enum OrgPermissionSubjects { Workspace = "workspace", + Project = "project", Role = "role", Member = "member", Settings = "settings", @@ -108,6 +116,7 @@ export enum OrgPermissionSubjects { AppConnections = "app-connections", Kmip = "kmip", Gateway = "gateway", + Relay = "relay", SecretShare = "secret-share" } @@ -117,6 +126,7 @@ export type AppConnectionSubjectFields = { export type OrgPermissionSet = | [OrgPermissionActions.Create, OrgPermissionSubjects.Workspace] + | [OrgPermissionActions.Create, OrgPermissionSubjects.Project] | [OrgPermissionActions, OrgPermissionSubjects.Role] | [OrgPermissionActions, OrgPermissionSubjects.Member] | [OrgPermissionActions, OrgPermissionSubjects.Settings] @@ -134,6 +144,7 @@ export type OrgPermissionSet = | [OrgPermissionAuditLogsActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] | [OrgPermissionGatewayActions, OrgPermissionSubjects.Gateway] + | [OrgPermissionRelayActions, OrgPermissionSubjects.Relay] | [ OrgPermissionAppConnectionActions, ( @@ -166,6 +177,10 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ subject: z.literal(OrgPermissionSubjects.Workspace).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_ENUM([OrgPermissionActions.Create]).describe("Describe what action an entity can take.") }), + z.object({ + subject: z.literal(OrgPermissionSubjects.Project).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_ENUM([OrgPermissionActions.Create]).describe("Describe what action an entity can take.") + }), z.object({ subject: z.literal(OrgPermissionSubjects.Role).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") @@ -273,6 +288,12 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionGatewayActions).describe( "Describe what action an entity can take." ) + }), + z.object({ + subject: z.literal(OrgPermissionSubjects.Relay).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionRelayActions).describe( + "Describe what action an entity can take." + ) }) ]); @@ -280,6 +301,7 @@ const buildAdminPermission = () => { const { can, rules } = new AbilityBuilder>(createMongoAbility); // ws permissions can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Project); // role permission can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); can(OrgPermissionActions.Create, OrgPermissionSubjects.Role); @@ -376,6 +398,11 @@ const buildAdminPermission = () => { can(OrgPermissionGatewayActions.DeleteGateways, OrgPermissionSubjects.Gateway); can(OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway); + can(OrgPermissionRelayActions.ListRelays, OrgPermissionSubjects.Relay); + can(OrgPermissionRelayActions.CreateRelays, OrgPermissionSubjects.Relay); + can(OrgPermissionRelayActions.EditRelays, OrgPermissionSubjects.Relay); + can(OrgPermissionRelayActions.DeleteRelays, OrgPermissionSubjects.Relay); + can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole); can(OrgPermissionKmipActions.Setup, OrgPermissionSubjects.Kmip); @@ -413,6 +440,7 @@ const buildMemberPermission = () => { const { can, rules } = new AbilityBuilder>(createMongoAbility); can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Project); can(OrgPermissionActions.Read, OrgPermissionSubjects.Member); can(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); @@ -437,6 +465,10 @@ const buildMemberPermission = () => { can(OrgPermissionGatewayActions.CreateGateways, OrgPermissionSubjects.Gateway); can(OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway); + can(OrgPermissionRelayActions.ListRelays, OrgPermissionSubjects.Relay); + can(OrgPermissionRelayActions.CreateRelays, OrgPermissionSubjects.Relay); + can(OrgPermissionRelayActions.EditRelays, OrgPermissionSubjects.Relay); + can(OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate); can( OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates, diff --git a/backend/src/ee/services/permission/permission-service-types.ts b/backend/src/ee/services/permission/permission-service-types.ts index 5e71c65d9..f25b84bd5 100644 --- a/backend/src/ee/services/permission/permission-service-types.ts +++ b/backend/src/ee/services/permission/permission-service-types.ts @@ -1,5 +1,6 @@ import { MongoAbility, RawRuleOf } from "@casl/ability"; import { MongoQuery } from "@ucast/mongo2js"; +import { Knex } from "knex"; import { ActionProjectType } from "@app/db/schemas"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; @@ -283,4 +284,5 @@ export type TPermissionServiceFactory = { projectId: string; checkPermissions: ProjectPermissionSet; }) => Promise; + invalidateProjectPermissionCache: (projectId: string, tx?: Knex) => Promise; }; diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 461ceef46..9e94e1415 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -3,6 +3,7 @@ import { PackRule, unpackRules } from "@casl/ability/extra"; import { requestContext } from "@fastify/request-context"; import { MongoQuery } from "@ucast/mongo2js"; import handlebars from "handlebars"; +import { Knex } from "knex"; import { ActionProjectType, @@ -20,9 +21,11 @@ import { projectViewerPermission, sshHostBootstrapPermissions } from "@app/ee/services/permission/default-roles"; +import { KeyStorePrefixes, KeyStoreTtls, TKeyStoreFactory } from "@app/keystore/keystore"; import { conditionsMatcher } from "@app/lib/casl"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { objectify } from "@app/lib/fn"; +import { logger } from "@app/lib/logger"; import { ActorType } from "@app/services/auth/auth-type"; import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -49,6 +52,7 @@ type TPermissionServiceFactoryDep = { serviceTokenDAL: Pick; projectDAL: Pick; permissionDAL: TPermissionDALFactory; + keyStore: TKeyStoreFactory; }; export const permissionServiceFactory = ({ @@ -56,7 +60,8 @@ export const permissionServiceFactory = ({ orgRoleDAL, projectRoleDAL, serviceTokenDAL, - projectDAL + projectDAL, + keyStore }: TPermissionServiceFactoryDep): TPermissionServiceFactory => { const buildOrgPermission = (orgUserRoles: TBuildOrgPermissionDTO) => { const rules = orgUserRoles @@ -83,6 +88,68 @@ export const permissionServiceFactory = ({ }); }; + const invalidateProjectPermissionCache = async (projectId: string, tx?: Knex) => { + const projectPermissionDalVersionKey = KeyStorePrefixes.ProjectPermissionDalVersion(projectId); + await keyStore.pgIncrementBy(projectPermissionDalVersionKey, { + incr: 1, + tx, + expiry: KeyStoreTtls.ProjectPermissionDalVersionTtl + }); + }; + + const calculateProjectPermissionTtl = (membership: unknown): number => { + const now = new Date(); + let minTtl = KeyStoreTtls.ProjectPermissionCacheInSeconds; + + const getMinEndTime = (items: Array<{ temporaryAccessEndTime?: Date | null; isTemporary?: boolean }>) => { + return items + .filter((item) => item.isTemporary && item.temporaryAccessEndTime) + .map((item) => item.temporaryAccessEndTime!) + .filter((endTime) => endTime > now) + .reduce((min, endTime) => (!min || endTime < min ? endTime : min), null as Date | null); + }; + + const roleTimes: Date[] = []; + const additionalPrivilegeTimes: Date[] = []; + + if ( + membership && + typeof membership === "object" && + "roles" in membership && + Array.isArray((membership as Record).roles) + ) { + const roles = (membership as Record).roles as Array<{ + temporaryAccessEndTime?: Date | null; + isTemporary?: boolean; + }>; + const minRoleEndTime = getMinEndTime(roles); + if (minRoleEndTime) roleTimes.push(minRoleEndTime); + } + + if ( + membership && + typeof membership === "object" && + "additionalPrivileges" in membership && + Array.isArray((membership as Record).additionalPrivileges) + ) { + const additionalPrivileges = (membership as Record).additionalPrivileges as Array<{ + temporaryAccessEndTime?: Date | null; + isTemporary?: boolean; + }>; + const minAdditionalEndTime = getMinEndTime(additionalPrivileges); + if (minAdditionalEndTime) additionalPrivilegeTimes.push(minAdditionalEndTime); + } + + const allEndTimes = [...roleTimes, ...additionalPrivilegeTimes]; + if (allEndTimes.length > 0) { + const nearestEndTime = allEndTimes.reduce((min, endTime) => (!min || endTime < min ? endTime : min)); + const timeUntilExpiry = Math.floor((nearestEndTime.getTime() - now.getTime()) / 1000); + minTtl = Math.min(minTtl, Math.max(1, timeUntilExpiry)); + } + + return minTtl; + }; + const buildProjectPermissionRules = (projectUserRoles: TBuildProjectPermissionDTO) => { const rules = projectUserRoles .map(({ role, permissions }) => { @@ -577,35 +644,94 @@ export const permissionServiceFactory = ({ actorId = assumedPrivilegeDetailsCtx.actorId; } + if (actor === ActorType.SERVICE) { + return getServiceTokenProjectPermission({ + serviceTokenId: actorId, + projectId, + actorOrgId, + actionProjectType + }) as Promise>; + } + + const cachedProjectPermissionVersion = await keyStore.pgGetIntItem( + KeyStorePrefixes.ProjectPermissionDalVersion(projectId) + ); + const projectPermissionVersion = Number(cachedProjectPermissionVersion || 0); + const cacheKey = KeyStorePrefixes.ProjectPermission( + projectId, + projectPermissionVersion, + actor, + actorId, + actionProjectType || ActionProjectType.Any + ); + + try { + const cachedData = await keyStore.getItem(cacheKey); + if (cachedData) { + const parsed = JSON.parse(cachedData) as { + rules: RawRuleOf>[]; + membership: { + roles?: Array<{ role: string; customRoleSlug?: string }>; + [key: string]: unknown; + }; + }; + const permission = createMongoAbility(parsed.rules, { + conditionsMatcher + }); + + return { + permission, + membership: parsed.membership, + hasRole: (role: string) => + parsed.membership.roles?.findIndex( + ({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug + ) !== -1 + } as TProjectPermissionRT; + } + } catch (error) { + logger.error(error, "Failed to get project permission"); + } + + let result: TProjectPermissionRT; + switch (actor) { case ActorType.USER: - return getUserProjectPermission({ + result = (await getUserProjectPermission({ userId: actorId, projectId, authMethod: actorAuthMethod, userOrgId: actorOrgId, actionProjectType - }) as Promise>; - case ActorType.SERVICE: - return getServiceTokenProjectPermission({ - serviceTokenId: actorId, - projectId, - actorOrgId, - actionProjectType - }) as Promise>; + })) as TProjectPermissionRT; + break; case ActorType.IDENTITY: - return getIdentityProjectPermission({ + result = (await getIdentityProjectPermission({ identityId: actorId, projectId, identityOrgId: actorOrgId, actionProjectType - }) as Promise>; + })) as TProjectPermissionRT; + break; default: throw new BadRequestError({ message: "Invalid actor provided", name: "Get project permission" }); } + + try { + const cacheData = { + rules: result.permission.rules, + membership: result.membership + }; + + const ttl = calculateProjectPermissionTtl(result.membership); + await keyStore.setItemWithExpiry(cacheKey, ttl, JSON.stringify(cacheData)); + } catch (error) { + logger.error(error, "Failed to cache project permission"); + } + + return result; }; const getProjectPermissionByRole: TPermissionServiceFactory["getProjectPermissionByRole"] = async ( @@ -668,6 +794,7 @@ export const permissionServiceFactory = ({ getProjectPermissionByRole, buildOrgPermission, buildProjectPermissionRules, - checkGroupProjectPermission + checkGroupProjectPermission, + invalidateProjectPermissionCache }; }; diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 20b4344d3..d79120256 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -120,6 +120,16 @@ export enum ProjectPermissionSecretSyncActions { RemoveSecrets = "remove-secrets" } +export enum ProjectPermissionPkiSyncActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + SyncCertificates = "sync-certificates", + ImportCertificates = "import-certificates", + RemoveCertificates = "remove-certificates" +} + export enum ProjectPermissionSecretRotationActions { Read = "read", ReadGeneratedCredentials = "read-generated-credentials", @@ -147,6 +157,14 @@ export enum ProjectPermissionSecretScanningDataSourceActions { ReadResources = "read-data-source-resources" } +export enum ProjectPermissionAppConnectionActions { + Read = "read-app-connections", + Create = "create-app-connections", + Edit = "edit-app-connections", + Delete = "delete-app-connections", + Connect = "connect-app-connections" +} + export enum ProjectPermissionSecretScanningFindingActions { Read = "read-findings", Update = "update-findings" @@ -204,11 +222,13 @@ export enum ProjectPermissionSub { Kms = "kms", Cmek = "cmek", SecretSyncs = "secret-syncs", + PkiSyncs = "pki-syncs", Kmip = "kmip", SecretScanningDataSources = "secret-scanning-data-sources", SecretScanningFindings = "secret-scanning-findings", SecretScanningConfigs = "secret-scanning-configs", - SecretEvents = "secret-events" + SecretEvents = "secret-events", + AppConnections = "app-connections" } export type SecretSubjectFields = { @@ -235,6 +255,10 @@ export type SecretSyncSubjectFields = { secretPath: string; }; +export type PkiSyncSubjectFields = { + subscriberName: string; +}; + export type DynamicSecretSubjectFields = { environment: string; secretPath: string; @@ -272,6 +296,10 @@ export type PkiSubscriberSubjectFields = { // (dangtony98): consider adding [commonName] as a subject field in the future }; +export type AppConnectionSubjectFields = { + connectionId: string; +}; + export type ProjectPermissionSet = | [ ProjectPermissionSecretActions, @@ -295,6 +323,10 @@ export type ProjectPermissionSet = ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs | (ForcedSubject & SecretSyncSubjectFields) ] + | [ + ProjectPermissionPkiSyncActions, + ProjectPermissionSub.PkiSyncs | (ForcedSubject & PkiSyncSubjectFields) + ] | [ ProjectPermissionActions, ( @@ -365,6 +397,13 @@ export type ProjectPermissionSet = | [ ProjectPermissionSecretEventActions, ProjectPermissionSub.SecretEvents | (ForcedSubject & SecretEventSubjectFields) + ] + | [ + ProjectPermissionAppConnectionActions, + ( + | ProjectPermissionSub.AppConnections + | (ForcedSubject & AppConnectionSubjectFields) + ) ]; const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'"; @@ -460,6 +499,22 @@ const SecretSyncConditionV2Schema = z }) .partial(); +const PkiSyncConditionSchema = z + .object({ + subscriberName: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]) + }) + .partial(); + const SecretImportConditionSchema = z .object({ environment: z.union([ @@ -580,6 +635,21 @@ const PkiTemplateConditionSchema = z }) .partial(); +const AppConnectionConditionSchema = z + .object({ + connectionId: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + }) + .partial() + ]) + }) + .partial(); + const GeneralPermissionSchema = [ z.object({ subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."), @@ -760,6 +830,16 @@ const GeneralPermissionSchema = [ action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretScanningConfigActions).describe( "Describe what action an entity can take." ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.AppConnections).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionAppConnectionActions).describe( + "Describe what action an entity can take." + ), + conditions: AppConnectionConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() }) ]; @@ -898,6 +978,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + z.object({ + subject: z.literal(ProjectPermissionSub.PkiSyncs).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPkiSyncActions).describe( + "Describe what action an entity can take." + ), + conditions: PkiSyncConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), z.object({ subject: z.literal(ProjectPermissionSub.SecretEvents).describe("The entity this permission pertains to."), inverted: z.boolean().optional().describe("Whether rule allows or forbids."), diff --git a/backend/src/ee/services/pit/pit-service.ts b/backend/src/ee/services/pit/pit-service.ts index ef7f9b5a3..d561064a6 100644 --- a/backend/src/ee/services/pit/pit-service.ts +++ b/backend/src/ee/services/pit/pit-service.ts @@ -754,7 +754,8 @@ export const pitServiceFactory = ({ secrets: newSecrets.map((secret) => ({ secretId: secret.id, secretKey: secret.secretKey, - secretVersion: secret.version + secretVersion: secret.version, + secretTags: secret.tags?.map((tag) => tag.name) })) } }); @@ -781,7 +782,8 @@ export const pitServiceFactory = ({ secrets: updatedSecrets.map((secret) => ({ secretId: secret.id, secretKey: secret.secretKey, - secretVersion: secret.version + secretVersion: secret.version, + secretTags: secret.tags?.map((tag) => tag.name) })) } }); diff --git a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts index 944775156..c2876572f 100644 --- a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts +++ b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts @@ -27,7 +27,7 @@ import { type TProjectUserAdditionalPrivilegeServiceFactoryDep = { projectUserAdditionalPrivilegeDAL: TProjectUserAdditionalPrivilegeDALFactory; projectMembershipDAL: Pick; - permissionService: Pick; + permissionService: Pick; accessApprovalRequestDAL: Pick; }; @@ -115,6 +115,9 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ slug, permissions: packedPermission }); + + await permissionService.invalidateProjectPermissionCache(projectMembership.projectId); + return { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) @@ -133,6 +136,9 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime), temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs) }); + + await permissionService.invalidateProjectPermissionCache(projectMembership.projectId); + return { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) @@ -230,6 +236,8 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || "")) }); + await permissionService.invalidateProjectPermissionCache(projectMembership.projectId); + return { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) @@ -245,6 +253,9 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ temporaryRange: null, temporaryMode: null }); + + await permissionService.invalidateProjectPermissionCache(projectMembership.projectId); + return { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) @@ -291,6 +302,9 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ } ); const deletedPrivilege = await projectUserAdditionalPrivilegeDAL.deleteById(userPrivilege.id); + + await permissionService.invalidateProjectPermissionCache(projectMembership.projectId); + return { ...deletedPrivilege, permissions: unpackPermissions(deletedPrivilege.permissions) diff --git a/backend/src/ee/services/relay/relay-constants.ts b/backend/src/ee/services/relay/relay-constants.ts new file mode 100644 index 000000000..d7f00f036 --- /dev/null +++ b/backend/src/ee/services/relay/relay-constants.ts @@ -0,0 +1 @@ +export const RELAY_CONNECTING_GATEWAY_INFO = "1.3.6.1.4.1.12345.100.3"; diff --git a/backend/src/ee/services/relay/relay-service.ts b/backend/src/ee/services/relay/relay-service.ts index 92401faaf..2fef71259 100644 --- a/backend/src/ee/services/relay/relay-service.ts +++ b/backend/src/ee/services/relay/relay-service.ts @@ -1,9 +1,13 @@ +import { isIP } from "node:net"; + +import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import { TRelays } from "@app/db/schemas"; import { PgSqlLock } from "@app/keystore/keystore"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { constructPemChainFromCerts, prependCertToPemChain } from "@app/services/certificate/certificate-fns"; import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; import { @@ -14,11 +18,15 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { verifyHostInputValidity } from "../dynamic-secret/dynamic-secret-fns"; +import { TLicenseServiceFactory } from "../license/license-service"; +import { OrgPermissionRelayActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { createSshCert, createSshKeyPair } from "../ssh/ssh-certificate-authority-fns"; import { SshCertType } from "../ssh/ssh-certificate-authority-types"; import { SshCertKeyAlgorithm } from "../ssh-certificate/ssh-certificate-types"; import { TInstanceRelayConfigDALFactory } from "./instance-relay-config-dal"; import { TOrgRelayConfigDALFactory } from "./org-relay-config-dal"; +import { RELAY_CONNECTING_GATEWAY_INFO } from "./relay-constants"; import { TRelayDALFactory } from "./relay-dal"; export type TRelayServiceFactory = ReturnType; @@ -29,12 +37,16 @@ export const relayServiceFactory = ({ instanceRelayConfigDAL, orgRelayConfigDAL, relayDAL, - kmsService + kmsService, + licenseService, + permissionService }: { instanceRelayConfigDAL: TInstanceRelayConfigDALFactory; orgRelayConfigDAL: TOrgRelayConfigDALFactory; relayDAL: TRelayDALFactory; kmsService: TKmsServiceFactory; + licenseService: TLicenseServiceFactory; + permissionService: TPermissionServiceFactory; }) => { const $getInstanceCAs = async () => { const instanceConfig = await instanceRelayConfigDAL.transaction(async (tx) => { @@ -639,8 +651,9 @@ export const relayServiceFactory = ({ true ), new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.SERVER_AUTH]], true), + // san - new x509.SubjectAlternativeNameExtension([{ type: "ip", value: host }], false) + new x509.SubjectAlternativeNameExtension([{ type: isIP(host) ? "ip" : "dns", value: host }], false) ]; const relayServerSerialNumber = createSerialNumber(); @@ -689,6 +702,7 @@ export const relayServiceFactory = ({ const $generateRelayClientCredentials = async ({ gatewayId, + gatewayName, orgId, orgName, relayPkiClientCaCertificate, @@ -697,6 +711,7 @@ export const relayServiceFactory = ({ relayPkiServerCaCertificateChain }: { gatewayId: string; + gatewayName: string; orgId: string; orgName: string; relayPkiClientCaCertificate: Buffer; @@ -727,6 +742,16 @@ export const relayServiceFactory = ({ const clientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey); const clientCertSerialNumber = createSerialNumber(); + const connectingGatewayInfoExtension = new x509.Extension( + RELAY_CONNECTING_GATEWAY_INFO, + false, + Buffer.from( + JSON.stringify({ + name: gatewayName + }) + ) + ); + // Build standard extensions const extensions: x509.Extension[] = [ new x509.BasicConstraintsExtension(false), @@ -740,7 +765,8 @@ export const relayServiceFactory = ({ x509.KeyUsageFlags[CertKeyUsage.KEY_AGREEMENT], true ), - new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.CLIENT_AUTH]], true) + new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.CLIENT_AUTH]], true), + connectingGatewayInfoExtension ]; const clientCert = await x509.X509CertificateGenerator.create({ @@ -768,11 +794,13 @@ export const relayServiceFactory = ({ const getCredentialsForGateway = async ({ relayName, orgId, - gatewayId + gatewayId, + gatewayName }: { relayName: string; orgId: string; gatewayId: string; + gatewayName: string; }) => { let relay: TRelays | null = await relayDAL.findOne({ orgId, @@ -819,10 +847,10 @@ export const relayServiceFactory = ({ const relayClientSshCert = await createSshCert({ caPrivateKey: orgCAs.relaySshClientCaPrivateKey.toString("utf8"), clientPublicKey: relayClientSshPublicKey, - keyId: `relay-client-${relay.id}`, - principals: [gatewayId], + keyId: `client-${relayName}`, + principals: [gatewayId, gatewayName], certType: SshCertType.USER, - requestedTtl: "30d" + requestedTtl: "1d" }); return { @@ -837,12 +865,14 @@ export const relayServiceFactory = ({ relayId, orgId, orgName, - gatewayId + gatewayId, + gatewayName }: { relayId: string; orgId: string; orgName: string; gatewayId: string; + gatewayName: string; }) => { const relay = await relayDAL.findOne({ id: relayId @@ -860,6 +890,7 @@ export const relayServiceFactory = ({ const instanceCAs = await $getInstanceCAs(); const relayCertificateCredentials = await $generateRelayClientCredentials({ gatewayId, + gatewayName, orgId, orgName, relayPkiClientCaCertificate: instanceCAs.instanceRelayPkiClientCaCertificate, @@ -877,6 +908,7 @@ export const relayServiceFactory = ({ const orgCAs = await $getOrgCAs(orgId); const relayCertificateCredentials = await $generateRelayClientCredentials({ gatewayId, + gatewayName, orgId, orgName, relayPkiClientCaCertificate: orgCAs.relayPkiClientCaCertificate, @@ -895,11 +927,13 @@ export const relayServiceFactory = ({ host, name, identityId, + actorAuthMethod, orgId }: { host: string; name: string; identityId?: string; + actorAuthMethod?: ActorAuthMethod; orgId?: string; }) => { let relay: TRelays; @@ -908,6 +942,27 @@ export const relayServiceFactory = ({ await verifyHostInputValidity(host); if (isOrgRelay) { + const orgLicensePlan = await licenseService.getPlan(orgId); + if (!orgLicensePlan.gateway) { + throw new BadRequestError({ + message: + "Relay registration failed due to organization plan restrictions. Please upgrade your instance to Infisical's Enterprise plan." + }); + } + + const { permission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityId, + orgId, + actorAuthMethod!, + orgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionRelayActions.CreateRelays, + OrgPermissionSubjects.Relay + ); + relay = await relayDAL.transaction(async (tx) => { const existingRelay = await relayDAL.findOne( { @@ -995,9 +1050,75 @@ export const relayServiceFactory = ({ }); }; + const getRelays = async ({ + actorId, + actor, + actorAuthMethod, + actorOrgId + }: { + actorId: string; + actor: ActorType; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + }) => { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionRelayActions.ListRelays, OrgPermissionSubjects.Relay); + + const instanceRelays = await relayDAL.find({ + orgId: null + }); + + const orgRelays = await relayDAL.find({ + orgId: actorOrgId + }); + + return [...instanceRelays, ...orgRelays]; + }; + + const deleteRelay = async ({ + id, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: { + id: string; + actorId: string; + actor: ActorType; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + }) => { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionRelayActions.DeleteRelays, OrgPermissionSubjects.Relay); + + const relay = await relayDAL.findById(id); + if (!relay || relay.orgId !== actorOrgId || relay.orgId === null) { + throw new NotFoundError({ message: "Relay not found" }); + } + + const deletedRelay = await relayDAL.deleteById(id); + return deletedRelay; + }; + return { registerRelay, getCredentialsForGateway, - getCredentialsForClient + getCredentialsForClient, + getRelays, + deleteRelay }; }; diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 31aaa70aa..f1ee313e8 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -1,6 +1,17 @@ +/* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; +import { Knex } from "knex"; +import RE2 from "re2"; -import { OrgMembershipStatus, TableName, TSamlConfigs, TSamlConfigsUpdate, TUsers } from "@app/db/schemas"; +import { + OrgMembershipRole, + OrgMembershipStatus, + TableName, + TGroups, + TSamlConfigs, + TSamlConfigsUpdate, + TUsers +} from "@app/db/schemas"; import { throwOnPlanSeatLimitReached } from "@app/ee/services/license/license-fns"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; @@ -8,12 +19,16 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/ import { AuthTokenType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TokenType } from "@app/services/auth-token/auth-token-types"; +import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TIdentityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; +import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { LoginMethod } from "@app/services/super-admin/super-admin-types"; @@ -22,17 +37,30 @@ import { normalizeUsername } from "@app/services/user/user-fns"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { UserAliasType } from "@app/services/user-alias/user-alias-types"; +import { TGroupDALFactory } from "../group/group-dal"; +import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "../group/group-fns"; +import { TUserGroupMembershipDALFactory } from "../group/user-group-membership-dal"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { TSamlConfigDALFactory } from "./saml-config-dal"; -import { TSamlConfigServiceFactory } from "./saml-config-types"; +import { SamlProviders, TSamlConfigServiceFactory } from "./saml-config-types"; + +// SAML providers that support group sync +const GROUP_SYNC_SUPPORTED_PROVIDERS = [SamlProviders.GOOGLE_SAML] as SamlProviders[]; type TSamlConfigServiceFactoryDep = { samlConfigDAL: Pick; userDAL: Pick< TUserDALFactory, - "create" | "findOne" | "transaction" | "updateById" | "findById" | "findUserEncKeyByUserId" + | "create" + | "findOne" + | "find" + | "transaction" + | "updateById" + | "findById" + | "findUserEncKeyByUserId" + | "findUserEncKeyByUserIdsBatch" >; userAliasDAL: Pick; orgDAL: Pick< @@ -41,6 +69,15 @@ type TSamlConfigServiceFactoryDep = { >; identityMetadataDAL: Pick; orgMembershipDAL: Pick; + groupDAL: Pick; + userGroupMembershipDAL: Pick< + TUserGroupMembershipDALFactory, + "find" | "delete" | "transaction" | "insertMany" | "filterProjectsByUserMembership" + >; + groupProjectDAL: Pick; + projectDAL: Pick; + projectBotDAL: Pick; + projectKeyDAL: Pick; permissionService: Pick; licenseService: Pick; tokenService: Pick; @@ -54,6 +91,12 @@ export const samlConfigServiceFactory = ({ orgMembershipDAL, userDAL, userAliasDAL, + groupDAL, + userGroupMembershipDAL, + groupProjectDAL, + projectDAL, + projectBotDAL, + projectKeyDAL, permissionService, licenseService, tokenService, @@ -61,6 +104,139 @@ export const samlConfigServiceFactory = ({ identityMetadataDAL, kmsService }: TSamlConfigServiceFactoryDep): TSamlConfigServiceFactory => { + const parseSamlGroups = (groupsValue: string): string[] => { + let samlGroups: string[] = []; + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const parsed = JSON.parse(groupsValue); + if (Array.isArray(parsed)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + samlGroups = parsed; + } else if (typeof parsed === "string") { + samlGroups = parsed + .split(",") + .map((g) => g.trim()) + .filter(Boolean); + } + } catch { + samlGroups = groupsValue + .split(",") + .map((g) => g.trim()) + .filter(Boolean); + } + + return samlGroups; + }; + + const syncUserGroupMemberships = async ({ + userId, + orgId, + samlGroups, + tx + }: { + userId: string; + orgId: string; + samlGroups: string[]; + tx?: Knex; + }) => { + const processGroupSync = async (transaction: Knex) => { + const currentGroupMemberships = await userGroupMembershipDAL.find( + { + userId + }, + { tx: transaction } + ); + + const orgGroups = await groupDAL.find({ orgId }, { tx: transaction }); + const orgGroupsMap = new Map(orgGroups.map((g: TGroups) => [g.name, g])); + const orgGroupIds = new Set(orgGroups.map((g) => g.id)); + + const currentOrgGroupMemberships = currentGroupMemberships.filter((m) => orgGroupIds.has(m.groupId)); + const currentGroupNames = new Set( + currentOrgGroupMemberships + .map((m) => { + const group = orgGroups.find((g) => g.id === m.groupId); + return group?.name; + }) + .filter(Boolean) + ); + + const targetGroupNames = new Set(samlGroups); + const groupsToAdd = samlGroups.filter((groupName) => !currentGroupNames.has(groupName)); + const groupsToRemove = Array.from(currentGroupNames).filter( + (groupName) => groupName && !targetGroupNames.has(groupName) + ); + // eslint-disable-next-line no-await-in-loop + for (const groupName of groupsToAdd) { + if (!orgGroupsMap.has(groupName)) { + const newGroup = await groupDAL.create( + { + name: groupName, + slug: `${groupName.toLowerCase().replace(new RE2("[^a-z0-9]", "g"), "-")}-${Date.now()}`, + orgId, + role: OrgMembershipRole.NoAccess, + roleId: null + }, + transaction + ); + orgGroupsMap.set(groupName, newGroup); + } + } + + // eslint-disable-next-line no-await-in-loop + for (const groupName of groupsToAdd) { + const group = orgGroupsMap.get(groupName); + if (group) { + try { + await addUsersToGroupByUserIds({ + userIds: [userId], + group, + userDAL, + userGroupMembershipDAL, + orgDAL, + groupProjectDAL, + projectKeyDAL, + projectDAL, + projectBotDAL, + tx: transaction + }); + } catch (error) { + // Continue if user already in group + } + } + } + + // eslint-disable-next-line no-await-in-loop + for (const groupName of groupsToRemove) { + if (groupName) { + const group = orgGroupsMap.get(groupName); + if (group) { + try { + await removeUsersFromGroupByUserIds({ + userIds: [userId], + group, + userDAL, + userGroupMembershipDAL, + groupProjectDAL, + projectKeyDAL, + tx: transaction + }); + } catch (error) { + // Continue if user not in group + } + } + } + } + }; + + if (tx) { + await processGroupSync(tx); + } else { + await userDAL.transaction(processGroupSync); + } + }; + const createSamlCfg: TSamlConfigServiceFactory["createSamlCfg"] = async ({ idpCert, actor, @@ -71,7 +247,8 @@ export const samlConfigServiceFactory = ({ actorId, isActive, entryPoint, - authProvider + authProvider, + enableGroupSync }) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Sso); @@ -96,6 +273,18 @@ export const samlConfigServiceFactory = ({ }); } + if (enableGroupSync && !GROUP_SYNC_SUPPORTED_PROVIDERS.includes(authProvider)) { + throw new BadRequestError({ + message: "Group sync is not supported for this SAML provider." + }); + } + + if (enableGroupSync && !plan.groups) { + throw new BadRequestError({ + message: "Failed to enable SAML group sync due to plan restriction. Upgrade plan to enable group sync." + }); + } + const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId @@ -107,7 +296,8 @@ export const samlConfigServiceFactory = ({ isActive, encryptedSamlCertificate: encryptor({ plainText: Buffer.from(idpCert) }).cipherTextBlob, encryptedSamlEntryPoint: encryptor({ plainText: Buffer.from(entryPoint) }).cipherTextBlob, - encryptedSamlIssuer: encryptor({ plainText: Buffer.from(issuer) }).cipherTextBlob + encryptedSamlIssuer: encryptor({ plainText: Buffer.from(issuer) }).cipherTextBlob, + enableGroupSync: enableGroupSync || false }); return samlConfig; @@ -123,7 +313,8 @@ export const samlConfigServiceFactory = ({ issuer, isActive, entryPoint, - authProvider + authProvider, + enableGroupSync }) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); @@ -147,7 +338,27 @@ export const samlConfigServiceFactory = ({ }); } - const updateQuery: TSamlConfigsUpdate = { authProvider, isActive, lastUsed: null }; + if (enableGroupSync && authProvider && !GROUP_SYNC_SUPPORTED_PROVIDERS.includes(authProvider)) { + throw new BadRequestError({ + message: "Group sync is not supported for this SAML provider." + }); + } + + if (enableGroupSync && !plan.groups) { + throw new BadRequestError({ + message: "Failed to enable SAML group sync due to plan restriction. Upgrade plan to enable group sync." + }); + } + + const updateQuery: TSamlConfigsUpdate = { + authProvider, + isActive, + lastUsed: null + }; + + if (enableGroupSync !== undefined) { + updateQuery.enableGroupSync = enableGroupSync; + } const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId @@ -250,7 +461,8 @@ export const samlConfigServiceFactory = ({ entryPoint, issuer, cert, - lastUsed: samlConfig.lastUsed + lastUsed: samlConfig.lastUsed, + enableGroupSync: samlConfig.enableGroupSync }; }; @@ -282,6 +494,12 @@ export const samlConfigServiceFactory = ({ const organization = await orgDAL.findOrgById(orgId); if (!organization) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); + const samlConfig = await samlConfigDAL.findOne({ orgId }); + const groupsMetadata = metadata?.find(({ key }) => key === "groups"); + + const plan = await licenseService.getPlan(orgId); + const shouldSyncGroups = !!samlConfig?.enableGroupSync && !!plan.groups; + let user: TUsers; if (userAlias) { user = await userDAL.transaction(async (tx) => { @@ -303,7 +521,7 @@ export const samlConfigServiceFactory = ({ orgId, role, roleId, - status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, isActive: true }, tx @@ -334,6 +552,17 @@ export const samlConfigServiceFactory = ({ } } + if (shouldSyncGroups && metadata && foundUser.id) { + const samlGroups = groupsMetadata?.value ? parseSamlGroups(groupsMetadata.value) : []; + + await syncUserGroupMemberships({ + userId: foundUser.id, + orgId, + samlGroups, + tx + }); + } + return foundUser; }); } else { @@ -425,6 +654,18 @@ export const samlConfigServiceFactory = ({ ); } } + + if (shouldSyncGroups && metadata && newUser.id) { + const samlGroups = groupsMetadata?.value ? parseSamlGroups(groupsMetadata.value) : []; + + await syncUserGroupMemberships({ + userId: newUser.id, + orgId, + samlGroups, + tx + }); + } + return newUser; }); } diff --git a/backend/src/ee/services/saml-config/saml-config-types.ts b/backend/src/ee/services/saml-config/saml-config-types.ts index f4ede04fa..bdf65b988 100644 --- a/backend/src/ee/services/saml-config/saml-config-types.ts +++ b/backend/src/ee/services/saml-config/saml-config-types.ts @@ -17,6 +17,7 @@ export type TCreateSamlCfgDTO = { entryPoint: string; issuer: string; idpCert: string; + enableGroupSync?: boolean; } & TOrgPermission; export type TUpdateSamlCfgDTO = Partial<{ @@ -25,6 +26,7 @@ export type TUpdateSamlCfgDTO = Partial<{ entryPoint: string; issuer: string; idpCert: string; + enableGroupSync?: boolean; }> & TOrgPermission; @@ -71,6 +73,7 @@ export type TSamlConfigServiceFactory = { issuer: string; cert: string; lastUsed: Date | null | undefined; + enableGroupSync?: boolean; }>; samlLogin: (arg: TSamlLoginDTO) => Promise<{ isUserCompleted: boolean; diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-fns.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-fns.ts index dfe425b8e..d96fb2e53 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-fns.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-fns.ts @@ -1,5 +1,7 @@ import { TSecretApprovalRequests } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; +import { TNotificationServiceFactory } from "@app/services/notification/notification-service"; +import { NotificationType } from "@app/services/notification/notification-types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; @@ -11,6 +13,7 @@ type TSendApprovalEmails = { smtpService: Pick; projectId: string; secretApprovalRequest: TSecretApprovalRequests; + notificationService: Pick; }; export const sendApprovalEmailsFn = async ({ @@ -18,7 +21,8 @@ export const sendApprovalEmailsFn = async ({ projectDAL, smtpService, projectId, - secretApprovalRequest + secretApprovalRequest, + notificationService }: TSendApprovalEmails) => { const cfg = getConfig(); @@ -26,6 +30,17 @@ export const sendApprovalEmailsFn = async ({ const project = await projectDAL.findProjectWithOrg(projectId); + await notificationService.createUserNotifications( + policy.userApprovers.map((approver) => ({ + userId: approver.userId, + orgId: project.orgId, + type: NotificationType.SECRET_CHANGE_REQUEST, + title: "Secret Change Request", + body: `You have a new secret change request pending your review for the project **${project.name}** in the organization **${project.organization.name}**.`, + link: `/projects/secret-management/${project.id}/approval?requestId=${secretApprovalRequest.id}` + })) + ); + // now we need to go through each of the reviewers and print out all the commits that they need to approve for await (const reviewerUser of policy.userApprovers) { await smtpService.sendMail({ diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts index 17182cddf..d0df188df 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts @@ -284,7 +284,8 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { db.ref("version").withSchema(TableName.SecretVersionV2).as("secVerVersion"), db.ref("key").withSchema(TableName.SecretVersionV2).as("secVerKey"), db.ref("encryptedValue").withSchema(TableName.SecretVersionV2).as("secVerValue"), - db.ref("encryptedComment").withSchema(TableName.SecretVersionV2).as("secVerComment") + db.ref("encryptedComment").withSchema(TableName.SecretVersionV2).as("secVerComment"), + db.ref("skipMultilineEncoding").withSchema(TableName.SecretVersionV2).as("secVerSkipMultilineEncoding") ) .select( db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), @@ -326,14 +327,22 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { { key: "secretVersion", label: "secretVersion" as const, - mapper: ({ secretVersion, secVerVersion, secVerKey, secVerValue, secVerComment }) => + mapper: ({ + secretVersion, + secVerVersion, + secVerKey, + secVerValue, + secVerComment, + secVerSkipMultilineEncoding + }) => secretVersion ? { version: secVerVersion, id: secretVersion, key: secVerKey, encryptedValue: secVerValue, - encryptedComment: secVerComment + encryptedComment: secVerComment, + skipMultilineEncoding: secVerSkipMultilineEncoding } : undefined, childrenMapper: [ diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 17b7d8347..affec39ad 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -28,6 +28,8 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; import { TProjectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal"; +import { TNotificationServiceFactory } from "@app/services/notification/notification-service"; +import { NotificationType } from "@app/services/notification/notification-types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; @@ -140,6 +142,7 @@ type TSecretApprovalRequestServiceFactoryDep = { projectMicrosoftTeamsConfigDAL: Pick; microsoftTeamsService: Pick; folderCommitService: Pick; + notificationService: Pick; }; export type TSecretApprovalRequestServiceFactory = ReturnType; @@ -172,7 +175,8 @@ export const secretApprovalRequestServiceFactory = ({ resourceMetadataDAL, projectMicrosoftTeamsConfigDAL, microsoftTeamsService, - folderCommitService + folderCommitService, + notificationService }: TSecretApprovalRequestServiceFactoryDep) => { const requestCount = async ({ projectId, @@ -281,13 +285,10 @@ export const secretApprovalRequestServiceFactory = ({ ) { throw new ForbiddenRequestError({ message: "User has insufficient privileges" }); } - const getHasSecretReadAccess = ( - shouldCheckSecretPermission: boolean | null | undefined, - environment: string, - tags: { slug: string }[], - secretPath?: string - ) => { - if (shouldCheckSecretPermission) { + const getHasSecretReadAccess = (environment: string, tags: { slug: string }[], secretPath?: string) => { + const isReviewer = policy.approvers.some(({ userId }) => userId === actorId); + + if (!isReviewer) { const canRead = hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { environment, secretPath: secretPath || "/", @@ -318,44 +319,33 @@ export const secretApprovalRequestServiceFactory = ({ version: el.version, secretMetadata: el.secretMetadata as ResourceMetadataDTO, isRotatedSecret: el.secret?.isRotatedSecret ?? false, - secretValueHidden: !getHasSecretReadAccess( - secretApprovalRequest.policy.shouldCheckSecretPermission, - secretApprovalRequest.environment, - el.tags, - secretPath?.[0]?.path - ), - secretValue: !getHasSecretReadAccess( - secretApprovalRequest.policy.shouldCheckSecretPermission, - secretApprovalRequest.environment, - el.tags, - secretPath?.[0]?.path - ) + secretValueHidden: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path), + secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path) ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : el.secret && el.secret.isRotatedSecret ? undefined - : el.encryptedValue + : el.encryptedValue !== undefined && el.encryptedValue !== null ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() - : "", - secretComment: el.encryptedComment - ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() - : "", + : undefined, + secretComment: + el.encryptedComment !== undefined && el.encryptedComment !== null + ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() + : undefined, + skipMultilineEncoding: + el.skipMultilineEncoding !== undefined && el.skipMultilineEncoding !== null + ? el.skipMultilineEncoding + : undefined, secret: el.secret ? { secretKey: el.secret.key, id: el.secret.id, version: el.secret.version, secretValueHidden: !getHasSecretReadAccess( - secretApprovalRequest.policy.shouldCheckSecretPermission, secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path ), - secretValue: !getHasSecretReadAccess( - secretApprovalRequest.policy.shouldCheckSecretPermission, - secretApprovalRequest.environment, - el.tags, - secretPath?.[0]?.path - ) + secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path) ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : el.secret.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedValue }).toString() @@ -371,17 +361,11 @@ export const secretApprovalRequestServiceFactory = ({ id: el.secretVersion.id, version: el.secretVersion.version, secretValueHidden: !getHasSecretReadAccess( - secretApprovalRequest.policy.shouldCheckSecretPermission, secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path ), - secretValue: !getHasSecretReadAccess( - secretApprovalRequest.policy.shouldCheckSecretPermission, - secretApprovalRequest.environment, - el.tags, - secretPath?.[0]?.path - ) + secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path) ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : el.secretVersion.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedValue }).toString() @@ -390,7 +374,8 @@ export const secretApprovalRequestServiceFactory = ({ ? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedComment }).toString() : "", tags: el.secretVersion.tags, - secretMetadata: el.oldSecretMetadata as ResourceMetadataDTO + secretMetadata: el.oldSecretMetadata as ResourceMetadataDTO, + skipMultilineEncoding: el.secretVersion.skipMultilineEncoding } : undefined })); @@ -399,12 +384,7 @@ export const secretApprovalRequestServiceFactory = ({ const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id); secrets = encryptedSecrets.map((el) => ({ ...el, - secretValueHidden: !getHasSecretReadAccess( - secretApprovalRequest.policy.shouldCheckSecretPermission, - secretApprovalRequest.environment, - el.tags, - secretPath?.[0]?.path - ), + secretValueHidden: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path), ...decryptSecretWithBot(el, botKey), secret: el.secret ? { @@ -729,9 +709,9 @@ export const secretApprovalRequestServiceFactory = ({ tx, inputSecrets: secretUpdationCommits.map((el) => { const encryptedValue = - !el.secret?.isRotatedSecret && typeof el.encryptedValue !== "undefined" + !el.secret?.isRotatedSecret && el.encryptedValue !== null && el.encryptedValue !== undefined ? { - encryptedValue: el.encryptedValue as Buffer, + encryptedValue: el.encryptedValue, references: el.encryptedValue ? getAllSecretReferencesV2Bridge( secretManagerDecryptor({ @@ -745,9 +725,9 @@ export const secretApprovalRequestServiceFactory = ({ filter: { id: el.secretId as string, type: SecretType.Shared }, data: { reminderRepeatDays: el.reminderRepeatDays, - encryptedComment: el.encryptedComment, + encryptedComment: el.encryptedComment !== null ? el.encryptedComment : undefined, reminderNote: el.reminderNote, - skipMultilineEncoding: el.skipMultilineEncoding, + skipMultilineEncoding: el.skipMultilineEncoding !== null ? el.skipMultilineEncoding : undefined, key: el.key, tags: el?.tags.map(({ id }) => id), secretMetadata: el.secretMetadata as ResourceMetadataDTO, @@ -1035,6 +1015,17 @@ export const secretApprovalRequestServiceFactory = ({ } }); + await notificationService.createUserNotifications( + approverUsers.map((approver) => ({ + userId: approver.id, + orgId: project.orgId, + type: NotificationType.SECRET_CHANGE_POLICY_BYPASSED, + title: "Secret Change Policy Bypassed", + body: `**${requestedByUser.firstName} ${requestedByUser.lastName}** (${requestedByUser.email}) has merged a secret to **${policy.secretPath}** in the **${env.name}** environment for project **${project.name}** without obtaining the required approval.`, + link: `/projects/secret-management/${project.id}/approval` + })) + ); + await smtpService.sendMail({ recipients: approverUsers.filter((approver) => approver.email).map((approver) => approver.email!), subjectLine: "Infisical Secret Change Policy Bypassed", @@ -1069,7 +1060,9 @@ export const secretApprovalRequestServiceFactory = ({ // @ts-expect-error not present on v1 secrets secretKey: secret.key as string, // @ts-expect-error not present on v1 secrets - secretMetadata: secret.secretMetadata as ResourceMetadataDTO + secretMetadata: secret.secretMetadata as ResourceMetadataDTO, + // @ts-expect-error not present on v1 secrets + secretTags: (secret.tags as { name: string }[])?.map((tag) => tag.name) })) } }); @@ -1085,7 +1078,9 @@ export const secretApprovalRequestServiceFactory = ({ // @ts-expect-error not present on v1 secrets secretKey: secret.key as string, // @ts-expect-error not present on v1 secrets - secretMetadata: secret.secretMetadata as ResourceMetadataDTO + secretMetadata: secret.secretMetadata as ResourceMetadataDTO, + // @ts-expect-error not present on v1 secrets + secretTags: (secret.tags as { name: string }[])?.map((tag) => tag.name) } }); } @@ -1104,7 +1099,9 @@ export const secretApprovalRequestServiceFactory = ({ // @ts-expect-error not present on v1 secrets secretKey: secret.key as string, // @ts-expect-error not present on v1 secrets - secretMetadata: secret.secretMetadata as ResourceMetadataDTO + secretMetadata: secret.secretMetadata as ResourceMetadataDTO, + // @ts-expect-error not present on v1 secrets + secretTags: (secret.tags as { name: string }[])?.map((tag) => tag.name) })) } }); @@ -1120,7 +1117,9 @@ export const secretApprovalRequestServiceFactory = ({ // @ts-expect-error not present on v1 secrets secretKey: secret.key as string, // @ts-expect-error not present on v1 secrets - secretMetadata: secret.secretMetadata as ResourceMetadataDTO + secretMetadata: secret.secretMetadata as ResourceMetadataDTO, + // @ts-expect-error not present on v1 secrets + secretTags: (secret.tags as { name: string }[])?.map((tag) => tag.name) } }); } @@ -1446,7 +1445,8 @@ export const secretApprovalRequestServiceFactory = ({ secretApprovalPolicyDAL, secretApprovalRequest, smtpService, - projectId + projectId, + notificationService }); return secretApprovalRequest; @@ -1609,11 +1609,13 @@ export const secretApprovalRequestServiceFactory = ({ key: newSecretName || secretKey, encryptedComment: setKnexStringValue( secretComment, - (value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob + (value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob, + true // scott: we need to encrypt empty string on update to differentiate not updating comment vs clearing comment ), encryptedValue: setKnexStringValue( secretValue, - (value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob + (value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob, + true // scott: we need to encrypt empty string on update to differentiate not updating value vs clearing value ), reminderRepeatDays, reminderNote, @@ -1813,7 +1815,8 @@ export const secretApprovalRequestServiceFactory = ({ secretApprovalPolicyDAL, secretApprovalRequest, smtpService, - projectId + projectId, + notificationService }); return secretApprovalRequest; }; diff --git a/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns.ts index 07cf97a7e..9b1fd14a0 100644 --- a/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns.ts @@ -175,7 +175,8 @@ export const ldapPasswordRotationFactory: TRotationFactory< const encryptedCredentials = await encryptAppConnectionCredentials({ credentials: updatedCredentials, orgId, - kmsService + kmsService, + projectId: connection.projectId }); await appConnectionDAL.updateById(connection.id, { encryptedCredentials }); diff --git a/backend/src/ee/services/secret-rotation-v2/redis-credentials/index.ts b/backend/src/ee/services/secret-rotation-v2/redis-credentials/index.ts new file mode 100644 index 000000000..2d90beab3 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/redis-credentials/index.ts @@ -0,0 +1,4 @@ +export * from "./redis-credentials-rotation-constants"; +export * from "./redis-credentials-rotation-fns"; +export * from "./redis-credentials-rotation-schemas"; +export * from "./redis-credentials-rotation-types"; diff --git a/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-constants.ts b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-constants.ts new file mode 100644 index 000000000..1cb14a922 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-constants.ts @@ -0,0 +1,15 @@ +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const REDIS_CREDENTIALS_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = { + name: "Redis Credentials", + type: SecretRotation.RedisCredentials, + connection: AppConnection.Redis, + template: { + secretsMapping: { + username: "REDIS_USERNAME", + password: "REDIS_PASSWORD" + } + } +}; diff --git a/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-fns.ts new file mode 100644 index 000000000..41e23a617 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-fns.ts @@ -0,0 +1,194 @@ +/* eslint-disable no-await-in-loop */ +import Redis from "ioredis"; + +import { + TRotationFactory, + TRotationFactoryGetSecretsPayload, + TRotationFactoryIssueCredentials, + TRotationFactoryRevokeCredentials, + TRotationFactoryRotateCredentials +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { BadRequestError } from "@app/lib/errors"; + +import { verifyHostInputValidity } from "../../dynamic-secret/dynamic-secret-fns"; +import { DEFAULT_PASSWORD_REQUIREMENTS, generatePassword } from "../shared/utils"; +import { + TRedisCredentialsRotationGeneratedCredentials, + TRedisCredentialsRotationWithConnection +} from "./redis-credentials-rotation-types"; + +const redactPasswords = (e: unknown, credentials: TRedisCredentialsRotationGeneratedCredentials) => { + const error = e as Error; + + if (!error?.message) return "Unknown error"; + + let redactedMessage = error.message; + + credentials.forEach(({ password }) => { + redactedMessage = redactedMessage.replaceAll(password, "*******************"); + }); + + return redactedMessage; +}; + +export const redisCredentialsRotationFactory: TRotationFactory< + TRedisCredentialsRotationWithConnection, + TRedisCredentialsRotationGeneratedCredentials +> = (secretRotation) => { + const { connection, secretsMapping, parameters } = secretRotation; + + const $getClient = async () => { + const [hostIp] = await verifyHostInputValidity(connection.credentials.host); + + let conn: Redis | null = null; + try { + conn = new Redis({ + username: connection.credentials.username, + host: hostIp, + port: connection.credentials.port, + password: connection.credentials.password, + ...(connection.credentials.sslEnabled && { + tls: { + rejectUnauthorized: connection.credentials.sslRejectUnauthorized, + ca: connection.credentials.sslCertificate + } + }) + }); + + let result: string; + if (connection.credentials.password) { + result = await conn.auth(connection.credentials.username, connection.credentials.password, () => {}); + } else { + result = await conn.auth(connection.credentials.username, () => {}); + } + + if (result !== "OK") { + throw new BadRequestError({ message: `Invalid credentials, Redis returned ${result} status` }); + } + + return conn; + } catch (err) { + if (conn) await conn.quit(); + + throw err; + } + }; + + /** + * Creates a new user and password for the redis user using ACL + */ + const $rotateAclUser = async () => { + let client: Redis | null = null; + + const username = generatePassword({ + length: 32, + required: { + symbols: 0, + digits: 5, + uppercase: 5, + lowercase: 5 + } + }); + + const password = generatePassword(parameters.passwordRequirements || DEFAULT_PASSWORD_REQUIREMENTS); + + try { + client = await $getClient(); + + // important: permissionScope is user input so we need to sanitize it, which we do by splitting the permission scope into parts and then passing them to the ACL command as separate arguments + const permissionParts = parameters.permissionScope.split(" "); + await client.call("ACL", "SETUSER", username, `>${password}`, "on", ...permissionParts); + + return { + username, + password + }; + } catch (error: unknown) { + throw new BadRequestError({ + message: `Unable to rotate credentials: ${redactPasswords(error, [{ username, password }])}` + }); + } finally { + if (client) await client.quit(); + } + }; + + /** + * Revokes a ACL password from the Redis server using its username and password. + */ + const revokeCredential = async (username: string) => { + let client: Redis | null = null; + + try { + client = await $getClient(); + await client.call("ACL", "DELUSER", username); + } catch (error: unknown) { + throw new BadRequestError({ + message: `Unable to revoke credential: ${redactPasswords(error, [{ username, password: username }])}` + }); + } finally { + if (client) await client.quit(); + } + }; + + /** + * Issues a new set of credentials. + */ + const issueCredentials: TRotationFactoryIssueCredentials = async ( + callback + ) => { + const credentials = await $rotateAclUser(); + + return callback(credentials); + }; + + /** + * Revokes a list of credentials. + */ + const revokeCredentials: TRotationFactoryRevokeCredentials = async ( + credentials, + callback + ) => { + if (!credentials?.length) return callback(); + + for (const { username } of credentials) { + await revokeCredential(username); + // eslint-disable-next-line no-promise-executor-return + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + return callback(); + }; + + /** + * Rotates credentials by issuing new ones and revoking the old. + */ + const rotateCredentials: TRotationFactoryRotateCredentials = async ( + oldCredentials, + callback + ) => { + const newCredentials = await $rotateAclUser(); + + if (oldCredentials?.username) { + await revokeCredential(oldCredentials.username); + } + + return callback(newCredentials); + }; + + /** + * Maps the generated credentials into the secret payload format. + */ + const getSecretsPayload: TRotationFactoryGetSecretsPayload = ({ + username, + password + }) => [ + { key: secretsMapping.username, value: username }, + { key: secretsMapping.password, value: password } + ]; + + return { + issueCredentials, + revokeCredentials, + rotateCredentials, + getSecretsPayload + }; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-schemas.ts new file mode 100644 index 000000000..c4948a26d --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-schemas.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; + +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + BaseCreateSecretRotationSchema, + BaseSecretRotationSchema, + BaseUpdateSecretRotationSchema +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas"; +import { SecretRotations } from "@app/lib/api-docs"; +import { SecretNameSchema } from "@app/server/lib/schemas"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { PasswordRequirementsSchema } from "../shared/general"; + +export const RedisCredentialsRotationGeneratedCredentialsSchema = z + .object({ + username: z.string(), + password: z.string() + }) + .array() + .min(1) + .max(2); + +const RedisCredentialsRotationSecretsMappingSchema = z.object({ + username: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.REDIS_CREDENTIALS.username), + password: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.REDIS_CREDENTIALS.password) +}); + +export const RedisCredentialsRotationParametersSchema = z.object({ + passwordRequirements: PasswordRequirementsSchema.optional(), + permissionScope: z + .string() + .trim() + .min(1, "Permission scope is required") + .describe(SecretRotations.PARAMETERS.REDIS_CREDENTIALS.permissionScope) +}); + +export const RedisCredentialsRotationTemplateSchema = z.object({ + secretsMapping: z.object({ + username: z.string(), + password: z.string() + }) +}); + +export const RedisCredentialsRotationSchema = BaseSecretRotationSchema(SecretRotation.RedisCredentials).extend({ + type: z.literal(SecretRotation.RedisCredentials), + parameters: RedisCredentialsRotationParametersSchema, + secretsMapping: RedisCredentialsRotationSecretsMappingSchema +}); + +export const CreateRedisCredentialsRotationSchema = BaseCreateSecretRotationSchema( + SecretRotation.RedisCredentials +).extend({ + parameters: RedisCredentialsRotationParametersSchema, + secretsMapping: RedisCredentialsRotationSecretsMappingSchema +}); + +export const UpdateRedisCredentialsRotationSchema = BaseUpdateSecretRotationSchema( + SecretRotation.RedisCredentials +).extend({ + parameters: RedisCredentialsRotationParametersSchema.optional(), + secretsMapping: RedisCredentialsRotationSecretsMappingSchema.optional() +}); + +export const RedisCredentialsRotationListItemSchema = z.object({ + name: z.literal("Redis Credentials"), + connection: z.literal(AppConnection.Redis), + type: z.literal(SecretRotation.RedisCredentials), + template: RedisCredentialsRotationTemplateSchema +}); diff --git a/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-types.ts new file mode 100644 index 000000000..46f217d61 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-types.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +import { TRedisConnection } from "@app/services/app-connection/redis"; + +import { + CreateRedisCredentialsRotationSchema, + RedisCredentialsRotationGeneratedCredentialsSchema, + RedisCredentialsRotationListItemSchema, + RedisCredentialsRotationSchema +} from "./redis-credentials-rotation-schemas"; + +export type TRedisCredentialsRotation = z.infer; + +export type TRedisCredentialsRotationInput = z.infer; + +export type TRedisCredentialsRotationListItem = z.infer; + +export type TRedisCredentialsRotationWithConnection = TRedisCredentialsRotation & { + connection: TRedisConnection; +}; + +export type TRedisCredentialsRotationGeneratedCredentials = z.infer< + typeof RedisCredentialsRotationGeneratedCredentialsSchema +>; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts index 787c07bae..cf236b56f 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts @@ -52,6 +52,7 @@ const baseSecretRotationV2Query = ({ db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"), db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"), db.ref("gatewayId").withSchema(TableName.AppConnection).as("connectionGatewayId"), + db.ref("projectId").withSchema(TableName.AppConnection).as("connectionProjectId"), db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"), db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"), db @@ -106,6 +107,7 @@ const expandSecretRotation = { diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts index d9a771101..2087fa195 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts @@ -10,7 +10,8 @@ export const SECRET_ROTATION_NAME_MAP: Record = { [SecretRotation.AzureClientSecret]: "Azure Client Secret", [SecretRotation.AwsIamUserSecret]: "AWS IAM User Secret", [SecretRotation.LdapPassword]: "LDAP Password", - [SecretRotation.OktaClientSecret]: "Okta Client Secret" + [SecretRotation.OktaClientSecret]: "Okta Client Secret", + [SecretRotation.RedisCredentials]: "Redis Credentials" }; export const SECRET_ROTATION_CONNECTION_MAP: Record = { @@ -22,5 +23,6 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record; projectMembershipDAL: Pick; projectDAL: Pick; + notificationService: Pick; }; export const secretRotationV2QueueServiceFactory = async ({ @@ -36,7 +39,8 @@ export const secretRotationV2QueueServiceFactory = async ({ secretRotationV2Service, projectMembershipDAL, projectDAL, - smtpService + smtpService, + notificationService }: TSecretRotationV2QueueServiceFactoryDep) => { const appCfg = getConfig(); @@ -152,6 +156,19 @@ export const secretRotationV2QueueServiceFactory = async ({ const rotationType = SECRET_ROTATION_NAME_MAP[type as SecretRotation]; + const rotationPath = `/projects/secret-management/${projectId}/secrets/${environment.slug}`; + + await notificationService.createUserNotifications( + projectAdmins.map((admin) => ({ + userId: admin.userId, + orgId: project.orgId, + type: NotificationType.SECRET_ROTATION_FAILED, + title: "Secret Rotation Failed", + body: `Your **${rotationType}** rotation **${rotationName}** failed to rotate.`, + link: rotationPath + })) + ); + await smtpService.sendMail({ recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean), template: SmtpTemplates.SecretRotationFailed, @@ -165,9 +182,7 @@ export const secretRotationV2QueueServiceFactory = async ({ secretPath: folder.path, environment: environment.name, projectName: project.name, - rotationUrl: encodeURI( - `${appCfg.SITE_URL}/projects/secret-management/${projectId}/secrets/${environment.slug}` - ) + rotationUrl: encodeURI(`${appCfg.SITE_URL}${rotationPath}`) } }); } catch (error) { diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts index 97a0f5700..105232b91 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts @@ -85,11 +85,12 @@ import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/se import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; import { awsIamUserSecretRotationFactory } from "./aws-iam-user-secret/aws-iam-user-secret-rotation-fns"; import { oktaClientSecretRotationFactory } from "./okta-client-secret/okta-client-secret-rotation-fns"; +import { redisCredentialsRotationFactory } from "./redis-credentials/redis-credentials-rotation-fns"; import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal"; export type TSecretRotationV2ServiceFactoryDep = { secretRotationV2DAL: TSecretRotationV2DALFactory; - appConnectionService: Pick; + appConnectionService: Pick; permissionService: Pick; projectBotService: Pick; kmsService: Pick; @@ -132,7 +133,8 @@ const SECRET_ROTATION_FACTORY_MAP: Record; auditLogService: Pick; keyStore: Pick; + notificationService: Pick; }; export type TSecretScanningV2QueueServiceFactory = Awaited>; @@ -65,7 +68,8 @@ export const secretScanningV2QueueServiceFactory = async ({ kmsService, auditLogService, keyStore, - appConnectionDAL + appConnectionDAL, + notificationService }: TSecretRotationV2QueueServiceFactoryDep) => { const queueDataSourceFullScan = async ( dataSource: TSecretScanningDataSourceWithConnection, @@ -592,16 +596,38 @@ export const secretScanningV2QueueServiceFactory = async ({ const timestamp = new Date().toISOString(); + const subjectLine = + payload.status === SecretScanningScanStatus.Completed + ? "Incident Alert: Secret(s) Leaked" + : `Secret Scanning Failed`; + + await notificationService.createUserNotifications( + recipients.map((member) => ({ + userId: member.userId, + orgId: project.orgId, + type: + payload.status === SecretScanningScanStatus.Completed + ? NotificationType.SECRET_SCANNING_SECRETS_DETECTED + : NotificationType.SECRET_SCANNING_SCAN_FAILED, + title: subjectLine, + body: + payload.status === SecretScanningScanStatus.Completed + ? `Uncovered **${payload.numberOfSecrets}** secret(s) ${payload.isDiffScan ? " from a recent commit to" : " in"} **${resourceName}**.` + : `Encountered an error while attempting to scan the resource **${resourceName}**: ${payload.errorMessage}`, + link: + payload.status === SecretScanningScanStatus.Completed + ? `/projects/secret-scanning/${projectId}/findings?search=scanId:${payload.scanId}` + : `/projects/secret-scanning/${projectId}/data-sources/${dataSource.type}/${dataSource.id}` + })) + ); + await smtpService.sendMail({ recipients: recipients.map((member) => member.user.email!).filter(Boolean), template: payload.status === SecretScanningScanStatus.Completed ? SmtpTemplates.SecretScanningV2SecretsDetected : SmtpTemplates.SecretScanningV2ScanFailed, - subjectLine: - payload.status === SecretScanningScanStatus.Completed - ? "Incident Alert: Secret(s) Leaked" - : `Secret Scanning Failed`, + subjectLine, substitutions: payload.status === SecretScanningScanStatus.Completed ? { diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts index 6bef41e10..c48139e17 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts @@ -60,7 +60,7 @@ import { TSecretScanningV2QueueServiceFactory } from "./secret-scanning-v2-queue export type TSecretScanningV2ServiceFactoryDep = { secretScanningV2DAL: TSecretScanningV2DALFactory; - appConnectionService: Pick; + appConnectionService: Pick; appConnectionDAL: Pick; permissionService: Pick; licenseService: Pick; @@ -252,9 +252,9 @@ export const secretScanningV2ServiceFactory = ({ let connection: TAppConnection | null = null; if (payload.connectionId) { // validates permission to connect and app is valid for data source - connection = await appConnectionService.connectAppConnectionById( + connection = await appConnectionService.validateAppConnectionUsageById( SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[payload.type], - payload.connectionId, + { connectionId: payload.connectionId, projectId: payload.projectId }, actor ); } @@ -373,9 +373,9 @@ export const secretScanningV2ServiceFactory = ({ let connection: TAppConnection | null = null; if (dataSource.connectionId) { // validates permission to connect and app is valid for data source - connection = await appConnectionService.connectAppConnectionById( + connection = await appConnectionService.validateAppConnectionUsageById( SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[dataSource.type], - dataSource.connectionId, + { connectionId: dataSource.connectionId, projectId: dataSource.projectId }, actor ); } diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 8b72ce464..17f4aa493 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -47,6 +47,7 @@ export const KeyStorePrefixes = { SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) => `sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const, SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const, + PkiSyncLock: (syncId: string) => `pki-sync-mutex-${syncId}` as const, AppConnectionConcurrentJobs: (connectionId: string) => `app-connection-concurrency-${connectionId}` as const, SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const, SecretScanningLock: (dataSourceId: string, resourceExternalId: string) => @@ -62,13 +63,28 @@ export const KeyStorePrefixes = { ActiveSSEConnectionsSet: (projectId: string, identityId: string) => `sse-connections:${projectId}:${identityId}` as const, ActiveSSEConnections: (projectId: string, identityId: string, connectionId: string) => - `sse-connections:${projectId}:${identityId}:${connectionId}` as const + `sse-connections:${projectId}:${identityId}:${connectionId}` as const, + + ProjectPermission: ( + projectId: string, + version: number, + actorType: string, + actorId: string, + actionProjectType: string + ) => `project-permission:${projectId}:${version}:${actorType}:${actorId}:${actionProjectType}` as const, + ProjectPermissionDalVersion: (projectId: string) => `project-permission:${projectId}:dal-version` as const, + UserProjectPermissionPattern: (userId: string) => `project-permission:*:*:USER:${userId}:*` as const, + IdentityProjectPermissionPattern: (identityId: string) => `project-permission:*:*:IDENTITY:${identityId}:*` as const, + GroupMemberProjectPermissionPattern: (projectId: string, groupId: string) => + `group-member-project-permission:${projectId}:${groupId}:*` as const }; export const KeyStoreTtls = { SetSyncSecretIntegrationLastRunTimestampInSeconds: 60, SetSecretSyncLastRunTimestampInSeconds: 60, - AccessTokenStatusUpdateInSeconds: 120 + AccessTokenStatusUpdateInSeconds: 120, + ProjectPermissionCacheInSeconds: 300, // 5 minutes + ProjectPermissionDalVersionTtl: "15m" // Project permission DAL version TTL }; type TDeleteItems = { diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index d61694d8f..2d0de59a2 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -50,6 +50,7 @@ export enum ApiDocsTags { IdentitySpecificPrivilegesV2 = "Identity Specific Privileges V2", AppConnections = "App Connections", SecretSyncs = "Secret Syncs", + PkiSyncs = "PKI Syncs", Integrations = "Integrations", ServiceTokens = "Service Tokens", AuditLogs = "Audit Logs", @@ -242,7 +243,12 @@ export const LDAP_AUTH = { accessTokenTTL: "The lifetime for an access token in seconds.", accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.", accessTokenNumUsesLimit: "The maximum number of times that an access token can be used.", - accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." + accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from.", + lockoutEnabled: "Whether the lockout feature is enabled.", + lockoutThreshold: "The amount of times login must fail before locking the identity auth method.", + lockoutDurationSeconds: "How long an identity auth method lockout lasts.", + lockoutCounterResetSeconds: + "How long to wait from the most recent failed login until resetting the lockout counter." }, UPDATE: { identityId: "The ID of the identity to update the configuration for.", @@ -257,13 +263,21 @@ export const LDAP_AUTH = { accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used.", accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from.", - templateId: "The ID of the identity auth template to update the configuration to." + templateId: "The ID of the identity auth template to update the configuration to.", + lockoutEnabled: "Whether the lockout feature is enabled.", + lockoutThreshold: "The amount of times login must fail before locking the identity auth method.", + lockoutDurationSeconds: "How long an identity auth method lockout lasts.", + lockoutCounterResetSeconds: + "How long to wait from the most recent failed login until resetting the lockout counter." }, RETRIEVE: { identityId: "The ID of the identity to retrieve the configuration for." }, REVOKE: { identityId: "The ID of the identity to revoke the configuration for." + }, + CLEAR_CLIENT_LOCKOUTS: { + identityId: "The ID of the identity to clear the client lockouts from." } } as const; @@ -711,13 +725,13 @@ export const PROJECTS = { template: "The name of the project template, if specified, to apply to this project." }, DELETE: { - workspaceId: "The ID of the project to delete." + projectId: "The ID of the project to delete." }, GET: { - workspaceId: "The ID of the project." + projectId: "The ID of the project." }, UPDATE: { - workspaceId: "The ID of the project to update.", + projectId: "The ID of the project to update.", name: "The new name of the project.", projectDescription: "An optional description label for the project.", autoCapitalization: "Disable or enable auto-capitalization for the project.", @@ -729,10 +743,10 @@ export const PROJECTS = { secretDetectionIgnoreValues: "The list of secret values to ignore for secret detection." }, GET_KEY: { - workspaceId: "The ID of the project to get the key from." + projectId: "The ID of the project to get the key from." }, GET_SNAPSHOTS: { - workspaceId: "The ID of the project to get snapshots from.", + projectId: "The ID of the project to get snapshots from.", environment: "The environment to get snapshots from.", path: "The secret path to get snapshots from.", offset: "The offset to start from. If you enter 10, it will start from the 10th snapshot.", @@ -759,10 +773,10 @@ export const PROJECTS = { projectId: "The ID of the project to list groups for." }, LIST_INTEGRATION: { - workspaceId: "The ID of the project to list integrations for." + projectId: "The ID of the project to list integrations for." }, LIST_INTEGRATION_AUTHORIZATION: { - workspaceId: "The ID of the project to list integration auths for." + projectId: "The ID of the project to list integration auths for." }, LIST_SSH_CAS: { projectId: "The ID of the project to list SSH CAs for." @@ -815,15 +829,15 @@ export const PROJECT_USERS = { usernames: "A list of usernames to remove from the project." }, GET_USER_MEMBERSHIPS: { - workspaceId: "The ID of the project to get memberships from." + projectId: "The ID of the project to get memberships from." }, GET_USER_MEMBERSHIP: { - workspaceId: "The ID of the project to get memberships from.", + projectId: "The ID of the project to get memberships from.", membershipId: "The ID of the user's project membership.", username: "The username to get project membership of. Email is the default username." }, UPDATE_USER_MEMBERSHIP: { - workspaceId: "The ID of the project to update the membership for.", + projectId: "The ID of the project to update the membership for.", membershipId: "The ID of the membership to update.", roles: "A list of roles to update the membership to." } @@ -877,31 +891,31 @@ export const PROJECT_IDENTITIES = { export const ENVIRONMENTS = { CREATE: { - workspaceId: "The ID of the project to create the environment in.", + projectId: "The ID of the project to create the environment in.", name: "The name of the environment to create.", slug: "The slug of the environment to create.", position: "The position of the environment. The lowest number will be displayed as the first environment." }, UPDATE: { - workspaceId: "The ID of the project to update the environment in.", + projectId: "The ID of the project to update the environment in.", id: "The ID of the environment to update.", name: "The new name of the environment.", slug: "The new slug of the environment.", position: "The new position of the environment. The lowest number will be displayed as the first environment." }, DELETE: { - workspaceId: "The ID of the project to delete the environment from.", + projectId: "The ID of the project to delete the environment from.", id: "The ID of the environment to delete." }, GET: { - workspaceId: "The ID of the project the environment belongs to.", + projectId: "The ID of the project the environment belongs to.", id: "The ID of the environment to fetch." } } as const; export const FOLDERS = { LIST: { - workspaceId: "The ID of the project to list folders from.", + projectId: "The ID of the project to list folders from.", environment: "The slug of the environment to list folders from.", path: "The path to list folders from.", directory: "The directory to list folders from. (Deprecated in favor of path)", @@ -913,7 +927,7 @@ export const FOLDERS = { folderId: "The ID of the folder to get details." }, CREATE: { - workspaceId: "The ID of the project to create the folder in.", + projectId: "The ID of the project to create the folder in.", environment: "The slug of the environment to create the folder in.", name: "The name of the folder to create.", path: "The path of the folder to create.", @@ -927,12 +941,12 @@ export const FOLDERS = { path: "The path of the folder to update.", directory: "The new directory of the folder to update. (Deprecated in favor of path)", projectSlug: "The slug of the project where the folder is located.", - workspaceId: "The ID of the project where the folder is located.", + projectId: "The ID of the project where the folder is located.", description: "An optional description label for the folder." }, DELETE: { folderIdOrName: "The ID or name of the folder to delete.", - workspaceId: "The ID of the project to delete the folder from.", + projectId: "The ID of the project to delete the folder from.", environment: "The slug of the environment where the folder is located.", directory: "The directory of the folder to delete. (Deprecated in favor of path)", path: "The path of the folder to delete." @@ -964,7 +978,7 @@ export const RAW_SECRETS = { expand: "Whether or not to expand secret references.", recursive: "Whether or not to fetch all secrets from the specified base path, and all of its subdirectories. Note, the max depth is 20 deep.", - workspaceId: "The ID of the project to list secrets from.", + projectId: "The ID of the project to list secrets from.", workspaceSlug: "The slug of the project to list secrets from. This parameter is only applicable by machine identities.", environment: "The slug of the environment to list secrets from.", @@ -984,7 +998,7 @@ export const RAW_SECRETS = { secretValue: "The value of the secret to create.", skipMultilineEncoding: "Skip multiline encoding for the secret value.", type: "The type of the secret to create.", - workspaceId: "The ID of the project to create the secret in.", + projectId: "The ID of the project to create the secret in.", tagIds: "The ID of the tags to be attached to the created secret.", secretReminderRepeatDays: "Interval for secret rotation notifications, measured in days.", secretReminderNote: "Note to be attached in notification email." @@ -992,7 +1006,7 @@ export const RAW_SECRETS = { GET: { expand: "Whether or not to expand secret references.", secretName: "The name of the secret to get.", - workspaceId: "The ID of the project to get the secret from.", + projectId: "The ID of the project to get the secret from.", workspaceSlug: "The slug of the project to get the secret from.", environment: "The slug of the environment to get the secret from.", secretPath: "The path of the secret to get.", @@ -1011,7 +1025,7 @@ export const RAW_SECRETS = { skipMultilineEncoding: "Skip multiline encoding for the secret value.", type: "The type of the secret to update.", projectSlug: "The slug of the project to update the secret in.", - workspaceId: "The ID of the project to update the secret in.", + projectId: "The ID of the project to update the secret in.", tagIds: "The ID of the tags to be attached to the updated secret.", secretReminderRepeatDays: "Interval for secret rotation notifications, measured in days.", secretReminderNote: "Note to be attached in notification email.", @@ -1025,17 +1039,17 @@ export const RAW_SECRETS = { secretPath: "The path of the secret.", type: "The type of the secret to delete.", projectSlug: "The slug of the project to delete the secret in.", - workspaceId: "The ID of the project where the secret is located." + projectId: "The ID of the project where the secret is located." }, GET_REFERENCE_TREE: { secretName: "The name of the secret to get the reference tree for.", - workspaceId: "The ID of the project where the secret is located.", + projectId: "The ID of the project where the secret is located.", environment: "The slug of the environment where the the secret is located.", secretPath: "The folder path where the secret is located." }, GET_ACCESS_LIST: { secretName: "The name of the secret to get the access list for.", - workspaceId: "The ID of the project where the secret is located.", + projectId: "The ID of the project where the secret is located.", environment: "The slug of the environment where the the secret is located.", secretPath: "The folder path where the secret is located." } @@ -1043,7 +1057,7 @@ export const RAW_SECRETS = { export const SECRET_IMPORTS = { LIST: { - workspaceId: "The ID of the project to list secret imports from.", + projectId: "The ID of the project to list secret imports from.", environment: "The slug of the environment to list secret imports from.", path: "The path to list secret imports from." }, @@ -1053,7 +1067,7 @@ export const SECRET_IMPORTS = { CREATE: { environment: "The slug of the environment to import into.", path: "The path to import into.", - workspaceId: "The ID of the project you are working in.", + projectId: "The ID of the project you are working in.", isReplication: "When true, secrets from the source will be automatically sent to the destination. If approval policies exist at the destination, the secrets will be sent as approval requests instead of being applied immediately.", import: { @@ -1070,10 +1084,10 @@ export const SECRET_IMPORTS = { position: "The new position of the secret import. The lowest number will be displayed as the first import." }, path: "The path of the secret import to update.", - workspaceId: "The ID of the project where the secret import is located." + projectId: "The ID of the project where the secret import is located." }, DELETE: { - workspaceId: "The ID of the project to delete the secret import from.", + projectId: "The ID of the project to delete the secret import from.", secretImportId: "The ID of the secret import to delete.", environment: "The slug of the environment where the secret import is located.", path: "The path of the secret import to delete." @@ -2185,11 +2199,15 @@ export const CertificateAuthorities = { }; export const AppConnections = { + LIST: (app?: AppConnection) => ({ + projectId: `The ID of the project to list ${app ? APP_CONNECTION_NAME_MAP[app] : "App"} Connections from.` + }), GET_BY_ID: (app: AppConnection) => ({ connectionId: `The ID of the ${APP_CONNECTION_NAME_MAP[app]} Connection to retrieve.` }), GET_BY_NAME: (app: AppConnection) => ({ - connectionName: `The name of the ${APP_CONNECTION_NAME_MAP[app]} Connection to retrieve.` + connectionName: `The name of the ${APP_CONNECTION_NAME_MAP[app]} Connection to retrieve.`, + projectId: `The project ID of the ${APP_CONNECTION_NAME_MAP[app]} Connection is associated with. Leave unspecified to get organization-level connections.` }), CREATE: (app: AppConnection) => { const appName = APP_CONNECTION_NAME_MAP[app]; @@ -2198,7 +2216,8 @@ export const AppConnections = { description: `An optional description for the ${appName} Connection.`, credentials: `The credentials used to connect with ${appName}.`, method: `The method used to authenticate with ${appName}.`, - isPlatformManagedCredentials: `Whether or not the ${appName} Connection credentials should be managed by Infisical. Once enabled this cannot be reversed.` + isPlatformManagedCredentials: `Whether or not the ${appName} Connection credentials should be managed by Infisical. Once enabled this cannot be reversed.`, + projectId: `The ID of the project to create the ${appName} Connection in.` }; }, UPDATE: (app: AppConnection) => { @@ -2667,9 +2686,16 @@ export const SecretRotations = { }, OKTA_CLIENT_SECRET: { clientId: "The ID of the Okta Application to rotate the client secret for." + }, + REDIS_CREDENTIALS: { + permissionScope: "The ACL permission scope to assign to the issued Redis users." } }, SECRETS_MAPPING: { + REDIS_CREDENTIALS: { + username: "The name of the secret that the username will be mapped to.", + password: "The name of the secret that the rotated password will be mapped to." + }, SQL_CREDENTIALS: { username: "The name of the secret that the active username will be mapped to.", password: "The name of the secret that the generated password will be mapped to." @@ -2846,7 +2872,9 @@ export const SamlSso = { entryPoint: "The entry point for the SAML authentication. This is the URL that the user will be redirected to after they have authenticated with the SAML provider.", issuer: "The SAML provider issuer URL or entity ID.", - cert: "The certificate to use for SAML authentication." + cert: "The certificate to use for SAML authentication.", + enableGroupSync: + "Whether to enable automatic synchronization of group memberships from the SAML provider to Infisical groups." }, CREATE_CONFIG: { organizationId: "The ID of the organization to create the SAML config for.", @@ -2855,7 +2883,9 @@ export const SamlSso = { entryPoint: "The entry point for the SAML authentication. This is the URL that the user will be redirected to after they have authenticated with the SAML provider.", issuer: "The SAML provider issuer URL or entity ID.", - cert: "The certificate to use for SAML authentication." + cert: "The certificate to use for SAML authentication.", + enableGroupSync: + "Whether to enable automatic synchronization of group memberships from the SAML provider to Infisical groups." } }; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index da0dd61a7..15f878323 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -129,6 +129,8 @@ const envSchema = z POSTHOG_HOST: zpStr(z.string().optional().default("https://app.posthog.com")), POSTHOG_PROJECT_API_KEY: zpStr(z.string().optional().default("phc_nSin8j5q2zdhpFDI1ETmFNUIuTG4DwKVyIigrY10XiE")), LOOPS_API_KEY: zpStr(z.string().optional()), + // GitHub API token for upgrade path tool + GITHUB_API_TOKEN: zpStr(z.string().optional()), // jwt options AUTH_SECRET: zpStr(z.string()).default(process.env.JWT_AUTH_SECRET), // for those still using old JWT_AUTH_SECRET JWT_AUTH_LIFETIME: zpStr(z.string().default("10d")), @@ -323,6 +325,10 @@ const envSchema = z INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID: zpStr(z.string().optional()), INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET: zpStr(z.string().optional()), + // Heroku App Connection + INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET: zpStr(z.string().optional()), + // datadog SHOULD_USE_DATADOG_TRACER: zodStrBool.default("false"), DATADOG_PROFILING_ENABLED: zodStrBool.default("false"), @@ -433,7 +439,10 @@ const envSchema = z INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID: data.INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID || data.INF_APP_CONNECTION_AZURE_CLIENT_ID, INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET: - data.INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET || data.INF_APP_CONNECTION_AZURE_CLIENT_SECRET + data.INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET || data.INF_APP_CONNECTION_AZURE_CLIENT_SECRET, + INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID: data.INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID || data.CLIENT_ID_HEROKU, + INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET: + data.INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET || data.CLIENT_SECRET_HEROKU })); export type TEnvConfig = Readonly>; @@ -736,6 +745,19 @@ export const overwriteSchema: { description: "The Client Secret of your GCP OAuth2 application." } ] + }, + heroku: { + name: "Heroku", + fields: [ + { + key: "INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID", + description: "The Client ID of your Heroku application." + }, + { + key: "INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET", + description: "The Client Secret of your Heroku application." + } + ] } }; diff --git a/backend/src/lib/knex/select.ts b/backend/src/lib/knex/select.ts index feccad4e3..8cc3f4892 100644 --- a/backend/src/lib/knex/select.ts +++ b/backend/src/lib/knex/select.ts @@ -16,8 +16,12 @@ export const stripUndefinedInWhere = (val: T): Exclude(value: string | null | undefined, cb: (arg: string) => T) => { +export const setKnexStringValue = ( + value: string | null | undefined, + cb: (arg: string) => T, + allowEmptyString?: boolean +) => { if (typeof value === "undefined") return; - if (value === "" || value === null) return null; + if ((value === "" && !allowEmptyString) || value === null) return null; return cb(value); }; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 5a7c92f22..335f25d5f 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -24,6 +24,11 @@ import { QueueWorkerProfile } from "@app/lib/types"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { ExternalPlatforms } from "@app/services/external-migration/external-migration-types"; import { TCreateUserNotificationDTO } from "@app/services/notification/notification-types"; +import { + TQueuePkiSyncImportCertificatesByIdDTO, + TQueuePkiSyncRemoveCertificatesByIdDTO, + TQueuePkiSyncSyncCertificatesByIdDTO +} from "@app/services/pki-sync/pki-sync-types"; import { TFailedIntegrationSyncEmailsPayload, TIntegrationSyncPayload, @@ -46,6 +51,7 @@ export enum QueueName { AuditLogPrune = "audit-log-prune", DailyResourceCleanUp = "daily-resource-cleanup", DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert", + PkiSyncCleanup = "pki-sync-cleanup", PkiSubscriber = "pki-subscriber", TelemetryInstanceStats = "telemtry-self-hosted-stats", IntegrationSync = "sync-integrations", @@ -58,6 +64,7 @@ export enum QueueName { CaLifecycle = "ca-lifecycle", // parent queue to ca-order-certificate-for-subscriber SecretReplication = "secret-replication", SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication + PkiSync = "pki-sync", ProjectV3Migration = "project-v3-migration", AccessTokenStatusUpdate = "access-token-status-update", ImportSecretsFromExternalSource = "import-secrets-from-external-source", @@ -80,6 +87,7 @@ export enum QueueJobs { AuditLogPrune = "audit-log-prune-job", DailyResourceCleanUp = "daily-resource-cleanup-job", DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert", + PkiSyncCleanup = "pki-sync-cleanup-job", SecWebhook = "secret-webhook-trigger", TelemetryInstanceStats = "telemetry-self-hosted-stats", IntegrationSync = "secret-integration-pull", @@ -91,6 +99,7 @@ export enum QueueJobs { CaCrlRotation = "ca-crl-rotation-job", SecretReplication = "secret-replication", SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication + PkiSync = "pki-sync", ProjectV3Migration = "project-v3-migration", IdentityAccessTokenStatusUpdate = "identity-access-token-status-update", ServiceTokenStatusUpdate = "service-token-status-update", @@ -99,6 +108,9 @@ export enum QueueJobs { SecretSyncImportSecrets = "secret-sync-import-secrets", SecretSyncRemoveSecrets = "secret-sync-remove-secrets", SecretSyncSendActionFailedNotifications = "secret-sync-send-action-failed-notifications", + PkiSyncSyncCertificates = "pki-sync-sync-certificates", + PkiSyncImportCertificates = "pki-sync-import-certificates", + PkiSyncRemoveCertificates = "pki-sync-remove-certificates", SecretRotationV2QueueRotations = "secret-rotation-v2-queue-rotations", SecretRotationV2RotateSecrets = "secret-rotation-v2-rotate-secrets", SecretRotationV2SendNotification = "secret-rotation-v2-send-notification", @@ -141,6 +153,10 @@ export type TQueueJobTypes = { name: QueueJobs.DailyExpiringPkiItemAlert; payload: undefined; }; + [QueueName.PkiSyncCleanup]: { + name: QueueJobs.PkiSyncCleanup; + payload: undefined; + }; [QueueName.AuditLogPrune]: { name: QueueJobs.AuditLogPrune; payload: undefined; @@ -218,6 +234,19 @@ export type TQueueJobTypes = { name: QueueJobs.SecretSync; payload: TSyncSecretsDTO; }; + [QueueName.PkiSync]: + | { + name: QueueJobs.PkiSyncSyncCertificates; + payload: TQueuePkiSyncSyncCertificatesByIdDTO; + } + | { + name: QueueJobs.PkiSyncImportCertificates; + payload: TQueuePkiSyncImportCertificatesByIdDTO; + } + | { + name: QueueJobs.PkiSyncRemoveCertificates; + payload: TQueuePkiSyncRemoveCertificatesByIdDTO; + }; [QueueName.ProjectV3Migration]: { name: QueueJobs.ProjectV3Migration; payload: { projectId: string }; @@ -231,6 +260,8 @@ export type TQueueJobTypes = { [QueueName.ImportSecretsFromExternalSource]: { name: QueueJobs.ImportSecretsFromExternalSource; payload: { + orgId: string; + actorId: string; actorEmail: string; importType: ExternalPlatforms; data: { diff --git a/backend/src/server/lib/schemas.ts b/backend/src/server/lib/schemas.ts index 00651d2cc..d0750926d 100644 --- a/backend/src/server/lib/schemas.ts +++ b/backend/src/server/lib/schemas.ts @@ -43,8 +43,6 @@ export const GenericResourceNameSchema = z export const BaseSecretNameSchema = z.string().trim().min(1); export const SecretNameSchema = BaseSecretNameSchema.refine( - (el) => !el.includes(" "), - "Secret name cannot contain spaces." -) - .refine((el) => !el.includes(":"), "Secret name cannot contain colon.") - .refine((el) => !el.includes("/"), "Secret name cannot contain forward slash."); + (el) => !el.includes(":"), + "Secret name cannot contain colon." +).refine((el) => !el.includes("/"), "Secret name cannot contain forward slash."); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 83a2a35f7..1a404e7b9 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -248,6 +248,10 @@ import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-co import { pkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { pkiSubscriberQueueServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-queue"; import { pkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service"; +import { pkiSyncCleanupQueueServiceFactory } from "@app/services/pki-sync/pki-sync-cleanup-queue"; +import { pkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; +import { pkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue"; +import { pkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service"; import { pkiTemplatesDALFactory } from "@app/services/pki-templates/pki-templates-dal"; import { pkiTemplatesServiceFactory } from "@app/services/pki-templates/pki-templates-service"; import { projectDALFactory } from "@app/services/project/project-dal"; @@ -309,6 +313,7 @@ import { telemetryQueueServiceFactory } from "@app/services/telemetry/telemetry- import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-service"; import { totpConfigDALFactory } from "@app/services/totp/totp-config-dal"; import { totpServiceFactory } from "@app/services/totp/totp-service"; +import { upgradePathServiceFactory } from "@app/services/upgrade-path/upgrade-path-service"; import { userDALFactory } from "@app/services/user/user-dal"; import { userServiceFactory } from "@app/services/user/user-service"; import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; @@ -328,6 +333,7 @@ import { registerV1Routes } from "./v1"; import { initializeOauthConfigSync } from "./v1/sso-router"; import { registerV2Routes } from "./v2"; import { registerV3Routes } from "./v3"; +import { registerV4Routes } from "./v4"; const histogram = monitorEventLoopDelay({ resolution: 20 }); histogram.enable(); @@ -525,7 +531,8 @@ export const registerRoutes = async ( orgRoleDAL, projectRoleDAL, serviceTokenDAL, - projectDAL + projectDAL, + keyStore }); const assumePrivilegeService = assumePrivilegeServiceFactory({ projectDAL, @@ -617,6 +624,12 @@ export const registerRoutes = async ( userDAL, userAliasDAL, samlConfigDAL, + groupDAL, + userGroupMembershipDAL, + groupProjectDAL, + projectDAL, + projectBotDAL, + projectKeyDAL, licenseService, tokenService, smtpService, @@ -760,6 +773,8 @@ export const registerRoutes = async ( userAliasDAL }); + const upgradePathService = upgradePathServiceFactory({ keyStore }); + const totpService = totpServiceFactory({ totpConfigDAL, userDAL, @@ -773,13 +788,15 @@ export const registerRoutes = async ( orgDAL, totpService, orgMembershipDAL, - auditLogService + auditLogService, + notificationService }); const passwordService = authPaswordServiceFactory({ tokenService, smtpService, authDAL, userDAL, + orgMembershipDAL, totpConfigDAL }); @@ -887,7 +904,8 @@ export const registerRoutes = async ( projectDAL, permissionService, projectUserMembershipRoleDAL, - projectMembershipDAL + projectMembershipDAL, + notificationService }); const rateLimitService = rateLimitServiceFactory({ @@ -926,7 +944,8 @@ export const registerRoutes = async ( projectRoleDAL, groupProjectDAL, secretReminderRecipientsDAL, - licenseService + licenseService, + notificationService }); const projectUserAdditionalPrivilegeService = projectUserAdditionalPrivilegeServiceFactory({ permissionService, @@ -976,6 +995,7 @@ export const registerRoutes = async ( const pkiCollectionDAL = pkiCollectionDALFactory(db); const pkiCollectionItemDAL = pkiCollectionItemDALFactory(db); const pkiSubscriberDAL = pkiSubscriberDALFactory(db); + const pkiSyncDAL = pkiSyncDALFactory(db); const pkiTemplatesDAL = pkiTemplatesDALFactory(db); const instanceRelayConfigDAL = instanceRelayConfigDalFactory(db); @@ -985,21 +1005,6 @@ export const registerRoutes = async ( const orgGatewayConfigV2DAL = orgGatewayConfigV2DalFactory(db); - const certificateService = certificateServiceFactory({ - certificateDAL, - certificateBodyDAL, - certificateSecretDAL, - certificateAuthorityDAL, - certificateAuthorityCertDAL, - certificateAuthorityCrlDAL, - certificateAuthoritySecretDAL, - projectDAL, - kmsService, - permissionService, - pkiCollectionDAL, - pkiCollectionItemDAL - }); - const sshCertificateAuthorityService = sshCertificateAuthorityServiceFactory({ sshCertificateAuthorityDAL, sshCertificateAuthoritySecretDAL, @@ -1107,7 +1112,9 @@ export const registerRoutes = async ( instanceRelayConfigDAL, orgRelayConfigDAL, relayDAL, - kmsService + kmsService, + licenseService, + permissionService }); const gatewayV2Service = gatewayV2ServiceFactory({ @@ -1145,7 +1152,8 @@ export const registerRoutes = async ( appConnectionDAL, licenseService, gatewayService, - gatewayV2Service + gatewayV2Service, + notificationService }); const secretQueueService = secretQueueFactory({ @@ -1229,7 +1237,8 @@ export const registerRoutes = async ( projectTemplateService, groupProjectDAL, smtpService, - reminderService + reminderService, + notificationService }); const projectEnvService = projectEnvServiceFactory({ @@ -1363,7 +1372,8 @@ export const registerRoutes = async ( resourceMetadataDAL, projectMicrosoftTeamsConfigDAL, microsoftTeamsService, - folderCommitService + folderCommitService, + notificationService }); const secretService = secretServiceFactory({ @@ -1681,7 +1691,8 @@ export const registerRoutes = async ( identityOrgMembershipDAL, licenseService, identityDAL, - identityAuthTemplateDAL + identityAuthTemplateDAL, + keyStore }); const dynamicSecretProviders = buildDynamicSecretProviders({ @@ -1813,7 +1824,8 @@ export const registerRoutes = async ( secretV2BridgeService, resourceMetadataDAL, folderCommitService, - folderVersionDAL + folderVersionDAL, + notificationService }); const migrationService = externalMigrationServiceFactory({ @@ -1838,7 +1850,8 @@ export const registerRoutes = async ( gatewayService, gatewayV2Service, gatewayDAL, - gatewayV2DAL + gatewayV2DAL, + projectDAL }); const secretSyncService = secretSyncServiceFactory({ @@ -1853,52 +1866,6 @@ export const registerRoutes = async ( licenseService }); - const certificateAuthorityQueue = certificateAuthorityQueueFactory({ - certificateAuthorityCrlDAL, - certificateAuthorityDAL, - certificateAuthoritySecretDAL, - certificateDAL, - projectDAL, - kmsService, - queueService, - pkiSubscriberDAL, - certificateBodyDAL, - certificateSecretDAL, - externalCertificateAuthorityDAL, - keyStore, - appConnectionDAL, - appConnectionService - }); - - const internalCertificateAuthorityService = internalCertificateAuthorityServiceFactory({ - certificateAuthorityDAL, - certificateAuthorityCertDAL, - certificateAuthoritySecretDAL, - certificateAuthorityCrlDAL, - certificateTemplateDAL, - certificateAuthorityQueue, - certificateDAL, - certificateBodyDAL, - certificateSecretDAL, - pkiCollectionDAL, - pkiCollectionItemDAL, - projectDAL, - internalCertificateAuthorityDAL, - kmsService, - permissionService - }); - - const certificateEstService = certificateEstServiceFactory({ - internalCertificateAuthorityService, - certificateTemplateService, - certificateTemplateDAL, - certificateAuthorityCertDAL, - certificateAuthorityDAL, - projectDAL, - kmsService, - licenseService - }); - const kmipService = kmipServiceFactory({ kmipClientDAL, permissionService, @@ -1941,6 +1908,79 @@ export const registerRoutes = async ( gatewayV2Service }); + const pkiSyncQueue = pkiSyncQueueFactory({ + queueService, + kmsService, + appConnectionDAL, + keyStore, + pkiSyncDAL, + auditLogService, + projectDAL, + licenseService, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL + }); + + const pkiSyncCleanup = pkiSyncCleanupQueueServiceFactory({ + queueService, + pkiSyncDAL, + pkiSyncQueue + }); + + const internalCaFns = InternalCertificateAuthorityFns({ + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + projectDAL, + kmsService, + pkiSyncDAL, + pkiSyncQueue + }); + + const certificateAuthorityQueue = certificateAuthorityQueueFactory({ + certificateAuthorityCrlDAL, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + certificateDAL, + projectDAL, + kmsService, + queueService, + pkiSubscriberDAL, + certificateBodyDAL, + certificateSecretDAL, + externalCertificateAuthorityDAL, + keyStore, + appConnectionDAL, + appConnectionService, + pkiSyncDAL, + pkiSyncQueue + }); + + const internalCertificateAuthorityService = internalCertificateAuthorityServiceFactory({ + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateTemplateDAL, + certificateAuthorityQueue, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + pkiCollectionDAL, + pkiCollectionItemDAL, + projectDAL, + internalCertificateAuthorityDAL, + kmsService, + permissionService + }); + const certificateAuthorityService = certificateAuthorityServiceFactory({ certificateAuthorityDAL, permissionService, @@ -1953,19 +1993,20 @@ export const registerRoutes = async ( certificateSecretDAL, kmsService, pkiSubscriberDAL, - projectDAL + projectDAL, + pkiSyncDAL, + pkiSyncQueue }); - const internalCaFns = InternalCertificateAuthorityFns({ - certificateAuthorityDAL, + const certificateEstService = certificateEstServiceFactory({ + internalCertificateAuthorityService, + certificateTemplateService, + certificateTemplateDAL, certificateAuthorityCertDAL, - certificateAuthoritySecretDAL, - certificateAuthorityCrlDAL, - certificateDAL, - certificateBodyDAL, - certificateSecretDAL, + certificateAuthorityDAL, projectDAL, - kmsService + kmsService, + licenseService }); const pkiSubscriberQueue = pkiSubscriberQueueServiceFactory({ @@ -1978,6 +2019,23 @@ export const registerRoutes = async ( internalCaFns }); + const certificateService = certificateServiceFactory({ + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthorityCrlDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService, + permissionService, + pkiCollectionDAL, + pkiCollectionItemDAL, + pkiSyncDAL, + pkiSyncQueue + }); + const pkiSubscriberService = pkiSubscriberServiceFactory({ pkiSubscriberDAL, certificateAuthorityDAL, @@ -1991,7 +2049,18 @@ export const registerRoutes = async ( kmsService, permissionService, certificateAuthorityQueue, - internalCaFns + internalCaFns, + pkiSyncDAL, + pkiSyncQueue + }); + + const pkiSyncService = pkiSyncServiceFactory({ + pkiSyncDAL, + pkiSubscriberDAL, + appConnectionService, + permissionService, + licenseService, + pkiSyncQueue }); const pkiTemplateService = pkiTemplatesServiceFactory({ @@ -2015,7 +2084,8 @@ export const registerRoutes = async ( queueService, projectDAL, projectMembershipDAL, - smtpService + smtpService, + notificationService }); const secretScanningV2Queue = await secretScanningV2QueueServiceFactory({ @@ -2027,7 +2097,8 @@ export const registerRoutes = async ( smtpService, kmsService, keyStore, - appConnectionDAL + appConnectionDAL, + notificationService }); const secretScanningV2Service = secretScanningV2ServiceFactory({ @@ -2054,6 +2125,7 @@ export const registerRoutes = async ( await telemetryQueue.startTelemetryCheck(); await telemetryQueue.startAggregatedEventsJob(); await dailyResourceCleanUp.init(); + await pkiSyncCleanup.init(); await dailyReminderQueueService.startDailyRemindersJob(); await dailyReminderQueueService.startSecretReminderMigrationJob(); await dailyExpiringPkiItemAlert.startSendingAlerts(); @@ -2137,6 +2209,7 @@ export const registerRoutes = async ( pkiAlert: pkiAlertService, pkiCollection: pkiCollectionService, pkiSubscriber: pkiSubscriberService, + pkiSync: pkiSyncService, pkiTemplate: pkiTemplateService, secretScanning: secretScanningService, license: licenseService, @@ -2175,7 +2248,8 @@ export const registerRoutes = async ( reminder: reminderService, bus: eventBusService, sse: sseService, - notification: notificationService + notification: notificationService, + upgradePath: upgradePathService }); const cronJobs: CronJob[] = []; @@ -2295,6 +2369,7 @@ export const registerRoutes = async ( { prefix: "/api/v2" } ); await server.register(registerV3Routes, { prefix: "/api/v3" }); + await server.register(registerV4Routes, { prefix: "/api/v4" }); server.addHook("onClose", async () => { cronJobs.forEach((job) => job.stop()); diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts index 50111b109..720726c34 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts @@ -26,6 +26,7 @@ export const registerAppConnectionEndpoints = ; updateSchema: z.ZodType<{ name?: string; @@ -47,18 +48,27 @@ export const registerAppConnectionEndpoints = { - const appConnections = (await server.services.appConnection.listAppConnectionsByOrg(req.permission, app)) as T[]; + const { projectId } = req.query; + const appConnections = (await server.services.appConnection.listAppConnections( + req.permission, + app, + projectId + )) as T[]; await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, + projectId, event: { type: EventType.GET_APP_CONNECTIONS, metadata: { @@ -82,14 +92,19 @@ export const registerAppConnectionEndpoints = { + const { projectId } = req.query; const appConnections = await server.services.appConnection.listAvailableAppConnectionsForUser( app, - req.permission + req.permission, + projectId ); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, + projectId, event: { type: EventType.GET_AVAILABLE_APP_CONNECTIONS_DETAILS, metadata: { @@ -149,6 +167,7 @@ export const registerAppConnectionEndpoints = { const { connectionName } = req.params; + const { projectId } = req.query; const appConnection = (await server.services.appConnection.findAppConnectionByName( app, - connectionName, + { + connectionName, + projectId + }, req.permission )) as T; await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, + projectId: appConnection.projectId ?? undefined, event: { type: EventType.GET_APP_CONNECTION, metadata: { @@ -216,9 +243,7 @@ export const registerAppConnectionEndpoints = { - const { name, method, credentials, description, isPlatformManagedCredentials, gatewayId } = req.body; + const { name, method, credentials, description, isPlatformManagedCredentials, gatewayId, projectId } = req.body; const appConnection = (await server.services.appConnection.createAppConnection( - { name, method, app, credentials, description, isPlatformManagedCredentials, gatewayId }, + { name, method, app, credentials, description, isPlatformManagedCredentials, gatewayId, projectId }, req.permission )) as T; await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, + projectId, event: { type: EventType.CREATE_APP_CONNECTION, metadata: { @@ -283,6 +309,7 @@ export const registerAppConnectionEndpoints = { + // const { connectionId } = req.params; + // + // const projects = await server.services.appConnection.findAppConnectionUsageById( + // app, + // connectionId, + // req.permission + // ); + // + // await server.services.auditLog.createAuditLog({ + // ...req.auditLogInfo, + // orgId: req.permission.orgId, + // event: { + // type: EventType.GET_APP_CONNECTION_USAGE, + // metadata: { + // connectionId + // } + // } + // }); + // + // return { projects }; + // } + // }); }; diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index c2033f4b4..e5549f9fe 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -1,12 +1,13 @@ import { z } from "zod"; +import { ProjectType } from "@app/db/schemas"; import { OCIConnectionListItemSchema, SanitizedOCIConnectionSchema } from "@app/ee/services/app-connections/oci"; import { OracleDBConnectionListItemSchema, SanitizedOracleDBConnectionSchema } from "@app/ee/services/app-connections/oracledb"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { ApiDocsTags } from "@app/lib/api-docs"; +import { ApiDocsTags, AppConnections } from "@app/lib/api-docs"; import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { @@ -92,6 +93,7 @@ import { RailwayConnectionListItemSchema, SanitizedRailwayConnectionSchema } from "@app/services/app-connection/railway"; +import { RedisConnectionListItemSchema, SanitizedRedisConnectionSchema } from "@app/services/app-connection/redis"; import { RenderConnectionListItemSchema, SanitizedRenderConnectionSchema @@ -155,7 +157,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedDigitalOceanConnectionSchema.options, ...SanitizedNetlifyConnectionSchema.options, ...SanitizedOktaConnectionSchema.options, - ...SanitizedAzureADCSConnectionSchema.options + ...SanitizedAzureADCSConnectionSchema.options, + ...SanitizedRedisConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -196,7 +199,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ DigitalOceanConnectionListItemSchema, NetlifyConnectionListItemSchema, OktaConnectionListItemSchema, - AzureADCSConnectionListItemSchema + AzureADCSConnectionListItemSchema, + RedisConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { @@ -210,6 +214,9 @@ export const registerAppConnectionRouter = async (server: FastifyZodProvider) => hide: false, tags: [ApiDocsTags.AppConnections], description: "List the available App Connection Options.", + querystring: z.object({ + projectType: z.nativeEnum(ProjectType).optional() + }), response: { 200: z.object({ appConnectionOptions: AppConnectionOptionsSchema.array() @@ -217,8 +224,8 @@ export const registerAppConnectionRouter = async (server: FastifyZodProvider) => } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: () => { - const appConnectionOptions = server.services.appConnection.listAppConnectionOptions(); + handler: (req) => { + const appConnectionOptions = server.services.appConnection.listAppConnectionOptions(req.query.projectType); return { appConnectionOptions }; } }); @@ -232,18 +239,27 @@ export const registerAppConnectionRouter = async (server: FastifyZodProvider) => schema: { hide: false, tags: [ApiDocsTags.AppConnections], - description: "List all the App Connections for the current organization.", + description: "List all the App Connections for the current organization or project.", + querystring: z.object({ + projectId: z.string().optional().describe(AppConnections.LIST().projectId) + }), response: { 200: z.object({ appConnections: SanitizedAppConnectionSchema.array() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const appConnections = await server.services.appConnection.listAppConnectionsByOrg(req.permission); + const { projectId } = req.query; + const appConnections = await server.services.appConnection.listAppConnections( + req.permission, + undefined, + projectId + ); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, + projectId, event: { type: EventType.GET_APP_CONNECTIONS, metadata: { diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 70804d173..11d9ce5e6 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -31,6 +31,7 @@ import { registerNetlifyConnectionRouter } from "./netlify-connection-router"; import { registerOktaConnectionRouter } from "./okta-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; import { registerRailwayConnectionRouter } from "./railway-connection-router"; +import { registerRedisConnectionRouter } from "./redis-connection-router"; import { registerRenderConnectionRouter } from "./render-connection-router"; import { registerSupabaseConnectionRouter } from "./supabase-connection-router"; import { registerTeamCityConnectionRouter } from "./teamcity-connection-router"; @@ -80,5 +81,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.Redis, + server, + sanitizedResponseSchema: SanitizedRedisConnectionSchema, + createSchema: CreateRedisConnectionSchema, + updateSchema: UpdateRedisConnectionSchema + }); +}; diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index a54bd5ccf..8cf9604a4 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -1,16 +1,16 @@ import { ForbiddenError } from "@casl/ability"; import { z } from "zod"; -import { SecretFoldersSchema, SecretImportsSchema, UsersSchema } from "@app/db/schemas"; +import { SecretFoldersSchema, SecretImportsSchema, SecretType, UsersSchema } from "@app/db/schemas"; import { RemindersSchema } from "@app/db/schemas/reminders"; import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission"; import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema"; import { DASHBOARD } from "@app/lib/api-docs"; -import { BadRequestError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; import { OrderByDirection } from "@app/lib/types"; -import { secretsLimit } from "@app/server/config/rateLimiter"; +import { readLimit, secretsLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { getUserAgentType } from "@app/server/plugins/audit-log"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -111,6 +111,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { SecretRotationV2Schema, z.object({ secrets: secretRawSchema + .omit({ secretValue: true }) .extend({ secretValueHidden: z.boolean(), secretPath: z.string().optional(), @@ -124,7 +125,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { .array() .optional(), secrets: secretRawSchema + .omit({ secretValue: true }) .extend({ + isEmpty: z.boolean(), secretValueHidden: z.boolean(), secretPath: z.string().optional(), secretMetadata: ResourceMetadataSchema.optional(), @@ -207,7 +210,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { const environments = req.query.environments.split(","); if (!projectId || environments.length === 0) - throw new BadRequestError({ message: "Missing workspace id or environment(s)" }); + throw new BadRequestError({ message: "Missing project id or environment(s)" }); const { shouldUseSecretV2Bridge } = await server.services.projectBot.getBotKey(projectId); @@ -219,7 +222,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { let imports: Awaited> | undefined; let folders: Awaited> | undefined; - let secrets: Awaited> | undefined; + let secrets: + | (Awaited>[number] & { isEmpty: boolean })[] + | undefined; let dynamicSecrets: | Awaited> | undefined; @@ -426,43 +431,51 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { }); if (remainingLimit > 0 && totalSecretCount > adjustedOffset) { - secrets = await server.services.secret.getSecretsRawMultiEnv({ - viewSecretValue: true, - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - environments, - actorAuthMethod: req.permission.authMethod, - projectId, - path: secretPath, - orderBy, - orderDirection, - search, - limit: remainingLimit, - offset: adjustedOffset, - isInternal: true - }); + secrets = ( + await server.services.secret.getSecretsRawMultiEnv({ + viewSecretValue: true, + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + environments, + actorAuthMethod: req.permission.authMethod, + projectId, + path: secretPath, + orderBy, + orderDirection, + search, + limit: remainingLimit, + offset: adjustedOffset, + isInternal: true + }) + ).map((secret) => ({ ...secret, isEmpty: !secret.secretValue })); } } if (secrets?.length || secretRotations?.length) { for await (const environment of environments) { - const secretCountFromEnv = - (secrets?.filter((secret) => secret.environment === environment).length ?? 0) + - (secretRotations - ?.filter((rotation) => rotation.environment.slug === environment) - .flatMap((rotation) => rotation.secrets.filter((secret) => Boolean(secret))).length ?? 0); + const secretIds = [ + ...new Set( + [ + ...(secrets?.filter((secret) => secret.environment === environment) ?? []), + ...(secretRotations + ?.filter((rotation) => rotation.environment.slug === environment) + .flatMap((rotation) => rotation.secrets.filter((secret) => Boolean(secret))) ?? []) + ].map((secret) => secret.id) + ) + ]; - if (secretCountFromEnv) { + if (secretIds) { await server.services.auditLog.createAuditLog({ projectId, ...req.auditLogInfo, event: { - type: EventType.GET_SECRETS, + type: EventType.DASHBOARD_LIST_SECRETS, metadata: { environment, secretPath, - numberOfSecrets: secretCountFromEnv + numberOfSecrets: secretIds.length, + secretIds } } }); @@ -473,8 +486,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { distinctId: getTelemetryDistinctId(req), organizationId: req.permission.orgId, properties: { - numberOfSecrets: secretCountFromEnv, - workspaceId: projectId, + numberOfSecrets: secretIds.length, + projectId, environment, secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -584,7 +597,6 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { .optional(), search: z.string().trim().describe(DASHBOARD.SECRET_DETAILS_LIST.search).optional(), tags: z.string().trim().transform(decodeURIComponent).describe(DASHBOARD.SECRET_DETAILS_LIST.tags).optional(), - viewSecretValue: booleanSchema.default(true), includeSecrets: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeSecrets), includeFolders: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeFolders), includeDynamicSecrets: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeDynamicSecrets), @@ -606,7 +618,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { SecretRotationV2Schema, z.object({ secrets: secretRawSchema + .omit({ secretValue: true }) .extend({ + isEmpty: z.boolean(), secretValueHidden: z.boolean(), secretPath: z.string().optional(), secretMetadata: ResourceMetadataSchema.optional(), @@ -619,7 +633,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { .array() .optional(), secrets: secretRawSchema + .omit({ secretValue: true }) .extend({ + isEmpty: z.boolean(), secretReminderRecipients: z .object({ user: UsersSchema.pick({ id: true, email: true, username: true }), @@ -696,7 +712,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { includeSecretRotations } = req.query; - if (!projectId || !environment) throw new BadRequestError({ message: "Missing workspace id or environment" }); + if (!projectId || !environment) throw new BadRequestError({ message: "Missing project id or environment" }); const { shouldUseSecretV2Bridge } = await server.services.projectBot.getBotKey(projectId); @@ -715,12 +731,21 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { let folders: Awaited> | undefined; let secrets: | (Awaited>["secrets"][number] & { + isEmpty: boolean; reminder: Awaited>[string] | null; })[] | undefined; let dynamicSecrets: Awaited> | undefined; let secretRotations: - | Awaited> + | (Awaited>[number] & { + secrets: (NonNullable< + Awaited< + ReturnType + >[number]["secrets"][number] & { + isEmpty: boolean; + } + > | null)[]; + })[] | undefined; let totalImportCount: number | undefined; @@ -822,19 +847,31 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { ); if (remainingLimit > 0 && totalSecretRotationCount > adjustedOffset) { - secretRotations = await server.services.secretRotationV2.getDashboardSecretRotations( - { - projectId, - search, - orderBy, - orderDirection, - environments: [environment], - secretPath, - limit: remainingLimit, - offset: adjustedOffset - }, - req.permission - ); + secretRotations = ( + await server.services.secretRotationV2.getDashboardSecretRotations( + { + projectId, + search, + orderBy, + orderDirection, + environments: [environment], + secretPath, + limit: remainingLimit, + offset: adjustedOffset + }, + req.permission + ) + ).map((rotation) => ({ + ...rotation, + secrets: rotation.secrets.map((secret) => + secret + ? { + ...secret, + isEmpty: !secret.secretValue + } + : secret + ) + })); await server.services.auditLog.createAuditLog({ projectId, @@ -919,7 +956,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { await server.services.secret.getSecretsRaw({ actorId: req.permission.id, actor: req.permission.type, - viewSecretValue: req.query.viewSecretValue, + viewSecretValue: true, throwOnMissingReadValuePermission: false, actorOrgId: req.permission.orgId, environment, @@ -943,6 +980,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { secrets = rawSecrets.map((secret) => ({ ...secret, + isEmpty: !secret.secretValue, reminder: reminders[secret.id] ?? null })); } @@ -977,19 +1015,25 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { })); if (secrets?.length || secretRotations?.length) { - const secretCount = - (secrets?.length ?? 0) + - (secretRotations?.flatMap((rotation) => rotation.secrets.filter((secret) => Boolean(secret))).length ?? 0); + const secretIds = [ + ...new Set( + [ + ...(secrets ?? []), + ...(secretRotations?.flatMap((rotation) => rotation.secrets.filter((secret) => Boolean(secret))) ?? []) + ].map((secret) => secret.id) + ) + ]; await server.services.auditLog.createAuditLog({ projectId, ...req.auditLogInfo, event: { - type: EventType.GET_SECRETS, + type: EventType.DASHBOARD_LIST_SECRETS, metadata: { environment, secretPath, - numberOfSecrets: secretCount + numberOfSecrets: secretIds.length, + secretIds } } }); @@ -1000,8 +1044,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { distinctId: getTelemetryDistinctId(req), organizationId: req.permission.orgId, properties: { - numberOfSecrets: secretCount, - workspaceId: projectId, + numberOfSecrets: secretIds.length, + projectId, environment, secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -1060,6 +1104,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { .array() .optional(), secrets: secretRawSchema + .omit({ secretValue: true }) .extend({ secretValueHidden: z.boolean(), secretPath: z.string().optional(), @@ -1145,18 +1190,20 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { ); for await (const environment of environments) { - const secretCountForEnv = secrets.filter((secret) => secret.environment === environment).length; + const envSecrets = secrets.filter((secret) => secret.environment === environment); + const secretCountForEnv = envSecrets.length; if (secretCountForEnv) { await server.services.auditLog.createAuditLog({ projectId, ...req.auditLogInfo, event: { - type: EventType.GET_SECRETS, + type: EventType.DASHBOARD_LIST_SECRETS, metadata: { environment, secretPath, - numberOfSecrets: secretCountForEnv + numberOfSecrets: secretCountForEnv, + secretIds: envSecrets.map((secret) => secret.id) } } }); @@ -1168,7 +1215,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { organizationId: req.permission.orgId, properties: { numberOfSecrets: secretCountForEnv, - workspaceId: projectId, + projectId, environment, secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -1259,6 +1306,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ + // TODO(scott): omit secretValue here, but requires refactor of uploading env/copy from board secrets: secretRawSchema .extend({ secretPath: z.string().optional(), @@ -1310,6 +1358,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ + // TODO(scott): omit secretValue here, but requires refactor of uploading env/copy from board secrets: secretRawSchema .extend({ secretValueHidden: z.boolean(), @@ -1345,11 +1394,12 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { projectId, ...req.auditLogInfo, event: { - type: EventType.GET_SECRETS, + type: EventType.DASHBOARD_LIST_SECRETS, metadata: { environment, secretPath, - numberOfSecrets: secrets.length + numberOfSecrets: secrets.length, + secretIds: secrets.map((secret) => secret.id) } } }); @@ -1361,7 +1411,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { organizationId: req.permission.orgId, properties: { numberOfSecrets: secrets.length, - workspaceId: projectId, + projectId, environment, secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -1373,4 +1423,256 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { return { secrets }; } }); + + server.route({ + method: "GET", + url: "/secret-value", + config: { + rateLimit: secretsLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + projectId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/").transform(removeTrailingSlash), + secretKey: z.string().trim(), + isOverride: z + .enum(["true", "false"]) + .transform((value) => value === "true") + .optional() + }), + response: { + 200: z.object({ + valueOverride: z.string().optional(), + value: z.string().optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { secretPath, projectId, environment, secretKey, isOverride } = req.query; + + // TODO (scott): just get the secret instead of searching for it in list + const { secrets } = await server.services.secret.getSecretsRaw({ + actorId: req.permission.id, + actor: req.permission.type, + viewSecretValue: true, + throwOnMissingReadValuePermission: false, + actorOrgId: req.permission.orgId, + environment, + actorAuthMethod: req.permission.authMethod, + projectId, + path: secretPath, + search: secretKey, + includeTagsInSearch: true, + includeMetadataInSearch: true + }); + + if (isOverride) { + const personalSecret = secrets.find( + (secret) => secret.type === SecretType.Personal && secret.secretKey === secretKey + ); + + if (!personalSecret) + throw new BadRequestError({ + message: `Could not find personal secret with key "${secretKey}" at secret path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"` + }); + + if (personalSecret) + return { + valueOverride: personalSecret.secretValue + }; + } + + const sharedSecret = secrets.find( + (secret) => secret.type === SecretType.Shared && secret.secretKey === secretKey + ); + + if (!sharedSecret) + throw new BadRequestError({ + message: `Could not find secret with key "${secretKey}" at secret path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"` + }); + + // only audit if not personal + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.DASHBOARD_GET_SECRET_VALUE, + metadata: { + environment: req.query.environment, + secretPath: req.query.secretPath, + secretKey, + secretId: sharedSecret.id + } + } + }); + + return { value: sharedSecret.secretValue }; + } + }); + + server.route({ + url: "/secret-imports", + method: "GET", + config: { + rateLimit: secretsLimit + }, + schema: { + querystring: z.object({ + projectId: z.string().trim(), + environment: z.string().trim(), + path: z.string().trim().default("/").transform(removeTrailingSlash) + }), + response: { + 200: z.object({ + secrets: z + .object({ + secretPath: z.string(), + environment: z.string(), + environmentInfo: z.object({ + id: z.string(), + name: z.string(), + slug: z.string() + }), + folderId: z.string().optional(), + secrets: secretRawSchema.omit({ secretValue: true }).extend({ isEmpty: z.boolean() }).array() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const importedSecrets = await server.services.secretImport.getRawSecretsFromImports({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + await server.services.auditLog.createAuditLog({ + projectId: req.query.projectId, + ...req.auditLogInfo, + event: { + type: EventType.DASHBOARD_LIST_SECRETS, + metadata: { + environment: req.query.environment, + secretPath: req.query.path, + numberOfSecrets: importedSecrets.length, + secretIds: importedSecrets.map((secret) => secret.id) + } + } + }); + + return { + secrets: importedSecrets.map((importData) => ({ + ...importData, + secrets: importData.secrets.map((secret) => ({ + ...secret, + isEmpty: !secret.secretValue + })) + })) + }; + } + }); + + server.route({ + method: "GET", + url: "/secret-versions/:secretId", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + secretId: z.string() + }), + querystring: z.object({ + offset: z.coerce.number(), + limit: z.coerce.number() + }), + response: { + 200: z.object({ + secretVersions: secretRawSchema + .omit({ secretValue: true }) + .extend({ + secretValueHidden: z.boolean() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const secretVersions = await server.services.secret.getSecretVersions({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + limit: req.query.limit, + offset: req.query.offset, + secretId: req.params.secretId + }); + + return { secretVersions }; + } + }); + + server.route({ + method: "GET", + url: "/secret-versions/:secretId/value/:version", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + secretId: z.string(), + version: z.string() + }), + + response: { + 200: z.object({ + value: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { version, secretId } = req.params; + + const [secretVersion] = await server.services.secret.getSecretVersions({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + secretId, + secretVersions: [version] + }); + + if (!secretVersion) + throw new NotFoundError({ + message: `Could not find secret version "${version}" for secret with ID "${secretId}` + }); + + await server.services.auditLog.createAuditLog({ + projectId: secretVersion.workspace, + ...req.auditLogInfo, + event: { + type: EventType.DASHBOARD_GET_SECRET_VERSION_VALUE, + metadata: { + secretId, + version + } + } + }); + + return { value: secretVersion.secretValue }; + } + }); }; diff --git a/backend/src/server/routes/v1/deprecated-project-env-router.ts b/backend/src/server/routes/v1/deprecated-project-env-router.ts new file mode 100644 index 000000000..1a187e2b1 --- /dev/null +++ b/backend/src/server/routes/v1/deprecated-project-env-router.ts @@ -0,0 +1,298 @@ +import { z } from "zod"; + +import { ProjectEnvironmentsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, ENVIRONMENTS } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerDeprecatedProjectEnvRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:workspaceId/environments/:envId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Environments], + description: "Get Environment", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + // NOTE(daniel): workspaceId isn't used, but we need to keep it for backwards compatibility. The endpoint defined below, uses no project ID, and is takes a pure environment ID. + workspaceId: z.string().trim().describe(ENVIRONMENTS.GET.projectId), + envId: z.string().trim().describe(ENVIRONMENTS.GET.id) + }), + response: { + 200: z.object({ + environment: ProjectEnvironmentsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const environment = await server.services.projectEnv.getEnvironmentById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + id: req.params.envId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: environment.projectId, + event: { + type: EventType.GET_ENVIRONMENT, + metadata: { + id: environment.id + } + } + }); + + return { environment }; + } + }); + + server.route({ + method: "GET", + url: "/environments/:envId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Environments], + description: "Get Environment by ID", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + envId: z.string().trim().describe(ENVIRONMENTS.GET.id) + }), + response: { + 200: z.object({ + environment: ProjectEnvironmentsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const environment = await server.services.projectEnv.getEnvironmentById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + id: req.params.envId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: environment.projectId, + event: { + type: EventType.GET_ENVIRONMENT, + metadata: { + id: environment.id + } + } + }); + + return { environment }; + } + }); + + server.route({ + method: "POST", + url: "/:workspaceId/environments", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Environments], + description: "Create environment", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + workspaceId: z.string().trim().describe(ENVIRONMENTS.CREATE.projectId) + }), + body: z.object({ + name: z.string().trim().describe(ENVIRONMENTS.CREATE.name), + position: z.number().min(1).optional().describe(ENVIRONMENTS.CREATE.position), + slug: slugSchema({ max: 64 }).describe(ENVIRONMENTS.CREATE.slug) + }), + response: { + 200: z.object({ + message: z.string(), + workspace: z.string(), + environment: ProjectEnvironmentsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const environment = await server.services.projectEnv.createEnvironment({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + projectId: req.params.workspaceId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: environment.projectId, + event: { + type: EventType.CREATE_ENVIRONMENT, + metadata: { + name: environment.name, + slug: environment.slug + } + } + }); + return { + message: "Successfully created new environment", + workspace: req.params.workspaceId, + environment + }; + } + }); + + server.route({ + method: "PATCH", + url: "/:workspaceId/environments/:id", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Environments], + description: "Update environment", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + workspaceId: z.string().trim().describe(ENVIRONMENTS.UPDATE.projectId), + id: z.string().trim().describe(ENVIRONMENTS.UPDATE.id) + }), + body: z.object({ + slug: slugSchema({ max: 64 }).optional().describe(ENVIRONMENTS.UPDATE.slug), + name: z.string().trim().optional().describe(ENVIRONMENTS.UPDATE.name), + position: z.number().optional().describe(ENVIRONMENTS.UPDATE.position) + }), + response: { + 200: z.object({ + message: z.string(), + workspace: z.string(), + environment: ProjectEnvironmentsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { environment, old } = await server.services.projectEnv.updateEnvironment({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + id: req.params.id, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: environment.projectId, + event: { + type: EventType.UPDATE_ENVIRONMENT, + metadata: { + oldName: old.name, + oldSlug: old.slug, + oldPos: old.position, + newName: environment.name, + newSlug: environment.slug, + newPos: environment.position + } + } + }); + + return { + message: "Successfully updated environment", + workspace: req.params.workspaceId, + environment + }; + } + }); + + server.route({ + method: "DELETE", + url: "/:workspaceId/environments/:id", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Environments], + description: "Delete environment", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + workspaceId: z.string().trim().describe(ENVIRONMENTS.DELETE.projectId), + id: z.string().trim().describe(ENVIRONMENTS.DELETE.id) + }), + response: { + 200: z.object({ + message: z.string(), + workspace: z.string(), + environment: ProjectEnvironmentsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const environment = await server.services.projectEnv.deleteEnvironment({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + id: req.params.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: environment.projectId, + event: { + type: EventType.DELETE_ENVIRONMENT, + metadata: { + slug: environment.slug, + name: environment.name + } + } + }); + + return { + message: "Successfully deleted environment", + workspace: req.params.workspaceId, + environment + }; + } + }); +}; diff --git a/backend/src/server/routes/v1/deprecated-project-membership-router.ts b/backend/src/server/routes/v1/deprecated-project-membership-router.ts new file mode 100644 index 000000000..ab225929f --- /dev/null +++ b/backend/src/server/routes/v1/deprecated-project-membership-router.ts @@ -0,0 +1,378 @@ +import { z } from "zod"; + +import { + OrgMembershipsSchema, + ProjectMembershipsSchema, + ProjectUserMembershipRolesSchema, + UserEncryptionKeysSchema, + UsersSchema +} from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, PROJECT_USERS } from "@app/lib/api-docs"; +import { ms } from "@app/lib/ms"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types"; + +export const registerDeprecatedProjectMembershipRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:workspaceId/memberships", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.ProjectUsers], + description: "Return project user memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + workspaceId: z.string().trim().describe(PROJECT_USERS.GET_USER_MEMBERSHIPS.projectId) + }), + response: { + 200: z.object({ + memberships: ProjectMembershipsSchema.extend({ + user: UsersSchema.pick({ + email: true, + firstName: true, + lastName: true, + id: true, + username: true + }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ) + }) + .omit({ updatedAt: true }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const memberships = await server.services.projectMembership.getProjectMemberships({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId + }); + return { memberships }; + } + }); + + server.route({ + method: "GET", + url: "/:workspaceId/memberships/:membershipId", + config: { + rateLimit: readLimit + }, + schema: { + description: "Return project user membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + workspaceId: z.string().min(1).trim().describe(PROJECT_USERS.GET_USER_MEMBERSHIP.projectId), + membershipId: z.string().min(1).trim().describe(PROJECT_USERS.GET_USER_MEMBERSHIP.membershipId) + }), + response: { + 200: z.object({ + membership: ProjectMembershipsSchema.extend({ + user: UsersSchema.pick({ + email: true, + firstName: true, + lastName: true, + id: true, + username: true + }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ) + }).omit({ updatedAt: true }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const membership = await server.services.projectMembership.getProjectMembershipById({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + id: req.params.membershipId + }); + return { membership }; + } + }); + + server.route({ + method: "POST", + url: "/:workspaceId/memberships/details", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.ProjectUsers], + description: "Return project user memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + workspaceId: z.string().min(1).trim().describe(PROJECT_USERS.GET_USER_MEMBERSHIP.projectId) + }), + body: z.object({ + username: z.string().min(1).trim().describe(PROJECT_USERS.GET_USER_MEMBERSHIP.username) + }), + response: { + 200: z.object({ + membership: ProjectMembershipsSchema.extend({ + user: UsersSchema.pick({ + email: true, + firstName: true, + lastName: true, + id: true + }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ) + }).omit({ createdAt: true, updatedAt: true }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const membership = await server.services.projectMembership.getProjectMembershipByUsername({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + username: req.body.username + }); + return { membership }; + } + }); + + server.route({ + method: "POST", + url: "/:workspaceId/memberships", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + members: z + .object({ + orgMembershipId: z.string().trim(), + workspaceEncryptedKey: z.string().trim(), + workspaceEncryptedNonce: z.string().trim() + }) + .array() + .min(1) + }), + response: { + 200: z.object({ + success: z.boolean(), + data: OrgMembershipsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.projectMembership.addUsersToProject({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + members: req.body.members + }); + + await server.services.auditLog.createAuditLog({ + projectId: req.params.workspaceId, + ...req.auditLogInfo, + event: { + type: EventType.ADD_BATCH_PROJECT_MEMBER, + metadata: data.map(({ userId }) => ({ + userId: userId || "", + email: "" + })) + } + }); + + return { data, success: true }; + } + }); + + server.route({ + method: "PATCH", + url: "/:workspaceId/memberships/:membershipId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.ProjectUsers], + description: "Update project user membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + workspaceId: z.string().trim().describe(PROJECT_USERS.UPDATE_USER_MEMBERSHIP.projectId), + membershipId: z.string().trim().describe(PROJECT_USERS.UPDATE_USER_MEMBERSHIP.membershipId) + }), + body: z.object({ + roles: z + .array( + z.union([ + z.object({ + role: z.string(), + isTemporary: z.literal(false).default(false) + }), + z.object({ + role: z.string(), + isTemporary: z.literal(true), + temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), + temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), + temporaryAccessStartTime: z.string().datetime() + }) + ]) + ) + .min(1) + .refine((data) => data.some(({ isTemporary }) => !isTemporary), "At least one long lived role is required") + .describe(PROJECT_USERS.UPDATE_USER_MEMBERSHIP.roles) + }), + response: { + 200: z.object({ + roles: ProjectUserMembershipRolesSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const roles = await server.services.projectMembership.updateProjectMembership({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + membershipId: req.params.membershipId, + roles: req.body.roles + }); + + // await server.services.auditLog.createAuditLog({ + // ...req.auditLogInfo, + // projectId: req.params.workspaceId, + // event: { + // type: EventType.UPDATE_USER_WORKSPACE_ROLE, + // metadata: { + // userId: membership.userId, + // newRole: req.body.role, + // oldRole: membership.role, + // email: "" + // } + // } + // }); + return { roles }; + } + }); + + server.route({ + method: "DELETE", + url: "/:workspaceId/memberships/:membershipId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Delete project user membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + workspaceId: z.string().trim(), + membershipId: z.string().trim() + }), + response: { + 200: z.object({ + membership: ProjectMembershipsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const membership = await server.services.projectMembership.deleteProjectMembership({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + membershipId: req.params.membershipId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.params.workspaceId, + event: { + type: EventType.REMOVE_PROJECT_MEMBER, + metadata: { + userId: membership.userId, + email: "" + } + } + }); + return { membership }; + } + }); +}; diff --git a/backend/src/server/routes/v1/deprecated-project-router.ts b/backend/src/server/routes/v1/deprecated-project-router.ts new file mode 100644 index 000000000..687d95b9b --- /dev/null +++ b/backend/src/server/routes/v1/deprecated-project-router.ts @@ -0,0 +1,728 @@ +import { z } from "zod"; + +import { + IntegrationsSchema, + ProjectRolesSchema, + ProjectSlackConfigsSchema, + ProjectSshConfigsSchema, + ProjectType, + SortDirection +} from "@app/db/schemas"; +import { ProjectMicrosoftTeamsConfigsSchema } from "@app/db/schemas/project-microsoft-teams-configs"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, PROJECTS } from "@app/lib/api-docs"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; +import { re2Validator } from "@app/lib/zod"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { validateMicrosoftTeamsChannelsSchema } from "@app/services/microsoft-teams/microsoft-teams-fns"; +import { ProjectFilterType, SearchProjectSortBy } from "@app/services/project/project-types"; +import { validateSlackChannelsField } from "@app/services/slack/slack-auth-validators"; +import { WorkflowIntegration } from "@app/services/workflow-integration/workflow-integration-types"; + +import { integrationAuthPubSchema, SanitizedProjectSchema } from "../sanitizedSchemas"; + +const projectWithEnv = SanitizedProjectSchema.merge( + z.object({ + _id: z.string(), + environments: z.object({ name: z.string(), slug: z.string(), id: z.string() }).array() + }) +); + +export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + includeRoles: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true"), + type: z.nativeEnum(ProjectType).optional() + }), + response: { + 200: z.object({ + workspaces: projectWithEnv + .extend({ + roles: ProjectRolesSchema.array().optional() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workspaces = await server.services.project.getProjects({ + includeRoles: req.query.includeRoles, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + type: req.query.type + }); + return { workspaces }; + } + }); + + server.route({ + method: "GET", + url: "/:workspaceId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Projects], + description: "Get project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + workspaceId: z.string().trim().describe(PROJECTS.GET.projectId) + }), + response: { + 200: z.object({ + workspace: projectWithEnv.optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workspace = await server.services.project.getAProject({ + filter: { + type: ProjectFilterType.ID, + projectId: req.params.workspaceId + }, + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId + }); + return { workspace }; + } + }); + + server.route({ + method: "DELETE", + url: "/:workspaceId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Projects], + description: "Delete project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + workspaceId: z.string().trim().describe(PROJECTS.DELETE.projectId) + }), + response: { + 200: z.object({ + workspace: SanitizedProjectSchema.optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workspace = await server.services.project.deleteProject({ + filter: { + type: ProjectFilterType.ID, + projectId: req.params.workspaceId + }, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.params.workspaceId, + event: { + type: EventType.DELETE_PROJECT, + metadata: workspace + } + }); + + return { workspace }; + } + }); + + server.route({ + method: "PATCH", + url: "/:workspaceId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Projects], + description: "Update project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + workspaceId: z.string().trim().describe(PROJECTS.UPDATE.projectId) + }), + body: z.object({ + name: z + .string() + .trim() + .max(64, { message: "Name must be 64 or fewer characters" }) + .optional() + .describe(PROJECTS.UPDATE.name), + description: z + .string() + .trim() + .max(256, { message: "Description must be 256 or fewer characters" }) + .optional() + .describe(PROJECTS.UPDATE.projectDescription), + autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization), + hasDeleteProtection: z.boolean().optional().describe(PROJECTS.UPDATE.hasDeleteProtection), + slug: z + .string() + .trim() + .max(64, { message: "Slug must be 64 characters or fewer" }) + .refine(re2Validator(/^[a-z0-9]+(?:[_-][a-z0-9]+)*$/), { + message: + "Project slug can only contain lowercase letters and numbers, with optional single hyphens (-) or underscores (_) between words. Cannot start or end with a hyphen or underscore." + }) + .optional() + .describe(PROJECTS.UPDATE.slug), + secretSharing: z.boolean().optional().describe(PROJECTS.UPDATE.secretSharing), + showSnapshotsLegacy: z.boolean().optional().describe(PROJECTS.UPDATE.showSnapshotsLegacy), + defaultProduct: z.nativeEnum(ProjectType).optional().describe(PROJECTS.UPDATE.defaultProduct), + secretDetectionIgnoreValues: z + .array(z.string()) + .optional() + .describe(PROJECTS.UPDATE.secretDetectionIgnoreValues) + }), + response: { + 200: z.object({ + workspace: SanitizedProjectSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workspace = await server.services.project.updateProject({ + filter: { + type: ProjectFilterType.ID, + projectId: req.params.workspaceId + }, + update: { + name: req.body.name, + description: req.body.description, + autoCapitalization: req.body.autoCapitalization, + defaultProduct: req.body.defaultProduct, + hasDeleteProtection: req.body.hasDeleteProtection, + slug: req.body.slug, + secretSharing: req.body.secretSharing, + showSnapshotsLegacy: req.body.showSnapshotsLegacy, + secretDetectionIgnoreValues: req.body.secretDetectionIgnoreValues + }, + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.params.workspaceId, + event: { + type: EventType.UPDATE_PROJECT, + metadata: req.body + } + }); + + return { + workspace + }; + } + }); + + server.route({ + method: "PUT", + url: "/:workspaceSlug/audit-logs-retention", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + workspaceSlug: z.string().trim() + }), + body: z.object({ + auditLogsRetentionDays: z.number().min(0) + }), + response: { + 200: z.object({ + message: z.string(), + workspace: SanitizedProjectSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workspace = await server.services.project.updateAuditLogsRetention({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + filter: { + type: ProjectFilterType.SLUG, + slug: req.params.workspaceSlug, + orgId: req.permission.orgId + }, + auditLogsRetentionDays: req.body.auditLogsRetentionDays + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: workspace.id, + event: { + type: EventType.UPDATE_PROJECT, + metadata: req.body + } + }); + + return { + message: "Successfully updated project's audit logs retention period", + workspace + }; + } + }); + + server.route({ + method: "GET", + url: "/:workspaceId/integrations", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Integrations], + description: "List integrations for a project.", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + workspaceId: z.string().trim().describe(PROJECTS.LIST_INTEGRATION.projectId) + }), + response: { + 200: z.object({ + integrations: IntegrationsSchema.merge( + z.object({ + environment: z.object({ + id: z.string(), + name: z.string(), + slug: z.string() + }) + }) + ).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const integrations = await server.services.integration.listIntegrationByProject({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId + }); + return { integrations }; + } + }); + + server.route({ + method: "GET", + url: "/:workspaceId/authorizations", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Integrations], + description: "List integration auth objects for a workspace.", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + workspaceId: z.string().trim().describe(PROJECTS.LIST_INTEGRATION_AUTHORIZATION.projectId) + }), + response: { + 200: z.object({ + authorizations: integrationAuthPubSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const authorizations = await server.services.integrationAuth.listIntegrationAuthByProjectId({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId + }); + return { authorizations }; + } + }); + + server.route({ + method: "GET", + url: "/:workspaceId/ssh-config", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + response: { + 200: ProjectSshConfigsSchema.pick({ + id: true, + createdAt: true, + updatedAt: true, + projectId: true, + defaultUserSshCaId: true, + defaultHostSshCaId: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const sshConfig = await server.services.project.getProjectSshConfig({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshConfig.projectId, + event: { + type: EventType.GET_PROJECT_SSH_CONFIG, + metadata: { + id: sshConfig.id, + projectId: sshConfig.projectId + } + } + }); + + return sshConfig; + } + }); + + server.route({ + method: "PATCH", + url: "/:workspaceId/ssh-config", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + defaultUserSshCaId: z.string().optional(), + defaultHostSshCaId: z.string().optional() + }), + response: { + 200: ProjectSshConfigsSchema.pick({ + id: true, + createdAt: true, + updatedAt: true, + projectId: true, + defaultUserSshCaId: true, + defaultHostSshCaId: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const sshConfig = await server.services.project.updateProjectSshConfig({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshConfig.projectId, + event: { + type: EventType.UPDATE_PROJECT_SSH_CONFIG, + metadata: { + id: sshConfig.id, + projectId: sshConfig.projectId, + defaultUserSshCaId: sshConfig.defaultUserSshCaId, + defaultHostSshCaId: sshConfig.defaultHostSshCaId + } + } + }); + + return sshConfig; + } + }); + + server.route({ + method: "GET", + url: "/:workspaceId/workflow-integration-config/:integration", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim(), + integration: z.nativeEnum(WorkflowIntegration) + }), + response: { + 200: z.discriminatedUnion("integration", [ + ProjectSlackConfigsSchema.pick({ + id: true, + isAccessRequestNotificationEnabled: true, + accessRequestChannels: true, + isSecretRequestNotificationEnabled: true, + secretRequestChannels: true + }).merge( + z.object({ + integration: z.literal(WorkflowIntegration.SLACK), + integrationId: z.string() + }) + ), + ProjectMicrosoftTeamsConfigsSchema.pick({ + id: true, + isAccessRequestNotificationEnabled: true, + accessRequestChannels: true, + isSecretRequestNotificationEnabled: true, + secretRequestChannels: true + }).merge( + z.object({ + integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS), + integrationId: z.string() + }) + ) + ]) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const config = await server.services.project.getProjectWorkflowIntegrationConfig({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + integration: req.params.integration + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.params.workspaceId, + event: { + type: EventType.GET_PROJECT_WORKFLOW_INTEGRATION_CONFIG, + metadata: { + id: config.id, + integration: config.integration + } + } + }); + + return config; + } + }); + + server.route({ + method: "DELETE", + url: "/:projectId/workflow-integration/:integration/:integrationId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim(), + integration: z.nativeEnum(WorkflowIntegration), + integrationId: z.string() + }), + response: { + 200: z.object({ + integrationConfig: z.object({ + id: z.string() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const deletedIntegration = await server.services.project.deleteProjectWorkflowIntegration({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.projectId, + integration: req.params.integration, + integrationId: req.params.integrationId + }); + + return { + integrationConfig: deletedIntegration + }; + } + }); + + server.route({ + method: "PUT", + url: "/:workspaceId/workflow-integration", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + + body: z.discriminatedUnion("integration", [ + z.object({ + integration: z.literal(WorkflowIntegration.SLACK), + integrationId: z.string(), + accessRequestChannels: validateSlackChannelsField, + secretRequestChannels: validateSlackChannelsField, + isAccessRequestNotificationEnabled: z.boolean(), + isSecretRequestNotificationEnabled: z.boolean() + }), + z.object({ + integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS), + integrationId: z.string(), + accessRequestChannels: validateMicrosoftTeamsChannelsSchema, + secretRequestChannels: validateMicrosoftTeamsChannelsSchema, + isAccessRequestNotificationEnabled: z.boolean(), + isSecretRequestNotificationEnabled: z.boolean() + }) + ]), + response: { + 200: z.discriminatedUnion("integration", [ + ProjectSlackConfigsSchema.pick({ + id: true, + isAccessRequestNotificationEnabled: true, + accessRequestChannels: true, + isSecretRequestNotificationEnabled: true, + secretRequestChannels: true + }).merge( + z.object({ + integration: z.literal(WorkflowIntegration.SLACK), + integrationId: z.string() + }) + ), + ProjectMicrosoftTeamsConfigsSchema.pick({ + id: true, + isAccessRequestNotificationEnabled: true, + isSecretRequestNotificationEnabled: true + }).merge( + z.object({ + integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS), + integrationId: z.string(), + accessRequestChannels: validateMicrosoftTeamsChannelsSchema, + secretRequestChannels: validateMicrosoftTeamsChannelsSchema + }) + ) + ]) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workflowIntegrationConfig = await server.services.project.updateProjectWorkflowIntegration({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.params.workspaceId, + event: { + type: EventType.UPDATE_PROJECT_WORKFLOW_INTEGRATION_CONFIG, + metadata: { + id: workflowIntegrationConfig.id, + integrationId: workflowIntegrationConfig.integrationId, + integration: workflowIntegrationConfig.integration, + isAccessRequestNotificationEnabled: workflowIntegrationConfig.isAccessRequestNotificationEnabled, + accessRequestChannels: workflowIntegrationConfig.accessRequestChannels, + isSecretRequestNotificationEnabled: workflowIntegrationConfig.isSecretRequestNotificationEnabled, + secretRequestChannels: workflowIntegrationConfig.secretRequestChannels + } + } + }); + + return workflowIntegrationConfig; + } + }); + + server.route({ + method: "POST", + url: "/search", + config: { + rateLimit: readLimit + }, + schema: { + body: z.object({ + limit: z.number().default(100), + offset: z.number().default(0), + type: z.nativeEnum(ProjectType).optional(), + orderBy: z.nativeEnum(SearchProjectSortBy).optional().default(SearchProjectSortBy.NAME), + orderDirection: z.nativeEnum(SortDirection).optional().default(SortDirection.ASC), + name: z + .string() + .trim() + .refine((val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), { + message: "Invalid pattern: only alphanumeric characters, - are allowed." + }) + .optional() + }), + response: { + 200: z.object({ + projects: SanitizedProjectSchema.extend({ isMember: z.boolean() }).array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { docs: projects, totalCount } = await server.services.project.searchProjects({ + permission: req.permission, + ...req.body + }); + + return { projects, totalCount }; + } + }); +}; diff --git a/backend/src/server/routes/v1/deprecated-secret-folder-router.ts b/backend/src/server/routes/v1/deprecated-secret-folder-router.ts new file mode 100644 index 000000000..ecb955025 --- /dev/null +++ b/backend/src/server/routes/v1/deprecated-secret-folder-router.ts @@ -0,0 +1,444 @@ +import { z } from "zod"; + +import { SecretFoldersSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, FOLDERS } from "@app/lib/api-docs"; +import { prefixWithSlash, removeTrailingSlash } from "@app/lib/fn"; +import { isValidFolderName } from "@app/lib/validator"; +import { readLimit, secretsLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { booleanSchema } from "../sanitizedSchemas"; + +export const registerDeprecatedSecretFolderRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Folders], + description: "Create folders", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + workspaceId: z.string().trim().describe(FOLDERS.CREATE.projectId), + environment: z.string().trim().describe(FOLDERS.CREATE.environment), + name: z + .string() + .trim() + .describe(FOLDERS.CREATE.name) + .refine((name) => isValidFolderName(name), { + message: "Invalid folder name. Only alphanumeric characters, dashes, and underscores are allowed." + }), + path: z + .string() + .trim() + .default("/") + .transform(prefixWithSlash) // Transformations get skipped if path is undefined + .transform(removeTrailingSlash) + .describe(FOLDERS.CREATE.path) + .optional(), + // backward compatibility with cli + directory: z + .string() + .trim() + .default("/") + .transform(prefixWithSlash) // Transformations get skipped if directory is undefined + .transform(removeTrailingSlash) + .describe(FOLDERS.CREATE.directory) + .optional(), + description: z.string().optional().nullable().describe(FOLDERS.CREATE.description) + }), + response: { + 200: z.object({ + folder: SecretFoldersSchema.extend({ + path: z.string() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const path = req.body.path || req.body.directory || "/"; + const folder = await server.services.folder.createFolder({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + projectId: req.body.workspaceId, + path, + description: req.body.description + }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.body.workspaceId, + event: { + type: EventType.CREATE_FOLDER, + metadata: { + environment: req.body.environment, + folderId: folder.id, + folderName: folder.name, + folderPath: path, + ...(req.body.description ? { description: req.body.description } : {}) + } + } + }); + return { folder }; + } + }); + + server.route({ + url: "/:folderId", + method: "PATCH", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Folders], + description: "Update folder", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + // old way this was name + folderId: z.string().describe(FOLDERS.UPDATE.folderId) + }), + body: z.object({ + workspaceId: z.string().trim().describe(FOLDERS.UPDATE.projectId), + environment: z.string().trim().describe(FOLDERS.UPDATE.environment), + name: z + .string() + .trim() + .describe(FOLDERS.UPDATE.name) + .refine((name) => isValidFolderName(name), { + message: "Invalid folder name. Only alphanumeric characters, dashes, and underscores are allowed." + }), + path: z + .string() + .trim() + .default("/") + .transform(prefixWithSlash) // Transformations get skipped if path is undefined + .transform(removeTrailingSlash) + .describe(FOLDERS.UPDATE.path) + .optional(), + // backward compatibility with cli + directory: z + .string() + .trim() + .default("/") + .transform(prefixWithSlash) // Transformations get skipped if directory is undefined + .transform(removeTrailingSlash) + .describe(FOLDERS.UPDATE.directory) + .optional(), + description: z.string().optional().nullable().describe(FOLDERS.UPDATE.description) + }), + response: { + 200: z.object({ + folder: SecretFoldersSchema.extend({ + path: z.string() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const path = req.body.path || req.body.directory || "/"; + const { folder, old } = await server.services.folder.updateFolder({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + projectId: req.body.workspaceId, + id: req.params.folderId, + path + }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.body.workspaceId, + event: { + type: EventType.UPDATE_FOLDER, + metadata: { + environment: req.body.environment, + folderId: folder.id, + folderPath: path, + newFolderName: folder.name, + oldFolderName: old.name + } + } + }); + return { folder }; + } + }); + + server.route({ + url: "/batch", + method: "PATCH", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Folders], + description: "Update folders by batch", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + projectSlug: z.string().trim().describe(FOLDERS.UPDATE.projectSlug), + folders: z + .object({ + id: z.string().describe(FOLDERS.UPDATE.folderId), + environment: z.string().trim().describe(FOLDERS.UPDATE.environment), + name: z + .string() + .trim() + .describe(FOLDERS.UPDATE.name) + .refine((name) => isValidFolderName(name), { + message: "Invalid folder name. Only alphanumeric characters, dashes, and underscores are allowed." + }), + path: z + .string() + .trim() + .default("/") + .transform(prefixWithSlash) + .transform(removeTrailingSlash) + .describe(FOLDERS.UPDATE.path), + description: z.string().optional().nullable().describe(FOLDERS.UPDATE.description) + }) + .array() + .min(1) + }), + response: { + 200: z.object({ + folders: SecretFoldersSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { newFolders, oldFolders, projectId } = await server.services.folder.updateManyFolders({ + ...req.body, + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await Promise.all( + req.body.folders.map(async (folder, index) => { + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.UPDATE_FOLDER, + metadata: { + environment: oldFolders[index].envId, + folderId: oldFolders[index].id, + folderPath: folder.path, + newFolderName: newFolders[index].name, + oldFolderName: oldFolders[index].name + } + } + }); + }) + ); + + return { folders: newFolders }; + } + }); + + // TODO(daniel): Expose this route in api reference and write docs for it. + server.route({ + method: "DELETE", + url: "/:folderIdOrName", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Folders], + description: "Delete a folder", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + folderIdOrName: z.string().describe(FOLDERS.DELETE.folderIdOrName) + }), + body: z.object({ + workspaceId: z.string().trim().describe(FOLDERS.DELETE.projectId), + environment: z.string().trim().describe(FOLDERS.DELETE.environment), + path: z + .string() + .trim() + .default("/") + .transform(prefixWithSlash) // Transformations get skipped if path is undefined + .transform(removeTrailingSlash) + .describe(FOLDERS.DELETE.path) + .optional(), + // keep this here as cli need directory + directory: z + .string() + .trim() + .default("/") + .transform(prefixWithSlash) // Transformations get skipped if directory is undefined + .transform(removeTrailingSlash) + .describe(FOLDERS.DELETE.directory) + .optional() + }), + response: { + 200: z.object({ + folder: SecretFoldersSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const path = req.body.path || req.body.directory || "/"; + const folder = await server.services.folder.deleteFolder({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + projectId: req.body.workspaceId, + idOrName: req.params.folderIdOrName, + path + }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.body.workspaceId, + event: { + type: EventType.DELETE_FOLDER, + metadata: { + environment: req.body.environment, + folderId: folder.id, + folderPath: path, + folderName: folder.name + } + } + }); + return { folder }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Folders], + description: "Get folders", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + workspaceId: z.string().trim().describe(FOLDERS.LIST.projectId), + environment: z.string().trim().describe(FOLDERS.LIST.environment), + lastSecretModified: z.string().datetime().trim().optional().describe(FOLDERS.LIST.lastSecretModified), + path: z + .string() + .trim() + .transform(prefixWithSlash) // Transformations get skipped if path is undefined + .transform(removeTrailingSlash) + .describe(FOLDERS.LIST.path) + .optional(), + // backward compatibility with cli + directory: z + .string() + .trim() + .transform(prefixWithSlash) // Transformations get skipped if directory is undefined + .transform(removeTrailingSlash) + .describe(FOLDERS.LIST.directory) + .optional(), + recursive: booleanSchema.default(false).describe(FOLDERS.LIST.recursive) + }), + response: { + 200: z.object({ + folders: SecretFoldersSchema.extend({ + relativePath: z.string().optional() + }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const path = req.query.path || req.query.directory || "/"; + const folders = await server.services.folder.getFolders({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query, + projectId: req.query.workspaceId, + path + }); + return { folders }; + } + }); + + server.route({ + method: "GET", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Folders], + description: "Get folder by id", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + id: z.string().trim().describe(FOLDERS.GET_BY_ID.folderId) + }), + response: { + 200: z.object({ + folder: SecretFoldersSchema.extend({ + environment: z.object({ + envId: z.string(), + envName: z.string(), + envSlug: z.string() + }), + path: z.string(), + projectId: z.string() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const folder = await server.services.folder.getFolderById({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.id + }); + return { folder }; + } + }); +}; diff --git a/backend/src/server/routes/v1/deprecated-secret-import-router.ts b/backend/src/server/routes/v1/deprecated-secret-import-router.ts new file mode 100644 index 000000000..2fdbe4216 --- /dev/null +++ b/backend/src/server/routes/v1/deprecated-secret-import-router.ts @@ -0,0 +1,472 @@ +import { z } from "zod"; + +import { SecretImportsSchema, SecretsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, SECRET_IMPORTS } from "@app/lib/api-docs"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { readLimit, secretsLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { secretRawSchema } from "../sanitizedSchemas"; + +export const registerDeprecatedSecretImportRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretImports], + description: "Create secret imports", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + workspaceId: z.string().trim().describe(SECRET_IMPORTS.CREATE.projectId), + environment: z.string().trim().describe(SECRET_IMPORTS.CREATE.environment), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.CREATE.path), + import: z.object({ + environment: z.string().trim().describe(SECRET_IMPORTS.CREATE.import.environment), + path: z.string().trim().transform(removeTrailingSlash).describe(SECRET_IMPORTS.CREATE.import.path) + }), + isReplication: z.boolean().default(false).describe(SECRET_IMPORTS.CREATE.isReplication) + }), + response: { + 200: z.object({ + message: z.string(), + secretImport: SecretImportsSchema.omit({ importEnv: true }).merge( + z.object({ + importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() }) + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const secretImport = await server.services.secretImport.createImport({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + projectId: req.body.workspaceId, + data: req.body.import + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.body.workspaceId, + event: { + type: EventType.CREATE_SECRET_IMPORT, + metadata: { + secretImportId: secretImport.id, + folderId: secretImport.folderId, + importFromSecretPath: secretImport.importPath, + importFromEnvironment: secretImport.importEnv.slug, + importToEnvironment: req.body.environment, + importToSecretPath: req.body.path + } + } + }); + return { message: "Successfully created secret import", secretImport }; + } + }); + + server.route({ + method: "PATCH", + url: "/:secretImportId", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretImports], + description: "Update secret imports", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + secretImportId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.secretImportId) + }), + body: z.object({ + workspaceId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.projectId), + environment: z.string().trim().describe(SECRET_IMPORTS.UPDATE.environment), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.UPDATE.path), + import: z.object({ + environment: z.string().trim().optional().describe(SECRET_IMPORTS.UPDATE.import.environment), + path: z + .string() + .trim() + .optional() + .transform((val) => (val ? removeTrailingSlash(val) : val)) + .describe(SECRET_IMPORTS.UPDATE.import.path), + position: z.number().optional().describe(SECRET_IMPORTS.UPDATE.import.position) + }) + }), + response: { + 200: z.object({ + message: z.string(), + secretImport: SecretImportsSchema.omit({ importEnv: true }).merge( + z.object({ + importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() }) + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const secretImport = await server.services.secretImport.updateImport({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.secretImportId, + ...req.body, + projectId: req.body.workspaceId, + data: req.body.import + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.body.workspaceId, + event: { + type: EventType.UPDATE_SECRET_IMPORT, + metadata: { + secretImportId: secretImport.id, + folderId: secretImport.folderId, + position: secretImport.position, + importToEnvironment: req.body.environment, + importToSecretPath: req.body.path + } + } + }); + + return { message: "Successfully updated secret import", secretImport }; + } + }); + + server.route({ + method: "DELETE", + url: "/:secretImportId", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretImports], + description: "Delete secret imports", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + secretImportId: z.string().trim().describe(SECRET_IMPORTS.DELETE.secretImportId) + }), + body: z.object({ + workspaceId: z.string().trim().describe(SECRET_IMPORTS.DELETE.projectId), + environment: z.string().trim().describe(SECRET_IMPORTS.DELETE.environment), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.DELETE.path) + }), + response: { + 200: z.object({ + message: z.string(), + secretImport: SecretImportsSchema.omit({ importEnv: true }).merge( + z.object({ + importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() }) + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const secretImport = await server.services.secretImport.deleteImport({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.secretImportId, + ...req.body, + projectId: req.body.workspaceId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.body.workspaceId, + event: { + type: EventType.DELETE_SECRET_IMPORT, + metadata: { + secretImportId: secretImport.id, + folderId: secretImport.folderId, + importFromEnvironment: secretImport.importEnv.slug, + importFromSecretPath: secretImport.importPath, + importToEnvironment: req.body.environment, + importToSecretPath: req.body.path + } + } + }); + return { message: "Successfully deleted secret import", secretImport }; + } + }); + + server.route({ + method: "POST", + url: "/:secretImportId/replication-resync", + config: { + rateLimit: secretsLimit + }, + schema: { + description: "Resync secret replication of secret imports", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + secretImportId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.secretImportId) + }), + body: z.object({ + workspaceId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.projectId), + environment: z.string().trim().describe(SECRET_IMPORTS.UPDATE.environment), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.UPDATE.path) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { message } = await server.services.secretImport.resyncSecretImportReplication({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.secretImportId, + ...req.body, + projectId: req.body.workspaceId + }); + + return { message }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretImports], + description: "Get secret imports", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + workspaceId: z.string().trim().describe(SECRET_IMPORTS.LIST.projectId), + environment: z.string().trim().describe(SECRET_IMPORTS.LIST.environment), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.LIST.path) + }), + response: { + 200: z.object({ + message: z.string(), + secretImports: SecretImportsSchema.omit({ importEnv: true }) + .extend({ + importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() }) + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const secretImports = await server.services.secretImport.getImports({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query, + projectId: req.query.workspaceId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.workspaceId, + event: { + type: EventType.GET_SECRET_IMPORTS, + metadata: { + environment: req.query.environment, + folderId: secretImports?.[0]?.folderId, + numberOfImports: secretImports.length + } + } + }); + return { message: "Successfully fetched secret imports", secretImports }; + } + }); + + server.route({ + url: "/:secretImportId", + method: "GET", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretImports], + description: "Get single secret import", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + secretImportId: z.string().trim().describe(SECRET_IMPORTS.GET.secretImportId) + }), + response: { + 200: z.object({ + secretImport: SecretImportsSchema.omit({ importEnv: true }).extend({ + environment: z.object({ + id: z.string(), + name: z.string(), + slug: z.string() + }), + projectId: z.string(), + importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() }), + secretPath: z.string() + }) + }) + } + }, + + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const secretImport = await server.services.secretImport.getImportById({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.secretImportId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: secretImport.projectId, + event: { + type: EventType.GET_SECRET_IMPORT, + metadata: { + secretImportId: secretImport.id, + folderId: secretImport.folderId + } + } + }); + + return { secretImport }; + } + }); + + server.route({ + url: "/secrets", + method: "GET", + config: { + rateLimit: secretsLimit + }, + schema: { + querystring: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + path: z.string().trim().default("/").transform(removeTrailingSlash) + }), + response: { + 200: z.object({ + secrets: z + .object({ + secretPath: z.string(), + environment: z.string(), + environmentInfo: z.object({ + id: z.string(), + name: z.string(), + slug: z.string() + }), + folderId: z.string().optional(), + secrets: SecretsSchema.omit({ secretBlindIndex: true }).array() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const importedSecrets = await server.services.secretImport.getSecretsFromImports({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query, + projectId: req.query.workspaceId + }); + return { secrets: importedSecrets }; + } + }); + + server.route({ + url: "/secrets/raw", + method: "GET", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretImports], + querystring: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + path: z.string().trim().default("/").transform(removeTrailingSlash) + }), + response: { + 200: z.object({ + secrets: z + .object({ + secretPath: z.string(), + environment: z.string(), + environmentInfo: z.object({ + id: z.string(), + name: z.string(), + slug: z.string() + }), + folderId: z.string().optional(), + secrets: secretRawSchema.array() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const importedSecrets = await server.services.secretImport.getRawSecretsFromImports({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query, + projectId: req.query.workspaceId + }); + return { secrets: importedSecrets }; + } + }); +}; diff --git a/backend/src/server/routes/v1/deprecated-secret-tag-router.ts b/backend/src/server/routes/v1/deprecated-secret-tag-router.ts new file mode 100644 index 000000000..d90475408 --- /dev/null +++ b/backend/src/server/routes/v1/deprecated-secret-tag-router.ts @@ -0,0 +1,213 @@ +import { z } from "zod"; + +import { SecretTagsSchema } from "@app/db/schemas"; +import { ApiDocsTags, SECRET_TAGS } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerDeprecatedSecretTagRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:projectId/tags", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Folders], + params: z.object({ + projectId: z.string().trim().describe(SECRET_TAGS.LIST.projectId) + }), + response: { + 200: z.object({ + workspaceTags: SecretTagsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workspaceTags = await server.services.secretTag.getProjectTags({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.projectId + }); + return { workspaceTags }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/tags/:tagId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Folders], + params: z.object({ + projectId: z.string().trim().describe(SECRET_TAGS.GET_TAG_BY_ID.projectId), + tagId: z.string().trim().describe(SECRET_TAGS.GET_TAG_BY_ID.tagId) + }), + response: { + 200: z.object({ + // akhilmhdh: for terraform backward compatiability + workspaceTag: SecretTagsSchema.extend({ name: z.string() }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workspaceTag = await server.services.secretTag.getTagById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.tagId + }); + return { workspaceTag }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/tags/slug/:tagSlug", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Folders], + params: z.object({ + projectId: z.string().trim().describe(SECRET_TAGS.GET_TAG_BY_SLUG.projectId), + tagSlug: z.string().trim().describe(SECRET_TAGS.GET_TAG_BY_SLUG.tagSlug) + }), + response: { + 200: z.object({ + // akhilmhdh: for terraform backward compatiability + workspaceTag: SecretTagsSchema.extend({ name: z.string() }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workspaceTag = await server.services.secretTag.getTagBySlug({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + slug: req.params.tagSlug, + projectId: req.params.projectId + }); + return { workspaceTag }; + } + }); + + server.route({ + method: "POST", + url: "/:projectId/tags", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Folders], + params: z.object({ + projectId: z.string().trim().describe(SECRET_TAGS.CREATE.projectId) + }), + body: z.object({ + slug: slugSchema({ max: 64 }).describe(SECRET_TAGS.CREATE.slug), + color: z.string().trim().describe(SECRET_TAGS.CREATE.color) + }), + response: { + 200: z.object({ + workspaceTag: SecretTagsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workspaceTag = await server.services.secretTag.createTag({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.projectId, + ...req.body + }); + return { workspaceTag }; + } + }); + + server.route({ + method: "PATCH", + url: "/:projectId/tags/:tagId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Folders], + params: z.object({ + projectId: z.string().trim().describe(SECRET_TAGS.UPDATE.projectId), + tagId: z.string().trim().describe(SECRET_TAGS.UPDATE.tagId) + }), + body: z.object({ + slug: slugSchema({ max: 64 }).describe(SECRET_TAGS.UPDATE.slug), + color: z.string().trim().describe(SECRET_TAGS.UPDATE.color) + }), + response: { + 200: z.object({ + workspaceTag: SecretTagsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workspaceTag = await server.services.secretTag.updateTag({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + id: req.params.tagId + }); + return { workspaceTag }; + } + }); + + server.route({ + method: "DELETE", + url: "/:projectId/tags/:tagId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Folders], + params: z.object({ + projectId: z.string().trim().describe(SECRET_TAGS.DELETE.projectId), + tagId: z.string().trim().describe(SECRET_TAGS.DELETE.tagId) + }), + response: { + 200: z.object({ + workspaceTag: SecretTagsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workspaceTag = await server.services.secretTag.deleteTag({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.tagId + }); + return { workspaceTag }; + } + }); +}; diff --git a/backend/src/server/routes/v2/group-project-router.ts b/backend/src/server/routes/v1/group-project-router.ts similarity index 100% rename from backend/src/server/routes/v2/group-project-router.ts rename to backend/src/server/routes/v1/group-project-router.ts diff --git a/backend/src/server/routes/v1/identity-ldap-auth-router.ts b/backend/src/server/routes/v1/identity-ldap-auth-router.ts index 5d3612bf5..512f253e0 100644 --- a/backend/src/server/routes/v1/identity-ldap-auth-router.ts +++ b/backend/src/server/routes/v1/identity-ldap-auth-router.ts @@ -8,7 +8,7 @@ import { Authenticator } from "@fastify/passport"; import fastifySession from "@fastify/session"; -import { FastifyRequest } from "fastify"; +import { FastifyReply, FastifyRequest } from "fastify"; import { IncomingMessage } from "http"; import LdapStrategy from "passport-ldapauth"; import { z } from "zod"; @@ -135,19 +135,26 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) }) } }, - preValidation: passport.authenticate("ldapauth", { - failWithError: true, - session: false - }) as any, + preValidation: [ + (req, res) => { + const passportAuth = (request: FastifyRequest, reply: FastifyReply) => + ( + passport.authenticate("ldapauth", { + failWithError: true, + session: false + }) as any + )(request, reply); - errorHandler: (error) => { - if (error.name === "AuthenticationError") { - throw new UnauthorizedError({ message: "Invalid credentials" }); + const { identityId, username } = req.body; + return server.services.identityLdapAuth.withLdapLockout( + { + identityId, + username + }, + () => passportAuth(req, res) + ); } - - throw error; - }, - + ], handler: async (req) => { if (!req.passportMachineIdentity?.identityId) { throw new UnauthorizedError({ message: "Invalid request. Missing identity ID or LDAP entry details." }); @@ -241,7 +248,21 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) .int() .min(0) .default(0) - .describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit) + .describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit), + lockoutEnabled: z.boolean().default(true).describe(LDAP_AUTH.ATTACH.lockoutEnabled), + lockoutThreshold: z.number().min(1).max(30).default(3).describe(LDAP_AUTH.ATTACH.lockoutThreshold), + lockoutDurationSeconds: z + .number() + .min(30) + .max(86400) + .default(300) + .describe(LDAP_AUTH.ATTACH.lockoutDurationSeconds), + lockoutCounterResetSeconds: z + .number() + .min(5) + .max(3600) + .default(30) + .describe(LDAP_AUTH.ATTACH.lockoutCounterResetSeconds) }) .refine( (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, @@ -291,7 +312,21 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) .int() .min(0) .default(0) - .describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit) + .describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit), + lockoutEnabled: z.boolean().default(true).describe(LDAP_AUTH.ATTACH.lockoutEnabled), + lockoutThreshold: z.number().min(1).max(30).default(3).describe(LDAP_AUTH.ATTACH.lockoutThreshold), + lockoutDurationSeconds: z + .number() + .min(30) + .max(86400) + .default(300) + .describe(LDAP_AUTH.ATTACH.lockoutDurationSeconds), + lockoutCounterResetSeconds: z + .number() + .min(5) + .max(3600) + .default(30) + .describe(LDAP_AUTH.ATTACH.lockoutCounterResetSeconds) }) .refine( (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, @@ -331,7 +366,11 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) accessTokenTTL: identityLdapAuth.accessTokenTTL, accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, allowedFields: req.body.allowedFields, - templateId: identityLdapAuth.templateId + templateId: identityLdapAuth.templateId, + lockoutEnabled: identityLdapAuth.lockoutEnabled, + lockoutThreshold: identityLdapAuth.lockoutThreshold, + lockoutDurationSeconds: identityLdapAuth.lockoutDurationSeconds, + lockoutCounterResetSeconds: identityLdapAuth.lockoutCounterResetSeconds } } }); @@ -395,7 +434,21 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) .max(315360000) .min(0) .optional() - .describe(LDAP_AUTH.UPDATE.accessTokenMaxTTL) + .describe(LDAP_AUTH.UPDATE.accessTokenMaxTTL), + lockoutEnabled: z.boolean().optional().describe(LDAP_AUTH.UPDATE.lockoutEnabled), + lockoutThreshold: z.number().min(1).max(30).optional().describe(LDAP_AUTH.UPDATE.lockoutThreshold), + lockoutDurationSeconds: z + .number() + .min(30) + .max(86400) + .optional() + .describe(LDAP_AUTH.UPDATE.lockoutDurationSeconds), + lockoutCounterResetSeconds: z + .number() + .min(5) + .max(3600) + .optional() + .describe(LDAP_AUTH.UPDATE.lockoutCounterResetSeconds) }) .refine( (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), @@ -434,7 +487,11 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, accessTokenTrustedIps: identityLdapAuth.accessTokenTrustedIps as TIdentityTrustedIp[], allowedFields: req.body.allowedFields, - templateId: identityLdapAuth.templateId + templateId: identityLdapAuth.templateId, + lockoutEnabled: identityLdapAuth.lockoutEnabled, + lockoutThreshold: identityLdapAuth.lockoutThreshold, + lockoutDurationSeconds: identityLdapAuth.lockoutDurationSeconds, + lockoutCounterResetSeconds: identityLdapAuth.lockoutCounterResetSeconds } } }); @@ -553,4 +610,53 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) return { identityLdapAuth }; } }); + + server.route({ + method: "POST", + url: "/ldap-auth/identities/:identityId/clear-lockouts", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.LdapAuth], + description: "Clear LDAP Auth Lockouts for identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(LDAP_AUTH.CLEAR_CLIENT_LOCKOUTS.identityId) + }), + response: { + 200: z.object({ + deleted: z.number() + }) + } + }, + handler: async (req) => { + const clearLockoutsData = await server.services.identityLdapAuth.clearLdapAuthLockouts({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: clearLockoutsData.orgId, + event: { + type: EventType.CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS, + metadata: { + identityId: clearLockoutsData.identityId + } + } + }); + + return clearLockoutsData; + } + }); }; diff --git a/backend/src/server/routes/v2/identity-project-router.ts b/backend/src/server/routes/v1/identity-project-router.ts similarity index 100% rename from backend/src/server/routes/v2/identity-project-router.ts rename to backend/src/server/routes/v1/identity-project-router.ts diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 6108be32b..84b1442f6 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -13,8 +13,15 @@ import { registerCaRouter } from "./certificate-authority-router"; import { CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP } from "./certificate-authority-routers"; import { registerCertRouter } from "./certificate-router"; import { registerCertificateTemplateRouter } from "./certificate-template-router"; +import { registerDeprecatedProjectEnvRouter } from "./deprecated-project-env-router"; +import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-membership-router"; +import { registerDeprecatedProjectRouter } from "./deprecated-project-router"; +import { registerDeprecatedSecretFolderRouter } from "./deprecated-secret-folder-router"; +import { registerDeprecatedSecretImportRouter } from "./deprecated-secret-import-router"; +import { registerDeprecatedSecretTagRouter } from "./deprecated-secret-tag-router"; import { registerEventRouter } from "./event-router"; import { registerExternalGroupOrgRoleMappingRouter } from "./external-group-org-role-mapping-router"; +import { registerGroupProjectRouter } from "./group-project-router"; import { registerIdentityAccessTokenRouter } from "./identity-access-token-router"; import { registerIdentityAliCloudAuthRouter } from "./identity-alicloud-auth-router"; import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router"; @@ -25,6 +32,7 @@ import { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-rou import { registerIdentityLdapAuthRouter } from "./identity-ldap-auth-router"; import { registerIdentityOciAuthRouter } from "./identity-oci-auth-router"; import { registerIdentityOidcAuthRouter } from "./identity-oidc-auth-router"; +import { registerIdentityProjectRouter } from "./identity-project-router"; import { registerIdentityRouter } from "./identity-router"; import { registerIdentityTlsCertAuthRouter } from "./identity-tls-cert-auth-router"; import { registerIdentityTokenAuthRouter } from "./identity-token-auth-router"; @@ -40,17 +48,17 @@ import { registerPasswordRouter } from "./password-router"; import { registerPkiAlertRouter } from "./pki-alert-router"; import { registerPkiCollectionRouter } from "./pki-collection-router"; import { registerPkiSubscriberRouter } from "./pki-subscriber-router"; +import { PKI_SYNC_REGISTER_ROUTER_MAP, registerPkiSyncRouter } from "./pki-sync-routers"; import { registerProjectEnvRouter } from "./project-env-router"; import { registerProjectKeyRouter } from "./project-key-router"; import { registerProjectMembershipRouter } from "./project-membership-router"; import { registerProjectRouter } from "./project-router"; import { SECRET_REMINDER_REGISTER_ROUTER_MAP } from "./reminder-routers"; -import { registerSecretFolderRouter } from "./secret-folder-router"; -import { registerSecretImportRouter } from "./secret-import-router"; import { registerSecretRequestsRouter } from "./secret-requests-router"; import { registerSecretSharingRouter } from "./secret-sharing-router"; import { registerSecretTagRouter } from "./secret-tag-router"; import { registerSlackRouter } from "./slack-router"; +import { registerUpgradePathRouter } from "./upgrade-path-router"; import { registerSsoRouter } from "./sso-router"; import { registerUserActionRouter } from "./user-action-router"; import { registerUserEngagementRouter } from "./user-engagement-router"; @@ -87,8 +95,8 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerNotificationRouter, { prefix: "/notifications" }); await server.register(registerInviteOrgRouter, { prefix: "/invite-org" }); await server.register(registerUserActionRouter, { prefix: "/user-action" }); - await server.register(registerSecretImportRouter, { prefix: "/secret-imports" }); - await server.register(registerSecretFolderRouter, { prefix: "/folders" }); + await server.register(registerDeprecatedSecretImportRouter, { prefix: "/secret-imports" }); + await server.register(registerDeprecatedSecretFolderRouter, { prefix: "/folders" }); await server.register( async (workflowIntegrationRouter) => { @@ -101,15 +109,28 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register( async (projectRouter) => { - await projectRouter.register(registerProjectRouter); - await projectRouter.register(registerProjectEnvRouter); + await projectRouter.register(registerDeprecatedProjectRouter); + await projectRouter.register(registerDeprecatedProjectEnvRouter); + // depreciated completed in use await projectRouter.register(registerProjectKeyRouter); - await projectRouter.register(registerProjectMembershipRouter); - await projectRouter.register(registerSecretTagRouter); + await projectRouter.register(registerDeprecatedProjectMembershipRouter); + await projectRouter.register(registerDeprecatedSecretTagRouter); }, { prefix: "/workspace" } ); + await server.register( + async (projectRouter) => { + await projectRouter.register(registerProjectRouter); + await projectRouter.register(registerProjectMembershipRouter); + await projectRouter.register(registerProjectEnvRouter); + await projectRouter.register(registerSecretTagRouter); + await projectRouter.register(registerGroupProjectRouter); + await projectRouter.register(registerIdentityProjectRouter); + }, + { prefix: "/projects" } + ); + await server.register( async (pkiRouter) => { await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); @@ -128,6 +149,15 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" }); await pkiRouter.register(registerPkiCollectionRouter, { prefix: "/collections" }); await pkiRouter.register(registerPkiSubscriberRouter, { prefix: "/subscribers" }); + await pkiRouter.register( + async (pkiSyncRouter) => { + await pkiSyncRouter.register(registerPkiSyncRouter); + for await (const [destination, router] of Object.entries(PKI_SYNC_REGISTER_ROUTER_MAP)) { + await pkiSyncRouter.register(router, { prefix: `/${destination}` }); + } + }, + { prefix: "/syncs" } + ); }, { prefix: "/pki" } ); @@ -188,4 +218,5 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { ); await server.register(registerEventRouter, { prefix: "/events" }); + await server.register(registerUpgradePathRouter, { prefix: "/upgrade-path" }); }; diff --git a/backend/src/server/routes/v1/notification-router.ts b/backend/src/server/routes/v1/notification-router.ts index 5f72b88d6..955a1174e 100644 --- a/backend/src/server/routes/v1/notification-router.ts +++ b/backend/src/server/routes/v1/notification-router.ts @@ -3,8 +3,10 @@ import { z } from "zod"; import { UserNotificationsSchema } from "@app/db/schemas/user-notifications"; import { UnauthorizedError } from "@app/lib/errors"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; export const registerNotificationRouter = async (server: FastifyZodProvider) => { server.route({ @@ -97,6 +99,16 @@ export const registerNotificationRouter = async (server: FastifyZodProvider) => ...req.body }); + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.NotificationUpdated, + distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, + properties: { + notificationId: req.params.notificationId, + ...req.body + } + }); + return { notification }; } }); diff --git a/backend/src/server/routes/v1/pki-sync-routers/azure-key-vault-pki-sync-router.ts b/backend/src/server/routes/v1/pki-sync-routers/azure-key-vault-pki-sync-router.ts new file mode 100644 index 000000000..5dbd89e45 --- /dev/null +++ b/backend/src/server/routes/v1/pki-sync-routers/azure-key-vault-pki-sync-router.ts @@ -0,0 +1,22 @@ +import { + AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION, + AzureKeyVaultPkiSyncSchema, + CreateAzureKeyVaultPkiSyncSchema, + UpdateAzureKeyVaultPkiSyncSchema +} from "@app/services/pki-sync/azure-key-vault"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; + +import { registerSyncPkiEndpoints } from "./pki-sync-endpoints"; + +export const registerAzureKeyVaultPkiSyncRouter = async (server: FastifyZodProvider) => + registerSyncPkiEndpoints({ + destination: PkiSync.AzureKeyVault, + server, + responseSchema: AzureKeyVaultPkiSyncSchema, + createSchema: CreateAzureKeyVaultPkiSyncSchema, + updateSchema: UpdateAzureKeyVaultPkiSyncSchema, + syncOptions: { + canImportCertificates: AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION.canImportCertificates, + canRemoveCertificates: AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION.canRemoveCertificates + } + }); diff --git a/backend/src/server/routes/v1/pki-sync-routers/index.ts b/backend/src/server/routes/v1/pki-sync-routers/index.ts new file mode 100644 index 000000000..326b650e9 --- /dev/null +++ b/backend/src/server/routes/v1/pki-sync-routers/index.ts @@ -0,0 +1,9 @@ +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; + +import { registerAzureKeyVaultPkiSyncRouter } from "./azure-key-vault-pki-sync-router"; + +export * from "./pki-sync-router"; + +export const PKI_SYNC_REGISTER_ROUTER_MAP: Record Promise> = { + [PkiSync.AzureKeyVault]: registerAzureKeyVaultPkiSyncRouter +}; diff --git a/backend/src/server/routes/v1/pki-sync-routers/pki-sync-endpoints.ts b/backend/src/server/routes/v1/pki-sync-routers/pki-sync-endpoints.ts new file mode 100644 index 000000000..3f803a1e0 --- /dev/null +++ b/backend/src/server/routes/v1/pki-sync-routers/pki-sync-endpoints.ts @@ -0,0 +1,341 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; +import { PKI_SYNC_NAME_MAP } from "@app/services/pki-sync/pki-sync-maps"; + +export const registerSyncPkiEndpoints = ({ + server, + destination, + createSchema, + updateSchema, + responseSchema, + syncOptions +}: { + destination: PkiSync; + server: FastifyZodProvider; + createSchema: z.ZodType<{ + name: string; + projectId: string; + connectionId: string; + destinationConfig: Record; + syncOptions?: Record; + description?: string; + isAutoSyncEnabled?: boolean; + subscriberId?: string; + }>; + updateSchema: z.ZodType<{ + connectionId?: string; + name?: string; + destinationConfig?: Record; + syncOptions?: Record; + description?: string; + isAutoSyncEnabled?: boolean; + subscriberId?: string; + }>; + responseSchema: z.ZodTypeAny; + syncOptions: { + canImportCertificates: boolean; + canRemoveCertificates: boolean; + }; +}) => { + const destinationName = PKI_SYNC_NAME_MAP[destination]; + + server.route({ + method: "GET", + url: `/`, + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: `List the ${destinationName} PKI Syncs for the specified project.`, + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required") + }), + response: { + 200: z.object({ pkiSyncs: responseSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId } + } = req; + + const pkiSyncs = await server.services.pkiSync.listPkiSyncsByProjectId({ projectId }, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_PKI_SYNCS, + metadata: { + projectId + } + } + }); + + return { pkiSyncs }; + } + }); + + server.route({ + method: "GET", + url: "/:pkiSyncId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: `Get the specified ${destinationName} PKI Sync by ID.`, + params: z.object({ + pkiSyncId: z.string() + }), + response: { + 200: responseSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + + const pkiSync = await server.services.pkiSync.findPkiSyncById({ id: pkiSyncId }, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: pkiSync.projectId, + event: { + type: EventType.GET_PKI_SYNC, + metadata: { + syncId: pkiSyncId, + destination + } + } + }); + + return pkiSync; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: `Create a ${destinationName} PKI Sync for the specified project.`, + body: createSchema, + response: { + 200: responseSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const pkiSync = await server.services.pkiSync.createPkiSync({ ...req.body, destination }, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: pkiSync.projectId, + event: { + type: EventType.CREATE_PKI_SYNC, + metadata: { + pkiSyncId: pkiSync.id, + name: pkiSync.name, + destination + } + } + }); + + return pkiSync; + } + }); + + server.route({ + method: "PATCH", + url: "/:pkiSyncId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: `Update the specified ${destinationName} PKI Sync.`, + params: z.object({ + pkiSyncId: z.string() + }), + body: updateSchema, + response: { + 200: responseSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + + const pkiSync = await server.services.pkiSync.updatePkiSync({ ...req.body, id: pkiSyncId }, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: pkiSync.projectId, + event: { + type: EventType.UPDATE_PKI_SYNC, + metadata: { + pkiSyncId, + name: pkiSync.name + } + } + }); + + return pkiSync; + } + }); + + server.route({ + method: "DELETE", + url: `/:pkiSyncId`, + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: `Delete the specified ${destinationName} PKI Sync.`, + params: z.object({ + pkiSyncId: z.string() + }), + response: { + 200: responseSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + + const pkiSync = await server.services.pkiSync.deletePkiSync({ id: pkiSyncId }, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: pkiSync.projectId, + event: { + type: EventType.DELETE_PKI_SYNC, + metadata: { + pkiSyncId, + name: pkiSync.name, + destination: pkiSync.destination + } + } + }); + + return pkiSync; + } + }); + + server.route({ + method: "POST", + url: "/:pkiSyncId/sync", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: `Trigger a sync for the specified ${destinationName} PKI Sync.`, + params: z.object({ + pkiSyncId: z.string() + }), + response: { + 200: z.object({ message: z.string() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + + const result = await server.services.pkiSync.triggerPkiSyncSyncCertificatesById( + { + id: pkiSyncId + }, + req.permission + ); + + return result; + } + }); + + // Only register import route if the destination supports it + if (syncOptions.canImportCertificates) { + server.route({ + method: "POST", + url: "/:pkiSyncId/import", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: `Import certificates from the specified ${destinationName} PKI Sync destination.`, + params: z.object({ + pkiSyncId: z.string() + }), + response: { + 200: z.object({ message: z.string() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + + const result = await server.services.pkiSync.triggerPkiSyncImportCertificatesById( + { + id: pkiSyncId + }, + req.permission + ); + + return result; + } + }); + } + + server.route({ + method: "POST", + url: "/:pkiSyncId/remove-certificates", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: `Remove certificates from the specified ${destinationName} PKI Sync destination.`, + params: z.object({ + pkiSyncId: z.string() + }), + response: { + 200: z.object({ message: z.string() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + + const result = await server.services.pkiSync.triggerPkiSyncRemoveCertificatesById( + { + id: pkiSyncId + }, + req.permission + ); + + return result; + } + }); +}; diff --git a/backend/src/server/routes/v1/pki-sync-routers/pki-sync-router.ts b/backend/src/server/routes/v1/pki-sync-routers/pki-sync-router.ts new file mode 100644 index 000000000..158d9e4dc --- /dev/null +++ b/backend/src/server/routes/v1/pki-sync-routers/pki-sync-router.ts @@ -0,0 +1,182 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; + +const PkiSyncSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable().optional(), + destination: z.nativeEnum(PkiSync), + isAutoSyncEnabled: z.boolean(), + destinationConfig: z.record(z.unknown()), + syncOptions: z.record(z.unknown()), + projectId: z.string().uuid(), + subscriberId: z.string().uuid().nullable().optional(), + connectionId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + // Sync status fields + syncStatus: z.string().nullable().optional(), + lastSyncJobId: z.string().nullable().optional(), + lastSyncMessage: z.string().nullable().optional(), + lastSyncedAt: z.date().nullable().optional(), + // Import status fields + importStatus: z.string().nullable().optional(), + lastImportJobId: z.string().nullable().optional(), + lastImportMessage: z.string().nullable().optional(), + lastImportedAt: z.date().nullable().optional(), + // Remove status fields + removeStatus: z.string().nullable().optional(), + lastRemoveJobId: z.string().nullable().optional(), + lastRemoveMessage: z.string().nullable().optional(), + lastRemovedAt: z.date().nullable().optional(), + // App connection info + appConnectionName: z.string(), + appConnectionApp: z.string(), + connection: z.object({ + id: z.string(), + name: z.string(), + app: z.string(), + encryptedCredentials: z.unknown().nullable(), + orgId: z.string().uuid(), + projectId: z.string().uuid().nullable().optional(), + method: z.string(), + description: z.string().nullable().optional(), + version: z.number(), + gatewayId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date(), + isPlatformManagedCredentials: z.boolean().nullable().optional() + }), + subscriber: z + .object({ + id: z.string(), + name: z.string() + }) + .nullable() + .optional() +}); + +const PkiSyncOptionsSchema = z.object({ + name: z.string(), + connection: z.nativeEnum(AppConnection), + destination: z.nativeEnum(PkiSync), + canImportCertificates: z.boolean(), + canRemoveCertificates: z.boolean(), + defaultCertificateNameSchema: z.string().optional(), + forbiddenCharacters: z.string().optional(), + allowedCharacterPattern: z.string().optional(), + maxCertificateNameLength: z.number().optional(), + minCertificateNameLength: z.number().optional() +}); + +export const registerPkiSyncRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/options", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: "List the available PKI Sync Options.", + response: { + 200: z.object({ + pkiSyncOptions: PkiSyncOptionsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: () => { + const pkiSyncOptions = server.services.pkiSync.getPkiSyncOptions(); + return { pkiSyncOptions }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: "List all the PKI Syncs for the specified project.", + querystring: z.object({ + projectId: z.string().trim().min(1) + }), + response: { + 200: z.object({ pkiSyncs: PkiSyncSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const pkiSyncs = await server.services.pkiSync.listPkiSyncsByProjectId({ projectId }, permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_PKI_SYNCS, + metadata: { + projectId + } + } + }); + + return { pkiSyncs }; + } + }); + + server.route({ + method: "GET", + url: "/:pkiSyncId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: "Get a PKI Sync by ID.", + params: z.object({ + pkiSyncId: z.string() + }), + response: { + 200: PkiSyncSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + + const pkiSync = await server.services.pkiSync.findPkiSyncById({ id: pkiSyncId }, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: pkiSync.projectId, + event: { + type: EventType.GET_PKI_SYNC, + metadata: { + syncId: pkiSyncId, + destination: pkiSync.destination + } + } + }); + + return pkiSync; + } + }); +}; diff --git a/backend/src/server/routes/v1/project-env-router.ts b/backend/src/server/routes/v1/project-env-router.ts index 9a136e160..81b828b55 100644 --- a/backend/src/server/routes/v1/project-env-router.ts +++ b/backend/src/server/routes/v1/project-env-router.ts @@ -11,58 +11,7 @@ import { AuthMode } from "@app/services/auth/auth-type"; export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/:workspaceId/environments/:envId", - config: { - rateLimit: readLimit - }, - schema: { - hide: false, - tags: [ApiDocsTags.Environments], - description: "Get Environment", - security: [ - { - bearerAuth: [] - } - ], - params: z.object({ - // NOTE(daniel): workspaceId isn't used, but we need to keep it for backwards compatibility. The endpoint defined below, uses no project ID, and is takes a pure environment ID. - workspaceId: z.string().trim().describe(ENVIRONMENTS.GET.workspaceId), - envId: z.string().trim().describe(ENVIRONMENTS.GET.id) - }), - response: { - 200: z.object({ - environment: ProjectEnvironmentsSchema - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const environment = await server.services.projectEnv.getEnvironmentById({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - id: req.params.envId - }); - - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: environment.projectId, - event: { - type: EventType.GET_ENVIRONMENT, - metadata: { - id: environment.id - } - } - }); - - return { environment }; - } - }); - - server.route({ - method: "GET", - url: "/environments/:envId", + url: "/:projectId/environments/:envId", config: { rateLimit: readLimit }, @@ -76,7 +25,8 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - envId: z.string().trim().describe(ENVIRONMENTS.GET.id) + envId: z.string().trim().describe(ENVIRONMENTS.GET.id), + projectId: z.string().trim().describe(ENVIRONMENTS.GET.projectId) }), response: { 200: z.object({ @@ -111,7 +61,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/:workspaceId/environments", + url: "/:projectId/environments", config: { rateLimit: writeLimit }, @@ -125,7 +75,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - workspaceId: z.string().trim().describe(ENVIRONMENTS.CREATE.workspaceId) + projectId: z.string().trim().describe(ENVIRONMENTS.CREATE.projectId) }), body: z.object({ name: z.string().trim().describe(ENVIRONMENTS.CREATE.name), @@ -135,7 +85,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ message: z.string(), - workspace: z.string(), + projectId: z.string(), environment: ProjectEnvironmentsSchema }) } @@ -147,7 +97,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { actor: req.permission.type, actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, - projectId: req.params.workspaceId, + projectId: req.params.projectId, ...req.body }); @@ -164,7 +114,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { }); return { message: "Successfully created new environment", - workspace: req.params.workspaceId, + projectId: req.params.projectId, environment }; } @@ -172,7 +122,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { server.route({ method: "PATCH", - url: "/:workspaceId/environments/:id", + url: "/:projectId/environments/:id", config: { rateLimit: writeLimit }, @@ -186,7 +136,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - workspaceId: z.string().trim().describe(ENVIRONMENTS.UPDATE.workspaceId), + projectId: z.string().trim().describe(ENVIRONMENTS.UPDATE.projectId), id: z.string().trim().describe(ENVIRONMENTS.UPDATE.id) }), body: z.object({ @@ -197,7 +147,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ message: z.string(), - workspace: z.string(), + projectId: z.string(), environment: ProjectEnvironmentsSchema }) } @@ -209,7 +159,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { actor: req.permission.type, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, + projectId: req.params.projectId, id: req.params.id, ...req.body }); @@ -232,7 +182,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { return { message: "Successfully updated environment", - workspace: req.params.workspaceId, + projectId: req.params.projectId, environment }; } @@ -240,7 +190,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { server.route({ method: "DELETE", - url: "/:workspaceId/environments/:id", + url: "/:projectId/environments/:id", config: { rateLimit: writeLimit }, @@ -254,13 +204,13 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - workspaceId: z.string().trim().describe(ENVIRONMENTS.DELETE.workspaceId), + projectId: z.string().trim().describe(ENVIRONMENTS.DELETE.projectId), id: z.string().trim().describe(ENVIRONMENTS.DELETE.id) }), response: { 200: z.object({ message: z.string(), - workspace: z.string(), + projectId: z.string(), environment: ProjectEnvironmentsSchema }) } @@ -272,7 +222,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { actor: req.permission.type, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, + projectId: req.params.projectId, id: req.params.id }); @@ -290,7 +240,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { return { message: "Successfully deleted environment", - workspace: req.params.workspaceId, + projectId: req.params.projectId, environment }; } diff --git a/backend/src/server/routes/v1/project-membership-router.ts b/backend/src/server/routes/v1/project-membership-router.ts index cd3734efc..f3cdfe701 100644 --- a/backend/src/server/routes/v1/project-membership-router.ts +++ b/backend/src/server/routes/v1/project-membership-router.ts @@ -1,7 +1,8 @@ import { z } from "zod"; import { - OrgMembershipsSchema, + OrgMembershipRole, + ProjectMembershipRole, ProjectMembershipsSchema, ProjectUserMembershipRolesSchema, UserEncryptionKeysSchema, @@ -18,7 +19,7 @@ import { ProjectUserMembershipTemporaryMode } from "@app/services/project-member export const registerProjectMembershipRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/:workspaceId/memberships", + url: "/:projectId/memberships", config: { rateLimit: readLimit }, @@ -32,7 +33,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider } ], params: z.object({ - workspaceId: z.string().trim().describe(PROJECT_USERS.GET_USER_MEMBERSHIPS.workspaceId) + projectId: z.string().trim().describe(PROJECT_USERS.GET_USER_MEMBERSHIPS.projectId) }), response: { 200: z.object({ @@ -71,7 +72,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider actor: req.permission.type, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId + projectId: req.params.projectId }); return { memberships }; } @@ -79,7 +80,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider server.route({ method: "GET", - url: "/:workspaceId/memberships/:membershipId", + url: "/:projectId/memberships/:membershipId", config: { rateLimit: readLimit }, @@ -91,7 +92,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider } ], params: z.object({ - workspaceId: z.string().min(1).trim().describe(PROJECT_USERS.GET_USER_MEMBERSHIP.workspaceId), + projectId: z.string().min(1).trim().describe(PROJECT_USERS.GET_USER_MEMBERSHIP.projectId), membershipId: z.string().min(1).trim().describe(PROJECT_USERS.GET_USER_MEMBERSHIP.membershipId) }), response: { @@ -129,7 +130,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider actor: req.permission.type, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, + projectId: req.params.projectId, id: req.params.membershipId }); return { membership }; @@ -138,7 +139,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider server.route({ method: "POST", - url: "/:workspaceId/memberships/details", + url: "/:projectId/memberships/details", config: { rateLimit: readLimit }, @@ -152,7 +153,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider } ], params: z.object({ - workspaceId: z.string().min(1).trim().describe(PROJECT_USERS.GET_USER_MEMBERSHIP.workspaceId) + projectId: z.string().min(1).trim().describe(PROJECT_USERS.GET_USER_MEMBERSHIP.projectId) }), body: z.object({ username: z.string().min(1).trim().describe(PROJECT_USERS.GET_USER_MEMBERSHIP.username) @@ -191,7 +192,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider actor: req.permission.type, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, + projectId: req.params.projectId, username: req.body.username }); return { membership }; @@ -200,61 +201,83 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider server.route({ method: "POST", - url: "/:workspaceId/memberships", + url: "/:projectId/memberships", config: { rateLimit: writeLimit }, schema: { + hide: false, + tags: [ApiDocsTags.ProjectUsers], + description: "Invite members to project", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - workspaceId: z.string().trim() + projectId: z.string().describe(PROJECT_USERS.INVITE_MEMBER.projectId) }), body: z.object({ - members: z - .object({ - orgMembershipId: z.string().trim(), - workspaceEncryptedKey: z.string().trim(), - workspaceEncryptedNonce: z.string().trim() - }) + emails: z + .string() + .email() .array() - .min(1) + .default([]) + .describe(PROJECT_USERS.INVITE_MEMBER.emails) + .refine((val) => val.every((el) => el === el.toLowerCase()), "Email must be lowercase"), + usernames: z + .string() + .array() + .default([]) + .describe(PROJECT_USERS.INVITE_MEMBER.usernames) + .refine((val) => val.every((el) => el === el.toLowerCase()), "Username must be lowercase"), + roleSlugs: z.string().array().min(1).optional().describe(PROJECT_USERS.INVITE_MEMBER.roleSlugs) }), response: { 200: z.object({ - success: z.boolean(), - data: OrgMembershipsSchema.array() + memberships: ProjectMembershipsSchema.array() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const data = await server.services.projectMembership.addUsersToProject({ - actorId: req.permission.id, - actor: req.permission.type, + const usernamesAndEmails = [...req.body.emails, ...req.body.usernames]; + const { projectMemberships: memberships } = await server.services.org.inviteUserToOrganization({ actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, - members: req.body.members + actor: req.permission.type, + inviteeEmails: usernamesAndEmails, + orgId: req.permission.orgId, + organizationRoleSlug: OrgMembershipRole.NoAccess, + projects: [ + { + id: req.params.projectId, + projectRoleSlug: req.body.roleSlugs || [ProjectMembershipRole.Member] + } + ] }); await server.services.auditLog.createAuditLog({ - projectId: req.params.workspaceId, + projectId: req.params.projectId, ...req.auditLogInfo, event: { - type: EventType.ADD_BATCH_WORKSPACE_MEMBER, - metadata: data.map(({ userId }) => ({ + type: EventType.ADD_BATCH_PROJECT_MEMBER, + metadata: memberships.map(({ userId, id }) => ({ userId: userId || "", + membershipId: id, email: "" })) } }); - return { data, success: true }; + return { memberships }; } }); server.route({ method: "PATCH", - url: "/:workspaceId/memberships/:membershipId", + url: "/:projectId/memberships/:membershipId", config: { rateLimit: writeLimit }, @@ -268,7 +291,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider } ], params: z.object({ - workspaceId: z.string().trim().describe(PROJECT_USERS.UPDATE_USER_MEMBERSHIP.workspaceId), + projectId: z.string().trim().describe(PROJECT_USERS.UPDATE_USER_MEMBERSHIP.projectId), membershipId: z.string().trim().describe(PROJECT_USERS.UPDATE_USER_MEMBERSHIP.membershipId) }), body: z.object({ @@ -305,31 +328,87 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider actor: req.permission.type, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, + projectId: req.params.projectId, membershipId: req.params.membershipId, roles: req.body.roles }); - // await server.services.auditLog.createAuditLog({ - // ...req.auditLogInfo, - // projectId: req.params.workspaceId, - // event: { - // type: EventType.UPDATE_USER_WORKSPACE_ROLE, - // metadata: { - // userId: membership.userId, - // newRole: req.body.role, - // oldRole: membership.role, - // email: "" - // } - // } - // }); return { roles }; } }); server.route({ method: "DELETE", - url: "/:workspaceId/memberships/:membershipId", + url: "/:projectId/memberships", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.ProjectUsers], + description: "Remove members from project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().describe(PROJECT_USERS.REMOVE_MEMBER.projectId) + }), + body: z.object({ + emails: z + .string() + .email() + .array() + .default([]) + .describe(PROJECT_USERS.REMOVE_MEMBER.emails) + .refine((val) => val.every((el) => el === el.toLowerCase()), "Email must be lowercase"), + usernames: z + .string() + .array() + .default([]) + .describe(PROJECT_USERS.REMOVE_MEMBER.usernames) + .refine((val) => val.every((el) => el === el.toLowerCase()), "Username must be lowercase") + }), + response: { + 200: z.object({ + memberships: ProjectMembershipsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const memberships = await server.services.projectMembership.deleteProjectMemberships({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.projectId, + emails: req.body.emails, + usernames: req.body.usernames + }); + + for (const membership of memberships) { + // eslint-disable-next-line no-await-in-loop + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.params.projectId, + event: { + type: EventType.REMOVE_PROJECT_MEMBER, + metadata: { + userId: membership.userId, + email: "" + } + } + }); + } + return { memberships }; + } + }); + + server.route({ + method: "DELETE", + url: "/:projectId/memberships/:membershipId", config: { rateLimit: writeLimit }, @@ -341,7 +420,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider } ], params: z.object({ - workspaceId: z.string().trim(), + projectId: z.string().trim(), membershipId: z.string().trim() }), response: { @@ -357,15 +436,15 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider actor: req.permission.type, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, + projectId: req.params.projectId, membershipId: req.params.membershipId }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.params.workspaceId, + projectId: req.params.projectId, event: { - type: EventType.REMOVE_WORKSPACE_MEMBER, + type: EventType.REMOVE_PROJECT_MEMBER, metadata: { userId: membership.userId, email: "" @@ -378,13 +457,13 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider server.route({ method: "DELETE", - url: "/:workspaceId/leave", + url: "/:projectId/leave", config: { rateLimit: writeLimit }, schema: { params: z.object({ - workspaceId: z.string().trim() + projectId: z.string().trim() }), response: { 200: z.object({ @@ -398,7 +477,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider const membership = await server.services.projectMembership.leaveProject({ actorId: req.permission.id, actor: req.permission.type, - projectId: req.params.workspaceId + projectId: req.params.projectId }); return { membership }; } diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 5e7dce76e..c1f4140e5 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -2,7 +2,10 @@ import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { + CertificatesSchema, IntegrationsSchema, + PkiAlertsSchema, + PkiCollectionsSchema, ProjectEnvironmentsSchema, ProjectMembershipsSchema, ProjectRolesSchema, @@ -16,18 +19,35 @@ import { } from "@app/db/schemas"; import { ProjectMicrosoftTeamsConfigsSchema } from "@app/db/schemas/project-microsoft-teams-configs"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types"; +import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-schema"; +import { sanitizedSshCertificate } from "@app/ee/services/ssh-certificate/ssh-certificate-schema"; +import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; +import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; +import { LoginMappingSource } from "@app/ee/services/ssh-host/ssh-host-types"; +import { sanitizedSshHostGroup } from "@app/ee/services/ssh-host-group/ssh-host-group-schema"; import { ApiDocsTags, PROJECTS } from "@app/lib/api-docs"; import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; import { re2Validator } from "@app/lib/zod"; import { readLimit, requestAccessLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; +import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode } from "@app/services/auth/auth-type"; +import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums"; +import { sanitizedCertificateTemplate } from "@app/services/certificate-template/certificate-template-schema"; import { validateMicrosoftTeamsChannelsSchema } from "@app/services/microsoft-teams/microsoft-teams-fns"; +import { sanitizedPkiSubscriber } from "@app/services/pki-subscriber/pki-subscriber-schema"; import { ProjectFilterType, SearchProjectSortBy } from "@app/services/project/project-types"; import { validateSlackChannelsField } from "@app/services/slack/slack-auth-validators"; +import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; import { WorkflowIntegration } from "@app/services/workflow-integration/workflow-integration-types"; -import { integrationAuthPubSchema, SanitizedProjectSchema } from "../sanitizedSchemas"; +import { + integrationAuthPubSchema, + InternalCertificateAuthorityResponseSchema, + SanitizedProjectSchema +} from "../sanitizedSchemas"; import { sanitizedServiceTokenSchema } from "../v2/service-token-router"; const projectWithEnv = SanitizedProjectSchema.merge( @@ -40,41 +60,7 @@ const projectWithEnv = SanitizedProjectSchema.merge( export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/:workspaceId/keys", - config: { - rateLimit: readLimit - }, - schema: { - params: z.object({ - workspaceId: z.string().trim() - }), - response: { - 200: z.object({ - publicKeys: z - .object({ - publicKey: z.string().nullable().optional(), - userId: z.string() - }) - .array() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req) => { - const publicKeys = await server.services.projectKey.getProjectPublicKeys({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId - }); - return { publicKeys }; - } - }); - - server.route({ - method: "GET", - url: "/:workspaceId/users", + url: "/:projectId/users", config: { rateLimit: readLimit }, @@ -96,7 +82,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { .optional() }), params: z.object({ - workspaceId: z.string().trim() + projectId: z.string().trim() }), response: { 200: z.object({ @@ -142,7 +128,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actor: req.permission.type, actorAuthMethod: req.permission.authMethod, includeGroupMembers: req.query.includeGroupMembers, - projectId: req.params.workspaceId, + projectId: req.params.projectId, actorOrgId: req.permission.orgId, roles }); @@ -151,6 +137,83 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Projects], + description: "Create a new project", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + projectName: z.string().trim().describe(PROJECTS.CREATE.projectName), + projectDescription: z.string().trim().optional().describe(PROJECTS.CREATE.projectDescription), + slug: slugSchema({ min: 5, max: 36 }).optional().describe(PROJECTS.CREATE.slug), + kmsKeyId: z.string().optional(), + template: slugSchema({ field: "Template Name", max: 64 }) + .optional() + .default(InfisicalProjectTemplate.Default) + .describe(PROJECTS.CREATE.template), + type: z.nativeEnum(ProjectType).default(ProjectType.SecretManager), + shouldCreateDefaultEnvs: z.boolean().optional().default(true) + }), + response: { + 200: z.object({ + project: projectWithEnv + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const project = await server.services.project.createProject({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + projectName: req.body.projectName, + projectDescription: req.body.projectDescription, + slug: req.body.slug, + kmsKeyId: req.body.kmsKeyId, + template: req.body.template, + type: req.body.type, + createDefaultEnvs: req.body.shouldCreateDefaultEnvs + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.ProjectCreated, + distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, + properties: { + orgId: project.orgId, + name: project.name, + ...req.auditLogInfo + } + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: project.id, + event: { + type: EventType.CREATE_PROJECT, + metadata: { + ...req.body, + name: req.body.projectName + } + } + }); + + return { project }; + } + }); + server.route({ method: "GET", url: "/", @@ -158,6 +221,14 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.Projects], + description: "List projects", + security: [ + { + bearerAuth: [] + } + ], querystring: z.object({ includeRoles: z .enum(["true", "false"]) @@ -167,7 +238,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - workspaces: projectWithEnv + projects: projectWithEnv .extend({ roles: ProjectRolesSchema.array().optional() }) @@ -177,7 +248,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const workspaces = await server.services.project.getProjects({ + const projects = await server.services.project.getProjects({ includeRoles: req.query.includeRoles, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -185,13 +256,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorOrgId: req.permission.orgId, type: req.query.type }); - return { workspaces }; + return { projects }; } }); server.route({ method: "GET", - url: "/:workspaceId", + url: "/:projectId", config: { rateLimit: readLimit }, @@ -205,33 +276,73 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - workspaceId: z.string().trim().describe(PROJECTS.GET.workspaceId) + projectId: z.string().trim().describe(PROJECTS.GET.projectId) }), response: { 200: z.object({ - workspace: projectWithEnv.optional() + project: projectWithEnv.optional() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const workspace = await server.services.project.getAProject({ + const project = await server.services.project.getAProject({ filter: { type: ProjectFilterType.ID, - projectId: req.params.workspaceId + projectId: req.params.projectId }, actorAuthMethod: req.permission.authMethod, actorId: req.permission.id, actor: req.permission.type, actorOrgId: req.permission.orgId }); - return { workspace }; + return { project }; + } + }); + + server.route({ + method: "GET", + url: "/slug/:slug", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Projects], + description: "Get project details by slug", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + slug: slugSchema({ max: 64 }).describe("The slug of the project to get.") + }), + response: { + 200: projectWithEnv + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const project = await server.services.project.getAProject({ + filter: { + slug: req.params.slug, + orgId: req.permission.orgId, + type: ProjectFilterType.SLUG + }, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type + }); + + return project; } }); server.route({ method: "DELETE", - url: "/:workspaceId", + url: "/:projectId", config: { rateLimit: writeLimit }, @@ -245,20 +356,20 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - workspaceId: z.string().trim().describe(PROJECTS.DELETE.workspaceId) + projectId: z.string().trim().describe(PROJECTS.DELETE.projectId) }), response: { 200: z.object({ - workspace: SanitizedProjectSchema.optional() + project: SanitizedProjectSchema.optional() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const workspace = await server.services.project.deleteProject({ + const project = await server.services.project.deleteProject({ filter: { type: ProjectFilterType.ID, - projectId: req.params.workspaceId + projectId: req.params.projectId }, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -269,68 +380,20 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, - projectId: req.params.workspaceId, + projectId: req.params.projectId, event: { type: EventType.DELETE_PROJECT, - metadata: workspace + metadata: project } }); - return { workspace }; - } - }); - - server.route({ - url: "/:workspaceId/name", - method: "POST", - config: { - rateLimit: writeLimit - }, - schema: { - params: z.object({ - workspaceId: z.string().trim() - }), - body: z.object({ - name: z.string().trim() - }), - response: { - 200: z.object({ - message: z.string(), - workspace: SanitizedProjectSchema - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req) => { - const workspace = await server.services.project.updateName({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, - name: req.body.name - }); - - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - orgId: req.permission.orgId, - projectId: req.params.workspaceId, - event: { - type: EventType.UPDATE_PROJECT, - metadata: req.body - } - }); - - return { - message: "Successfully changed workspace name", - workspace - }; + return { project }; } }); server.route({ method: "PATCH", - url: "/:workspaceId", + url: "/:projectId", config: { rateLimit: writeLimit }, @@ -344,7 +407,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - workspaceId: z.string().trim().describe(PROJECTS.UPDATE.workspaceId) + projectId: z.string().trim().describe(PROJECTS.UPDATE.projectId) }), body: z.object({ name: z @@ -373,35 +436,35 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { .describe(PROJECTS.UPDATE.slug), secretSharing: z.boolean().optional().describe(PROJECTS.UPDATE.secretSharing), showSnapshotsLegacy: z.boolean().optional().describe(PROJECTS.UPDATE.showSnapshotsLegacy), - defaultProduct: z.nativeEnum(ProjectType).optional().describe(PROJECTS.UPDATE.defaultProduct), secretDetectionIgnoreValues: z .array(z.string()) .optional() - .describe(PROJECTS.UPDATE.secretDetectionIgnoreValues) + .describe(PROJECTS.UPDATE.secretDetectionIgnoreValues), + pitVersionLimit: z.number().min(1).max(100).optional() }), response: { 200: z.object({ - workspace: SanitizedProjectSchema + project: SanitizedProjectSchema }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const workspace = await server.services.project.updateProject({ + const project = await server.services.project.updateProject({ filter: { type: ProjectFilterType.ID, - projectId: req.params.workspaceId + projectId: req.params.projectId }, update: { name: req.body.name, description: req.body.description, autoCapitalization: req.body.autoCapitalization, - defaultProduct: req.body.defaultProduct, hasDeleteProtection: req.body.hasDeleteProtection, slug: req.body.slug, secretSharing: req.body.secretSharing, showSnapshotsLegacy: req.body.showSnapshotsLegacy, - secretDetectionIgnoreValues: req.body.secretDetectionIgnoreValues + secretDetectionIgnoreValues: req.body.secretDetectionIgnoreValues, + pitVersionLimit: req.body.pitVersionLimit }, actorAuthMethod: req.permission.authMethod, actorId: req.permission.id, @@ -412,7 +475,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, - projectId: req.params.workspaceId, + projectId: req.params.projectId, event: { type: EventType.UPDATE_PROJECT, metadata: req.body @@ -420,164 +483,20 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }); return { - workspace - }; - } - }); - - server.route({ - method: "POST", - url: "/:workspaceId/auto-capitalization", - config: { - rateLimit: writeLimit - }, - schema: { - params: z.object({ - workspaceId: z.string().trim() - }), - body: z.object({ - autoCapitalization: z.boolean() - }), - response: { - 200: z.object({ - message: z.string(), - workspace: SanitizedProjectSchema - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req) => { - const workspace = await server.services.project.toggleAutoCapitalization({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, - autoCapitalization: req.body.autoCapitalization - }); - - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - orgId: req.permission.orgId, - projectId: req.params.workspaceId, - event: { - type: EventType.UPDATE_PROJECT, - metadata: req.body - } - }); - - return { - message: "Successfully changed workspace settings", - workspace - }; - } - }); - - server.route({ - method: "POST", - url: "/:workspaceId/delete-protection", - config: { - rateLimit: writeLimit - }, - schema: { - params: z.object({ - workspaceId: z.string().trim() - }), - body: z.object({ - hasDeleteProtection: z.boolean() - }), - response: { - 200: z.object({ - message: z.string(), - workspace: SanitizedProjectSchema - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req) => { - const workspace = await server.services.project.toggleDeleteProtection({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, - hasDeleteProtection: req.body.hasDeleteProtection - }); - - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - orgId: req.permission.orgId, - projectId: req.params.workspaceId, - event: { - type: EventType.UPDATE_PROJECT, - metadata: req.body - } - }); - - return { - message: "Successfully changed workspace settings", - workspace + project }; } }); server.route({ method: "PUT", - url: "/:workspaceSlug/version-limit", + url: "/:projectId/audit-logs-retention", config: { rateLimit: writeLimit }, schema: { params: z.object({ - workspaceSlug: z.string().trim() - }), - body: z.object({ - pitVersionLimit: z.number().min(1).max(100) - }), - response: { - 200: z.object({ - message: z.string(), - workspace: SanitizedProjectSchema - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req) => { - const workspace = await server.services.project.updateVersionLimit({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - pitVersionLimit: req.body.pitVersionLimit, - workspaceSlug: req.params.workspaceSlug - }); - - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - orgId: req.permission.orgId, - projectId: workspace.id, - event: { - type: EventType.UPDATE_PROJECT, - metadata: req.body - } - }); - - return { - message: "Successfully changed workspace version limit", - workspace - }; - } - }); - - server.route({ - method: "PUT", - url: "/:workspaceSlug/audit-logs-retention", - config: { - rateLimit: writeLimit - }, - schema: { - params: z.object({ - workspaceSlug: z.string().trim() + projectId: z.string().trim() }), body: z.object({ auditLogsRetentionDays: z.number().min(0) @@ -585,25 +504,28 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ message: z.string(), - workspace: SanitizedProjectSchema + project: SanitizedProjectSchema }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const workspace = await server.services.project.updateAuditLogsRetention({ + const project = await server.services.project.updateAuditLogsRetention({ actorId: req.permission.id, actor: req.permission.type, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - workspaceSlug: req.params.workspaceSlug, - auditLogsRetentionDays: req.body.auditLogsRetentionDays + auditLogsRetentionDays: req.body.auditLogsRetentionDays, + filter: { + projectId: req.params.projectId, + type: ProjectFilterType.ID + } }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, - projectId: workspace.id, + projectId: project.id, event: { type: EventType.UPDATE_PROJECT, metadata: req.body @@ -612,14 +534,14 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return { message: "Successfully updated project's audit logs retention period", - workspace + project }; } }); server.route({ method: "GET", - url: "/:workspaceId/integrations", + url: "/:projectId/integrations", config: { rateLimit: readLimit }, @@ -633,7 +555,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - workspaceId: z.string().trim().describe(PROJECTS.LIST_INTEGRATION.workspaceId) + projectId: z.string().trim().describe(PROJECTS.LIST_INTEGRATION.projectId) }), response: { 200: z.object({ @@ -656,7 +578,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorAuthMethod: req.permission.authMethod, actor: req.permission.type, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId + projectId: req.params.projectId }); return { integrations }; } @@ -664,21 +586,21 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/:workspaceId/authorizations", + url: "/:projectId/authorizations", config: { rateLimit: readLimit }, schema: { hide: false, tags: [ApiDocsTags.Integrations], - description: "List integration auth objects for a workspace.", + description: "List integration auth objects for a project.", security: [ { bearerAuth: [] } ], params: z.object({ - workspaceId: z.string().trim().describe(PROJECTS.LIST_INTEGRATION_AUTHORIZATION.workspaceId) + projectId: z.string().trim().describe(PROJECTS.LIST_INTEGRATION_AUTHORIZATION.projectId) }), response: { 200: z.object({ @@ -693,7 +615,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorAuthMethod: req.permission.authMethod, actor: req.permission.type, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId + projectId: req.params.projectId }); return { authorizations }; } @@ -701,13 +623,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/:workspaceId/service-token-data", + url: "/:projectId/service-token-data", config: { rateLimit: readLimit }, schema: { params: z.object({ - workspaceId: z.string().trim() + projectId: z.string().trim() }), response: { 200: z.object({ @@ -722,7 +644,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorAuthMethod: req.permission.authMethod, actor: req.permission.type, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId + projectId: req.params.projectId }); return { serviceTokenData }; } @@ -730,13 +652,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/:workspaceId/ssh-config", + url: "/:projectId/ssh-config", config: { rateLimit: readLimit }, schema: { params: z.object({ - workspaceId: z.string().trim() + projectId: z.string().trim() }), response: { 200: ProjectSshConfigsSchema.pick({ @@ -756,7 +678,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorAuthMethod: req.permission.authMethod, actor: req.permission.type, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId + projectId: req.params.projectId }); await server.services.auditLog.createAuditLog({ @@ -777,13 +699,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "PATCH", - url: "/:workspaceId/ssh-config", + url: "/:projectId/ssh-config", config: { rateLimit: writeLimit }, schema: { params: z.object({ - workspaceId: z.string().trim() + projectId: z.string().trim() }), body: z.object({ defaultUserSshCaId: z.string().optional(), @@ -807,7 +729,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorAuthMethod: req.permission.authMethod, actor: req.permission.type, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, + projectId: req.params.projectId, ...req.body }); @@ -831,13 +753,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/:workspaceId/workflow-integration-config/:integration", + url: "/:projectId/workflow-integration-config/:integration", config: { rateLimit: readLimit }, schema: { params: z.object({ - workspaceId: z.string().trim(), + projectId: z.string().trim(), integration: z.nativeEnum(WorkflowIntegration) }), response: { @@ -876,13 +798,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorAuthMethod: req.permission.authMethod, actor: req.permission.type, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, + projectId: req.params.projectId, integration: req.params.integration }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.params.workspaceId, + projectId: req.params.projectId, event: { type: EventType.GET_PROJECT_WORKFLOW_INTEGRATION_CONFIG, metadata: { @@ -936,15 +858,14 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "PUT", - url: "/:workspaceId/workflow-integration", + url: "/:projectId/workflow-integration", config: { rateLimit: readLimit }, schema: { params: z.object({ - workspaceId: z.string().trim() + projectId: z.string().trim() }), - body: z.discriminatedUnion("integration", [ z.object({ integration: z.literal(WorkflowIntegration.SLACK), @@ -999,13 +920,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorAuthMethod: req.permission.authMethod, actor: req.permission.type, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, + projectId: req.params.projectId, ...req.body }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.params.workspaceId, + projectId: req.params.projectId, event: { type: EventType.UPDATE_PROJECT_WORKFLOW_INTEGRATION_CONFIG, metadata: { @@ -1026,13 +947,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/:workspaceId/environment-folder-tree", + url: "/:projectId/environment-folder-tree", config: { rateLimit: readLimit }, schema: { params: z.object({ - workspaceId: z.string().trim() + projectId: z.string().trim() }), response: { 200: z.record( @@ -1043,7 +964,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { const environmentsFolders = await server.services.folder.getProjectEnvironmentsFolders( - req.params.workspaceId, + req.params.projectId, req.permission ); @@ -1064,6 +985,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { type: z.nativeEnum(ProjectType).optional(), orderBy: z.nativeEnum(SearchProjectSortBy).optional().default(SearchProjectSortBy.NAME), orderDirection: z.nativeEnum(SortDirection).optional().default(SortDirection.ASC), + projectIds: z.string().trim().array().optional(), name: z .string() .trim() @@ -1092,13 +1014,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/:workspaceId/project-access", + url: "/:projectId/project-access", config: { rateLimit: requestAccessLimit }, schema: { params: z.object({ - workspaceId: z.string().trim() + projectId: z.string().trim() }), body: z.object({ comment: z @@ -1132,17 +1054,17 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { await server.services.project.requestProjectAccess({ permission: req.permission, comment: req.body.comment, - projectId: req.params.workspaceId + projectId: req.params.projectId }); if (req.auth.actor === ActorType.USER) { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.params.workspaceId, + projectId: req.params.projectId, event: { type: EventType.PROJECT_ACCESS_REQUEST, metadata: { - projectId: req.params.workspaceId, + projectId: req.params.projectId, requesterEmail: req.auth.user.email || req.auth.user.username, requesterId: req.auth.userId } @@ -1153,4 +1075,456 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return { message: "Project access request has been send to project admins" }; } }); + + /* Start upgrade of a project */ + server.route({ + method: "POST", + url: "/:projectId/upgrade", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim() + }), + body: z.object({ + userPrivateKey: z.string().trim() + }), + response: { + 200: z.void() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.project.upgradeProject({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + projectId: req.params.projectId, + userPrivateKey: req.body.userPrivateKey + }); + } + }); + + /* Get upgrade status of project */ + server.route({ + url: "/:projectId/upgrade/status", + method: "GET", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim() + }), + response: { + 200: z.object({ + status: z.string().nullable() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const status = await server.services.project.getProjectUpgradeStatus({ + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.projectId, + actor: req.permission.type, + actorId: req.permission.id + }); + + return { status }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/cas", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + params: z.object({ + projectId: z.string().trim() + }), + querystring: z.object({ + status: z.enum([CaStatus.ACTIVE, CaStatus.PENDING_CERTIFICATE]).optional().describe(PROJECTS.LIST_CAS.status), + friendlyName: z.string().optional().describe(PROJECTS.LIST_CAS.friendlyName), + commonName: z.string().optional().describe(PROJECTS.LIST_CAS.commonName), + offset: z.coerce.number().min(0).max(100).default(0).describe(PROJECTS.LIST_CAS.offset), + limit: z.coerce.number().min(1).max(100).default(25).describe(PROJECTS.LIST_CAS.limit) + }), + response: { + 200: z.object({ + cas: z.array(InternalCertificateAuthorityResponseSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const cas = await server.services.project.listProjectCas({ + filter: { + projectId: req.params.projectId, + type: ProjectFilterType.ID + }, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + ...req.query + }); + return { cas }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/certificates", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + params: z.object({ + projectId: z.string().trim() + }), + querystring: z.object({ + friendlyName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.friendlyName), + commonName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.commonName), + offset: z.coerce.number().min(0).max(100).default(0).describe(PROJECTS.LIST_CERTIFICATES.offset), + limit: z.coerce.number().min(1).max(100).default(25).describe(PROJECTS.LIST_CERTIFICATES.limit) + }), + response: { + 200: z.object({ + certificates: z.array(CertificatesSchema), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificates, totalCount } = await server.services.project.listProjectCertificates({ + filter: { + projectId: req.params.projectId, + type: ProjectFilterType.ID + }, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + ...req.query + }); + return { certificates, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/pki-alerts", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiAlerting], + params: z.object({ + projectId: z.string().trim() + }), + response: { + 200: z.object({ + alerts: z.array(PkiAlertsSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { alerts } = await server.services.project.listProjectAlerts({ + projectId: req.params.projectId, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type + }); + + return { alerts }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/pki-collections", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateCollections], + params: z.object({ + projectId: z.string().trim() + }), + response: { + 200: z.object({ + collections: z.array(PkiCollectionsSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiCollections } = await server.services.project.listProjectPkiCollections({ + projectId: req.params.projectId, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type + }); + + return { collections: pkiCollections }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/pki-subscribers", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSubscribers], + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_PKI_SUBSCRIBERS.projectId) + }), + response: { + 200: z.object({ + subscribers: z.array(sanitizedPkiSubscriber) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const subscribers = await server.services.project.listProjectPkiSubscribers({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId + }); + + return { subscribers }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/certificate-templates", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + projectId: z.string().trim() + }), + response: { + 200: z.object({ + certificateTemplates: sanitizedCertificateTemplate.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplates } = await server.services.project.listProjectCertificateTemplates({ + projectId: req.params.projectId, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type + }); + + return { certificateTemplates }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-certificates", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_CAS.projectId) + }), + querystring: z.object({ + offset: z.coerce.number().default(0).describe(PROJECTS.LIST_SSH_CERTIFICATES.offset), + limit: z.coerce.number().default(25).describe(PROJECTS.LIST_SSH_CERTIFICATES.limit) + }), + response: { + 200: z.object({ + certificates: z.array(sanitizedSshCertificate), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificates, totalCount } = await server.services.project.listProjectSshCertificates({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId, + offset: req.query.offset, + limit: req.query.limit + }); + + return { certificates, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-certificate-templates", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SshCertificateTemplates], + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_CERTIFICATE_TEMPLATES.projectId) + }), + response: { + 200: z.object({ + certificateTemplates: z.array(sanitizedSshCertificateTemplate) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplates } = await server.services.project.listProjectSshCertificateTemplates({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId + }); + + return { certificateTemplates }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-cas", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SshCertificateAuthorities], + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_CAS.projectId) + }), + response: { + 200: z.object({ + cas: z.array(sanitizedSshCa) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const cas = await server.services.project.listProjectSshCas({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId + }); + + return { cas }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-hosts", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_HOSTS.projectId) + }), + response: { + 200: z.object({ + hosts: z.array( + sanitizedSshHost.extend({ + loginMappings: loginMappingSchema + .extend({ + source: z.nativeEnum(LoginMappingSource) + }) + .array() + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const hosts = await server.services.project.listProjectSshHosts({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId + }); + + return { hosts }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-host-groups", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_HOST_GROUPS.projectId) + }), + response: { + 200: z.object({ + groups: z.array( + sanitizedSshHostGroup.extend({ + loginMappings: loginMappingSchema.array(), + hostCount: z.number() + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const groups = await server.services.project.listProjectSshHostGroups({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId + }); + + return { groups }; + } + }); }; diff --git a/backend/src/server/routes/v1/secret-tag-router.ts b/backend/src/server/routes/v1/secret-tag-router.ts index 01ba783fe..3c6c99eaf 100644 --- a/backend/src/server/routes/v1/secret-tag-router.ts +++ b/backend/src/server/routes/v1/secret-tag-router.ts @@ -22,20 +22,20 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - workspaceTags: SecretTagsSchema.array() + tags: SecretTagsSchema.array() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const workspaceTags = await server.services.secretTag.getProjectTags({ + const tags = await server.services.secretTag.getProjectTags({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, projectId: req.params.projectId }); - return { workspaceTags }; + return { tags }; } }); @@ -55,20 +55,20 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ // akhilmhdh: for terraform backward compatiability - workspaceTag: SecretTagsSchema.extend({ name: z.string() }) + tag: SecretTagsSchema.extend({ name: z.string() }) }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const workspaceTag = await server.services.secretTag.getTagById({ + const tag = await server.services.secretTag.getTagById({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, id: req.params.tagId }); - return { workspaceTag }; + return { tag }; } }); @@ -88,13 +88,13 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ // akhilmhdh: for terraform backward compatiability - workspaceTag: SecretTagsSchema.extend({ name: z.string() }) + tag: SecretTagsSchema.extend({ name: z.string() }) }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const workspaceTag = await server.services.secretTag.getTagBySlug({ + const tag = await server.services.secretTag.getTagBySlug({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -102,7 +102,7 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { slug: req.params.tagSlug, projectId: req.params.projectId }); - return { workspaceTag }; + return { tag }; } }); @@ -124,13 +124,13 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - workspaceTag: SecretTagsSchema + tag: SecretTagsSchema }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const workspaceTag = await server.services.secretTag.createTag({ + const tag = await server.services.secretTag.createTag({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -138,7 +138,7 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { projectId: req.params.projectId, ...req.body }); - return { workspaceTag }; + return { tag }; } }); @@ -161,13 +161,13 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - workspaceTag: SecretTagsSchema + tag: SecretTagsSchema }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const workspaceTag = await server.services.secretTag.updateTag({ + const tag = await server.services.secretTag.updateTag({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -175,7 +175,7 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { ...req.body, id: req.params.tagId }); - return { workspaceTag }; + return { tag }; } }); @@ -194,20 +194,20 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - workspaceTag: SecretTagsSchema + tag: SecretTagsSchema }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const workspaceTag = await server.services.secretTag.deleteTag({ + const tag = await server.services.secretTag.deleteTag({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, id: req.params.tagId }); - return { workspaceTag }; + return { tag }; } }); }; diff --git a/backend/src/server/routes/v1/upgrade-path-router.ts b/backend/src/server/routes/v1/upgrade-path-router.ts new file mode 100644 index 000000000..481bee5e6 --- /dev/null +++ b/backend/src/server/routes/v1/upgrade-path-router.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { publicEndpointLimit } from "@app/server/config/rateLimiter"; +import { versionSchema } from "@app/services/upgrade-path/upgrade-path-schemas"; + +export const registerUpgradePathRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/versions", + config: { + rateLimit: publicEndpointLimit + }, + schema: { + response: { + 200: z.object({ + versions: z.array( + z.object({ + tagName: z.string(), + name: z.string(), + publishedAt: z.string(), + prerelease: z.boolean(), + draft: z.boolean() + }) + ) + }) + } + }, + handler: async (req) => { + try { + const versions = await req.server.services.upgradePath.getGitHubReleases(); + + return { + versions + }; + } catch (error) { + logger.error(error, "Failed to fetch versions"); + if (error instanceof z.ZodError) { + throw new BadRequestError({ message: "Invalid query parameters" }); + } + throw new BadRequestError({ message: "Failed to fetch GitHub releases" }); + } + } + }); + + server.route({ + method: "POST", + url: "/calculate", + config: { + rateLimit: publicEndpointLimit + }, + schema: { + body: z.object({ + fromVersion: versionSchema, + toVersion: versionSchema + }), + response: { + 200: z.object({ + path: z.array( + z.object({ + version: z.string(), + name: z.string(), + publishedAt: z.string(), + prerelease: z.boolean() + }) + ), + breakingChanges: z.array( + z.object({ + version: z.string(), + changes: z.array( + z.object({ + title: z.string(), + description: z.string(), + action: z.string() + }) + ) + }) + ), + features: z.array( + z.object({ + version: z.string(), + name: z.string(), + body: z.string(), + publishedAt: z.string() + }) + ), + hasDbMigration: z.boolean(), + config: z.record(z.unknown()) + }) + } + }, + handler: async (req) => { + try { + const { fromVersion, toVersion } = req.body; + + const result = await req.server.services.upgradePath.calculateUpgradePath(fromVersion, toVersion); + + logger.info( + { pathLength: result.path.length, hasBreaking: result.breakingChanges.length > 0 }, + "Upgrade path calculated" + ); + + return result; + } catch (error) { + logger.error(error, "Failed to calculate upgrade path"); + if (error instanceof z.ZodError) { + throw new BadRequestError({ message: `Invalid input: ${error.errors.map((e) => e.message).join(", ")}` }); + } + if (error instanceof Error) { + throw new BadRequestError({ message: error.message }); + } + throw new BadRequestError({ message: "Failed to calculate upgrade path" }); + } + } + }); +}; diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index 7ef2e0d33..40fbcf5a2 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -255,7 +255,9 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { totp: z.string() }), response: { - 200: z.object({}) + 200: z.object({ + recoveryCodes: z.string().array() + }) } }, onRequest: verifyAuth([AuthMode.JWT], { diff --git a/backend/src/server/routes/v1/webhook-router.ts b/backend/src/server/routes/v1/webhook-router.ts index 377af135c..386628d28 100644 --- a/backend/src/server/routes/v1/webhook-router.ts +++ b/backend/src/server/routes/v1/webhook-router.ts @@ -39,7 +39,7 @@ export const registerWebhookRouter = async (server: FastifyZodProvider) => { body: z .object({ type: z.nativeEnum(WebhookType).default(WebhookType.GENERAL), - workspaceId: z.string().trim(), + projectId: z.string().trim(), environment: z.string().trim(), webhookUrl: z.string().url().trim(), webhookSecretKey: z.string().trim().optional(), @@ -67,13 +67,12 @@ export const registerWebhookRouter = async (server: FastifyZodProvider) => { actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.body.workspaceId, ...req.body }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.body.workspaceId, + projectId: req.body.projectId, event: { type: EventType.CREATE_WEBHOOK, metadata: { @@ -216,7 +215,7 @@ export const registerWebhookRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT]), schema: { querystring: z.object({ - workspaceId: z.string().trim(), + projectId: z.string().trim(), environment: z.string().trim().optional(), secretPath: z .string() @@ -238,7 +237,7 @@ export const registerWebhookRouter = async (server: FastifyZodProvider) => { actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, ...req.query, - projectId: req.query.workspaceId + projectId: req.query.projectId }); return { message: "Successfully fetched webhook", webhooks }; } diff --git a/backend/src/server/routes/v2/deprecated-group-project-router.ts b/backend/src/server/routes/v2/deprecated-group-project-router.ts new file mode 100644 index 000000000..f0e4ee705 --- /dev/null +++ b/backend/src/server/routes/v2/deprecated-group-project-router.ts @@ -0,0 +1,363 @@ +import { z } from "zod"; + +import { + GroupProjectMembershipsSchema, + GroupsSchema, + ProjectMembershipRole, + ProjectUserMembershipRolesSchema, + UsersSchema +} from "@app/db/schemas"; +import { EFilterReturnedUsers } from "@app/ee/services/group/group-types"; +import { ApiDocsTags, GROUPS, PROJECTS } from "@app/lib/api-docs"; +import { ms } from "@app/lib/ms"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types"; + +export const registerDeprecatedGroupProjectRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/:projectId/groups/:groupIdOrName", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.ProjectGroups], + description: "Add group to project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.ADD_GROUP_TO_PROJECT.projectId), + groupIdOrName: z.string().trim().describe(PROJECTS.ADD_GROUP_TO_PROJECT.groupIdOrName) + }), + body: z + .object({ + role: z + .string() + .trim() + .min(1) + .default(ProjectMembershipRole.NoAccess) + .describe(PROJECTS.ADD_GROUP_TO_PROJECT.role), + roles: z + .array( + z.union([ + z.object({ + role: z.string(), + isTemporary: z.literal(false).default(false) + }), + z.object({ + role: z.string(), + isTemporary: z.literal(true), + temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), + temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), + temporaryAccessStartTime: z.string().datetime() + }) + ]) + ) + .optional() + }) + .refine((data) => data.role || data.roles, { + message: "Either role or roles must be present", + path: ["role", "roles"] + }), + response: { + 200: z.object({ + groupMembership: GroupProjectMembershipsSchema + }) + } + }, + handler: async (req) => { + const groupMembership = await server.services.groupProject.addGroupToProject({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + roles: req.body.roles || [{ role: req.body.role }], + projectId: req.params.projectId, + groupIdOrName: req.params.groupIdOrName + }); + + return { groupMembership }; + } + }); + + server.route({ + method: "PATCH", + url: "/:projectId/groups/:groupId", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.ProjectGroups], + description: "Update group in project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.UPDATE_GROUP_IN_PROJECT.projectId), + groupId: z.string().trim().describe(PROJECTS.UPDATE_GROUP_IN_PROJECT.groupId) + }), + body: z.object({ + roles: z + .array( + z.union([ + z.object({ + role: z.string(), + isTemporary: z.literal(false).default(false) + }), + z.object({ + role: z.string(), + isTemporary: z.literal(true), + temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), + temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), + temporaryAccessStartTime: z.string().datetime() + }) + ]) + ) + .min(1) + .describe(PROJECTS.UPDATE_GROUP_IN_PROJECT.roles) + }), + response: { + 200: z.object({ + roles: ProjectUserMembershipRolesSchema.array() + }) + } + }, + handler: async (req) => { + const roles = await server.services.groupProject.updateGroupInProject({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.projectId, + groupId: req.params.groupId, + roles: req.body.roles + }); + + return { roles }; + } + }); + + server.route({ + method: "DELETE", + url: "/:projectId/groups/:groupId", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.ProjectGroups], + description: "Remove group from project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.REMOVE_GROUP_FROM_PROJECT.projectId), + groupId: z.string().trim().describe(PROJECTS.REMOVE_GROUP_FROM_PROJECT.groupId) + }), + response: { + 200: z.object({ + groupMembership: GroupProjectMembershipsSchema + }) + } + }, + handler: async (req) => { + const groupMembership = await server.services.groupProject.removeGroupFromProject({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + groupId: req.params.groupId, + projectId: req.params.projectId + }); + + return { groupMembership }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/groups", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.ProjectGroups], + description: "Return list of groups in project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_GROUPS_IN_PROJECT.projectId) + }), + response: { + 200: z.object({ + groupMemberships: z + .object({ + id: z.string(), + groupId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + group: GroupsSchema.pick({ name: true, id: true, slug: true }) + }) + .array() + }) + } + }, + handler: async (req) => { + const groupMemberships = await server.services.groupProject.listGroupsInProject({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.projectId + }); + + return { groupMemberships }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/groups/:groupId", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.ProjectGroups], + description: "Return project group", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim(), + groupId: z.string().trim() + }), + response: { + 200: z.object({ + groupMembership: z.object({ + id: z.string(), + groupId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + group: GroupsSchema.pick({ name: true, id: true, slug: true }) + }) + }) + } + }, + handler: async (req) => { + const groupMembership = await server.services.groupProject.getGroupInProject({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.params + }); + + return { groupMembership }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/groups/:groupId/users", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.ProjectGroups], + description: "Return project group users", + params: z.object({ + projectId: z.string().trim().describe(GROUPS.LIST_USERS.projectId), + groupId: z.string().trim().describe(GROUPS.LIST_USERS.id) + }), + querystring: z.object({ + offset: z.coerce.number().min(0).max(100).default(0).describe(GROUPS.LIST_USERS.offset), + limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit), + username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username), + search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search), + filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers) + }), + response: { + 200: z.object({ + users: UsersSchema.pick({ + email: true, + username: true, + firstName: true, + lastName: true, + id: true + }) + .merge( + z.object({ + isPartOfGroup: z.boolean(), + joinedGroupAt: z.date().nullable() + }) + ) + .array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { users, totalCount } = await server.services.groupProject.listProjectGroupUsers({ + id: req.params.groupId, + projectId: req.params.projectId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + return { users, totalCount }; + } + }); +}; diff --git a/backend/src/server/routes/v2/deprecated-identity-project-router.ts b/backend/src/server/routes/v2/deprecated-identity-project-router.ts new file mode 100644 index 000000000..c16c874ae --- /dev/null +++ b/backend/src/server/routes/v2/deprecated-identity-project-router.ts @@ -0,0 +1,418 @@ +import { z } from "zod"; + +import { + IdentitiesSchema, + IdentityProjectMembershipsSchema, + ProjectMembershipRole, + ProjectUserMembershipRolesSchema +} from "@app/db/schemas"; +import { ApiDocsTags, ORGANIZATIONS, PROJECT_IDENTITIES } from "@app/lib/api-docs"; +import { BadRequestError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; +import { OrderByDirection } from "@app/lib/types"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { ProjectIdentityOrderBy } from "@app/services/identity-project/identity-project-types"; +import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types"; + +import { SanitizedProjectSchema } from "../sanitizedSchemas"; + +export const registerDeprecatedIdentityProjectRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/:projectId/identity-memberships/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.ProjectIdentities], + description: "Create project identity membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim(), + identityId: z.string().trim() + }), + body: z.object({ + // @depreciated + role: z.string().trim().optional().default(ProjectMembershipRole.NoAccess), + roles: z + .array( + z.union([ + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(false) + .default(false) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + }), + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryMode: z + .nativeEnum(ProjectUserMembershipTemporaryMode) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + }) + ]) + ) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.description) + .optional() + }), + response: { + 200: z.object({ + identityMembership: IdentityProjectMembershipsSchema + }) + } + }, + handler: async (req) => { + const { role, roles } = req.body; + if (!role && !roles) throw new BadRequestError({ message: "You must provide either role or roles field" }); + + const identityMembership = await server.services.identityProject.createProjectIdentity({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId, + projectId: req.params.projectId, + roles: roles || [{ role }] + }); + return { identityMembership }; + } + }); + + server.route({ + method: "PATCH", + url: "/:projectId/identity-memberships/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.ProjectIdentities], + description: "Update project identity memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.projectId), + identityId: z.string().trim().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.identityId) + }), + body: z.object({ + roles: z + .array( + z.union([ + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(false) + .default(false) + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary) + }), + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary), + temporaryMode: z + .nativeEnum(ProjectUserMembershipTemporaryMode) + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryAccessStartTime) + }) + ]) + ) + .min(1) + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.description) + }), + response: { + 200: z.object({ + roles: ProjectUserMembershipRolesSchema.array() + }) + } + }, + handler: async (req) => { + const roles = await server.services.identityProject.updateProjectIdentity({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId, + projectId: req.params.projectId, + roles: req.body.roles + }); + return { roles }; + } + }); + + server.route({ + method: "DELETE", + url: "/:projectId/identity-memberships/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.ProjectIdentities], + description: "Delete project identity memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITIES.DELETE_IDENTITY_MEMBERSHIP.projectId), + identityId: z.string().trim().describe(PROJECT_IDENTITIES.DELETE_IDENTITY_MEMBERSHIP.identityId) + }), + response: { + 200: z.object({ + identityMembership: IdentityProjectMembershipsSchema + }) + } + }, + handler: async (req) => { + const identityMembership = await server.services.identityProject.deleteProjectIdentity({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId, + projectId: req.params.projectId + }); + return { identityMembership }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/identity-memberships", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.ProjectIdentities], + description: "Return project identity memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.projectId) + }), + querystring: z.object({ + offset: z.coerce + .number() + .min(0) + .default(0) + .describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.offset) + .optional(), + limit: z.coerce + .number() + .min(1) + .max(20000) // TODO: temp limit until combobox added to add identity to project modal, reduce once added + .default(100) + .describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.limit) + .optional(), + orderBy: z + .nativeEnum(ProjectIdentityOrderBy) + .default(ProjectIdentityOrderBy.Name) + .describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.orderBy) + .optional(), + orderDirection: z + .nativeEnum(OrderByDirection) + .default(OrderByDirection.ASC) + .describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.orderDirection) + .optional(), + search: z.string().trim().describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.search).optional() + }), + response: { + 200: z.object({ + identityMemberships: z + .object({ + id: z.string(), + identityId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + authMethods: z.array(z.string()) + }), + project: SanitizedProjectSchema.pick({ name: true, id: true }) + }) + .array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { identityMemberships, totalCount } = await server.services.identityProject.listProjectIdentities({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.projectId, + limit: req.query.limit, + offset: req.query.offset, + orderBy: req.query.orderBy, + orderDirection: req.query.orderDirection, + search: req.query.search + }); + + return { identityMemberships, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/identity-memberships/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.ProjectIdentities], + description: "Return project identity membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITIES.GET_IDENTITY_MEMBERSHIP_BY_ID.projectId), + identityId: z.string().trim().describe(PROJECT_IDENTITIES.GET_IDENTITY_MEMBERSHIP_BY_ID.identityId) + }), + response: { + 200: z.object({ + identityMembership: z.object({ + id: z.string(), + identityId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + authMethods: z.array(z.string()) + }), + project: SanitizedProjectSchema.pick({ name: true, id: true }) + }) + }) + } + }, + handler: async (req) => { + const identityMembership = await server.services.identityProject.getProjectIdentityByIdentityId({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.projectId, + identityId: req.params.identityId + }); + return { identityMembership }; + } + }); + + server.route({ + method: "GET", + url: "/identity-memberships/:identityMembershipId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.ProjectIdentities], + params: z.object({ + identityMembershipId: z.string().trim() + }), + response: { + 200: z.object({ + identityMembership: z.object({ + id: z.string(), + identityId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + authMethods: z.array(z.string()) + }), + project: SanitizedProjectSchema.pick({ name: true, id: true }) + }) + }) + } + }, + handler: async (req) => { + const identityMembership = await server.services.identityProject.getProjectIdentityByMembershipId({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityMembershipId: req.params.identityMembershipId + }); + return { identityMembership }; + } + }); +}; diff --git a/backend/src/server/routes/v2/project-membership-router.ts b/backend/src/server/routes/v2/deprecated-project-membership-router.ts similarity index 96% rename from backend/src/server/routes/v2/project-membership-router.ts rename to backend/src/server/routes/v2/deprecated-project-membership-router.ts index 76f1e9c5e..d88d2f996 100644 --- a/backend/src/server/routes/v2/project-membership-router.ts +++ b/backend/src/server/routes/v2/deprecated-project-membership-router.ts @@ -7,7 +7,7 @@ import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -export const registerProjectMembershipRouter = async (server: FastifyZodProvider) => { +export const registerDeprecatedProjectMembershipRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", url: "/:projectId/memberships", @@ -71,7 +71,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider projectId: req.params.projectId, ...req.auditLogInfo, event: { - type: EventType.ADD_BATCH_WORKSPACE_MEMBER, + type: EventType.ADD_BATCH_PROJECT_MEMBER, metadata: memberships.map(({ userId, id }) => ({ userId: userId || "", membershipId: id, @@ -141,7 +141,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider ...req.auditLogInfo, projectId: req.params.projectId, event: { - type: EventType.REMOVE_WORKSPACE_MEMBER, + type: EventType.REMOVE_PROJECT_MEMBER, metadata: { userId: membership.userId, email: "" diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/deprecated-project-router.ts similarity index 90% rename from backend/src/server/routes/v2/project-router.ts rename to backend/src/server/routes/v2/deprecated-project-router.ts index 8b9091364..55493681a 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/deprecated-project-router.ts @@ -35,7 +35,8 @@ const projectWithEnv = SanitizedProjectSchema.extend({ kmsSecretManagerKeyId: z.string().nullable().optional() }); -export const registerProjectRouter = async (server: FastifyZodProvider) => { +export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider) => { + // depreciated /* Get project key */ server.route({ method: "GET", @@ -46,7 +47,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { schema: { description: "Return encrypted project key", params: z.object({ - workspaceId: z.string().trim().describe(PROJECTS.GET_KEY.workspaceId) + workspaceId: z.string().trim().describe(PROJECTS.GET_KEY.projectId) }), response: { 200: ProjectKeysSchema.merge( @@ -72,7 +73,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { ...req.auditLogInfo, projectId: req.params.workspaceId, event: { - type: EventType.GET_WORKSPACE_KEY, + type: EventType.GET_PROJECT_KEY, metadata: { keyId: key?.id as string } @@ -83,68 +84,6 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); - /* Start upgrade of a project */ - server.route({ - method: "POST", - url: "/:projectId/upgrade", - config: { - rateLimit: writeLimit - }, - schema: { - params: z.object({ - projectId: z.string().trim() - }), - body: z.object({ - userPrivateKey: z.string().trim() - }), - response: { - 200: z.void() - } - }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req) => { - await server.services.project.upgradeProject({ - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - projectId: req.params.projectId, - userPrivateKey: req.body.userPrivateKey - }); - } - }); - - /* Get upgrade status of project */ - server.route({ - url: "/:projectId/upgrade/status", - method: "GET", - config: { - rateLimit: readLimit - }, - schema: { - params: z.object({ - projectId: z.string().trim() - }), - response: { - 200: z.object({ - status: z.string().nullable() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req) => { - const status = await server.services.project.getProjectUpgradeStatus({ - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.params.projectId, - actor: req.permission.type, - actorId: req.permission.id - }); - - return { status }; - } - }); - /* Create new project */ server.route({ method: "POST", @@ -186,8 +125,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actor: req.permission.type, actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, - workspaceName: req.body.projectName, - workspaceDescription: req.body.projectDescription, + projectName: req.body.projectName, + projectDescription: req.body.projectDescription, slug: req.body.slug, kmsKeyId: req.body.kmsKeyId, template: req.body.template, @@ -224,6 +163,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }); /* Delete a project by slug */ + // moved to DELETE /v1/projects/slug/:slug server.route({ method: "DELETE", url: "/:slug", @@ -240,7 +180,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - slug: slugSchema({ min: 5, max: 36 }).describe("The slug of the project to delete.") + slug: slugSchema({ min: 5, max: 64 }).describe("The slug of the project to delete.") }), response: { 200: SanitizedProjectSchema @@ -276,6 +216,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }); /* Get a project by slug */ + // moved to GET /v1/projects/slug/:slug server.route({ method: "GET", url: "/:slug", @@ -292,7 +233,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - slug: slugSchema({ max: 36 }).describe("The slug of the project to get.") + slug: slugSchema({ max: 64 }).describe("The slug of the project to get.") }), response: { 200: projectWithEnv @@ -325,7 +266,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ - slug: slugSchema({ min: 5, max: 36 }).describe("The slug of the project to update.") + slug: slugSchema({ min: 5, max: 64 }).describe("The slug of the project to update.") }), body: z.object({ name: z.string().trim().optional().describe(PROJECTS.UPDATE.name), @@ -337,7 +278,6 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { 200: SanitizedProjectSchema } }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const project = await server.services.project.updateProject({ @@ -382,7 +322,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiCertificateAuthorities], params: z.object({ - slug: slugSchema({ min: 5, max: 36 }).describe(PROJECTS.LIST_CAS.slug) + slug: slugSchema({ min: 5, max: 64 }).describe(PROJECTS.LIST_CAS.slug) }), querystring: z.object({ status: z.enum([CaStatus.ACTIVE, CaStatus.PENDING_CERTIFICATE]).optional().describe(PROJECTS.LIST_CAS.status), @@ -425,7 +365,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiCertificates], params: z.object({ - slug: slugSchema({ min: 5, max: 36 }).describe(PROJECTS.LIST_CERTIFICATES.slug) + slug: slugSchema({ min: 5, max: 64 }).describe(PROJECTS.LIST_CERTIFICATES.slug) }), querystring: z.object({ friendlyName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.friendlyName), diff --git a/backend/src/server/routes/v2/index.ts b/backend/src/server/routes/v2/index.ts index 93c422d15..aade29bb7 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -1,13 +1,15 @@ import { registerCaRouter } from "./certificate-authority-router"; -import { registerGroupProjectRouter } from "./group-project-router"; +import { registerDeprecatedGroupProjectRouter } from "./deprecated-group-project-router"; +import { registerDeprecatedIdentityProjectRouter } from "./deprecated-identity-project-router"; +import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-membership-router"; +import { registerDeprecatedProjectRouter } from "./deprecated-project-router"; import { registerIdentityOrgRouter } from "./identity-org-router"; -import { registerIdentityProjectRouter } from "./identity-project-router"; import { registerMfaRouter } from "./mfa-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; import { registerPkiTemplatesRouter } from "./pki-templates-router"; -import { registerProjectMembershipRouter } from "./project-membership-router"; -import { registerProjectRouter } from "./project-router"; +import { registerSecretFolderRouter } from "./secret-folder-router"; +import { registerSecretImportRouter } from "./secret-import-router"; import { registerServiceTokenRouter } from "./service-token-router"; import { registerUserRouter } from "./user-router"; @@ -32,12 +34,17 @@ export const registerV2Routes = async (server: FastifyZodProvider) => { }, { prefix: "/organizations" } ); + + await server.register(registerSecretFolderRouter, { prefix: "/folders" }); + await server.register(registerSecretImportRouter, { prefix: "/secret-imports" }); + + // moved to v1/projects await server.register( async (projectServer) => { - await projectServer.register(registerProjectRouter); - await projectServer.register(registerIdentityProjectRouter); - await projectServer.register(registerGroupProjectRouter); - await projectServer.register(registerProjectMembershipRouter); + await projectServer.register(registerDeprecatedProjectRouter); + await projectServer.register(registerDeprecatedIdentityProjectRouter); + await projectServer.register(registerDeprecatedGroupProjectRouter); + await projectServer.register(registerDeprecatedProjectMembershipRouter); }, { prefix: "/workspace" } ); diff --git a/backend/src/server/routes/v2/mfa-router.ts b/backend/src/server/routes/v2/mfa-router.ts index d8a57d29a..e8c2cea69 100644 --- a/backend/src/server/routes/v2/mfa-router.ts +++ b/backend/src/server/routes/v2/mfa-router.ts @@ -1,5 +1,7 @@ +import { FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; +import { TUsers } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -7,11 +9,54 @@ import { mfaRateLimit } from "@app/server/config/rateLimiter"; import { addAuthOriginDomainCookie } from "@app/server/lib/cookie"; import { AuthModeMfaJwtTokenPayload, AuthTokenType, MfaMethod } from "@app/services/auth/auth-type"; +const handleMfaVerification = async ( + req: FastifyRequest & { mfa: { userId: string; orgId?: string; user: TUsers } }, + res: FastifyReply, + server: FastifyZodProvider, + mfaToken: string, + mfaMethod: MfaMethod, + isRecoveryCode?: boolean +) => { + const userAgent = req.headers["user-agent"]; + const mfaJwtToken = req.headers.authorization?.replace("Bearer ", ""); + if (!userAgent) throw new Error("user agent header is required"); + if (!mfaJwtToken) throw new Error("authorization header is required"); + const appCfg = getConfig(); + + const { user, token } = await server.services.login.verifyMfaToken({ + userAgent, + mfaJwtToken, + ip: req.realIp, + userId: req.mfa.userId, + orgId: req.mfa.orgId, + mfaToken, + mfaMethod, + isRecoveryCode + }); + + void res.setCookie("jid", token.refresh, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED + }); + + addAuthOriginDomainCookie(res); + + return { + ...user, + token: token.access, + protectedKey: user.protectedKey || null, + protectedKeyIV: user.protectedKeyIV || null, + protectedKeyTag: user.protectedKeyTag || null + }; +}; + export const registerMfaRouter = async (server: FastifyZodProvider) => { const cfg = getConfig(); server.decorateRequest("mfa", null); - server.addHook("preParsing", async (req, res) => { + server.addHook("preValidation", async (req, res) => { const authorizationHeader = req.headers.authorization; if (!authorizationHeader || !authorizationHeader.startsWith("Bearer ")) { @@ -109,38 +154,36 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req, res) => { - const userAgent = req.headers["user-agent"]; - const mfaJwtToken = req.headers.authorization?.replace("Bearer ", ""); - if (!userAgent) throw new Error("user agent header is required"); - if (!mfaJwtToken) throw new Error("authorization header is required"); - const appCfg = getConfig(); + return handleMfaVerification(req, res, server, req.body.mfaToken, req.body.mfaMethod); + } + }); - const { user, token } = await server.services.login.verifyMfaToken({ - userAgent, - mfaJwtToken, - ip: req.realIp, - userId: req.mfa.userId, - orgId: req.mfa.orgId, - mfaToken: req.body.mfaToken, - mfaMethod: req.body.mfaMethod - }); - - void res.setCookie("jid", token.refresh, { - httpOnly: true, - path: "/", - sameSite: "strict", - secure: appCfg.HTTPS_ENABLED - }); - - addAuthOriginDomainCookie(res); - - return { - ...user, - token: token.access, - protectedKey: user.protectedKey || null, - protectedKeyIV: user.protectedKeyIV || null, - protectedKeyTag: user.protectedKeyTag || null - }; + server.route({ + url: "/mfa/verify/recovery-code", + method: "POST", + config: { + rateLimit: mfaRateLimit + }, + schema: { + body: z.object({ + recoveryCode: z.string().trim().length(8, "Recovery code must be 8 characters") + }), + response: { + 200: z.object({ + encryptionVersion: z.number().default(1).nullable().optional(), + protectedKey: z.string().nullish(), + protectedKeyIV: z.string().nullish(), + protectedKeyTag: z.string().nullish(), + publicKey: z.string().nullish(), + encryptedPrivateKey: z.string().nullish(), + iv: z.string().nullish(), + tag: z.string().nullish(), + token: z.string() + }) + } + }, + handler: async (req, res) => { + return handleMfaVerification(req, res, server, req.body.recoveryCode, MfaMethod.TOTP, true); } }); }; diff --git a/backend/src/server/routes/v1/secret-folder-router.ts b/backend/src/server/routes/v2/secret-folder-router.ts similarity index 80% rename from backend/src/server/routes/v1/secret-folder-router.ts rename to backend/src/server/routes/v2/secret-folder-router.ts index 871259147..0bf062452 100644 --- a/backend/src/server/routes/v1/secret-folder-router.ts +++ b/backend/src/server/routes/v2/secret-folder-router.ts @@ -28,7 +28,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => } ], body: z.object({ - workspaceId: z.string().trim().describe(FOLDERS.CREATE.workspaceId), + projectId: z.string().trim().describe(FOLDERS.CREATE.projectId), environment: z.string().trim().describe(FOLDERS.CREATE.environment), name: z .string() @@ -43,17 +43,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .default("/") .transform(prefixWithSlash) // Transformations get skipped if path is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.CREATE.path) - .optional(), - // backward compatibility with cli - directory: z - .string() - .trim() - .default("/") - .transform(prefixWithSlash) // Transformations get skipped if directory is undefined - .transform(removeTrailingSlash) - .describe(FOLDERS.CREATE.directory) - .optional(), + .describe(FOLDERS.CREATE.path), description: z.string().optional().nullable().describe(FOLDERS.CREATE.description) }), response: { @@ -66,27 +56,24 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const path = req.body.path || req.body.directory || "/"; const folder = await server.services.folder.createFolder({ actorId: req.permission.id, actor: req.permission.type, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, ...req.body, - projectId: req.body.workspaceId, - path, description: req.body.description }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.body.workspaceId, + projectId: req.body.projectId, event: { type: EventType.CREATE_FOLDER, metadata: { environment: req.body.environment, folderId: folder.id, folderName: folder.name, - folderPath: path, + folderPath: req.body.path, ...(req.body.description ? { description: req.body.description } : {}) } } @@ -115,7 +102,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => folderId: z.string().describe(FOLDERS.UPDATE.folderId) }), body: z.object({ - workspaceId: z.string().trim().describe(FOLDERS.UPDATE.workspaceId), + projectId: z.string().trim().describe(FOLDERS.UPDATE.projectId), environment: z.string().trim().describe(FOLDERS.UPDATE.environment), name: z .string() @@ -130,17 +117,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .default("/") .transform(prefixWithSlash) // Transformations get skipped if path is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.UPDATE.path) - .optional(), - // backward compatibility with cli - directory: z - .string() - .trim() - .default("/") - .transform(prefixWithSlash) // Transformations get skipped if directory is undefined - .transform(removeTrailingSlash) - .describe(FOLDERS.UPDATE.directory) - .optional(), + .describe(FOLDERS.UPDATE.path), description: z.string().optional().nullable().describe(FOLDERS.UPDATE.description) }), response: { @@ -153,26 +130,23 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const path = req.body.path || req.body.directory || "/"; const { folder, old } = await server.services.folder.updateFolder({ actorId: req.permission.id, actor: req.permission.type, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, ...req.body, - projectId: req.body.workspaceId, - id: req.params.folderId, - path + id: req.params.folderId }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.body.workspaceId, + projectId: req.body.projectId, event: { type: EventType.UPDATE_FOLDER, metadata: { environment: req.body.environment, folderId: folder.id, - folderPath: path, + folderPath: req.body.path, newFolderName: folder.name, oldFolderName: old.name } @@ -198,7 +172,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => } ], body: z.object({ - projectSlug: z.string().trim().describe(FOLDERS.UPDATE.projectSlug), + projectId: z.string().trim().describe(FOLDERS.UPDATE.projectId), folders: z .object({ id: z.string().describe(FOLDERS.UPDATE.folderId), @@ -281,7 +255,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => folderIdOrName: z.string().describe(FOLDERS.DELETE.folderIdOrName) }), body: z.object({ - workspaceId: z.string().trim().describe(FOLDERS.DELETE.workspaceId), + projectId: z.string().trim().describe(FOLDERS.DELETE.projectId), environment: z.string().trim().describe(FOLDERS.DELETE.environment), path: z .string() @@ -290,16 +264,6 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .transform(prefixWithSlash) // Transformations get skipped if path is undefined .transform(removeTrailingSlash) .describe(FOLDERS.DELETE.path) - .optional(), - // keep this here as cli need directory - directory: z - .string() - .trim() - .default("/") - .transform(prefixWithSlash) // Transformations get skipped if directory is undefined - .transform(removeTrailingSlash) - .describe(FOLDERS.DELETE.directory) - .optional() }), response: { 200: z.object({ @@ -309,26 +273,23 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const path = req.body.path || req.body.directory || "/"; const folder = await server.services.folder.deleteFolder({ actorId: req.permission.id, actor: req.permission.type, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, ...req.body, - projectId: req.body.workspaceId, - idOrName: req.params.folderIdOrName, - path + idOrName: req.params.folderIdOrName }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.body.workspaceId, + projectId: req.body.projectId, event: { type: EventType.DELETE_FOLDER, metadata: { environment: req.body.environment, folderId: folder.id, - folderPath: path, + folderPath: req.body.path, folderName: folder.name } } @@ -353,7 +314,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => } ], querystring: z.object({ - workspaceId: z.string().trim().describe(FOLDERS.LIST.workspaceId), + projectId: z.string().trim().describe(FOLDERS.LIST.projectId), environment: z.string().trim().describe(FOLDERS.LIST.environment), lastSecretModified: z.string().datetime().trim().optional().describe(FOLDERS.LIST.lastSecretModified), path: z @@ -361,16 +322,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .trim() .transform(prefixWithSlash) // Transformations get skipped if path is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.LIST.path) - .optional(), - // backward compatibility with cli - directory: z - .string() - .trim() - .transform(prefixWithSlash) // Transformations get skipped if directory is undefined - .transform(removeTrailingSlash) - .describe(FOLDERS.LIST.directory) - .optional(), + .describe(FOLDERS.LIST.path), recursive: booleanSchema.default(false).describe(FOLDERS.LIST.recursive) }), response: { @@ -383,15 +335,12 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const path = req.query.path || req.query.directory || "/"; const folders = await server.services.folder.getFolders({ actorId: req.permission.id, actor: req.permission.type, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - ...req.query, - projectId: req.query.workspaceId, - path + ...req.query }); return { folders }; } diff --git a/backend/src/server/routes/v1/secret-import-router.ts b/backend/src/server/routes/v2/secret-import-router.ts similarity index 85% rename from backend/src/server/routes/v1/secret-import-router.ts rename to backend/src/server/routes/v2/secret-import-router.ts index fca11f8a0..d9802c4b3 100644 --- a/backend/src/server/routes/v1/secret-import-router.ts +++ b/backend/src/server/routes/v2/secret-import-router.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { SecretImportsSchema, SecretsSchema } from "@app/db/schemas"; +import { SecretImportsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, SECRET_IMPORTS } from "@app/lib/api-docs"; import { removeTrailingSlash } from "@app/lib/fn"; @@ -27,7 +27,7 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => } ], body: z.object({ - workspaceId: z.string().trim().describe(SECRET_IMPORTS.CREATE.workspaceId), + projectId: z.string().trim().describe(SECRET_IMPORTS.CREATE.projectId), environment: z.string().trim().describe(SECRET_IMPORTS.CREATE.environment), path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.CREATE.path), import: z.object({ @@ -55,13 +55,13 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, ...req.body, - projectId: req.body.workspaceId, + projectId: req.body.projectId, data: req.body.import }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.body.workspaceId, + projectId: req.body.projectId, event: { type: EventType.CREATE_SECRET_IMPORT, metadata: { @@ -97,7 +97,7 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => secretImportId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.secretImportId) }), body: z.object({ - workspaceId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.workspaceId), + projectId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.projectId), environment: z.string().trim().describe(SECRET_IMPORTS.UPDATE.environment), path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.UPDATE.path), import: z.object({ @@ -131,13 +131,13 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => actorOrgId: req.permission.orgId, id: req.params.secretImportId, ...req.body, - projectId: req.body.workspaceId, + projectId: req.body.projectId, data: req.body.import }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.body.workspaceId, + projectId: req.body.projectId, event: { type: EventType.UPDATE_SECRET_IMPORT, metadata: { @@ -173,7 +173,7 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => secretImportId: z.string().trim().describe(SECRET_IMPORTS.DELETE.secretImportId) }), body: z.object({ - workspaceId: z.string().trim().describe(SECRET_IMPORTS.DELETE.workspaceId), + projectId: z.string().trim().describe(SECRET_IMPORTS.DELETE.projectId), environment: z.string().trim().describe(SECRET_IMPORTS.DELETE.environment), path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.DELETE.path) }), @@ -197,12 +197,12 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => actorOrgId: req.permission.orgId, id: req.params.secretImportId, ...req.body, - projectId: req.body.workspaceId + projectId: req.body.projectId }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.body.workspaceId, + projectId: req.body.projectId, event: { type: EventType.DELETE_SECRET_IMPORT, metadata: { @@ -236,7 +236,7 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => secretImportId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.secretImportId) }), body: z.object({ - workspaceId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.workspaceId), + projectId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.projectId), environment: z.string().trim().describe(SECRET_IMPORTS.UPDATE.environment), path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.UPDATE.path) }), @@ -255,7 +255,7 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => actorOrgId: req.permission.orgId, id: req.params.secretImportId, ...req.body, - projectId: req.body.workspaceId + projectId: req.body.projectId }); return { message }; @@ -278,7 +278,7 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => } ], querystring: z.object({ - workspaceId: z.string().trim().describe(SECRET_IMPORTS.LIST.workspaceId), + projectId: z.string().trim().describe(SECRET_IMPORTS.LIST.projectId), environment: z.string().trim().describe(SECRET_IMPORTS.LIST.environment), path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.LIST.path) }), @@ -301,12 +301,12 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, ...req.query, - projectId: req.query.workspaceId + projectId: req.query.projectId }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.query.workspaceId, + projectId: req.query.projectId, event: { type: EventType.GET_SECRET_IMPORTS, metadata: { @@ -386,55 +386,11 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => config: { rateLimit: secretsLimit }, - schema: { - querystring: z.object({ - workspaceId: z.string().trim(), - environment: z.string().trim(), - path: z.string().trim().default("/").transform(removeTrailingSlash) - }), - response: { - 200: z.object({ - secrets: z - .object({ - secretPath: z.string(), - environment: z.string(), - environmentInfo: z.object({ - id: z.string(), - name: z.string(), - slug: z.string() - }), - folderId: z.string().optional(), - secrets: SecretsSchema.omit({ secretBlindIndex: true }).array() - }) - .array() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const importedSecrets = await server.services.secretImport.getSecretsFromImports({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - ...req.query, - projectId: req.query.workspaceId - }); - return { secrets: importedSecrets }; - } - }); - - server.route({ - url: "/secrets/raw", - method: "GET", - config: { - rateLimit: secretsLimit - }, schema: { hide: false, tags: [ApiDocsTags.SecretImports], querystring: z.object({ - workspaceId: z.string().trim(), + projectId: z.string().trim(), environment: z.string().trim(), path: z.string().trim().default("/").transform(removeTrailingSlash) }), @@ -463,8 +419,7 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => actor: req.permission.type, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - ...req.query, - projectId: req.query.workspaceId + ...req.query }); return { secrets: importedSecrets }; } diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/deprecated-secret-router.ts similarity index 95% rename from backend/src/server/routes/v3/secret-router.ts rename to backend/src/server/routes/v3/deprecated-secret-router.ts index ce0d4f188..2a0d425d6 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/deprecated-secret-router.ts @@ -39,7 +39,7 @@ const SecretReferenceNodeTree: z.ZodType = SecretReference children: z.lazy(() => SecretReferenceNodeTree.array()) }); -export const registerSecretRouter = async (server: FastifyZodProvider) => { +export const registerDeprecatedSecretRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", url: "/tags/:secretName", @@ -230,7 +230,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }) .describe(RAW_SECRETS.LIST.metadataFilter), - workspaceId: z.string().trim().optional().describe(RAW_SECRETS.LIST.workspaceId), + workspaceId: z.string().trim().optional().describe(RAW_SECRETS.LIST.projectId), workspaceSlug: z.string().trim().optional().describe(RAW_SECRETS.LIST.workspaceSlug), environment: z.string().trim().optional().describe(RAW_SECRETS.LIST.environment), secretPath: z.string().trim().default("/").transform(removeTrailingSlash).describe(RAW_SECRETS.LIST.secretPath), @@ -298,7 +298,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { workspaceId = projectId; } - if (!workspaceId || !environment) throw new BadRequestError({ message: "Missing workspace id or environment" }); + if (!workspaceId || !environment) throw new BadRequestError({ message: "Missing project id or environment" }); const { secrets, imports } = await server.services.secret.getSecretsRaw({ actorId: req.permission.id, @@ -336,7 +336,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { organizationId: req.permission.orgId, properties: { numberOfSecrets: secrets.length, - workspaceId, + projectId: workspaceId, environment, secretPath: req.query.secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -405,7 +405,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretName: z.string().trim().describe(RAW_SECRETS.GET.secretName) }), querystring: z.object({ - workspaceId: z.string().trim().optional().describe(RAW_SECRETS.GET.workspaceId), + workspaceId: z.string().trim().optional().describe(RAW_SECRETS.GET.projectId), workspaceSlug: z.string().trim().optional().describe(RAW_SECRETS.GET.workspaceSlug), environment: z.string().trim().optional().describe(RAW_SECRETS.GET.environment), secretPath: z.string().trim().default("/").transform(removeTrailingSlash).describe(RAW_SECRETS.GET.secretPath), @@ -495,7 +495,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { distinctId: getTelemetryDistinctId(req), properties: { numberOfSecrets: 1, - workspaceId: secret.workspace, + projectId: secret.workspace, environment, secretPath: req.query.secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -526,7 +526,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretName: SecretNameSchema.describe(RAW_SECRETS.CREATE.secretName) }), body: z.object({ - workspaceId: z.string().trim().optional().describe(RAW_SECRETS.CREATE.workspaceId), + workspaceId: z.string().trim().optional().describe(RAW_SECRETS.CREATE.projectId), projectSlug: z.string().trim().optional().describe(RAW_SECRETS.CREATE.projectSlug), environment: z.string().trim().describe(RAW_SECRETS.CREATE.environment), secretPath: z @@ -627,7 +627,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretId: secret.id, secretKey: req.params.secretName, secretVersion: secret.version, - secretMetadata: req.body.secretMetadata + secretMetadata: req.body.secretMetadata, + secretTags: secret.tags?.map((tag) => tag.name) } } }); @@ -638,7 +639,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { organizationId: req.permission.orgId, properties: { numberOfSecrets: 1, - workspaceId: projectId, + projectId, environment: req.body.environment, secretPath: req.body.secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -669,7 +670,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretName: BaseSecretNameSchema.describe(RAW_SECRETS.UPDATE.secretName) }), body: z.object({ - workspaceId: z.string().trim().optional().describe(RAW_SECRETS.UPDATE.workspaceId), + workspaceId: z.string().trim().optional().describe(RAW_SECRETS.UPDATE.projectId), projectSlug: z.string().trim().optional().describe(RAW_SECRETS.UPDATE.projectSlug), environment: z.string().trim().describe(RAW_SECRETS.UPDATE.environment), secretValue: z @@ -780,7 +781,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretId: secret.id, secretKey: req.params.secretName, secretVersion: secret.version, - secretMetadata: req.body.secretMetadata + secretMetadata: req.body.secretMetadata, + secretTags: secret.tags?.map((tag) => tag.name) } } }); @@ -791,7 +793,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { organizationId: req.permission.orgId, properties: { numberOfSecrets: 1, - workspaceId: projectId, + projectId, environment: req.body.environment, secretPath: req.body.secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -821,7 +823,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretName: z.string().min(1).describe(RAW_SECRETS.DELETE.secretName) }), body: z.object({ - workspaceId: z.string().trim().optional().describe(RAW_SECRETS.DELETE.workspaceId), + workspaceId: z.string().trim().optional().describe(RAW_SECRETS.DELETE.projectId), projectSlug: z.string().trim().optional().describe(RAW_SECRETS.DELETE.projectSlug), environment: z.string().trim().describe(RAW_SECRETS.DELETE.environment), secretPath: z @@ -909,7 +911,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { organizationId: req.permission.orgId, properties: { numberOfSecrets: 1, - workspaceId: projectId, + projectId, environment: req.body.environment, secretPath: req.body.secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -1017,7 +1019,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { organizationId: req.permission.orgId, properties: { numberOfSecrets: secrets.length, - workspaceId: req.query.workspaceId, + projectId: req.query.workspaceId, environment: req.query.environment, secretPath: req.query.secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -1097,7 +1099,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { organizationId: req.permission.orgId, properties: { numberOfSecrets: 1, - workspaceId: req.query.workspaceId, + projectId: req.query.workspaceId, environment: req.query.environment, secretPath: req.query.secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -1272,7 +1274,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { organizationId: req.permission.orgId, properties: { numberOfSecrets: 1, - workspaceId: req.body.workspaceId, + projectId: req.body.workspaceId, environment: req.body.environment, secretPath: req.body.secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -1466,7 +1468,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { organizationId: req.permission.orgId, properties: { numberOfSecrets: 1, - workspaceId: req.body.workspaceId, + projectId: req.body.workspaceId, environment: req.body.environment, secretPath: req.body.secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -1594,7 +1596,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { organizationId: req.permission.orgId, properties: { numberOfSecrets: 1, - workspaceId: req.body.workspaceId, + projectId: req.body.workspaceId, environment: req.body.environment, secretPath: req.body.secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -1781,7 +1783,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { organizationId: req.permission.orgId, properties: { numberOfSecrets: secrets.length, - workspaceId: req.body.workspaceId, + projectId: req.body.workspaceId, environment: req.body.environment, secretPath: req.body.secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -1914,7 +1916,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { organizationId: req.permission.orgId, properties: { numberOfSecrets: secrets.length, - workspaceId: req.body.workspaceId, + projectId: req.body.workspaceId, environment: req.body.environment, secretPath: req.body.secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -2039,7 +2041,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { organizationId: req.permission.orgId, properties: { numberOfSecrets: secrets.length, - workspaceId: req.body.workspaceId, + projectId: req.body.workspaceId, environment: req.body.environment, secretPath: req.body.secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -2067,7 +2069,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { ], body: z.object({ projectSlug: z.string().trim().optional().describe(RAW_SECRETS.UPDATE.projectSlug), - workspaceId: z.string().trim().optional().describe(RAW_SECRETS.UPDATE.workspaceId), + workspaceId: z.string().trim().optional().describe(RAW_SECRETS.UPDATE.projectId), environment: z.string().trim().describe(RAW_SECRETS.CREATE.environment), secretPath: z .string() @@ -2154,7 +2156,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretId: secret.id, secretKey: secret.secretKey, secretVersion: secret.version, - secretMetadata: secretMetadataMap.get(secret.secretKey) + secretMetadata: secretMetadataMap.get(secret.secretKey), + secretTags: secret.tags?.map((tag) => tag.name) })) } } @@ -2166,7 +2169,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { organizationId: req.permission.orgId, properties: { numberOfSecrets: secrets.length, - workspaceId: secrets[0].workspace, + projectId: secrets[0].workspace, environment: req.body.environment, secretPath: req.body.secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -2194,7 +2197,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { ], body: z.object({ projectSlug: z.string().trim().optional().describe(RAW_SECRETS.DELETE.projectSlug), - workspaceId: z.string().trim().optional().describe(RAW_SECRETS.DELETE.workspaceId), + workspaceId: z.string().trim().optional().describe(RAW_SECRETS.DELETE.projectId), environment: z.string().trim().describe(RAW_SECRETS.UPDATE.environment), secretPath: z .string() @@ -2288,7 +2291,6 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { return { approval: secretOperation.approval }; } const { secrets } = secretOperation; - const secretMetadataMap = new Map( inputSecrets.map(({ secretKey, secretMetadata }) => [secretKey, secretMetadata]) ); @@ -2308,7 +2310,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretPath: secret.secretPath, secretKey: secret.secretKey, secretVersion: secret.version, - secretMetadata: secretMetadataMap.get(secret.secretKey) + secretMetadata: secretMetadataMap.get(secret.secretKey), + secretTags: secret.tags?.map((tag) => tag.name) })) } } @@ -2328,7 +2331,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretPath: secret.secretPath, secretKey: secret.secretKey, secretVersion: secret.version, - secretMetadata: secretMetadataMap.get(secret.secretKey) + secretMetadata: secretMetadataMap.get(secret.secretKey), + secretTags: secret.tags?.map((tag) => tag.name) })) } } @@ -2341,7 +2345,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { organizationId: req.permission.orgId, properties: { numberOfSecrets: secrets.length, - workspaceId: secrets[0].workspace, + projectId: secrets[0].workspace, environment: req.body.environment, secretPath: req.body.secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -2369,7 +2373,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { ], body: z.object({ projectSlug: z.string().trim().optional().describe(RAW_SECRETS.DELETE.projectSlug), - workspaceId: z.string().trim().optional().describe(RAW_SECRETS.DELETE.workspaceId), + workspaceId: z.string().trim().optional().describe(RAW_SECRETS.DELETE.projectId), environment: z.string().trim().describe(RAW_SECRETS.DELETE.environment), secretPath: z .string() @@ -2459,7 +2463,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { organizationId: req.permission.orgId, properties: { numberOfSecrets: secrets.length, - workspaceId: secrets[0].workspace, + projectId: secrets[0].workspace, environment: req.body.environment, secretPath: req.body.secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -2469,95 +2473,4 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { return { secrets }; } }); - - server.route({ - method: "GET", - url: "/raw/:secretName/secret-reference-tree", - config: { - rateLimit: secretsLimit - }, - schema: { - hide: false, - tags: [ApiDocsTags.Secrets], - description: "Get secret reference tree", - security: [ - { - bearerAuth: [] - } - ], - params: z.object({ - secretName: z.string().trim().describe(RAW_SECRETS.GET_REFERENCE_TREE.secretName) - }), - querystring: z.object({ - workspaceId: z.string().trim().describe(RAW_SECRETS.GET_REFERENCE_TREE.workspaceId), - environment: z.string().trim().describe(RAW_SECRETS.GET_REFERENCE_TREE.environment), - secretPath: z - .string() - .trim() - .default("/") - .transform(removeTrailingSlash) - .describe(RAW_SECRETS.GET_REFERENCE_TREE.secretPath) - }), - response: { - 200: z.object({ - tree: SecretReferenceNodeTree, - value: z.string().optional() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req) => { - const { secretName } = req.params; - const { secretPath, environment, workspaceId } = req.query; - const { tree, value } = await server.services.secret.getSecretReferenceTree({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: workspaceId, - secretName, - secretPath, - environment - }); - - return { tree, value }; - } - }); - - server.route({ - method: "POST", - url: "/backfill-secret-references", - config: { - rateLimit: secretsLimit - }, - schema: { - description: "Backfill secret references", - security: [ - { - bearerAuth: [] - } - ], - body: z.object({ - projectId: z.string().trim().min(1) - }), - response: { - 200: z.object({ - message: z.string() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req) => { - const { projectId } = req.body; - const message = await server.services.secret.backfillSecretReferences({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId - }); - - return message; - } - }); }; diff --git a/backend/src/server/routes/v3/index.ts b/backend/src/server/routes/v3/index.ts index 1c31741a1..d7fa94d6b 100644 --- a/backend/src/server/routes/v3/index.ts +++ b/backend/src/server/routes/v3/index.ts @@ -1,7 +1,6 @@ +import { registerDeprecatedSecretRouter } from "./deprecated-secret-router"; import { registerExternalMigrationRouter } from "./external-migration-router"; import { registerLoginRouter } from "./login-router"; -import { registerSecretBlindIndexRouter } from "./secret-blind-index-router"; -import { registerSecretRouter } from "./secret-router"; import { registerSignupRouter } from "./signup-router"; import { registerUserRouter } from "./user-router"; @@ -9,7 +8,6 @@ export const registerV3Routes = async (server: FastifyZodProvider) => { await server.register(registerSignupRouter, { prefix: "/signup" }); await server.register(registerLoginRouter, { prefix: "/auth" }); await server.register(registerUserRouter, { prefix: "/users" }); - await server.register(registerSecretRouter, { prefix: "/secrets" }); - await server.register(registerSecretBlindIndexRouter, { prefix: "/workspaces" }); + await server.register(registerDeprecatedSecretRouter, { prefix: "/secrets" }); await server.register(registerExternalMigrationRouter, { prefix: "/external-migration" }); }; diff --git a/backend/src/server/routes/v3/secret-blind-index-router.ts b/backend/src/server/routes/v3/secret-blind-index-router.ts deleted file mode 100644 index cfb27a58e..000000000 --- a/backend/src/server/routes/v3/secret-blind-index-router.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { z } from "zod"; - -import { SecretsSchema } from "@app/db/schemas"; -import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; -import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { AuthMode } from "@app/services/auth/auth-type"; - -export const registerSecretBlindIndexRouter = async (server: FastifyZodProvider) => { - server.route({ - method: "GET", - url: "/:projectId/secrets/blind-index-status", - config: { - rateLimit: readLimit - }, - schema: { - params: z.object({ - projectId: z.string().trim() - }), - response: { - 200: z.boolean() - } - }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req) => { - const count = await server.services.secretBlindIndex.getSecretBlindIndexStatus({ - projectId: req.params.projectId, - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId - }); - return count === 0; - } - }); - - server.route({ - method: "GET", - url: "/:projectId/secrets", - config: { - rateLimit: readLimit - }, - schema: { - params: z.object({ - projectId: z.string().trim() - }), - response: { - 200: z.object({ - secrets: SecretsSchema.omit({ secretBlindIndex: true }) - .merge( - z.object({ - environment: z.string(), - workspace: z.string() - }) - ) - .array() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req) => { - const secrets = await server.services.secretBlindIndex.getProjectSecrets({ - projectId: req.params.projectId, - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId - }); - return { secrets }; - } - }); - - server.route({ - method: "POST", - url: "/:projectId/secrets/names", - config: { - rateLimit: writeLimit - }, - schema: { - params: z.object({ - projectId: z.string().trim() - }), - body: z.object({ - secretsToUpdate: z - .object({ - secretName: z.string().trim(), - secretId: z.string().trim() - }) - .array() - }), - response: { - 200: z.object({ - message: z.string() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req) => { - await server.services.secretBlindIndex.updateProjectSecretName({ - projectId: req.params.projectId, - secretsToUpdate: req.body.secretsToUpdate, - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId - }); - return { message: "Successfully named workspace secrets" }; - } - }); -}; diff --git a/backend/src/server/routes/v4/index.ts b/backend/src/server/routes/v4/index.ts new file mode 100644 index 000000000..db2e69222 --- /dev/null +++ b/backend/src/server/routes/v4/index.ts @@ -0,0 +1,5 @@ +import { registerSecretRouter } from "./secret-router"; + +export const registerV4Routes = async (server: FastifyZodProvider) => { + await server.register(registerSecretRouter, { prefix: "/secrets" }); +}; diff --git a/backend/src/server/routes/v4/secret-router.ts b/backend/src/server/routes/v4/secret-router.ts new file mode 100644 index 000000000..e68bb87a4 --- /dev/null +++ b/backend/src/server/routes/v4/secret-router.ts @@ -0,0 +1,1335 @@ +import picomatch from "picomatch"; +import { z } from "zod"; + +import { SecretApprovalRequestsSchema, SecretType, ServiceTokenScopes } from "@app/db/schemas"; +import { EventType, SecretApprovalEvent, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, RAW_SECRETS } from "@app/lib/api-docs"; +import { BadRequestError } from "@app/lib/errors"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { secretsLimit } from "@app/server/config/rateLimiter"; +import { BaseSecretNameSchema, SecretNameSchema } from "@app/server/lib/schemas"; +import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; +import { getUserAgentType } from "@app/server/plugins/audit-log"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { ActorType, AuthMode } from "@app/services/auth/auth-type"; +import { ResourceMetadataSchema } from "@app/services/resource-metadata/resource-metadata-schema"; +import { SecretProtectionType } from "@app/services/secret/secret-types"; +import { SecretUpdateMode } from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; +import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; + +import { SanitizedTagSchema, secretRawSchema } from "../sanitizedSchemas"; + +const SecretReferenceNode = z.object({ + key: z.string(), + value: z.string().optional(), + environment: z.string(), + secretPath: z.string() +}); + +const convertStringBoolean = (defaultValue: boolean = false) => { + return z + .enum(["true", "false"]) + .default(defaultValue ? "true" : "false") + .transform((value) => value === "true"); +}; + +type TSecretReferenceNode = z.infer & { children: TSecretReferenceNode[] }; + +const SecretReferenceNodeTree: z.ZodType = SecretReferenceNode.extend({ + children: z.lazy(() => SecretReferenceNodeTree.array()) +}); + +export const registerSecretRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Secrets], + description: "List secrets", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + metadataFilter: z + .string() + .optional() + .transform((val) => { + if (!val) return undefined; + + const result: { key?: string; value?: string }[] = []; + const pairs = val.split("|"); + + for (const pair of pairs) { + const keyValuePair: { key?: string; value?: string } = {}; + const parts = pair.split(/[,=]/); + + for (let i = 0; i < parts.length; i += 2) { + const identifier = parts[i].trim().toLowerCase(); + const value = parts[i + 1]?.trim(); + + if (identifier === "key" && value) { + keyValuePair.key = value; + } else if (identifier === "value" && value) { + keyValuePair.value = value; + } + } + + if (keyValuePair.key && keyValuePair.value) { + result.push(keyValuePair); + } + } + + return result.length ? result : undefined; + }) + .superRefine((metadata, ctx) => { + if (metadata && !Array.isArray(metadata)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Invalid secretMetadata format. Correct format is key=value1,value=value2|key=value3,value=value4." + }); + } + + if (metadata) { + if (metadata.length > 10) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "You can only filter by up to 10 metadata fields" + }); + } + + for (const item of metadata) { + if (!item.key && !item.value) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Invalid secretMetadata format, key or value must be provided. Correct format is key=value1,value=value2|key=value3,value=value4." + }); + } + } + } + }) + .describe(RAW_SECRETS.LIST.metadataFilter), + projectId: z.string().trim().optional().describe(RAW_SECRETS.LIST.projectId), + environment: z.string().trim().optional().describe(RAW_SECRETS.LIST.environment), + secretPath: z.string().trim().default("/").transform(removeTrailingSlash).describe(RAW_SECRETS.LIST.secretPath), + viewSecretValue: convertStringBoolean(true).describe(RAW_SECRETS.LIST.viewSecretValue), + expandSecretReferences: convertStringBoolean().describe(RAW_SECRETS.LIST.expand), + recursive: convertStringBoolean().describe(RAW_SECRETS.LIST.recursive), + include_imports: convertStringBoolean().describe(RAW_SECRETS.LIST.includeImports), + tagSlugs: z + .string() + .describe(RAW_SECRETS.LIST.tagSlugs) + .optional() + // split by comma and trim the strings + .transform((el) => (el ? el.split(",").map((i) => i.trim()) : [])) + }), + response: { + 200: z.object({ + secrets: secretRawSchema + .extend({ + secretPath: z.string().optional(), + secretValueHidden: z.boolean(), + secretMetadata: ResourceMetadataSchema.optional(), + tags: SanitizedTagSchema.array().optional() + }) + .array(), + imports: z + .object({ + secretPath: z.string(), + environment: z.string(), + folderId: z.string().optional(), + secrets: secretRawSchema + .omit({ createdAt: true, updatedAt: true }) + .extend({ + secretValueHidden: z.boolean(), + secretMetadata: ResourceMetadataSchema.optional() + }) + .array() + }) + .array() + .optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + // just for delivery hero usecase + let { secretPath, environment, projectId } = req.query; + if (req.auth.actor === ActorType.SERVICE) { + const scope = ServiceTokenScopes.parse(req.auth.serviceToken.scopes); + const isSingleScope = scope.length === 1; + if (isSingleScope && !picomatch.scan(scope[0].secretPath).isGlob) { + secretPath = scope[0].secretPath; + environment = scope[0].environment; + projectId = req.auth.serviceToken.projectId; + } + } + + if (!projectId || !environment) throw new BadRequestError({ message: "Missing project id or environment" }); + + const { secrets, imports } = await server.services.secret.getSecretsRaw({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + environment, + expandSecretReferences: req.query.expandSecretReferences, + actorAuthMethod: req.permission.authMethod, + projectId, + viewSecretValue: req.query.viewSecretValue, + path: secretPath, + metadataFilter: req.query.metadataFilter, + includeImports: req.query.include_imports, + recursive: req.query.recursive, + tagSlugs: req.query.tagSlugs + }); + + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.GET_SECRETS, + metadata: { + environment, + secretPath: req.query.secretPath, + numberOfSecrets: secrets.length + } + } + }); + + if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretPulled, + distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, + properties: { + numberOfSecrets: secrets.length, + projectId, + environment, + secretPath: req.query.secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + } + + return { secrets, imports }; + } + }); + + server.route({ + method: "GET", + url: "/id/:secretId", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Secrets], + params: z.object({ + secretId: z.string() + }), + response: { + 200: z.object({ + secret: secretRawSchema.extend({ + secretPath: z.string(), + tags: SanitizedTagSchema.array().optional(), + secretMetadata: ResourceMetadataSchema.optional() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { secretId } = req.params; + const secret = await server.services.secret.getSecretByIdRaw({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + secretId + }); + + return { secret }; + } + }); + + server.route({ + method: "GET", + url: "/:secretName", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Secrets], + description: "Get a secret by name", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + secretName: z.string().trim().describe(RAW_SECRETS.GET.secretName) + }), + querystring: z.object({ + projectId: z.string().trim().describe(RAW_SECRETS.GET.projectId), + environment: z.string().trim().optional().describe(RAW_SECRETS.GET.environment), + secretPath: z.string().trim().default("/").transform(removeTrailingSlash).describe(RAW_SECRETS.GET.secretPath), + version: z.coerce.number().optional().describe(RAW_SECRETS.GET.version), + type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(RAW_SECRETS.GET.type), + viewSecretValue: convertStringBoolean(true).describe(RAW_SECRETS.GET.viewSecretValue), + expandSecretReferences: convertStringBoolean().describe(RAW_SECRETS.GET.expand), + include_imports: convertStringBoolean().describe(RAW_SECRETS.GET.includeImports) + }), + response: { + 200: z.object({ + secret: secretRawSchema.extend({ + secretValueHidden: z.boolean(), + secretPath: z.string(), + tags: SanitizedTagSchema.array().optional(), + secretMetadata: ResourceMetadataSchema.optional() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + let { secretPath, environment, projectId } = req.query; + if (req.auth.actor === ActorType.SERVICE) { + const scope = ServiceTokenScopes.parse(req.auth.serviceToken.scopes); + const isSingleScope = scope.length === 1; + if (isSingleScope && !picomatch.scan(scope[0].secretPath).isGlob) { + secretPath = scope[0].secretPath; + environment = scope[0].environment; + projectId = req.auth.serviceToken.projectId; + } + } + + if (!environment) throw new BadRequestError({ message: "Missing environment" }); + if (!projectId) { + throw new BadRequestError({ message: "You must provide workspaceId" }); + } + + const secret = await server.services.secret.getSecretByNameRaw({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + expandSecretReferences: req.query.expandSecretReferences, + environment, + projectId, + viewSecretValue: req.query.viewSecretValue, + path: secretPath, + secretName: req.params.secretName, + type: req.query.type, + includeImports: req.query.include_imports, + version: req.query.version + }); + + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.GET_SECRET, + metadata: { + environment, + secretPath: req.query.secretPath, + secretId: secret.id, + secretKey: req.params.secretName, + secretVersion: secret.version, + secretMetadata: secret.secretMetadata + } + } + }); + + if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretPulled, + organizationId: req.permission.orgId, + distinctId: getTelemetryDistinctId(req), + properties: { + numberOfSecrets: 1, + projectId, + environment, + secretPath: req.query.secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + } + return { secret }; + } + }); + + server.route({ + method: "POST", + url: "/:secretName", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Secrets], + description: "Create secret", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + secretName: SecretNameSchema.describe(RAW_SECRETS.CREATE.secretName) + }), + body: z.object({ + projectId: z.string().trim().describe(RAW_SECRETS.CREATE.projectId), + environment: z.string().trim().describe(RAW_SECRETS.CREATE.environment), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(RAW_SECRETS.CREATE.secretPath), + secretValue: z + .string() + .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())) + .describe(RAW_SECRETS.CREATE.secretValue), + secretComment: z.string().trim().optional().default("").describe(RAW_SECRETS.CREATE.secretComment), + secretMetadata: ResourceMetadataSchema.optional(), + tagIds: z.string().array().optional().describe(RAW_SECRETS.CREATE.tagIds), + skipMultilineEncoding: z.boolean().optional().describe(RAW_SECRETS.CREATE.skipMultilineEncoding), + type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(RAW_SECRETS.CREATE.type), + secretReminderRepeatDays: z + .number() + .optional() + .nullable() + .describe(RAW_SECRETS.CREATE.secretReminderRepeatDays), + secretReminderNote: z + .string() + .max(1024, "Secret reminder note cannot exceed 1024 characters") + .optional() + .nullable() + .describe(RAW_SECRETS.CREATE.secretReminderNote) + }), + response: { + 200: z.union([ + z.object({ + secret: secretRawSchema + }), + z.object({ approval: SecretApprovalRequestsSchema }).describe("When secret protection policy is enabled") + ]) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const secretOperation = await server.services.secret.createSecretRaw({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + environment: req.body.environment, + actorAuthMethod: req.permission.authMethod, + projectId: req.body.projectId, + secretPath: req.body.secretPath, + secretName: req.params.secretName, + type: req.body.type, + secretValue: req.body.secretValue, + skipMultilineEncoding: req.body.skipMultilineEncoding, + secretComment: req.body.secretComment, + secretMetadata: req.body.secretMetadata, + tagIds: req.body.tagIds, + secretReminderNote: req.body.secretReminderNote, + secretReminderRepeatDays: req.body.secretReminderRepeatDays + }); + if (secretOperation.type === SecretProtectionType.Approval) { + await server.services.auditLog.createAuditLog({ + projectId: req.body.projectId, + ...req.auditLogInfo, + event: { + type: EventType.SECRET_APPROVAL_REQUEST, + metadata: { + committedBy: secretOperation.approval.committerUserId, + secretApprovalRequestId: secretOperation.approval.id, + secretApprovalRequestSlug: secretOperation.approval.slug, + secretPath: req.body.secretPath, + environment: req.body.environment, + secretKey: req.params.secretName, + eventType: SecretApprovalEvent.Create + } + } + }); + + return { approval: secretOperation.approval }; + } + + const { secret } = secretOperation; + await server.services.auditLog.createAuditLog({ + projectId: req.body.projectId, + ...req.auditLogInfo, + event: { + type: EventType.CREATE_SECRET, + metadata: { + environment: req.body.environment, + secretPath: req.body.secretPath, + secretId: secret.id, + secretKey: req.params.secretName, + secretVersion: secret.version, + secretMetadata: req.body.secretMetadata, + secretTags: secret.tags?.map((tag) => tag.name) + } + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretCreated, + distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, + properties: { + numberOfSecrets: 1, + projectId: req.body.projectId, + environment: req.body.environment, + secretPath: req.body.secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + + return { secret }; + } + }); + + server.route({ + method: "PATCH", + url: "/:secretName", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Secrets], + description: "Update secret", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + secretName: BaseSecretNameSchema.describe(RAW_SECRETS.UPDATE.secretName) + }), + body: z.object({ + projectId: z.string().trim().describe(RAW_SECRETS.UPDATE.projectId), + environment: z.string().trim().describe(RAW_SECRETS.UPDATE.environment), + secretValue: z + .string() + .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())) + .optional() + .describe(RAW_SECRETS.UPDATE.secretValue), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(RAW_SECRETS.UPDATE.secretPath), + skipMultilineEncoding: z.boolean().optional().describe(RAW_SECRETS.UPDATE.skipMultilineEncoding), + type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(RAW_SECRETS.UPDATE.type), + tagIds: z.string().array().optional().describe(RAW_SECRETS.UPDATE.tagIds), + metadata: z.record(z.string()).optional(), + secretMetadata: ResourceMetadataSchema.optional(), + secretReminderNote: z + .string() + .max(1024, "Secret reminder note cannot exceed 1024 characters") + .optional() + .nullable() + .describe(RAW_SECRETS.UPDATE.secretReminderNote), + secretReminderRepeatDays: z + .number() + .optional() + .nullable() + .describe(RAW_SECRETS.UPDATE.secretReminderRepeatDays), + secretReminderRecipients: z.string().array().optional().describe(RAW_SECRETS.UPDATE.secretReminderRecipients), + newSecretName: SecretNameSchema.optional().describe(RAW_SECRETS.UPDATE.newSecretName), + secretComment: z.string().optional().describe(RAW_SECRETS.UPDATE.secretComment) + }), + response: { + 200: z.union([ + z.object({ + secret: secretRawSchema.extend({ + secretValueHidden: z.boolean() + }) + }), + z.object({ approval: SecretApprovalRequestsSchema }).describe("When secret protection policy is enabled") + ]) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const secretOperation = await server.services.secret.updateSecretRaw({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + environment: req.body.environment, + projectId: req.body.projectId, + secretPath: req.body.secretPath, + secretName: req.params.secretName, + type: req.body.type, + secretValue: req.body.secretValue, + skipMultilineEncoding: req.body.skipMultilineEncoding, + tagIds: req.body.tagIds, + secretReminderRepeatDays: req.body.secretReminderRepeatDays, + secretReminderRecipients: req.body.secretReminderRecipients, + secretReminderNote: req.body.secretReminderNote, + metadata: req.body.metadata, + newSecretName: req.body.newSecretName, + secretComment: req.body.secretComment, + secretMetadata: req.body.secretMetadata + }); + + if (secretOperation.type === SecretProtectionType.Approval) { + await server.services.auditLog.createAuditLog({ + projectId: req.body.projectId, + ...req.auditLogInfo, + event: { + type: EventType.SECRET_APPROVAL_REQUEST, + metadata: { + committedBy: secretOperation.approval.committerUserId, + secretApprovalRequestId: secretOperation.approval.id, + secretApprovalRequestSlug: secretOperation.approval.slug, + secretPath: req.body.secretPath, + environment: req.body.environment, + secretKey: req.params.secretName, + eventType: SecretApprovalEvent.Update + } + } + }); + + return { approval: secretOperation.approval }; + } + const { secret } = secretOperation; + + await server.services.auditLog.createAuditLog({ + projectId: req.body.projectId, + ...req.auditLogInfo, + event: { + type: EventType.UPDATE_SECRET, + metadata: { + environment: req.body.environment, + secretPath: req.body.secretPath, + secretId: secret.id, + secretKey: req.params.secretName, + secretVersion: secret.version, + secretMetadata: req.body.secretMetadata, + secretTags: secret.tags?.map((tag) => tag.name) + } + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretUpdated, + distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, + properties: { + numberOfSecrets: 1, + projectId: req.body.projectId, + environment: req.body.environment, + secretPath: req.body.secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + return { secret }; + } + }); + + server.route({ + method: "DELETE", + url: "/:secretName", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Secrets], + description: "Delete secret", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + secretName: z.string().min(1).describe(RAW_SECRETS.DELETE.secretName) + }), + body: z.object({ + projectId: z.string().trim().describe(RAW_SECRETS.DELETE.projectId), + environment: z.string().trim().describe(RAW_SECRETS.DELETE.environment), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(RAW_SECRETS.DELETE.secretPath), + type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(RAW_SECRETS.DELETE.type) + }), + response: { + 200: z.union([ + z.object({ + secret: secretRawSchema.extend({ + secretValueHidden: z.boolean() + }) + }), + z.object({ approval: SecretApprovalRequestsSchema }).describe("When secret protection policy is enabled") + ]) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const secretOperation = await server.services.secret.deleteSecretRaw({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + environment: req.body.environment, + projectId: req.body.projectId, + secretPath: req.body.secretPath, + secretName: req.params.secretName, + type: req.body.type + }); + if (secretOperation.type === SecretProtectionType.Approval) { + await server.services.auditLog.createAuditLog({ + projectId: req.body.projectId, + ...req.auditLogInfo, + event: { + type: EventType.SECRET_APPROVAL_REQUEST, + metadata: { + committedBy: secretOperation.approval.committerUserId, + secretApprovalRequestId: secretOperation.approval.id, + secretApprovalRequestSlug: secretOperation.approval.slug, + secretPath: req.body.secretPath, + environment: req.body.environment, + secretKey: req.params.secretName, + eventType: SecretApprovalEvent.Delete + } + } + }); + + return { approval: secretOperation.approval }; + } + + const { secret } = secretOperation; + + await server.services.auditLog.createAuditLog({ + projectId: req.body.projectId, + ...req.auditLogInfo, + event: { + type: EventType.DELETE_SECRET, + metadata: { + environment: req.body.environment, + secretPath: req.body.secretPath, + secretId: secret.id, + secretKey: req.params.secretName, + secretVersion: secret.version + } + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretDeleted, + distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, + properties: { + numberOfSecrets: 1, + projectId: req.body.projectId, + environment: req.body.environment, + secretPath: req.body.secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + + return { secret }; + } + }); + + server.route({ + method: "POST", + url: "/move", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Secrets], + body: z.object({ + projectId: z.string().trim(), + sourceEnvironment: z.string().trim(), + sourceSecretPath: z.string().trim().default("/").transform(removeTrailingSlash), + destinationEnvironment: z.string().trim(), + destinationSecretPath: z.string().trim().default("/").transform(removeTrailingSlash), + secretIds: z.string().array(), + shouldOverwrite: z.boolean().default(false) + }), + response: { + 200: z.object({ + isSourceUpdated: z.boolean(), + isDestinationUpdated: z.boolean() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { projectId, isSourceUpdated, isDestinationUpdated } = await server.services.secret.moveSecrets({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.MOVE_SECRETS, + metadata: { + sourceEnvironment: req.body.sourceEnvironment, + sourceSecretPath: req.body.sourceSecretPath, + destinationEnvironment: req.body.destinationEnvironment, + destinationSecretPath: req.body.destinationSecretPath, + secretIds: req.body.secretIds + } + } + }); + + return { + isSourceUpdated, + isDestinationUpdated + }; + } + }); + + server.route({ + method: "POST", + url: "/batch", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Secrets], + description: "Create many secrets", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + projectId: z.string().trim().describe(RAW_SECRETS.UPDATE.projectId), + environment: z.string().trim().describe(RAW_SECRETS.CREATE.environment), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(RAW_SECRETS.CREATE.secretPath), + secrets: z + .object({ + secretKey: SecretNameSchema.describe(RAW_SECRETS.CREATE.secretName), + secretValue: z + .string() + .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())) + .describe(RAW_SECRETS.CREATE.secretValue), + secretComment: z.string().trim().optional().default("").describe(RAW_SECRETS.CREATE.secretComment), + skipMultilineEncoding: z.boolean().optional().describe(RAW_SECRETS.CREATE.skipMultilineEncoding), + metadata: z.record(z.string()).optional(), + secretMetadata: ResourceMetadataSchema.optional(), + tagIds: z.string().array().optional().describe(RAW_SECRETS.CREATE.tagIds) + }) + .array() + .min(1) + }), + response: { + 200: z.union([ + z.object({ + secrets: secretRawSchema.array() + }), + z.object({ approval: SecretApprovalRequestsSchema }).describe("When secret protection policy is enabled") + ]) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { environment, secretPath, secrets: inputSecrets } = req.body; + + const secretOperation = await server.services.secret.createManySecretsRaw({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + secretPath, + environment, + projectId: req.body.projectId, + secrets: inputSecrets + }); + if (secretOperation.type === SecretProtectionType.Approval) { + await server.services.auditLog.createAuditLog({ + projectId: req.body.projectId, + ...req.auditLogInfo, + event: { + type: EventType.SECRET_APPROVAL_REQUEST, + metadata: { + committedBy: secretOperation.approval.committerUserId, + secretApprovalRequestId: secretOperation.approval.id, + secretApprovalRequestSlug: secretOperation.approval.slug, + secretPath, + environment, + secrets: inputSecrets.map((secret) => ({ + secretKey: secret.secretKey + })), + eventType: SecretApprovalEvent.CreateMany + } + } + }); + return { approval: secretOperation.approval }; + } + const { secrets } = secretOperation; + + const secretMetadataMap = new Map( + inputSecrets.map(({ secretKey, secretMetadata }) => [secretKey, secretMetadata]) + ); + + await server.services.auditLog.createAuditLog({ + projectId: req.body.projectId, + ...req.auditLogInfo, + event: { + type: EventType.CREATE_SECRETS, + metadata: { + environment: req.body.environment, + secretPath: req.body.secretPath, + secrets: secrets.map((secret) => ({ + secretId: secret.id, + secretKey: secret.secretKey, + secretVersion: secret.version, + secretMetadata: secretMetadataMap.get(secret.secretKey), + secretTags: secret.tags?.map((tag) => tag.name) + })) + } + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretCreated, + distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, + properties: { + numberOfSecrets: secrets.length, + projectId: req.body.projectId, + environment: req.body.environment, + secretPath: req.body.secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + return { secrets }; + } + }); + + server.route({ + method: "PATCH", + url: "/batch", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Secrets], + description: "Update many secrets", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + projectId: z.string().trim().describe(RAW_SECRETS.DELETE.projectId), + environment: z.string().trim().describe(RAW_SECRETS.UPDATE.environment), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(RAW_SECRETS.UPDATE.secretPath), + mode: z + .nativeEnum(SecretUpdateMode) + .optional() + .default(SecretUpdateMode.FailOnNotFound) + .describe(RAW_SECRETS.UPDATE.mode), + secrets: z + .object({ + secretKey: SecretNameSchema.describe(RAW_SECRETS.UPDATE.secretName), + secretValue: z + .string() + .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())) + .optional() + .describe(RAW_SECRETS.UPDATE.secretValue), + secretPath: z + .string() + .trim() + .transform(removeTrailingSlash) + .optional() + .describe(RAW_SECRETS.UPDATE.secretPath), + secretComment: z.string().trim().optional().describe(RAW_SECRETS.UPDATE.secretComment), + skipMultilineEncoding: z.boolean().optional().describe(RAW_SECRETS.UPDATE.skipMultilineEncoding), + newSecretName: SecretNameSchema.optional().describe(RAW_SECRETS.UPDATE.newSecretName), + tagIds: z.string().array().optional().describe(RAW_SECRETS.UPDATE.tagIds), + secretReminderNote: z + .string() + .max(1024, "Secret reminder note cannot exceed 1024 characters") + .optional() + .nullable() + .describe(RAW_SECRETS.UPDATE.secretReminderNote), + secretMetadata: ResourceMetadataSchema.optional(), + secretReminderRepeatDays: z + .number() + .optional() + .nullable() + .describe(RAW_SECRETS.UPDATE.secretReminderRepeatDays) + }) + .array() + .min(1) + }), + response: { + 200: z.union([ + z.object({ + secrets: secretRawSchema.extend({ secretValueHidden: z.boolean() }).array() + }), + z.object({ approval: SecretApprovalRequestsSchema }).describe("When secret protection policy is enabled") + ]) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { environment, secretPath, secrets: inputSecrets } = req.body; + const secretOperation = await server.services.secret.updateManySecretsRaw({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + secretPath, + environment, + projectId: req.body.projectId, + secrets: inputSecrets, + mode: req.body.mode + }); + if (secretOperation.type === SecretProtectionType.Approval) { + await server.services.auditLog.createAuditLog({ + projectId: req.body.projectId, + ...req.auditLogInfo, + event: { + type: EventType.SECRET_APPROVAL_REQUEST, + metadata: { + committedBy: secretOperation.approval.committerUserId, + secretApprovalRequestId: secretOperation.approval.id, + secretApprovalRequestSlug: secretOperation.approval.slug, + secretPath, + environment, + secrets: inputSecrets.map((secret) => ({ + secretKey: secret.secretKey, + secretPath: secret.secretPath + })), + eventType: SecretApprovalEvent.UpdateMany + } + } + }); + return { approval: secretOperation.approval }; + } + const { secrets } = secretOperation; + + const secretMetadataMap = new Map( + inputSecrets.map(({ secretKey, secretMetadata }) => [secretKey, secretMetadata]) + ); + + await server.services.auditLog.createAuditLog({ + projectId: req.body.projectId, + ...req.auditLogInfo, + event: { + type: EventType.UPDATE_SECRETS, + metadata: { + environment: req.body.environment, + secretPath: req.body.secretPath, + secrets: secrets + .filter((el) => el.version > 1) + .map((secret) => ({ + secretId: secret.id, + secretPath: secret.secretPath, + secretKey: secret.secretKey, + secretVersion: secret.version, + secretMetadata: secretMetadataMap.get(secret.secretKey), + secretTags: secret.tags?.map((tag) => tag.name) + })) + } + } + }); + const createdSecrets = secrets.filter((el) => el.version === 1); + if (createdSecrets.length) { + await server.services.auditLog.createAuditLog({ + projectId: req.body.projectId, + ...req.auditLogInfo, + event: { + type: EventType.CREATE_SECRETS, + metadata: { + environment: req.body.environment, + secretPath: req.body.secretPath, + secrets: createdSecrets.map((secret) => ({ + secretId: secret.id, + secretPath: secret.secretPath, + secretKey: secret.secretKey, + secretVersion: secret.version, + secretMetadata: secretMetadataMap.get(secret.secretKey) + })) + } + } + }); + } + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretUpdated, + distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, + properties: { + numberOfSecrets: secrets.length, + projectId: req.body.projectId, + environment: req.body.environment, + secretPath: req.body.secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + return { secrets }; + } + }); + + server.route({ + method: "DELETE", + url: "/batch", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Secrets], + description: "Delete many secrets", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + projectId: z.string().trim().describe(RAW_SECRETS.DELETE.projectId), + environment: z.string().trim().describe(RAW_SECRETS.DELETE.environment), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(RAW_SECRETS.DELETE.secretPath), + secrets: z + .object({ + secretKey: z.string().describe(RAW_SECRETS.DELETE.secretName), + type: z.nativeEnum(SecretType).default(SecretType.Shared) + }) + .array() + .min(1) + }), + response: { + 200: z.union([ + z.object({ + secrets: secretRawSchema + .extend({ + secretValueHidden: z.boolean() + }) + .array() + }), + z.object({ approval: SecretApprovalRequestsSchema }).describe("When secret protection policy is enabled") + ]) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { environment, secretPath, secrets: inputSecrets } = req.body; + const secretOperation = await server.services.secret.deleteManySecretsRaw({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + environment, + secretPath, + projectId: req.body.projectId, + secrets: inputSecrets + }); + if (secretOperation.type === SecretProtectionType.Approval) { + await server.services.auditLog.createAuditLog({ + projectId: req.body.projectId, + ...req.auditLogInfo, + event: { + type: EventType.SECRET_APPROVAL_REQUEST, + metadata: { + committedBy: secretOperation.approval.committerUserId, + secretApprovalRequestId: secretOperation.approval.id, + secretApprovalRequestSlug: secretOperation.approval.slug, + secretPath, + environment, + secrets: inputSecrets.map((secret) => ({ + secretKey: secret.secretKey + })), + eventType: SecretApprovalEvent.DeleteMany + } + } + }); + + return { approval: secretOperation.approval }; + } + const { secrets } = secretOperation; + + await server.services.auditLog.createAuditLog({ + projectId: secrets[0].workspace, + ...req.auditLogInfo, + event: { + type: EventType.DELETE_SECRETS, + metadata: { + environment: req.body.environment, + secretPath: req.body.secretPath, + secrets: secrets.map((secret) => ({ + secretId: secret.id, + secretKey: secret.secretKey, + secretVersion: secret.version + })) + } + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretDeleted, + distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, + properties: { + numberOfSecrets: secrets.length, + projectId: secrets[0].workspace, + environment: req.body.environment, + secretPath: req.body.secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + return { secrets }; + } + }); + + server.route({ + method: "GET", + url: "/:secretName/secret-reference-tree", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Secrets], + description: "Get secret reference tree", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + secretName: z.string().trim().describe(RAW_SECRETS.GET_REFERENCE_TREE.secretName) + }), + querystring: z.object({ + projectId: z.string().trim().describe(RAW_SECRETS.GET_REFERENCE_TREE.projectId), + environment: z.string().trim().describe(RAW_SECRETS.GET_REFERENCE_TREE.environment), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(RAW_SECRETS.GET_REFERENCE_TREE.secretPath) + }), + response: { + 200: z.object({ + tree: SecretReferenceNodeTree, + value: z.string().optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { secretName } = req.params; + const { secretPath, environment, projectId } = req.query; + const { tree, value, secret } = await server.services.secret.getSecretReferenceTree({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + secretName, + secretPath, + environment + }); + + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.GET_SECRET, + metadata: { + environment, + secretPath, + secretId: secret.id, + secretKey: secretName, + secretVersion: secret.version + } + } + }); + + return { tree, value }; + } + }); + + server.route({ + method: "POST", + url: "/backfill-secret-references", + config: { + rateLimit: secretsLimit + }, + schema: { + description: "Backfill secret references", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + projectId: z.string().trim().min(1) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { projectId } = req.body; + const message = await server.services.secret.backfillSecretReferences({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId + }); + + return message; + } + }); +}; diff --git a/backend/src/services/app-connection/app-connection-dal.ts b/backend/src/services/app-connection/app-connection-dal.ts index f74f7cf06..10b6a6274 100644 --- a/backend/src/services/app-connection/app-connection-dal.ts +++ b/backend/src/services/app-connection/app-connection-dal.ts @@ -1,11 +1,115 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; +import { TableName, TAppConnections } from "@app/db/schemas"; +import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex"; +import { transformUsageToProjects } from "@app/services/app-connection/app-connection-fns"; export type TAppConnectionDALFactory = ReturnType; +type AppConnectionFindFilter = Parameters>[0]; + export const appConnectionDALFactory = (db: TDbClient) => { const appConnectionOrm = ormify(db, TableName.AppConnection); - return { ...appConnectionOrm }; + const findWithProjectDetails = async (filter: AppConnectionFindFilter, tx?: Knex) => { + const query = (tx || db.replicaNode())(TableName.AppConnection) + .leftJoin(TableName.Project, `${TableName.AppConnection}.projectId`, `${TableName.Project}.id`) + .select(selectAllTableCols(TableName.AppConnection)) + .select( + // project + db.ref("name").withSchema(TableName.Project).as("projectName"), + db.ref("type").withSchema(TableName.Project).as("projectType"), + db.ref("slug").withSchema(TableName.Project).as("projectSlug") + ); + + if (filter) { + /* eslint-disable @typescript-eslint/no-misused-promises */ + void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.AppConnection, filter))); + } + + const connections = await query; + + return connections.map(({ projectName, projectSlug, projectType, projectId, ...connection }) => ({ + ...connection, + projectId, + project: projectId + ? { + name: projectName, + type: projectType, + slug: projectSlug, + id: projectId + } + : null + })); + }; + + const findAppConnectionUsageById = async (connectionId: string, tx?: Knex) => { + const secretSyncs = await (tx || db.replicaNode())(TableName.SecretSync) + .where(`${TableName.SecretSync}.connectionId`, connectionId) + .join(TableName.Project, `${TableName.SecretSync}.projectId`, `${TableName.Project}.id`) + .select( + db.ref("name").withSchema(TableName.SecretSync), + db.ref("id").withSchema(TableName.SecretSync), + db.ref("projectId").withSchema(TableName.SecretSync), + db.ref("name").as("projectName").withSchema(TableName.Project), + db.ref("slug").as("projectSlug").withSchema(TableName.Project), + db.ref("type").as("projectType").withSchema(TableName.Project) + ); + + const secretRotations = await (tx || db.replicaNode())(TableName.SecretRotationV2) + .where(`${TableName.SecretRotationV2}.connectionId`, connectionId) + .join(TableName.SecretFolder, `${TableName.SecretRotationV2}.folderId`, `${TableName.SecretFolder}.id`) + .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .join(TableName.Project, `${TableName.Environment}.projectId`, `${TableName.Project}.id`) + .select( + db.ref("name").withSchema(TableName.SecretRotationV2), + db.ref("id").withSchema(TableName.SecretRotationV2), + db.ref("id").as("projectId").withSchema(TableName.Project), + db.ref("name").as("projectName").withSchema(TableName.Project), + db.ref("slug").as("projectSlug").withSchema(TableName.Project), + db.ref("type").as("projectType").withSchema(TableName.Project) + ); + + const externalCas = await (tx || db.replicaNode())(TableName.ExternalCertificateAuthority) + .where(`${TableName.ExternalCertificateAuthority}.appConnectionId`, connectionId) + .orWhere(`${TableName.ExternalCertificateAuthority}.dnsAppConnectionId`, connectionId) + .join( + TableName.CertificateAuthority, + `${TableName.ExternalCertificateAuthority}.caId`, + `${TableName.CertificateAuthority}.id` + ) + .join(TableName.Project, `${TableName.CertificateAuthority}.projectId`, `${TableName.Project}.id`) + .select( + db.ref("name").withSchema(TableName.CertificateAuthority), + db.ref("id").withSchema(TableName.ExternalCertificateAuthority), + db.ref("appConnectionId").withSchema(TableName.ExternalCertificateAuthority), + db.ref("dnsAppConnectionId").withSchema(TableName.ExternalCertificateAuthority), + db.ref("id").as("projectId").withSchema(TableName.Project), + db.ref("name").as("projectName").withSchema(TableName.Project), + db.ref("slug").as("projectSlug").withSchema(TableName.Project), + db.ref("type").as("projectType").withSchema(TableName.Project) + ); + + const dataSources = await (tx || db.replicaNode())(TableName.SecretScanningDataSource) + .where(`${TableName.SecretScanningDataSource}.connectionId`, connectionId) + .join(TableName.Project, `${TableName.SecretScanningDataSource}.projectId`, `${TableName.Project}.id`) + .select( + db.ref("name").withSchema(TableName.SecretScanningDataSource), + db.ref("id").withSchema(TableName.SecretScanningDataSource), + db.ref("id").as("projectId").withSchema(TableName.Project), + db.ref("name").as("projectName").withSchema(TableName.Project), + db.ref("slug").as("projectSlug").withSchema(TableName.Project), + db.ref("type").as("projectType").withSchema(TableName.Project) + ); + + return transformUsageToProjects({ + secretSyncs, + secretRotations, + dataSources, + externalCas + }); + }; + + return { ...appConnectionOrm, findAppConnectionUsageById, findWithProjectDetails }; }; diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 76dcdd5f0..996cd872a 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -36,7 +36,8 @@ export enum AppConnection { Supabase = "supabase", DigitalOcean = "digital-ocean", Netlify = "netlify", - Okta = "okta" + Okta = "okta", + Redis = "redis" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 5aacab1b1..a86fa7420 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -1,3 +1,4 @@ +import { ProjectType } from "@app/db/schemas"; import { TAppConnections } from "@app/db/schemas/app-connections"; import { getOCIConnectionListItem, @@ -8,6 +9,8 @@ import { getOracleDBConnectionListItem, OracleDBConnectionMethod } from "@app/ee import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { SECRET_ROTATION_CONNECTION_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps"; +import { SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-maps"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError } from "@app/lib/errors"; import { APP_CONNECTION_NAME_MAP, APP_CONNECTION_PLAN_MAP } from "@app/services/app-connection/app-connection-maps"; @@ -16,6 +19,7 @@ import { validateSqlConnectionCredentials } from "@app/services/app-connection/shared/sql"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { SECRET_SYNC_CONNECTION_MAP } from "@app/services/secret-sync/secret-sync-maps"; import { getOnePassConnectionListItem, @@ -107,6 +111,7 @@ import { getNetlifyConnectionListItem, validateNetlifyConnectionCredentials } fr import { getOktaConnectionListItem, OktaConnectionMethod, validateOktaConnectionCredentials } from "./okta"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; import { getRailwayConnectionListItem, validateRailwayConnectionCredentials } from "./railway"; +import { getRedisConnectionListItem, RedisConnectionMethod, validateRedisConnectionCredentials } from "./redis"; import { RenderConnectionMethod } from "./render/render-connection-enums"; import { getRenderConnectionListItem, validateRenderConnectionCredentials } from "./render/render-connection-fns"; import { @@ -133,7 +138,27 @@ import { } from "./windmill"; import { getZabbixConnectionListItem, validateZabbixConnectionCredentials, ZabbixConnectionMethod } from "./zabbix"; -export const listAppConnectionOptions = () => { +const SECRET_SYNC_APP_CONNECTION_MAP = Object.fromEntries( + Object.entries(SECRET_SYNC_CONNECTION_MAP).map(([key, value]) => [value, key]) +); + +const SECRET_ROTATION_APP_CONNECTION_MAP = Object.fromEntries( + Object.entries(SECRET_ROTATION_CONNECTION_MAP).map(([key, value]) => [value, key]) +); + +const SECRET_SCANNING_APP_CONNECTION_MAP = Object.fromEntries( + Object.entries(SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP).map(([key, value]) => [value, key]) +); + +// scott: ideally this would be derived from a utilized map like the above +const PKI_APP_CONNECTIONS = [ + AppConnection.AWS, + AppConnection.Cloudflare, + AppConnection.AzureADCS, + AppConnection.AzureKeyVault +]; + +export const listAppConnectionOptions = (projectType?: ProjectType) => { return [ getAwsConnectionListItem(), getGitHubConnectionListItem(), @@ -172,23 +197,53 @@ export const listAppConnectionOptions = () => { getSupabaseConnectionListItem(), getDigitalOceanConnectionListItem(), getNetlifyConnectionListItem(), - getOktaConnectionListItem() - ].sort((a, b) => a.name.localeCompare(b.name)); + getOktaConnectionListItem(), + getRedisConnectionListItem() + ] + .filter((option) => { + switch (projectType) { + case ProjectType.SecretManager: + return ( + Boolean(SECRET_SYNC_APP_CONNECTION_MAP[option.app]) || + Boolean(SECRET_ROTATION_APP_CONNECTION_MAP[option.app]) + ); + case ProjectType.SecretScanning: + return Boolean(SECRET_SCANNING_APP_CONNECTION_MAP[option.app]); + case ProjectType.CertificateManager: + return PKI_APP_CONNECTIONS.includes(option.app); + case ProjectType.KMS: + return false; + case ProjectType.SSH: + return false; + default: + return true; + } + }) + .sort((a, b) => a.name.localeCompare(b.name)); }; export const encryptAppConnectionCredentials = async ({ orgId, credentials, - kmsService + kmsService, + projectId }: { orgId: string; credentials: TAppConnection["credentials"]; kmsService: TAppConnectionServiceFactoryDep["kmsService"]; + projectId: string | null | undefined; }) => { - const { encryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId - }); + const { encryptor } = await kmsService.createCipherPairWithDataKey( + projectId + ? { + type: KmsDataKey.SecretManager, + projectId + } + : { + type: KmsDataKey.Organization, + orgId + } + ); const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({ plainText: Buffer.from(JSON.stringify(credentials)) @@ -200,16 +255,22 @@ export const encryptAppConnectionCredentials = async ({ export const decryptAppConnectionCredentials = async ({ orgId, encryptedCredentials, - kmsService + kmsService, + projectId }: { orgId: string; encryptedCredentials: Buffer; kmsService: TAppConnectionServiceFactoryDep["kmsService"]; + projectId: string | null | undefined; }) => { - const { decryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId - }); + const { decryptor } = await kmsService.createCipherPairWithDataKey( + projectId + ? { type: KmsDataKey.SecretManager, projectId } + : { + type: KmsDataKey.Organization, + orgId + } + ); const decryptedPlainTextBlob = decryptor({ cipherTextBlob: encryptedCredentials @@ -263,7 +324,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Supabase]: validateSupabaseConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.DigitalOcean]: validateDigitalOceanConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Netlify]: validateNetlifyConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.Netlify]: validateNetlifyConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Redis]: validateRedisConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection, gatewayService, gatewayV2Service); @@ -310,6 +372,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case MySqlConnectionMethod.UsernameAndPassword: case OracleDBConnectionMethod.UsernameAndPassword: case AzureADCSConnectionMethod.UsernamePassword: + case RedisConnectionMethod.UsernameAndPassword: return "Username & Password"; case WindmillConnectionMethod.AccessToken: case HCVaultConnectionMethod.AccessToken: @@ -343,6 +406,7 @@ export const decryptAppConnection = async ( credentials: await decryptAppConnectionCredentials({ encryptedCredentials: appConnection.encryptedCredentials, orgId: appConnection.orgId, + projectId: appConnection.projectId, kmsService }), credentialsHash: crypto.nativeCrypto.createHash("sha256").update(appConnection.encryptedCredentials).digest("hex") @@ -396,7 +460,8 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Supabase]: platformManagedCredentialsNotSupported, [AppConnection.DigitalOcean]: platformManagedCredentialsNotSupported, [AppConnection.Netlify]: platformManagedCredentialsNotSupported, - [AppConnection.Okta]: platformManagedCredentialsNotSupported + [AppConnection.Okta]: platformManagedCredentialsNotSupported, + [AppConnection.Redis]: platformManagedCredentialsNotSupported }; export const enterpriseAppCheck = async ( @@ -413,3 +478,73 @@ export const enterpriseAppCheck = async ( }); } }; + +type Resource = { + name: string; + id: string; + projectId: string; + projectName: string; + projectSlug: string; + projectType: string; +}; + +type UsageData = { + secretSyncs: Resource[]; + secretRotations: Resource[]; + dataSources: Resource[]; + externalCas: Resource[]; +}; + +type ResourceSummary = { + name: string; + id: string; +}; + +type ProjectWithResources = { + id: string; + name: string; + slug: string; + type: ProjectType; + resources: { + secretSyncs: ResourceSummary[]; + secretRotations: ResourceSummary[]; + dataSources: ResourceSummary[]; + externalCas: (ResourceSummary & { appConnectionId?: string; dnsAppConnectionId?: string })[]; + }; +}; + +export const transformUsageToProjects = (data: UsageData): ProjectWithResources[] => { + const projectMap = new Map(); + + Object.entries(data).forEach(([resourceType, resources]) => { + resources.forEach((resource) => { + const { projectId, projectName, projectSlug, projectType, name, id, ...rest } = resource; + + const projectKey = projectId; + + if (!projectMap.has(projectKey)) { + projectMap.set(projectKey, { + id: projectId, + name: projectName, + slug: projectSlug, + type: projectType as ProjectType, + resources: { + secretSyncs: [], + secretRotations: [], + dataSources: [], + externalCas: [] + } + }); + } + + const project = projectMap.get(projectKey)!; + project.resources[resourceType as keyof ProjectWithResources["resources"]].push({ + name, + id, + ...rest + }); + }); + }); + + return Array.from(projectMap.values()); +}; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index a2ce02669..e3235d2f7 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -38,7 +38,8 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Supabase]: "Supabase", [AppConnection.DigitalOcean]: "DigitalOcean App Platform", [AppConnection.Netlify]: "Netlify", - [AppConnection.Okta]: "Okta" + [AppConnection.Okta]: "Okta", + [AppConnection.Redis]: "Redis" }; export const APP_CONNECTION_PLAN_MAP: Record = { @@ -79,5 +80,6 @@ export const APP_CONNECTION_PLAN_MAP: Record; + permissionService: Pick; kmsService: Pick; licenseService: Pick; gatewayService: Pick; gatewayV2Service: Pick; gatewayDAL: Pick; gatewayV2DAL: Pick; + projectDAL: Pick; }; export type TAppConnectionServiceFactory = ReturnType; @@ -157,7 +167,8 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record { - const listAppConnectionsByOrg = async (actor: OrgServiceActor, app?: AppConnection) => { - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const listAppConnections = async (actor: OrgServiceActor, app?: AppConnection, projectId?: string) => { + let appConnections: TAppConnections[]; - ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionAppConnectionActions.Read, - OrgPermissionSubjects.AppConnections - ); + if (projectId) { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.Any + }); - const appConnections = await appConnectionDAL.find( - app - ? { orgId: actor.orgId, app } - : { - orgId: actor.orgId - } - ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionAppConnectionActions.Read, + ProjectPermissionSub.AppConnections + ); + + appConnections = ( + await appConnectionDAL.findWithProjectDetails({ + projectId, + ...(app ? { app } : {}) + }) + ).filter((appConnection) => + permission.can( + ProjectPermissionAppConnectionActions.Read, + subject(ProjectPermissionSub.AppConnections, { connectionId: appConnection.id }) + ) + ); + } else { + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionAppConnectionActions.Read, + OrgPermissionSubjects.AppConnections + ); + + appConnections = ( + await appConnectionDAL.findWithProjectDetails({ + orgId: actor.orgId, + ...(app ? { app } : {}) + }) + ).filter((appConnection) => + permission.can( + OrgPermissionAppConnectionActions.Read, + subject(OrgPermissionSubjects.AppConnections, { connectionId: appConnection.id }) + ) + ); + } return Promise.all( appConnections @@ -204,18 +250,34 @@ export const appConnectionServiceFactory = ({ if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - appConnection.orgId - ); + if (appConnection.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: appConnection.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.Any + }); - ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionAppConnectionActions.Read, - OrgPermissionSubjects.AppConnections - ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionAppConnectionActions.Read, + subject(ProjectPermissionSub.AppConnections, { connectionId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + appConnection.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionAppConnectionActions.Read, + subject(OrgPermissionSubjects.AppConnections, { connectionId }) + ); + } if (appConnection.app !== app) throw new BadRequestError({ message: `App Connection with ID ${connectionId} is not for App "${app}"` }); @@ -223,24 +285,49 @@ export const appConnectionServiceFactory = ({ return decryptAppConnection(appConnection, kmsService); }; - const findAppConnectionByName = async (app: AppConnection, connectionName: string, actor: OrgServiceActor) => { - const appConnection = await appConnectionDAL.findOne({ name: connectionName, orgId: actor.orgId }); + const findAppConnectionByName = async ( + app: AppConnection, + { connectionName, projectId }: TGetAppConnectionByNameDTO, + actor: OrgServiceActor + ) => { + const appConnection = await appConnectionDAL.findOne({ + name: connectionName, + ...(projectId ? { projectId } : { orgId: actor.orgId, projectId: null }) + }); if (!appConnection) - throw new NotFoundError({ message: `Could not find App Connection with name ${connectionName}` }); + throw new NotFoundError({ + message: `Could not find App Connection with name ${connectionName} in ${projectId ? "project" : "organization"} scope.` + }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - appConnection.orgId - ); + if (appConnection.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: appConnection.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.Any + }); - ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionAppConnectionActions.Read, - OrgPermissionSubjects.AppConnections - ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionAppConnectionActions.Read, + subject(ProjectPermissionSub.AppConnections, { connectionId: appConnection.id }) + ); + } else { + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + appConnection.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionAppConnectionActions.Read, + subject(OrgPermissionSubjects.AppConnections, { connectionId: appConnection.id }) + ); + } if (appConnection.app !== app) throw new BadRequestError({ message: `App Connection with name ${connectionName} is not for App "${app}"` }); @@ -249,10 +336,10 @@ export const appConnectionServiceFactory = ({ }; const createAppConnection = async ( - { method, app, credentials, gatewayId, ...params }: TCreateAppConnectionDTO, + { method, app, credentials, gatewayId, projectId, ...params }: TCreateAppConnectionDTO, actor: OrgServiceActor ) => { - const { permission } = await permissionService.getOrgPermission( + const { permission: orgPermission } = await permissionService.getOrgPermission( actor.type, actor.id, actor.orgId, @@ -260,13 +347,33 @@ export const appConnectionServiceFactory = ({ actor.orgId ); - ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionAppConnectionActions.Create, - OrgPermissionSubjects.AppConnections - ); + if (projectId) { + const project = await projectDAL.findProjectById(projectId); + + if (!project) throw new BadRequestError({ message: `Could not find project with ID ${projectId}` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.Any + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionAppConnectionActions.Create, + ProjectPermissionSub.AppConnections + ); + } else { + ForbiddenError.from(orgPermission).throwUnlessCan( + OrgPermissionAppConnectionActions.Create, + OrgPermissionSubjects.AppConnections + ); + } if (gatewayId) { - ForbiddenError.from(permission).throwUnlessCan( + ForbiddenError.from(orgPermission).throwUnlessCan( OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway ); @@ -304,7 +411,8 @@ export const appConnectionServiceFactory = ({ const encryptedCredentials = await encryptAppConnectionCredentials({ credentials: connectionCredentials, orgId: actor.orgId, - kmsService + kmsService, + projectId }); return appConnectionDAL.create({ @@ -313,6 +421,7 @@ export const appConnectionServiceFactory = ({ method, app, gatewayId, + projectId, ...params }); }; @@ -365,21 +474,37 @@ export const appConnectionServiceFactory = ({ "Failed to update app connection due to plan restriction. Upgrade plan to access enterprise app connections." ); - const { permission } = await permissionService.getOrgPermission( + const { permission: orgPermission } = await permissionService.getOrgPermission( actor.type, actor.id, - actor.orgId, + appConnection.orgId, actor.authMethod, - appConnection.orgId + actor.orgId ); - ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionAppConnectionActions.Edit, - OrgPermissionSubjects.AppConnections - ); + if (appConnection.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: appConnection.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.Any + }); - if (gatewayId !== appConnection.gatewayId) { ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionAppConnectionActions.Edit, + subject(ProjectPermissionSub.AppConnections, { connectionId }) + ); + } else { + ForbiddenError.from(orgPermission).throwUnlessCan( + OrgPermissionAppConnectionActions.Edit, + subject(OrgPermissionSubjects.AppConnections, { connectionId }) + ); + } + + if (gatewayId !== undefined && gatewayId !== appConnection.gatewayId) { + ForbiddenError.from(orgPermission).throwUnlessCan( OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway ); @@ -441,7 +566,8 @@ export const appConnectionServiceFactory = ({ ? await encryptAppConnectionCredentials({ credentials: connectionCredentials, orgId: actor.orgId, - kmsService + kmsService, + projectId: appConnection.projectId }) : undefined; @@ -491,18 +617,34 @@ export const appConnectionServiceFactory = ({ if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - appConnection.orgId - ); + if (appConnection.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: appConnection.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.Any + }); - ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionAppConnectionActions.Delete, - OrgPermissionSubjects.AppConnections - ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionAppConnectionActions.Delete, + subject(ProjectPermissionSub.AppConnections, { connectionId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + appConnection.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionAppConnectionActions.Delete, + subject(OrgPermissionSubjects.AppConnections, { connectionId }) + ); + } if (appConnection.app !== app) throw new BadRequestError({ message: `App Connection with ID ${connectionId} is not for App "${app}"` }); @@ -544,18 +686,34 @@ export const appConnectionServiceFactory = ({ "Failed to connect app due to plan restriction. Upgrade plan to access enterprise app connections." ); - const { permission: orgPermission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - appConnection.orgId, - actor.authMethod, - actor.orgId - ); + if (appConnection.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: appConnection.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.Any + }); - ForbiddenError.from(orgPermission).throwUnlessCan( - OrgPermissionAppConnectionActions.Connect, - subject(OrgPermissionSubjects.AppConnections, { connectionId: appConnection.id }) - ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionAppConnectionActions.Connect, + subject(ProjectPermissionSub.AppConnections, { connectionId }) + ); + } else { + const { permission: orgPermission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + appConnection.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(orgPermission).throwUnlessCan( + OrgPermissionAppConnectionActions.Connect, + subject(OrgPermissionSubjects.AppConnections, { connectionId }) + ); + } if (appConnection.app !== app) throw new BadRequestError({ @@ -569,7 +727,23 @@ export const appConnectionServiceFactory = ({ return connection as T; }; - const listAvailableAppConnectionsForUser = async (app: AppConnection, actor: OrgServiceActor) => { + const validateAppConnectionUsageById = async ( + app: AppConnection, + { connectionId, projectId }: TValidateAppConnectionUsageByIdDTO, + actor: OrgServiceActor + ) => { + const appConnection = await connectAppConnectionById(app, connectionId, actor); + + if (appConnection.projectId && appConnection.projectId !== projectId) { + throw new BadRequestError({ + message: `You cannot connect project App Connection with ID "${appConnection.id}" from project with ID "${appConnection.projectId}" to project with ID "${projectId}"` + }); + } + + return appConnection; + }; + + const listAvailableAppConnectionsForUser = async (app: AppConnection, actor: OrgServiceActor, projectId?: string) => { const { permission: orgPermission } = await permissionService.getOrgPermission( actor.type, actor.id, @@ -578,28 +752,89 @@ export const appConnectionServiceFactory = ({ actor.orgId ); - const appConnections = await appConnectionDAL.find({ app, orgId: actor.orgId }); + let availableProjectConnections: TAppConnections[] = []; - const availableConnections = appConnections.filter((connection) => + if (projectId) { + const project = await projectDAL.findProjectById(projectId); + + if (!project) throw new BadRequestError({ message: `Could not find project with ID ${projectId}` }); + + const { permission: projectPermission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.Any + }); + + ForbiddenError.from(projectPermission).throwUnlessCan( + ProjectPermissionAppConnectionActions.Connect, + ProjectPermissionSub.AppConnections + ); + + const projectAppConnections = await appConnectionDAL.find({ app, projectId }); + + availableProjectConnections = projectAppConnections.filter((connection) => + projectPermission.can( + ProjectPermissionAppConnectionActions.Connect, + subject(ProjectPermissionSub.AppConnections, { connectionId: connection.id }) + ) + ); + } + + const orgAppConnections = await appConnectionDAL.find({ app, orgId: actor.orgId, projectId: null }); + + const availableOrgConnections = orgAppConnections.filter((connection) => orgPermission.can( OrgPermissionAppConnectionActions.Connect, subject(OrgPermissionSubjects.AppConnections, { connectionId: connection.id }) ) ); - return availableConnections as Omit[]; + return [...availableOrgConnections, ...availableProjectConnections].sort((a, b) => + a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + ) as Omit[]; + }; + + const findAppConnectionUsageById = async (app: AppConnection, connectionId: string, actor: OrgServiceActor) => { + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + appConnection.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionAppConnectionActions.Read, + OrgPermissionSubjects.AppConnections + ); + + if (appConnection.app !== app) + throw new BadRequestError({ message: `App Connection with ID ${connectionId} is not for App "${app}"` }); + + const projectUsage = await appConnectionDAL.findAppConnectionUsageById(connectionId); + + return projectUsage; }; return { listAppConnectionOptions, - listAppConnectionsByOrg, + listAppConnections, findAppConnectionById, findAppConnectionByName, createAppConnection, updateAppConnection, deleteAppConnection, connectAppConnectionById, + validateAppConnectionUsageById, listAvailableAppConnectionsForUser, + findAppConnectionUsageById, github: githubConnectionService(connectAppConnectionById, gatewayService, gatewayV2Service), githubRadar: githubRadarConnectionService(connectAppConnectionById), gcp: gcpConnectionService(connectAppConnectionById), diff --git a/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts index e4af79926..cf6f45f6c 100644 --- a/backend/src/services/app-connection/app-connection-types.ts +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -179,6 +179,12 @@ import { TRailwayConnectionInput, TValidateRailwayConnectionCredentialsSchema } from "./railway"; +import { + TRedisConnection, + TRedisConnectionConfig, + TRedisConnectionInput, + TValidateRedisConnectionCredentialsSchema +} from "./redis"; import { TRenderConnection, TRenderConnectionConfig, @@ -261,6 +267,7 @@ export type TAppConnection = { id: string } & ( | TDigitalOceanConnection | TNetlifyConnection | TOktaConnection + | TRedisConnection ); export type TAppConnectionRaw = NonNullable>>; @@ -306,6 +313,7 @@ export type TAppConnectionInput = { id: string } & ( | TDigitalOceanConnectionInput | TNetlifyConnectionInput | TOktaConnectionInput + | TRedisConnectionInput ); export type TSqlConnectionInput = @@ -316,13 +324,23 @@ export type TSqlConnectionInput = export type TCreateAppConnectionDTO = Pick< TAppConnectionInput, - "credentials" | "method" | "name" | "app" | "description" | "isPlatformManagedCredentials" | "gatewayId" + "credentials" | "method" | "name" | "app" | "description" | "isPlatformManagedCredentials" | "gatewayId" | "projectId" >; -export type TUpdateAppConnectionDTO = Partial> & { +export type TUpdateAppConnectionDTO = Partial> & { connectionId: string; }; +export type TGetAppConnectionByNameDTO = { + connectionName: string; + projectId?: string; +}; + +export type TValidateAppConnectionUsageByIdDTO = { + connectionId: string; + projectId: string; +}; + export type TAppConnectionConfig = | TAwsConnectionConfig | TGitHubConnectionConfig @@ -358,7 +376,8 @@ export type TAppConnectionConfig = | TSupabaseConnectionConfig | TDigitalOceanConnectionConfig | TNetlifyConnectionConfig - | TOktaConnectionConfig; + | TOktaConnectionConfig + | TRedisConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -398,7 +417,8 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateSupabaseConnectionCredentialsSchema | TValidateDigitalOceanCredentialsSchema | TValidateNetlifyConnectionCredentialsSchema - | TValidateOktaConnectionCredentialsSchema; + | TValidateOktaConnectionCredentialsSchema + | TValidateRedisConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; diff --git a/backend/src/services/app-connection/auth0/auth0-connection-fns.ts b/backend/src/services/app-connection/auth0/auth0-connection-fns.ts index de4faf683..944b1f69a 100644 --- a/backend/src/services/app-connection/auth0/auth0-connection-fns.ts +++ b/backend/src/services/app-connection/auth0/auth0-connection-fns.ts @@ -51,7 +51,7 @@ const authorizeAuth0Connection = async ({ }; export const getAuth0ConnectionAccessToken = async ( - { id, orgId, credentials }: TAuth0Connection, + { id, orgId, credentials, projectId }: TAuth0Connection, appConnectionDAL: Pick, kmsService: Pick ) => { @@ -72,7 +72,8 @@ export const getAuth0ConnectionAccessToken = async ( const encryptedCredentials = await encryptAppConnectionCredentials({ credentials: updatedCredentials, orgId, - kmsService + kmsService, + projectId }); await appConnectionDAL.updateById(id, { encryptedCredentials }); diff --git a/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-fns.ts b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-fns.ts index 552bd89f5..5e86f6740 100644 --- a/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-fns.ts +++ b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-fns.ts @@ -352,7 +352,8 @@ export const getAzureADCSConnectionCredentials = async ( const credentials = (await decryptAppConnectionCredentials({ orgId: appConnection.orgId, kmsService, - encryptedCredentials: appConnection.encryptedCredentials + encryptedCredentials: appConnection.encryptedCredentials, + projectId: appConnection.projectId })) as { username: string; password: string; diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts index 2614cfd12..22cec0ae7 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts @@ -57,6 +57,7 @@ export const getAzureConnectionAccessToken = async ( const credentials = (await decryptAppConnectionCredentials({ orgId: appConnection.orgId, kmsService, + projectId: appConnection.projectId, encryptedCredentials: appConnection.encryptedCredentials })) as TAzureClientSecretsConnectionCredentials; @@ -93,6 +94,7 @@ export const getAzureConnectionAccessToken = async ( const encryptedCredentials = await encryptAppConnectionCredentials({ credentials: updatedCredentials, orgId: appConnection.orgId, + projectId: appConnection.projectId, kmsService }); @@ -102,6 +104,7 @@ export const getAzureConnectionAccessToken = async ( case AzureClientSecretsConnectionMethod.ClientSecret: const accessTokenCredentials = (await decryptAppConnectionCredentials({ orgId: appConnection.orgId, + projectId: appConnection.projectId, kmsService, encryptedCredentials: appConnection.encryptedCredentials })) as TAzureClientSecretsConnectionClientSecretCredentials; @@ -129,6 +132,7 @@ export const getAzureConnectionAccessToken = async ( const encryptedClientCredentials = await encryptAppConnectionCredentials({ credentials: updatedClientCredentials, orgId: appConnection.orgId, + projectId: appConnection.projectId, kmsService }); diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts b/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts index a3a9f10bd..0bd2188ac 100644 --- a/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts +++ b/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts @@ -70,7 +70,8 @@ export const getAzureDevopsConnection = async ( const oauthCredentials = (await decryptAppConnectionCredentials({ orgId: appConnection.orgId, kmsService, - encryptedCredentials: appConnection.encryptedCredentials + encryptedCredentials: appConnection.encryptedCredentials, + projectId: appConnection.projectId })) as TAzureDevOpsConnectionCredentials; if (!("refreshToken" in oauthCredentials)) { @@ -100,7 +101,8 @@ export const getAzureDevopsConnection = async ( const encryptedOAuthCredentials = await encryptAppConnectionCredentials({ credentials: updatedOAuthCredentials, orgId: appConnection.orgId, - kmsService + kmsService, + projectId: appConnection.projectId }); await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedOAuthCredentials }); @@ -111,7 +113,8 @@ export const getAzureDevopsConnection = async ( const accessTokenCredentials = (await decryptAppConnectionCredentials({ orgId: appConnection.orgId, kmsService, - encryptedCredentials: appConnection.encryptedCredentials + encryptedCredentials: appConnection.encryptedCredentials, + projectId: appConnection.projectId })) as { accessToken: string }; if (!("accessToken" in accessTokenCredentials)) { @@ -124,7 +127,8 @@ export const getAzureDevopsConnection = async ( const clientSecretCredentials = (await decryptAppConnectionCredentials({ orgId: appConnection.orgId, kmsService, - encryptedCredentials: appConnection.encryptedCredentials + encryptedCredentials: appConnection.encryptedCredentials, + projectId: appConnection.projectId })) as TAzureDevOpsConnectionClientSecretCredentials; const { accessToken, expiresAt, clientId, clientSecret, tenantId: clientTenantId } = clientSecretCredentials; @@ -153,7 +157,8 @@ export const getAzureDevopsConnection = async ( const encryptedClientCredentials = await encryptAppConnectionCredentials({ credentials: updatedClientCredentials, orgId: appConnection.orgId, - kmsService + kmsService, + projectId: appConnection.projectId }); await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials }); diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts index d6a260050..cd3583800 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts @@ -58,7 +58,8 @@ export const getAzureConnectionAccessToken = async ( const oauthCredentials = (await decryptAppConnectionCredentials({ orgId: appConnection.orgId, kmsService, - encryptedCredentials: appConnection.encryptedCredentials + encryptedCredentials: appConnection.encryptedCredentials, + projectId: appConnection.projectId })) as TAzureKeyVaultConnectionCredentials; const { data } = await request.post( @@ -82,7 +83,8 @@ export const getAzureConnectionAccessToken = async ( const encryptedOAuthCredentials = await encryptAppConnectionCredentials({ credentials: updatedOAuthCredentials, orgId: appConnection.orgId, - kmsService + kmsService, + projectId: appConnection.projectId }); await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedOAuthCredentials }); @@ -95,7 +97,8 @@ export const getAzureConnectionAccessToken = async ( const clientSecretCredentials = (await decryptAppConnectionCredentials({ orgId: appConnection.orgId, kmsService, - encryptedCredentials: appConnection.encryptedCredentials + encryptedCredentials: appConnection.encryptedCredentials, + projectId: appConnection.projectId })) as TAzureKeyVaultConnectionClientSecretCredentials; const { accessToken, expiresAt, clientId, clientSecret, tenantId } = clientSecretCredentials; @@ -124,7 +127,8 @@ export const getAzureConnectionAccessToken = async ( const encryptedClientCredentials = await encryptAppConnectionCredentials({ credentials: updatedClientCredentials, orgId: appConnection.orgId, - kmsService + kmsService, + projectId: appConnection.projectId }); await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials }); diff --git a/backend/src/services/app-connection/camunda/camunda-connection-fns.ts b/backend/src/services/app-connection/camunda/camunda-connection-fns.ts index 91a033c0e..b764da336 100644 --- a/backend/src/services/app-connection/camunda/camunda-connection-fns.ts +++ b/backend/src/services/app-connection/camunda/camunda-connection-fns.ts @@ -40,7 +40,7 @@ const authorizeCamundaConnection = async ({ }; export const getCamundaConnectionAccessToken = async ( - { id, orgId, credentials }: TCamundaConnection, + { id, orgId, credentials, projectId }: TCamundaConnection, appConnectionDAL: Pick, kmsService: Pick ) => { @@ -61,7 +61,8 @@ export const getCamundaConnectionAccessToken = async ( const encryptedCredentials = await encryptAppConnectionCredentials({ credentials: updatedCredentials, orgId, - kmsService + kmsService, + projectId }); await appConnectionDAL.updateById(id, { encryptedCredentials }); diff --git a/backend/src/services/app-connection/databricks/databricks-connection-fns.ts b/backend/src/services/app-connection/databricks/databricks-connection-fns.ts index 8912ad936..a9128ec51 100644 --- a/backend/src/services/app-connection/databricks/databricks-connection-fns.ts +++ b/backend/src/services/app-connection/databricks/databricks-connection-fns.ts @@ -47,7 +47,7 @@ const authorizeDatabricksConnection = async ({ }; export const getDatabricksConnectionAccessToken = async ( - { id, orgId, credentials }: TDatabricksConnection, + { id, orgId, credentials, projectId }: TDatabricksConnection, appConnectionDAL: Pick, kmsService: Pick ) => { @@ -68,7 +68,8 @@ export const getDatabricksConnectionAccessToken = async ( const encryptedCredentials = await encryptAppConnectionCredentials({ credentials: updatedCredentials, orgId, - kmsService + kmsService, + projectId }); await appConnectionDAL.updateById(id, { encryptedCredentials }); diff --git a/backend/src/services/app-connection/gitlab/gitlab-connection-fns.ts b/backend/src/services/app-connection/gitlab/gitlab-connection-fns.ts index cb4e27e94..9499d6bf2 100644 --- a/backend/src/services/app-connection/gitlab/gitlab-connection-fns.ts +++ b/backend/src/services/app-connection/gitlab/gitlab-connection-fns.ts @@ -64,6 +64,7 @@ export const refreshGitLabToken = async ( refreshToken: string, appId: string, orgId: string, + projectId: string | undefined | null, appConnectionDAL: Pick, kmsService: Pick, instanceUrl?: string @@ -105,7 +106,8 @@ export const refreshGitLabToken = async ( expiresAt }, orgId, - kmsService + kmsService, + projectId }); await appConnectionDAL.updateById(appId, { encryptedCredentials }); @@ -238,6 +240,7 @@ export const getGitLabConnectionClient = async ( appConnection.credentials.refreshToken, appConnection.id, appConnection.orgId, + appConnection.projectId, appConnectionDAL, kmsService, appConnection.credentials.instanceUrl @@ -273,6 +276,7 @@ export const listGitLabProjects = async ({ appConnection.credentials.refreshToken, appConnection.id, appConnection.orgId, + appConnection.projectId, appConnectionDAL, kmsService, appConnection.credentials.instanceUrl @@ -341,6 +345,7 @@ export const listGitLabGroups = async ({ appConnection.credentials.refreshToken, appConnection.id, appConnection.orgId, + appConnection.projectId, appConnectionDAL, kmsService, appConnection.credentials.instanceUrl diff --git a/backend/src/services/app-connection/heroku/heroku-connection-fns.ts b/backend/src/services/app-connection/heroku/heroku-connection-fns.ts index 5a8533c83..bfb22cbc4 100644 --- a/backend/src/services/app-connection/heroku/heroku-connection-fns.ts +++ b/backend/src/services/app-connection/heroku/heroku-connection-fns.ts @@ -22,13 +22,13 @@ interface HerokuOAuthTokenResponse { } export const getHerokuConnectionListItem = () => { - const { CLIENT_ID_HEROKU } = getConfig(); + const { INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID } = getConfig(); return { name: "Heroku" as const, app: AppConnection.Heroku as const, methods: Object.values(HerokuConnectionMethod) as [HerokuConnectionMethod.AuthToken, HerokuConnectionMethod.OAuth], - oauthClientId: CLIENT_ID_HEROKU + oauthClientId: INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID }; }; @@ -36,15 +36,16 @@ export const refreshHerokuToken = async ( refreshToken: string, appId: string, orgId: string, + projectId: string | null | undefined, appConnectionDAL: Pick, kmsService: Pick ): Promise => { - const { CLIENT_SECRET_HEROKU } = getConfig(); + const { INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET } = getConfig(); const payload = { grant_type: "refresh_token", refresh_token: refreshToken, - client_secret: CLIENT_SECRET_HEROKU + client_secret: INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET }; const { data } = await request.post<{ access_token: string; expires_in: number }>( @@ -64,7 +65,8 @@ export const refreshHerokuToken = async ( expiresAt: new Date(Date.now() + data.expires_in * 1000 - 60000) }, orgId, - kmsService + kmsService, + projectId }); await appConnectionDAL.updateById(appId, { encryptedCredentials }); @@ -73,7 +75,7 @@ export const refreshHerokuToken = async ( }; export const exchangeHerokuOAuthCode = async (code: string): Promise => { - const { CLIENT_SECRET_HEROKU } = getConfig(); + const { INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET } = getConfig(); try { const response = await request.post( @@ -81,7 +83,7 @@ export const exchangeHerokuOAuthCode = async (code: string): Promise { + return { + name: "Redis" as const, + app: AppConnection.Redis as const, + methods: Object.values(RedisConnectionMethod) as [RedisConnectionMethod.UsernameAndPassword], + supportsPlatformManagement: false as const + }; +}; + +export const validateRedisConnectionCredentials = async (config: TRedisConnectionConfig) => { + const [hostIp] = await verifyHostInputValidity(config.credentials.host); + + let connection: Redis | null = null; + try { + connection = new Redis({ + username: config.credentials.username, + host: hostIp, + port: config.credentials.port, + password: config.credentials.password, + ...(config.credentials.sslEnabled && { + tls: { + rejectUnauthorized: config.credentials.sslRejectUnauthorized, + ca: config.credentials.sslCertificate + } + }) + }); + + let result: string; + if (config.credentials.password) { + result = await connection.auth(config.credentials.username, config.credentials.password, () => {}); + } else { + result = await connection.auth(config.credentials.username, () => {}); + } + + if (result !== "OK") { + throw new BadRequestError({ message: `Invalid credentials, Redis returned ${result} status` }); + } + + return config.credentials; + } catch (err) { + if (err instanceof BadRequestError) { + throw err; + } + throw new BadRequestError({ + message: `Unable to validate connection: ${(err as Error)?.message || "verify credentials"}` + }); + } finally { + if (connection) await connection.quit(); + } +}; diff --git a/backend/src/services/app-connection/redis/redis-connection-schemas.ts b/backend/src/services/app-connection/redis/redis-connection-schemas.ts new file mode 100644 index 000000000..f29a2d036 --- /dev/null +++ b/backend/src/services/app-connection/redis/redis-connection-schemas.ts @@ -0,0 +1,87 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { AppConnection } from "../app-connection-enums"; +import { RedisConnectionMethod } from "./redis-connection-enums"; + +export const BaseRedisUsernameAndPasswordConnectionSchema = z.object({ + host: z.string().toLowerCase().min(1), + port: z.coerce.number(), + username: z.string().min(1), + password: z.string().min(1).optional(), + + sslRejectUnauthorized: z.boolean(), + sslEnabled: z.boolean(), + sslCertificate: z + .string() + .trim() + .transform((value) => value || undefined) + .optional() +}); + +export const RedisConnectionAccessTokenCredentialsSchema = BaseRedisUsernameAndPasswordConnectionSchema; + +const BaseRedisConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Redis) }); + +export const RedisConnectionSchema = BaseRedisConnectionSchema.extend({ + method: z.literal(RedisConnectionMethod.UsernameAndPassword), + credentials: RedisConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedRedisConnectionSchema = z.discriminatedUnion("method", [ + BaseRedisConnectionSchema.extend({ + method: z.literal(RedisConnectionMethod.UsernameAndPassword), + credentials: RedisConnectionAccessTokenCredentialsSchema.pick({ + host: true, + port: true, + username: true, + sslEnabled: true, + sslRejectUnauthorized: true, + sslCertificate: true + }) + }) +]); + +export const ValidateRedisConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(RedisConnectionMethod.UsernameAndPassword) + .describe(AppConnections.CREATE(AppConnection.Redis).method), + credentials: RedisConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Redis).credentials + ) + }) +]); + +export const CreateRedisConnectionSchema = ValidateRedisConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Redis, { + supportsPlatformManagedCredentials: false, + supportsGateways: false + }) +); + +export const UpdateRedisConnectionSchema = z + .object({ + credentials: RedisConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Redis).credentials + ) + }) + .and( + GenericUpdateAppConnectionFieldsSchema(AppConnection.Redis, { + supportsPlatformManagedCredentials: false, + supportsGateways: false + }) + ); + +export const RedisConnectionListItemSchema = z.object({ + name: z.literal("Redis"), + app: z.literal(AppConnection.Redis), + methods: z.nativeEnum(RedisConnectionMethod).array(), + supportsPlatformManagement: z.literal(false) +}); diff --git a/backend/src/services/app-connection/redis/redis-connection-types.ts b/backend/src/services/app-connection/redis/redis-connection-types.ts new file mode 100644 index 000000000..2d1ba7699 --- /dev/null +++ b/backend/src/services/app-connection/redis/redis-connection-types.ts @@ -0,0 +1,22 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateRedisConnectionSchema, + RedisConnectionSchema, + ValidateRedisConnectionCredentialsSchema +} from "./redis-connection-schemas"; + +export type TRedisConnection = z.infer; + +export type TRedisConnectionInput = z.infer & { + app: AppConnection.Redis; +}; + +export type TValidateRedisConnectionCredentialsSchema = typeof ValidateRedisConnectionCredentialsSchema; + +export type TRedisConnectionConfig = DiscriminativePick & { + orgId: string; +}; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index ede9e29e3..d69c836e9 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -14,6 +14,8 @@ import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; +import { TNotificationServiceFactory } from "../notification/notification-service"; +import { NotificationType } from "../notification/notification-types"; import { TOrgDALFactory } from "../org/org-dal"; import { getDefaultOrgMembershipRole } from "../org/org-role-fns"; import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal"; @@ -47,6 +49,7 @@ type TAuthLoginServiceFactoryDep = { totpService: Pick; auditLogService: Pick; orgMembershipDAL: TOrgMembershipDALFactory; + notificationService: Pick; }; export type TAuthLoginFactory = ReturnType; @@ -57,7 +60,8 @@ export const authLoginServiceFactory = ({ orgDAL, orgMembershipDAL, totpService, - auditLogService + auditLogService, + notificationService }: TAuthLoginServiceFactoryDep) => { /* * Private @@ -71,6 +75,16 @@ export const authLoginServiceFactory = ({ if (!isDeviceSeen) { const newDeviceList = devices.concat([{ ip, userAgent }]); await userDAL.updateById(user.id, { devices: JSON.stringify(newDeviceList) }, tx); + + await notificationService.createUserNotifications([ + { + userId: user.id, + type: NotificationType.LOGIN_FROM_NEW_DEVICE, + title: "Login From New Device", + body: `A new device with IP **${ip}** and User Agent **${userAgent}** has logged into your account.` + } + ]); + if (user.email) { await smtpService.sendMail({ template: SmtpTemplates.NewDeviceJoin, @@ -563,6 +577,18 @@ export const authLoginServiceFactory = ({ .filter(Boolean) as string[]; if (adminEmails.length > 0) { + await notificationService.createUserNotifications( + orgAdmins + .filter((admin) => admin.user.id !== user.id) + .map((admin) => ({ + userId: admin.user.id, + orgId: organizationId, + type: NotificationType.ADMIN_SSO_BYPASS, + title: "Security Alert: Admin SSO Bypass", + body: `The org admin **${user.email}** has bypassed enforced SSO login.` + })) + ); + await smtpService.sendMail({ recipients: adminEmails, subjectLine: "Security Alert: Admin SSO Bypass", @@ -658,7 +684,8 @@ export const authLoginServiceFactory = ({ mfaJwtToken, ip, userAgent, - orgId + orgId, + isRecoveryCode = false }: TVerifyMfaTokenDTO) => { const appCfg = getConfig(); const user = await userDAL.findById(userId); @@ -672,16 +699,21 @@ export const authLoginServiceFactory = ({ code: mfaToken }); } else if (mfaMethod === MfaMethod.TOTP) { - if (mfaToken.length === 6) { - await totpService.verifyUserTotp({ - userId, - totp: mfaToken - }); - } else { + if (isRecoveryCode) { await totpService.verifyWithUserRecoveryCode({ userId, recoveryCode: mfaToken }); + } else { + if (mfaToken.length !== 6) { + throw new BadRequestError({ + message: "Please use a valid TOTP code." + }); + } + await totpService.verifyUserTotp({ + userId, + totp: mfaToken + }); } } } catch (err) { diff --git a/backend/src/services/auth/auth-login-type.ts b/backend/src/services/auth/auth-login-type.ts index 09d81033f..9b010a860 100644 --- a/backend/src/services/auth/auth-login-type.ts +++ b/backend/src/services/auth/auth-login-type.ts @@ -24,6 +24,7 @@ export type TVerifyMfaTokenDTO = { ip: string; userAgent: string; orgId?: string; + isRecoveryCode?: boolean; }; export type TOauthLoginDTO = { diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index efc8b3cc0..21a51ef3f 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -6,6 +6,7 @@ import { OrgServiceActor } from "@app/lib/types"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; +import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TTotpConfigDALFactory } from "../totp/totp-config-dal"; import { TUserDALFactory } from "../user/user-dal"; @@ -22,6 +23,7 @@ import { ActorType, AuthMethod, AuthTokenType } from "./auth-type"; type TAuthPasswordServiceFactoryDep = { authDAL: TAuthDALFactory; userDAL: TUserDALFactory; + orgMembershipDAL: Pick; tokenService: TAuthTokenServiceFactory; smtpService: TSmtpService; totpConfigDAL: Pick; @@ -31,6 +33,7 @@ export type TAuthPasswordFactory = ReturnType; export const authPaswordServiceFactory = ({ authDAL, userDAL, + orgMembershipDAL, tokenService, smtpService, totpConfigDAL @@ -47,21 +50,46 @@ export const authPaswordServiceFactory = ({ if (user && user.isAccepted) { const cfg = getConfig(); - const token = await tokenService.createTokenForUser({ - type: TokenType.TOKEN_EMAIL_PASSWORD_RESET, - userId: user.id - }); - await smtpService.sendMail({ - template: SmtpTemplates.ResetPassword, - recipients: [email], - subjectLine: "Infisical password reset", - substitutions: { + const hasEmailAuth = user.authMethods?.includes(AuthMethod.EMAIL); + + if (!hasEmailAuth) { + const orgMemberships = await orgMembershipDAL.find({ userId: user.id }); + const lastLoginMethod = + orgMemberships + .filter((membership) => membership.lastLoginAuthMethod) + .sort((a, b) => (b.updatedAt || new Date(0)).getTime() - (a.updatedAt || new Date(0)).getTime())[0] + ?.lastLoginAuthMethod || null; + const substitutions = { email, - token, - callback_url: cfg.SITE_URL ? `${cfg.SITE_URL}/password-reset` : "" - } - }); + lastLoginMethod, + isCloud: cfg.isCloud, + siteUrl: cfg.SITE_URL || "" + }; + + await smtpService.sendMail({ + template: SmtpTemplates.OAuthPasswordReset, + recipients: [email], + subjectLine: "Password reset not available", + substitutions + }); + } else { + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_PASSWORD_RESET, + userId: user.id + }); + + await smtpService.sendMail({ + template: SmtpTemplates.ResetPassword, + recipients: [email], + subjectLine: "Infisical password reset", + substitutions: { + email, + token, + callback_url: cfg.SITE_URL ? `${cfg.SITE_URL}/password-reset` : "" + } + }); + } } }; diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts index 830378ca8..0dea986d6 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts @@ -23,6 +23,9 @@ import { } from "@app/services/certificate/certificate-types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; +import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; +import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue"; +import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; @@ -42,10 +45,10 @@ import { route53DeleteTxtRecord, route53InsertTxtRecord } from "./dns-providers/ type TAcmeCertificateAuthorityFnsDeps = { appConnectionDAL: Pick; - appConnectionService: Pick; + appConnectionService: Pick; certificateAuthorityDAL: Pick< TCertificateAuthorityDALFactory, - "create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" + "create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById" >; externalCertificateAuthorityDAL: Pick; certificateDAL: Pick; @@ -56,6 +59,8 @@ type TAcmeCertificateAuthorityFnsDeps = { "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey" >; pkiSubscriberDAL: Pick; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; projectDAL: Pick; }; @@ -109,7 +114,9 @@ export const AcmeCertificateAuthorityFns = ({ certificateSecretDAL, kmsService, projectDAL, - pkiSubscriberDAL + pkiSubscriberDAL, + pkiSyncDAL, + pkiSyncQueue }: TAcmeCertificateAuthorityFnsDeps) => { const createCertificateAuthority = async ({ name, @@ -152,7 +159,11 @@ export const AcmeCertificateAuthorityFns = ({ } // validates permission to connect - await appConnectionService.connectAppConnectionById(appConnection.app as AppConnection, dnsAppConnectionId, actor); + await appConnectionService.validateAppConnectionUsageById( + appConnection.app as AppConnection, + { connectionId: dnsAppConnectionId, projectId }, + actor + ); const caEntity = await certificateAuthorityDAL.transaction(async (tx) => { try { @@ -242,10 +253,16 @@ export const AcmeCertificateAuthorityFns = ({ }); } + const ca = await certificateAuthorityDAL.findById(id); + + if (!ca) { + throw new NotFoundError({ message: `Could not find Certificate Authority with ID "${id}"` }); + } + // validates permission to connect - await appConnectionService.connectAppConnectionById( + await appConnectionService.validateAppConnectionUsageById( appConnection.app as AppConnection, - dnsAppConnectionId, + { connectionId: dnsAppConnectionId, projectId: ca.projectId }, actor ); @@ -514,6 +531,8 @@ export const AcmeCertificateAuthorityFns = ({ tx ); }); + + await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue }); }; return { diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts index 0e2619a27..4937a5331 100644 --- a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts @@ -26,6 +26,9 @@ import { import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { TPkiSubscriberProperties } from "@app/services/pki-subscriber/pki-subscriber-types"; +import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; +import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue"; +import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; @@ -41,10 +44,10 @@ import { type TAzureAdCsCertificateAuthorityFnsDeps = { appConnectionDAL: Pick; - appConnectionService: Pick; + appConnectionService: Pick; certificateAuthorityDAL: Pick< TCertificateAuthorityDALFactory, - "create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" + "create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById" >; externalCertificateAuthorityDAL: Pick; certificateDAL: Pick; @@ -55,6 +58,8 @@ type TAzureAdCsCertificateAuthorityFnsDeps = { "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey" >; pkiSubscriberDAL: Pick; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; projectDAL: Pick; }; @@ -584,7 +589,9 @@ export const AzureAdCsCertificateAuthorityFns = ({ certificateSecretDAL, kmsService, projectDAL, - pkiSubscriberDAL + pkiSubscriberDAL, + pkiSyncDAL, + pkiSyncQueue }: TAzureAdCsCertificateAuthorityFnsDeps) => { const createCertificateAuthority = async ({ name, @@ -621,9 +628,9 @@ export const AzureAdCsCertificateAuthorityFns = ({ }); } - await appConnectionService.connectAppConnectionById( + await appConnectionService.validateAppConnectionUsageById( appConnection.app as AppConnection, - azureAdcsConnectionId, + { connectionId: azureAdcsConnectionId, projectId }, actor ); @@ -705,9 +712,15 @@ export const AzureAdCsCertificateAuthorityFns = ({ }); } - await appConnectionService.connectAppConnectionById( + const ca = await certificateAuthorityDAL.findById(id); + + if (!ca) { + throw new NotFoundError({ message: `Could not find Certificate Authority with ID "${id}"` }); + } + + await appConnectionService.validateAppConnectionUsageById( appConnection.app as AppConnection, - azureAdcsConnectionId, + { connectionId: azureAdcsConnectionId, projectId: ca.projectId }, actor ); @@ -1018,6 +1031,8 @@ export const AzureAdCsCertificateAuthorityFns = ({ ); }); + await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue }); + return { certificate: certificatePem, certificateChain: certificateChainPem, diff --git a/backend/src/services/certificate-authority/certificate-authority-queue.ts b/backend/src/services/certificate-authority/certificate-authority-queue.ts index 0e015da03..81aab337d 100644 --- a/backend/src/services/certificate-authority/certificate-authority-queue.ts +++ b/backend/src/services/certificate-authority/certificate-authority-queue.ts @@ -20,6 +20,8 @@ import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal" import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal"; import { SubscriberOperationStatus } from "../pki-subscriber/pki-subscriber-types"; +import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal"; +import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue"; import { AcmeCertificateAuthorityFns } from "./acme/acme-certificate-authority-fns"; import { AzureAdCsCertificateAuthorityFns } from "./azure-ad-cs/azure-ad-cs-certificate-authority-fns"; import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; @@ -35,7 +37,7 @@ import { type TCertificateAuthorityQueueFactoryDep = { certificateAuthorityDAL: TCertificateAuthorityDALFactory; appConnectionDAL: Pick; - appConnectionService: Pick; + appConnectionService: Pick; externalCertificateAuthorityDAL: Pick; keyStore: Pick; certificateAuthorityCrlDAL: TCertificateAuthorityCrlDALFactory; @@ -50,6 +52,8 @@ type TCertificateAuthorityQueueFactoryDep = { certificateSecretDAL: Pick; queueService: TQueueServiceFactory; pkiSubscriberDAL: Pick; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; }; export type TCertificateAuthorityQueueFactory = ReturnType; @@ -68,7 +72,9 @@ export const certificateAuthorityQueueFactory = ({ externalCertificateAuthorityDAL, certificateBodyDAL, certificateSecretDAL, - pkiSubscriberDAL + pkiSubscriberDAL, + pkiSyncDAL, + pkiSyncQueue }: TCertificateAuthorityQueueFactoryDep) => { const acmeFns = AcmeCertificateAuthorityFns({ appConnectionDAL, @@ -80,7 +86,9 @@ export const certificateAuthorityQueueFactory = ({ certificateSecretDAL, kmsService, pkiSubscriberDAL, - projectDAL + projectDAL, + pkiSyncDAL, + pkiSyncQueue }); const azureAdCsFns = AzureAdCsCertificateAuthorityFns({ @@ -93,7 +101,9 @@ export const certificateAuthorityQueueFactory = ({ certificateSecretDAL, kmsService, pkiSubscriberDAL, - projectDAL + projectDAL, + pkiSyncDAL, + pkiSyncQueue }); // TODO 1: auto-periodic rotation diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 6cf55fc52..ed27f7571 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -13,6 +13,8 @@ import { TCertificateDALFactory } from "../certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal"; +import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal"; +import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue"; import { TProjectDALFactory } from "../project/project-dal"; import { AcmeCertificateAuthorityFns, @@ -43,7 +45,7 @@ import { TCreateInternalCertificateAuthorityDTO } from "./internal/internal-cert type TCertificateAuthorityServiceFactoryDep = { appConnectionDAL: Pick; - appConnectionService: Pick; + appConnectionService: Pick; certificateAuthorityDAL: Pick< TCertificateAuthorityDALFactory, | "transaction" @@ -68,6 +70,8 @@ type TCertificateAuthorityServiceFactoryDep = { "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey" >; pkiSubscriberDAL: Pick; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; }; export type TCertificateAuthorityServiceFactory = ReturnType; @@ -84,7 +88,9 @@ export const certificateAuthorityServiceFactory = ({ certificateBodyDAL, certificateSecretDAL, kmsService, - pkiSubscriberDAL + pkiSubscriberDAL, + pkiSyncDAL, + pkiSyncQueue }: TCertificateAuthorityServiceFactoryDep) => { const acmeFns = AcmeCertificateAuthorityFns({ appConnectionDAL, @@ -96,7 +102,9 @@ export const certificateAuthorityServiceFactory = ({ certificateSecretDAL, kmsService, pkiSubscriberDAL, - projectDAL + projectDAL, + pkiSyncDAL, + pkiSyncQueue }); const azureAdCsFns = AzureAdCsCertificateAuthorityFns({ @@ -109,7 +117,9 @@ export const certificateAuthorityServiceFactory = ({ certificateSecretDAL, kmsService, pkiSubscriberDAL, - projectDAL + projectDAL, + pkiSyncDAL, + pkiSyncQueue }); const createCertificateAuthority = async ( diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts index 9f1a5e5c2..6f730fd72 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts @@ -19,6 +19,9 @@ import { TAltNameMapping } from "@app/services/certificate/certificate-types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; +import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue"; +import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; @@ -51,6 +54,8 @@ type TInternalCertificateAuthorityFnsDeps = { certificateDAL: Pick; certificateBodyDAL: Pick; certificateSecretDAL: Pick; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; }; export const InternalCertificateAuthorityFns = ({ @@ -62,7 +67,9 @@ export const InternalCertificateAuthorityFns = ({ certificateAuthorityCrlDAL, certificateDAL, certificateBodyDAL, - certificateSecretDAL + certificateSecretDAL, + pkiSyncDAL, + pkiSyncQueue }: TInternalCertificateAuthorityFnsDeps) => { const issueCertificate = async ( subscriber: TPkiSubscribers, @@ -251,6 +258,8 @@ export const InternalCertificateAuthorityFns = ({ ); }); + await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue }); + return { certificate: leafCert.toString("pem"), certificateChain: certificateChainPem, diff --git a/backend/src/services/certificate/certificate-dal.ts b/backend/src/services/certificate/certificate-dal.ts index 9db473236..88808bd63 100644 --- a/backend/src/services/certificate/certificate-dal.ts +++ b/backend/src/services/certificate/certificate-dal.ts @@ -1,5 +1,5 @@ import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TableName, TCertificates } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; @@ -25,6 +25,20 @@ export const certificateDALFactory = (db: TDbClient) => { } }; + const findAllActiveCertsForSubscriber = async ({ subscriberId }: { subscriberId: string }) => { + try { + const certs = await db + .replicaNode()(TableName.Certificate) + .where({ pkiSubscriberId: subscriberId, status: CertStatus.ACTIVE }) + .where("notAfter", ">", new Date()) + .orderBy("notBefore", "desc"); + + return certs; + } catch (error) { + throw new DatabaseError({ error, name: "Find all active certificates for subscriber" }); + } + }; + const countCertificatesInProject = async ({ projectId, friendlyName, @@ -79,10 +93,33 @@ export const certificateDALFactory = (db: TDbClient) => { } }; + const findExpiredSyncedCertificates = async (): Promise => { + try { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const certs = await db + .replicaNode()(TableName.Certificate) + .where("notAfter", ">=", yesterday) + .where("notAfter", "<", today) + .whereNotNull("pkiSubscriberId"); + + return certs; + } catch (error) { + throw new DatabaseError({ error, name: "Find expired synced certificates" }); + } + }; + return { ...certificateOrm, countCertificatesInProject, countCertificatesForPkiSubscriber, - findLatestActiveCertForSubscriber + findLatestActiveCertForSubscriber, + findAllActiveCertsForSubscriber, + findExpiredSyncedCertificates }; }; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index 7adc60d52..91731c387 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; @@ -20,6 +21,9 @@ import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TPkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal"; import { TPkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal"; +import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; +import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue"; +import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; @@ -53,6 +57,8 @@ type TCertificateServiceFactoryDep = { projectDAL: Pick; kmsService: Pick; permissionService: Pick; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; }; export type TCertificateServiceFactory = ReturnType; @@ -69,7 +75,9 @@ export const certificateServiceFactory = ({ pkiCollectionItemDAL, projectDAL, kmsService, - permissionService + permissionService, + pkiSyncDAL, + pkiSyncQueue }: TCertificateServiceFactoryDep) => { /** * Return details for certificate with serial number [serialNumber] @@ -158,6 +166,11 @@ export const certificateServiceFactory = ({ const deletedCert = await certificateDAL.deleteById(cert.id); + // Trigger auto sync for PKI syncs connected to this certificate's subscriber + if (cert.pkiSubscriberId) { + await triggerAutoSyncForSubscriber(cert.pkiSubscriberId, { pkiSyncDAL, pkiSyncQueue }); + } + return { deletedCert }; @@ -222,6 +235,11 @@ export const certificateServiceFactory = ({ } ); + // Trigger auto sync for PKI syncs connected to this certificate's subscriber + if (cert.pkiSubscriberId) { + await triggerAutoSyncForSubscriber(cert.pkiSubscriberId, { pkiSyncDAL, pkiSyncQueue }); + } + // Note: External CA revocation handling would go here for supported CA types // Currently, only internal CAs and ACME CAs support revocation diff --git a/backend/src/services/connection-queue/connection-queue-fns.ts b/backend/src/services/connection-queue/connection-queue-fns.ts new file mode 100644 index 000000000..8c0fba4a2 --- /dev/null +++ b/backend/src/services/connection-queue/connection-queue-fns.ts @@ -0,0 +1,129 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; + +import { logger } from "@app/lib/logger"; + +export type RateLimitConfig = { + MAX_CONCURRENT_REQUESTS: number; + BASE_DELAY: number; + MAX_DELAY: number; + MAX_RETRIES: number; + RATE_LIMIT_STATUS_CODES: number[]; +}; + +export type RateLimitContext = { + operation: string; + identifier?: string; + syncId: string; +}; + +export type ConcurrencyContext = { + operation: string; + syncId: string; +}; + +export const sleep = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +export const createRateLimitErrorChecker = + (config: RateLimitConfig) => + (error: unknown): boolean => { + if (error instanceof AxiosError) { + return ( + config.RATE_LIMIT_STATUS_CODES.includes(error.response?.status || 0) || + error.message.toLowerCase().includes("rate limit") || + error.message.toLowerCase().includes("throttl") + ); + } + return false; + }; + +export const createRateLimitRetry = + (config: RateLimitConfig, isRateLimitError: (error: unknown) => boolean) => + async (fn: () => Promise, context: RateLimitContext, retryCount = 0): Promise => { + try { + return await fn(); + } catch (error) { + if (isRateLimitError(error) && retryCount < config.MAX_RETRIES) { + const delay = Math.min(config.BASE_DELAY * 2 ** retryCount, config.MAX_DELAY); + + logger.warn( + { + syncId: context.syncId, + operation: context.operation, + identifier: context.identifier, + retryCount: retryCount + 1, + delayMs: delay, + error: error instanceof AxiosError ? error.message : String(error) + }, + "Rate limit hit, retrying with exponential backoff" + ); + + await sleep(delay); + return createRateLimitRetry(config, isRateLimitError)(fn, context, retryCount + 1); + } + + throw error; + } + }; + +export const createConcurrencyLimitExecutor = + ( + config: RateLimitConfig, + withRateLimitRetry: (fn: () => Promise, context: RateLimitContext, retryCount?: number) => Promise + ) => + async ( + items: T[], + executor: (item: T) => Promise, + context: ConcurrencyContext, + concurrencyLimit = config.MAX_CONCURRENT_REQUESTS + ): Promise[]> => { + const results: PromiseSettledResult[] = []; + + for (let i = 0; i < items.length; i += concurrencyLimit) { + const batch = items.slice(i, i + concurrencyLimit); + + logger.debug( + { + syncId: context.syncId, + operation: context.operation, + batchStart: i + 1, + batchEnd: Math.min(i + concurrencyLimit, items.length), + totalItems: items.length + }, + "Processing batch with rate limit protection" + ); + + const batchPromises = batch.map((item, batchIndex) => + withRateLimitRetry(() => executor(item), { + operation: context.operation, + identifier: `batch-${i + batchIndex + 1}`, + syncId: context.syncId + }) + ); + + const batchResults = await Promise.allSettled(batchPromises); + results.push(...batchResults); + + if (i + concurrencyLimit < items.length) { + await sleep(100); + } + } + + return results; + }; + +export const createConnectionQueue = (config: RateLimitConfig) => { + const isRateLimitError = createRateLimitErrorChecker(config); + const withRateLimitRetry = createRateLimitRetry(config, isRateLimitError); + const executeWithConcurrencyLimit = createConcurrencyLimitExecutor(config, withRateLimitRetry); + + return { + sleep, + isRateLimitError, + withRateLimitRetry, + executeWithConcurrencyLimit + }; +}; diff --git a/backend/src/services/connection-queue/index.ts b/backend/src/services/connection-queue/index.ts new file mode 100644 index 000000000..7e51d42aa --- /dev/null +++ b/backend/src/services/connection-queue/index.ts @@ -0,0 +1 @@ +export * from "./connection-queue-fns"; diff --git a/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-fns.ts b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-fns.ts index fe6724251..552eed307 100644 --- a/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-fns.ts +++ b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-fns.ts @@ -33,6 +33,7 @@ export const constructGroupOrgMembershipRoleMappings = async ({ let customRolesMap: Map = new Map(); if (customRoleSlugs.length > 0) { const customRoles = await orgRoleDAL.find({ + orgId, $in: { slug: customRoleSlugs } diff --git a/backend/src/services/external-migration/external-migration-fns/import.ts b/backend/src/services/external-migration/external-migration-fns/import.ts index 5728bf1c0..62888c5bf 100644 --- a/backend/src/services/external-migration/external-migration-fns/import.ts +++ b/backend/src/services/external-migration/external-migration-fns/import.ts @@ -55,7 +55,7 @@ export const importDataIntoInfisicalFn = async ({ actorId, actorOrgId, actorAuthMethod, - workspaceName: project.name, + projectName: project.name, createDefaultEnvs: false, tx }) diff --git a/backend/src/services/external-migration/external-migration-queue.ts b/backend/src/services/external-migration/external-migration-queue.ts index b4974d2ae..770cbbe3a 100644 --- a/backend/src/services/external-migration/external-migration-queue.ts +++ b/backend/src/services/external-migration/external-migration-queue.ts @@ -5,6 +5,8 @@ import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; import { TKmsServiceFactory } from "../kms/kms-service"; +import { TNotificationServiceFactory } from "../notification/notification-service"; +import { NotificationType } from "../notification/notification-types"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectServiceFactory } from "../project/project-service"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; @@ -42,6 +44,7 @@ export type TExternalMigrationQueueFactoryDep = { folderVersionDAL: Pick; resourceMetadataDAL: Pick; + notificationService: Pick; }; export type TExternalMigrationQueueFactory = ReturnType; @@ -62,9 +65,12 @@ export const externalMigrationQueueFactory = ({ folderDAL, folderCommitService, folderVersionDAL, - resourceMetadataDAL + resourceMetadataDAL, + notificationService }: TExternalMigrationQueueFactoryDep) => { const startImport = async (dto: { + orgId: string; + actorId: string; actorEmail: string; importType: ExternalPlatforms; data: { @@ -87,9 +93,19 @@ export const externalMigrationQueueFactory = ({ }; queueService.start(QueueName.ImportSecretsFromExternalSource, async (job) => { - const { data, actorEmail, importType } = job.data; + const { data, actorEmail, importType, actorId, orgId } = job.data; try { + await notificationService.createUserNotifications([ + { + userId: actorId, + orgId, + type: NotificationType.IMPORT_STARTED, + title: "Import Started", + body: `An import from **${importType}** to Infisical has been started.` + } + ]); + await smtpService.sendMail({ recipients: [actorEmail], subjectLine: "Infisical import started", @@ -137,6 +153,16 @@ export const externalMigrationQueueFactory = ({ ); } + await notificationService.createUserNotifications([ + { + userId: actorId, + orgId, + type: NotificationType.IMPORT_SUCCESSFUL, + title: "Import Successful", + body: `An import from **${importType}** to Infisical has successfully completed.` + } + ]); + await smtpService.sendMail({ recipients: [actorEmail], subjectLine: "Infisical import successful", @@ -146,6 +172,17 @@ export const externalMigrationQueueFactory = ({ template: SmtpTemplates.ExternalImportSuccessful }); } catch (err) { + await notificationService.createUserNotifications([ + { + userId: actorId, + orgId, + type: NotificationType.IMPORT_FAILED, + title: "Import Failed", + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + body: `An import from **${importType}** to Infisical has failed: ${(err as any)?.message || "Unknown error"}.` + } + ]); + await smtpService.sendMail({ recipients: [job.data.actorEmail], subjectLine: "Infisical import failed", diff --git a/backend/src/services/external-migration/external-migration-service.ts b/backend/src/services/external-migration/external-migration-service.ts index 73fac00b9..e801b607e 100644 --- a/backend/src/services/external-migration/external-migration-service.ts +++ b/backend/src/services/external-migration/external-migration-service.ts @@ -73,6 +73,8 @@ export const externalMigrationServiceFactory = ({ const encrypted = crypto.encryption().symmetric().encryptWithRootEncryptionKey(stringifiedJson); await externalMigrationQueue.startImport({ + actorId: user.id, + orgId: actorOrgId, actorEmail: user.email!, importType: ExternalPlatforms.EnvKey, data: { @@ -131,6 +133,8 @@ export const externalMigrationServiceFactory = ({ const encrypted = crypto.encryption().symmetric().encryptWithRootEncryptionKey(stringifiedJson); await externalMigrationQueue.startImport({ + actorId: user.id, + orgId: actorOrgId, actorEmail: user.email!, importType: ExternalPlatforms.Vault, data: { diff --git a/backend/src/services/group-project/group-project-service.ts b/backend/src/services/group-project/group-project-service.ts index 838d89437..913aca2f3 100644 --- a/backend/src/services/group-project/group-project-service.ts +++ b/backend/src/services/group-project/group-project-service.ts @@ -37,13 +37,16 @@ type TGroupProjectServiceFactoryDep = { TGroupProjectMembershipRoleDALFactory, "create" | "transaction" | "insertMany" | "delete" >; - userGroupMembershipDAL: Pick; + userGroupMembershipDAL: Pick; projectDAL: Pick; projectKeyDAL: Pick; projectRoleDAL: Pick; projectBotDAL: TProjectBotDALFactory; groupDAL: Pick; - permissionService: Pick; + permissionService: Pick< + TPermissionServiceFactory, + "getProjectPermission" | "getProjectPermissionByRole" | "invalidateProjectPermissionCache" + >; }; export type TGroupProjectServiceFactory = ReturnType; @@ -263,6 +266,8 @@ export const groupProjectServiceFactory = ({ return groupProjectMembership; }); + await permissionService.invalidateProjectPermissionCache(projectId); + return projectGroup; }; @@ -372,6 +377,8 @@ export const groupProjectServiceFactory = ({ return groupProjectMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); }); + await permissionService.invalidateProjectPermissionCache(projectId); + return updatedRoles; }; @@ -404,14 +411,18 @@ export const groupProjectServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Delete, ProjectPermissionSub.Groups); const deletedProjectGroup = await groupProjectDAL.transaction(async (tx) => { - const groupMembers = await userGroupMembershipDAL.findGroupMembersNotInProject(group.id, project.id, tx); + const groupMembersNotInProject = await userGroupMembershipDAL.findGroupMembersNotInProject( + group.id, + project.id, + tx + ); - if (groupMembers.length) { + if (groupMembersNotInProject.length) { await projectKeyDAL.delete( { projectId: project.id, $in: { - receiverId: groupMembers.map(({ user: { id } }) => id) + receiverId: groupMembersNotInProject.map(({ user: { id } }) => id) } }, tx @@ -422,6 +433,8 @@ export const groupProjectServiceFactory = ({ return projectGroup; }); + await permissionService.invalidateProjectPermissionCache(projectId); + return deletedProjectGroup; }; diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts index 47188e26d..46ec2f98a 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { ForbiddenError } from "@casl/ability"; +import slugify from "@sindresorhus/slugify"; import { IdentityAuthMethod } from "@app/db/schemas"; import { TIdentityAuthTemplateDALFactory } from "@app/ee/services/identity-auth-template"; @@ -15,10 +16,18 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; +import { + BadRequestError, + NotFoundError, + PermissionBoundaryError, + RateLimitError, + UnauthorizedError +} from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { logger } from "@app/lib/logger"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -32,6 +41,8 @@ import { TIdentityLdapAuthDALFactory } from "./identity-ldap-auth-dal"; import { AllowedFieldsSchema, TAttachLdapAuthDTO, + TCheckLdapAuthLockoutDTO, + TClearLdapAuthLockoutsDTO, TGetLdapAuthDTO, TLoginLdapAuthDTO, TRevokeLdapAuthDTO, @@ -50,10 +61,19 @@ type TIdentityLdapAuthServiceFactoryDep = { kmsService: TKmsServiceFactory; identityDAL: TIdentityDALFactory; identityAuthTemplateDAL: TIdentityAuthTemplateDALFactory; + keyStore: Pick< + TKeyStoreFactory, + "setItemWithExpiry" | "getItem" | "deleteItem" | "getKeysByPattern" | "deleteItems" | "acquireLock" + >; }; export type TIdentityLdapAuthServiceFactory = ReturnType; +type LockoutObject = { + lockedOut: boolean; + failedAttempts: number; +}; + export const identityLdapAuthServiceFactory = ({ identityAccessTokenDAL, identityDAL, @@ -62,7 +82,8 @@ export const identityLdapAuthServiceFactory = ({ licenseService, permissionService, kmsService, - identityAuthTemplateDAL + identityAuthTemplateDAL, + keyStore }: TIdentityLdapAuthServiceFactoryDep) => { const getLdapConfig = async (identityId: string) => { const identity = await identityDAL.findOne({ id: identityId }); @@ -126,13 +147,17 @@ export const identityLdapAuthServiceFactory = ({ const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) { - throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + throw new UnauthorizedError({ + message: "Invalid credentials" + }); } const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId }); if (!identityLdapAuth) { - throw new NotFoundError({ message: `Failed to find LDAP auth for identity with ID ${identityId}` }); + throw new UnauthorizedError({ + message: "Invalid credentials" + }); } const plan = await licenseService.getPlan(identityMembershipOrg.orgId); @@ -204,7 +229,11 @@ export const identityLdapAuthServiceFactory = ({ actor, actorOrgId, isActorSuperAdmin, - allowedFields + allowedFields, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }: TAttachLdapAuthDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); @@ -337,7 +366,11 @@ export const identityLdapAuthServiceFactory = ({ accessTokenNumUsesLimit, accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), allowedFields: allowedFields ? JSON.stringify(allowedFields) : undefined, - templateId + templateId, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }, tx ); @@ -363,7 +396,11 @@ export const identityLdapAuthServiceFactory = ({ actorId, actorAuthMethod, actor, - actorOrgId + actorOrgId, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }: TUpdateLdapAuthDTO) => { const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); @@ -511,7 +548,11 @@ export const identityLdapAuthServiceFactory = ({ accessTokenNumUsesLimit, accessTokenTrustedIps: reformattedAccessTokenTrustedIps ? JSON.stringify(reformattedAccessTokenTrustedIps) - : undefined + : undefined, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }); return { ...updatedLdapAuth, orgId: identityMembershipOrg.orgId }; @@ -611,12 +652,123 @@ export const identityLdapAuthServiceFactory = ({ return revokedIdentityLdapAuth; }; + const withLdapLockout = async ( + { identityId, username }: TCheckLdapAuthLockoutDTO, + authFn: () => Promise + ): Promise => { + const usernameSlug = slugify(username.trim().toLowerCase()); + + const LOCKOUT_KEY = `lockout:identity:${identityId}:${IdentityAuthMethod.LDAP_AUTH}:${usernameSlug}`; + + let lock: Awaited>; + try { + lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 3000, { + retryCount: 3, + retryDelay: 1500, + retryJitter: 100 + }); + } catch (e) { + logger.info( + `identity login failed to acquire lock [identityId=${identityId}] [authMethod=${IdentityAuthMethod.LDAP_AUTH}]` + ); + throw new RateLimitError({ message: "Failed to acquire lock: rate limit exceeded" }); + } + + try { + const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY); + if (lockoutRaw) { + const lockout = JSON.parse(lockoutRaw) as LockoutObject; + if (lockout.lockedOut) { + throw new UnauthorizedError({ + message: "This identity auth method is temporarily locked, please try again later" + }); + } + } + + const result = await authFn(); + + await keyStore.deleteItem(LOCKOUT_KEY); + + return result; + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + if ((error as any).status === 401) { + const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId }); + if (!identityLdapAuth) { + throw new UnauthorizedError({ message: "Invalid credentials" }); + } + + if (identityLdapAuth.lockoutEnabled) { + let lockout: LockoutObject = { + lockedOut: false, + failedAttempts: 0 + }; + + const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY); + if (lockoutRaw) { + lockout = JSON.parse(lockoutRaw) as LockoutObject; + } + + lockout.failedAttempts += 1; + if (lockout.failedAttempts >= identityLdapAuth.lockoutThreshold) { + lockout.lockedOut = true; + } + + await keyStore.setItemWithExpiry( + LOCKOUT_KEY, + lockout.lockedOut ? identityLdapAuth.lockoutDurationSeconds : identityLdapAuth.lockoutCounterResetSeconds, + JSON.stringify(lockout) + ); + } + + throw new UnauthorizedError({ message: "Invalid credentials" }); + } + throw error; + } finally { + await lock.release(); + } + }; + + const clearLdapAuthLockouts = async ({ + identityId, + actorId, + actor, + actorOrgId, + actorAuthMethod + }: TClearLdapAuthLockoutsDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.LDAP_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have ldap auth" + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const deleted = await keyStore.deleteItems({ + pattern: `lockout:identity:${identityId}:${IdentityAuthMethod.LDAP_AUTH}:*` + }); + + return { deleted, identityId, orgId: identityMembershipOrg.orgId }; + }; + return { attachLdapAuth, getLdapConfig, updateLdapAuth, login, revokeIdentityLdapAuth, - getLdapAuth + getLdapAuth, + withLdapLockout, + clearLdapAuthLockouts }; }; diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts index 8629763bb..a4aea7573 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts @@ -27,6 +27,10 @@ export type TAttachLdapAuthDTO = { accessTokenNumUsesLimit: number; accessTokenTrustedIps: { ipAddress: string }[]; isActorSuperAdmin?: boolean; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; } & Omit; export type TUpdateLdapAuthDTO = { @@ -43,6 +47,10 @@ export type TUpdateLdapAuthDTO = { accessTokenMaxTTL?: number; accessTokenNumUsesLimit?: number; accessTokenTrustedIps?: { ipAddress: string }[]; + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDurationSeconds?: number; + lockoutCounterResetSeconds?: number; } & Omit; export type TGetLdapAuthDTO = { @@ -56,3 +64,12 @@ export type TLoginLdapAuthDTO = { export type TRevokeLdapAuthDTO = { identityId: string; } & Omit; + +export type TClearLdapAuthLockoutsDTO = { + identityId: string; +} & Omit; + +export type TCheckLdapAuthLockoutDTO = { + identityId: string; + username: string; +}; diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index 4f0964f42..08fd2cf0b 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -35,7 +35,10 @@ type TIdentityProjectServiceFactoryDep = { projectDAL: Pick; projectRoleDAL: Pick; identityOrgMembershipDAL: Pick; - permissionService: Pick; + permissionService: Pick< + TPermissionServiceFactory, + "getProjectPermission" | "getProjectPermissionByRole" | "invalidateProjectPermissionCache" + >; }; export type TIdentityProjectServiceFactory = ReturnType; @@ -165,6 +168,9 @@ export const identityProjectServiceFactory = ({ const identityRoles = await identityProjectMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); return { ...identityProjectMembership, roles: identityRoles }; }); + + await permissionService.invalidateProjectPermissionCache(projectId); + return projectIdentity; }; @@ -272,6 +278,8 @@ export const identityProjectServiceFactory = ({ return identityProjectMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); }); + await permissionService.invalidateProjectPermissionCache(projectId); + return updatedRoles; }; @@ -302,6 +310,9 @@ export const identityProjectServiceFactory = ({ ); const [deletedIdentity] = await identityProjectDAL.delete({ identityId, projectId }); + + await permissionService.invalidateProjectPermissionCache(projectId); + return deletedIdentity; }; diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 8aec16371..979e6f25f 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -84,69 +84,70 @@ export const identityUaServiceFactory = ({ const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`; - let lock: Awaited> | undefined; - if (identityUa.lockoutEnabled) { - try { - lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 500, { - retryCount: 3, - retryDelay: 300, - retryJitter: 100 - }); - } catch (e) { - logger.info( - `identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]` - ); - throw new RateLimitError({ message: "Failed to acquire lock: rate limit exceeded" }); + const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY); + + let lockout: LockoutObject | undefined; + if (lockoutRaw) { + lockout = JSON.parse(lockoutRaw) as LockoutObject; + } + + if (lockout && lockout.lockedOut) { + throw new UnauthorizedError({ + message: "This identity auth method is temporarily locked, please try again later" + }); + } + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId }); + if (!identityMembershipOrg) { + throw new UnauthorizedError({ + message: "Invalid credentials" + }); + } + + const clientSecretPrefix = clientSecret.slice(0, 4); + const clientSecretInfo = await identityUaClientSecretDAL.find({ + identityUAId: identityUa.id, + isClientSecretRevoked: false, + clientSecretPrefix + }); + + let validClientSecretInfo: (typeof clientSecretInfo)[0] | null = null; + for await (const info of clientSecretInfo) { + const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash); + + if (isMatch) { + validClientSecretInfo = info; + break; } } - try { - const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY); + if (!validClientSecretInfo) { + if (identityUa.lockoutEnabled) { + let lock: Awaited> | undefined; + try { + lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 300, { + retryCount: 3, + retryDelay: 300, + retryJitter: 100 + }); - let lockout: LockoutObject | undefined; - if (lockoutRaw) { - lockout = JSON.parse(lockoutRaw) as LockoutObject; - } - - if (lockout && lockout.lockedOut) { - throw new UnauthorizedError({ - message: "This identity auth method is temporarily locked, please try again later" - }); - } - - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId }); - if (!identityMembershipOrg) { - throw new UnauthorizedError({ - message: "Invalid credentials" - }); - } - - const clientSecretPrefix = clientSecret.slice(0, 4); - const clientSecretInfo = await identityUaClientSecretDAL.find({ - identityUAId: identityUa.id, - isClientSecretRevoked: false, - clientSecretPrefix - }); - - let validClientSecretInfo: (typeof clientSecretInfo)[0] | null = null; - for await (const info of clientSecretInfo) { - const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash); - - if (isMatch) { - validClientSecretInfo = info; - break; - } - } - - if (!validClientSecretInfo) { - if (identityUa.lockoutEnabled) { - if (!lockout) { + // Re-fetch the latest lockout data while holding the lock + const lockoutRawNew = await keyStore.getItem(LOCKOUT_KEY); + if (lockoutRawNew) { + lockout = JSON.parse(lockoutRawNew) as LockoutObject; + } else { lockout = { lockedOut: false, failedAttempts: 0 }; } + if (lockout.lockedOut) { + throw new UnauthorizedError({ + message: "This identity auth method is temporarily locked, please try again later" + }); + } + lockout.failedAttempts += 1; if (lockout.failedAttempts >= identityUa.lockoutThreshold) { lockout.lockedOut = true; @@ -157,110 +158,121 @@ export const identityUaServiceFactory = ({ lockout.lockedOut ? identityUa.lockoutDurationSeconds : identityUa.lockoutCounterResetSeconds, JSON.stringify(lockout) ); - } - - throw new UnauthorizedError({ message: "Invalid credentials" }); - } else if (lockout) { - await keyStore.deleteItem(LOCKOUT_KEY); - } - - const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo; - if (Number(clientSecretTTL) > 0) { - const clientSecretCreated = new Date(validClientSecretInfo.createdAt); - const ttlInMilliseconds = Number(clientSecretTTL) * 1000; - const currentDate = new Date(); - const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds); - - if (currentDate > expirationTime) { - await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { - isClientSecretRevoked: true - }); - - throw new UnauthorizedError({ - message: "Access denied due to expired client secret" - }); + } catch (e) { + if (lock === undefined) { + logger.info( + `identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]` + ); + throw new RateLimitError({ message: "Failed to acquire lock: rate limit exceeded" }); + } + throw e; + } finally { + if (lock) { + await lock.release(); + } } } - if (clientSecretNumUsesLimit > 0 && clientSecretNumUses >= clientSecretNumUsesLimit) { - // number of times client secret can be used for - // a login operation reached + throw new UnauthorizedError({ message: "Invalid credentials" }); + } else if (lockout) { + // If credentials are valid, clear any existing lockout record + await keyStore.deleteItem(LOCKOUT_KEY); + } + + const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo; + if (Number(clientSecretTTL) > 0) { + const clientSecretCreated = new Date(validClientSecretInfo.createdAt); + const ttlInMilliseconds = Number(clientSecretTTL) * 1000; + const currentDate = new Date(); + const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds); + + if (currentDate > expirationTime) { await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { isClientSecretRevoked: true }); + throw new UnauthorizedError({ - message: "Access denied due to client secret usage limit reached" + message: "Access denied due to expired client secret" }); } + } - const accessTokenTTLParams = - Number(identityUa.accessTokenPeriod) === 0 - ? { - accessTokenTTL: identityUa.accessTokenTTL, - accessTokenMaxTTL: identityUa.accessTokenMaxTTL - } - : { - accessTokenTTL: identityUa.accessTokenPeriod, - // We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token - // without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever" - accessTokenMaxTTL: 1000000000 - }; - - const identityAccessToken = await identityUaDAL.transaction(async (tx) => { - const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx); - await identityOrgMembershipDAL.updateById( - identityMembershipOrg.id, - { - lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH, - lastLoginTime: new Date() - }, - tx - ); - const newToken = await identityAccessTokenDAL.create( - { - identityId: identityUa.identityId, - isAccessTokenRevoked: false, - identityUAClientSecretId: uaClientSecretDoc.id, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit, - accessTokenPeriod: identityUa.accessTokenPeriod, - authMethod: IdentityAuthMethod.UNIVERSAL_AUTH, - ...accessTokenTTLParams - }, - tx - ); - - return newToken; + if (clientSecretNumUsesLimit > 0 && clientSecretNumUses >= clientSecretNumUsesLimit) { + // number of times client secret can be used for + // a login operation reached + await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { + isClientSecretRevoked: true }); + throw new UnauthorizedError({ + message: "Access denied due to client secret usage limit reached" + }); + } - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( + const accessTokenTTLParams = + Number(identityUa.accessTokenPeriod) === 0 + ? { + accessTokenTTL: identityUa.accessTokenTTL, + accessTokenMaxTTL: identityUa.accessTokenMaxTTL + } + : { + accessTokenTTL: identityUa.accessTokenPeriod, + // We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token + // without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever" + accessTokenMaxTTL: 1000000000 + }; + + const identityAccessToken = await identityUaDAL.transaction(async (tx) => { + const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx); + await identityOrgMembershipDAL.updateById( + identityMembershipOrg.id, + { + lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH, + lastLoginTime: new Date() + }, + tx + ); + const newToken = await identityAccessTokenDAL.create( { identityId: identityUa.identityId, - clientSecretId: validClientSecretInfo.id, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } + isAccessTokenRevoked: false, + identityUAClientSecretId: uaClientSecretDoc.id, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit, + accessTokenPeriod: identityUa.accessTokenPeriod, + authMethod: IdentityAuthMethod.UNIVERSAL_AUTH, + ...accessTokenTTLParams + }, + tx ); - return { - accessToken, - identityUa, - validClientSecretInfo, - identityAccessToken, - identityMembershipOrg, - ...accessTokenTTLParams - }; - } finally { - if (lock) await lock.release(); - } + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = crypto.jwt().sign( + { + identityId: identityUa.identityId, + clientSecretId: validClientSecretInfo.id, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } + ); + + return { + accessToken, + identityUa, + validClientSecretInfo, + identityAccessToken, + identityMembershipOrg, + ...accessTokenTTLParams + }; }; const attachUniversalAuth = async ({ diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index 969d00331..f216d6483 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -33,7 +33,7 @@ type TIdentityServiceFactoryDep = { identityProjectDAL: Pick; permissionService: Pick; licenseService: Pick; - keyStore: Pick; + keyStore: Pick; }; export type TIdentityServiceFactory = ReturnType; @@ -261,12 +261,18 @@ export const identityServiceFactory = ({ const activeLockouts = await keyStore.getKeysByPattern(`lockout:identity:${id}:*`); const activeLockoutAuthMethods = new Set(); - activeLockouts.forEach((key) => { + for await (const key of activeLockouts) { const parts = key.split(":"); if (parts.length > 3) { - activeLockoutAuthMethods.add(parts[3]); + const lockoutRaw = await keyStore.getItem(key); + if (lockoutRaw) { + const lockout = JSON.parse(lockoutRaw) as { lockedOut: boolean }; + if (lockout.lockedOut) { + activeLockoutAuthMethods.add(parts[3]); + } + } } - }); + } return { ...identity, diff --git a/backend/src/services/notification/notification-types.ts b/backend/src/services/notification/notification-types.ts index 30bc87244..a3657c680 100644 --- a/backend/src/services/notification/notification-types.ts +++ b/backend/src/services/notification/notification-types.ts @@ -1,6 +1,21 @@ export enum NotificationType { ACCESS_APPROVAL_REQUEST = "access-approval-request", - ACCESS_APPROVAL_REQUEST_UPDATED = "access-approval-request-updated" + ACCESS_APPROVAL_REQUEST_UPDATED = "access-approval-request-updated", + ACCESS_POLICY_BYPASSED = "access-policy-bypassed", + SECRET_CHANGE_REQUEST = "secret-change-request", + SECRET_CHANGE_POLICY_BYPASSED = "secret-change-policy-bypassed", + SECRET_ROTATION_FAILED = "secret-rotation-failed", + SECRET_SCANNING_SECRETS_DETECTED = "secret-scanning-secrets-detected", + SECRET_SCANNING_SCAN_FAILED = "secret-scanning-scan-failed", + LOGIN_FROM_NEW_DEVICE = "login-from-new-device", + ADMIN_SSO_BYPASS = "admin-sso-bypass", + IMPORT_STARTED = "import-started", + IMPORT_SUCCESSFUL = "import-successful", + IMPORT_FAILED = "import-failed", + DIRECT_PROJECT_ACCESS_ISSUED_TO_ADMIN = "direct-project-access-issued-to-admin", + PROJECT_ACCESS_REQUEST = "project-access-request", + PROJECT_INVITATION = "project-invitation", + SECRET_SYNC_FAILED = "secret-sync-failed" } export interface TCreateUserNotificationDTO { diff --git a/backend/src/services/org-admin/org-admin-service.ts b/backend/src/services/org-admin/org-admin-service.ts index 23005e52b..995afadc1 100644 --- a/backend/src/services/org-admin/org-admin-service.ts +++ b/backend/src/services/org-admin/org-admin-service.ts @@ -5,6 +5,8 @@ import { OrgPermissionAdminConsoleAction, OrgPermissionSubjects } from "@app/ee/ import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TNotificationServiceFactory } from "../notification/notification-service"; +import { NotificationType } from "../notification/notification-types"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; @@ -20,6 +22,7 @@ type TOrgAdminServiceFactoryDep = { >; projectUserMembershipRoleDAL: Pick; smtpService: Pick; + notificationService: Pick; }; export type TOrgAdminServiceFactory = ReturnType; @@ -29,7 +32,8 @@ export const orgAdminServiceFactory = ({ projectDAL, projectMembershipDAL, projectUserMembershipRoleDAL, - smtpService + smtpService, + notificationService }: TOrgAdminServiceFactoryDep) => { const listOrgProjects = async ({ actor, @@ -130,23 +134,34 @@ export const orgAdminServiceFactory = ({ }); const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); - const filteredProjectMembers = projectMembers - .filter( - (member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin) && member.userId !== actorId - ) - .map((el) => el.user.email!) - .filter(Boolean); + const projectAdmins = projectMembers.filter( + (member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin) && member.userId !== actorId + ); + const mappedProjectAdmins = projectAdmins.map((el) => el.user.email!).filter(Boolean); + const actorEmail = projectMembers.find((el) => el.userId === actorId)?.user?.username; - if (filteredProjectMembers.length) { - await smtpService.sendMail({ - template: SmtpTemplates.OrgAdminProjectDirectAccess, - recipients: filteredProjectMembers, - subjectLine: "Organization Admin Project Direct Access Issued", - substitutions: { - projectName: project.name, - email: projectMembers.find((el) => el.userId === actorId)?.user?.username - } - }); + if (actorEmail) { + await notificationService.createUserNotifications( + projectAdmins.map((member) => ({ + userId: member.userId, + orgId: project.orgId, + type: NotificationType.DIRECT_PROJECT_ACCESS_ISSUED_TO_ADMIN, + title: "Direct Project Access Issued", + body: `The organization admin **${actorEmail}** has self-issued direct access to the project **${project.name}**.` + })) + ); + + if (mappedProjectAdmins.length) { + await smtpService.sendMail({ + template: SmtpTemplates.OrgAdminProjectDirectAccess, + recipients: mappedProjectAdmins, + subjectLine: "Organization Admin Project Direct Access Issued", + substitutions: { + projectName: project.name, + email: actorEmail + } + }); + } } return { isExistingMember: false, membership: updatedMembership }; }; diff --git a/backend/src/services/pki-subscriber/pki-subscriber-service.ts b/backend/src/services/pki-subscriber/pki-subscriber-service.ts index 391b5b2a8..ebb1ef599 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-service.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-service.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-await-in-loop */ /* eslint-disable no-bitwise */ import { ForbiddenError, subject } from "@casl/ability"; import * as x509 from "@peculiar/x509"; @@ -36,6 +37,9 @@ import { import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; +import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; +import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue"; +import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; @@ -79,6 +83,8 @@ type TPkiSubscriberServiceFactoryDep = { kmsService: Pick; permissionService: Pick; internalCaFns: ReturnType; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; }; export type TPkiSubscriberServiceFactory = ReturnType; @@ -96,7 +102,9 @@ export const pkiSubscriberServiceFactory = ({ kmsService, permissionService, certificateAuthorityQueue, - internalCaFns + internalCaFns, + pkiSyncDAL, + pkiSyncQueue }: TPkiSubscriberServiceFactoryDep) => { const createSubscriber = async ({ name, @@ -413,7 +421,12 @@ export const pkiSubscriberServiceFactory = ({ const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId); if (ca.internalCa?.id) { - return internalCaFns.issueCertificate(subscriber, ca); + const result = await internalCaFns.issueCertificate(subscriber, ca); + + // Trigger auto sync for PKI syncs connected to this subscriber after certificate issuance + await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue }); + + return result; } throw new BadRequestError({ message: "CA does not support immediate issuance of certificates" }); @@ -671,6 +684,9 @@ export const pkiSubscriberServiceFactory = ({ return cert; }); + // Trigger auto sync for PKI syncs connected to this subscriber after certificate signing + await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue }); + return { certificate: leafCert.toString("pem"), certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), diff --git a/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-constants.ts b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-constants.ts new file mode 100644 index 000000000..3c64bc86d --- /dev/null +++ b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-constants.ts @@ -0,0 +1,52 @@ +import RE2 from "re2"; + +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; + +/** + * Azure Key Vault naming constraints for certificates + */ +export const AZURE_KEY_VAULT_CERTIFICATE_NAMING = { + /** + * Regular expression pattern for valid Azure Key Vault certificate names + * Must contain only alphanumeric characters and hyphens (a-z, A-Z, 0-9, -) + * Must be 1-127 characters long + */ + NAME_PATTERN: new RE2("^[a-zA-Z0-9-]{1,127}$"), + + /** + * String of characters that are forbidden in Azure Key Vault certificate names + */ + FORBIDDEN_CHARACTERS: "!@#$%^&*()+=[]{}|\\:;\"'<>,.?/~` _", + + /** + * Maximum length for certificate names in Azure Key Vault + */ + MAX_NAME_LENGTH: 127, + + /** + * Minimum length for certificate names in Azure Key Vault + */ + MIN_NAME_LENGTH: 1, + + /** + * String representation of the allowed character pattern (for UI display) + */ + ALLOWED_CHARACTER_PATTERN: "^[a-zA-Z0-9-]{1,127}$" +} as const; + +/** + * Azure Key Vault PKI Sync list option configuration + */ +export const AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION = { + name: "Azure Key Vault" as const, + connection: AppConnection.AzureKeyVault, + destination: PkiSync.AzureKeyVault, + canImportCertificates: false, + canRemoveCertificates: true, + defaultCertificateNameSchema: "Infisical-PKI-Sync-{{certificateId}}", + forbiddenCharacters: AZURE_KEY_VAULT_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS, + allowedCharacterPattern: AZURE_KEY_VAULT_CERTIFICATE_NAMING.ALLOWED_CHARACTER_PATTERN, + maxCertificateNameLength: AZURE_KEY_VAULT_CERTIFICATE_NAMING.MAX_NAME_LENGTH, + minCertificateNameLength: AZURE_KEY_VAULT_CERTIFICATE_NAMING.MIN_NAME_LENGTH +} as const; diff --git a/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-fns.ts b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-fns.ts new file mode 100644 index 000000000..b423f42db --- /dev/null +++ b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-fns.ts @@ -0,0 +1,680 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; +import * as crypto from "crypto"; + +import { request } from "@app/lib/config/request"; +import { logger } from "@app/lib/logger"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure-key-vault"; +import { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { matchesCertificateNameSchema } from "@app/services/pki-sync/pki-sync-fns"; +import { TCertificateMap } from "@app/services/pki-sync/pki-sync-types"; + +import { PkiSyncError } from "../pki-sync-errors"; +import { TPkiSyncWithCredentials } from "../pki-sync-types"; +import { GetAzureKeyVaultCertificate, TAzureKeyVaultPkiSyncConfig } from "./azure-key-vault-pki-sync-types"; + +const AZURE_RATE_LIMIT_CONFIG: RateLimitConfig = { + MAX_CONCURRENT_REQUESTS: 10, + BASE_DELAY: 1000, + MAX_DELAY: 30000, + MAX_RETRIES: 3, + RATE_LIMIT_STATUS_CODES: [429, 503] +}; + +const azureConnectionQueue = createConnectionQueue(AZURE_RATE_LIMIT_CONFIG); + +const { withRateLimitRetry, executeWithConcurrencyLimit } = azureConnectionQueue; + +const extractCertificateNameFromId = (certificateId: string): string => { + return certificateId.substring(certificateId.lastIndexOf("/") + 1); +}; + +const isInfisicalManagedCertificate = (certificateName: string, pkiSync: TPkiSyncWithCredentials): boolean => { + const syncOptions = pkiSync.syncOptions as { certificateNameSchema?: string } | undefined; + const certificateNameSchema = syncOptions?.certificateNameSchema; + + if (certificateNameSchema) { + const environment = "global"; + return matchesCertificateNameSchema(certificateName, environment, certificateNameSchema); + } + + return certificateName.startsWith("Infisical-PKI-Sync-"); +}; + +type TAzureKeyVaultPkiSyncFactoryDeps = { + appConnectionDAL: Pick; + kmsService: Pick; +}; + +const parseCertificateX509Props = (certPem: string) => { + try { + const cert = new crypto.X509Certificate(certPem); + + const { subject } = cert; + + const sans = { + dns_names: [] as string[], + emails: [] as string[], + upns: [] as string[] + }; + + if (cert.subjectAltName) { + const sanEntries = cert.subjectAltName.split(", "); + for (const entry of sanEntries) { + if (entry.startsWith("DNS:")) { + sans.dns_names.push(entry.substring(4)); + } else if (entry.startsWith("email:")) { + sans.emails.push(entry.substring(6)); + } else if (entry.startsWith("othername:UPN:")) { + sans.upns.push(entry.substring(14)); + } + } + } + + return { + subject, + sans + }; + } catch (error) { + logger.warn( + { error: error instanceof Error ? error.message : String(error) }, + "Failed to parse certificate X.509 properties, using empty values" + ); + return { + subject: "", + sans: { + dns_names: [], + emails: [], + upns: [] + } + }; + } +}; + +const parseCertificateKeyProps = (certPem: string) => { + try { + const publicKeyObject = crypto.createPublicKey(certPem); + const keyDetails = publicKeyObject.asymmetricKeyDetails; + + if (!keyDetails) { + if (publicKeyObject.asymmetricKeyType === "rsa") { + const pubKeyStr = publicKeyObject.export({ type: "spki", format: "der" }).toString("hex"); + const estimatedBits = pubKeyStr.length * 4; + + let keySize = 2048; + if (estimatedBits >= 4000) { + keySize = 4096; + } else if (estimatedBits >= 3000) { + keySize = 3072; + } else if (estimatedBits >= 2000) { + keySize = 2048; + } else if (estimatedBits >= 1000) { + keySize = 1024; + } + + return { + kty: "RSA", + key_size: keySize + }; + } + + if (publicKeyObject.asymmetricKeyType === "ec") { + return { + kty: "EC", + curve: "P-256" + }; + } + + return { + kty: "RSA", + key_size: 2048 + }; + } + + if (publicKeyObject.asymmetricKeyType === "rsa") { + const modulusLength = keyDetails.modulusLength || 2048; + return { + kty: "RSA", + key_size: modulusLength + }; + } + + if (publicKeyObject.asymmetricKeyType === "ec") { + const { namedCurve } = keyDetails; + let curveName = "P-256"; + + switch (namedCurve) { + case "prime256v1": + case "secp256r1": + curveName = "P-256"; + break; + case "secp384r1": + curveName = "P-384"; + break; + case "secp521r1": + curveName = "P-521"; + break; + default: + curveName = "P-256"; + } + + return { + kty: "EC", + curve: curveName + }; + } + + const keyType = publicKeyObject.asymmetricKeyType; + if (keyType && !["rsa", "ec"].includes(keyType)) { + throw new Error(`Unsupported certificate key type: ${keyType}. Azure Key Vault only supports RSA and EC keys.`); + } + + logger.warn({ keyType }, "Unable to determine certificate key type, defaulting to RSA 2048"); + return { + kty: "RSA", + key_size: 2048 + }; + } catch (error) { + logger.warn( + { error: error instanceof Error ? error.message : String(error) }, + "Failed to parse certificate key properties, defaulting to RSA 2048" + ); + return { + kty: "RSA", + key_size: 2048 + }; + } +}; + +export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TAzureKeyVaultPkiSyncFactoryDeps) => { + const $getAzureKeyVaultCertificates = async (accessToken: string, vaultBaseUrl: string, syncId = "unknown") => { + const paginateAzureKeyVaultCertificates = async () => { + let result: GetAzureKeyVaultCertificate[] = []; + + let currentUrl = `${vaultBaseUrl}/certificates?api-version=7.4`; + + while (currentUrl) { + const urlToFetch = currentUrl; // Capture current URL to avoid loop function issue + const res = await withRateLimitRetry( + () => + request.get<{ value: GetAzureKeyVaultCertificate[]; nextLink: string }>(urlToFetch, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }), + { operation: "list-certificates", syncId } + ); + + result = result.concat(res.data.value); + currentUrl = res.data.nextLink; + } + + return result; + }; + + const getAzureKeyVaultCertificates = await paginateAzureKeyVaultCertificates(); + + const enabledAzureKeyVaultCertificates = getAzureKeyVaultCertificates.filter((cert) => cert.attributes.enabled); + + // disabled certificates to skip sending updates to + const disabledAzureKeyVaultCertificateKeys = getAzureKeyVaultCertificates + .filter(({ attributes }) => !attributes.enabled) + .map((certificate) => extractCertificateNameFromId(certificate.id)); + + // Use rate-limited concurrent execution for fetching certificate details + const certificateResults = await executeWithConcurrencyLimit( + enabledAzureKeyVaultCertificates, + async (getAzureKeyVaultCertificate) => { + const azureKeyVaultCertificate = await request.get( + `${getAzureKeyVaultCertificate.id}?api-version=7.4`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + let certPem = ""; + if (azureKeyVaultCertificate.data.cer) { + try { + // Azure Key Vault stores certificate in base64 DER format + // We need to convert it to PEM format with proper headers + const base64Cert = azureKeyVaultCertificate.data.cer; + const base64Lines = base64Cert.match(/.{1,64}/g); + if (!base64Lines) { + throw new Error("Failed to format base64 certificate data"); + } + certPem = `-----BEGIN CERTIFICATE-----\n${base64Lines.join("\n")}\n-----END CERTIFICATE-----`; + } catch (error) { + logger.warn( + { + error: error instanceof Error ? error.message : String(error), + certificateId: getAzureKeyVaultCertificate.id + }, + "Failed to convert Azure Key Vault certificate to PEM format, skipping certificate" + ); + certPem = ""; // Skip this certificate if we can't convert it properly + } + } + + return { + ...azureKeyVaultCertificate.data, + key: extractCertificateNameFromId(getAzureKeyVaultCertificate.id), + cert: certPem, + privateKey: "" // Private keys cannot be extracted from Azure Key Vault for security reasons + }; + }, + { operation: "fetch-certificate-details", syncId } + ); + + const successfulCertificates = certificateResults + .filter( + ( + result + ): result is PromiseFulfilledResult< + GetAzureKeyVaultCertificate & { + key: string; + cert: string; + privateKey: string; + } + > => result.status === "fulfilled" + ) + .map((result) => result.value); + + // Log any failures + const failedFetches = certificateResults.filter((result) => result.status === "rejected"); + if (failedFetches.length > 0) { + logger.warn( + { + syncId, + failedCount: failedFetches.length, + totalCount: enabledAzureKeyVaultCertificates.length + }, + "Some certificate details could not be fetched from Azure Key Vault" + ); + } + + const res: Record = successfulCertificates.reduce( + (obj, certificate) => ({ + ...obj, + [certificate.key]: { + cert: certificate.cert, + privateKey: certificate.privateKey + } + }), + {} as Record + ); + + return { + vaultCertificates: res, + disabledAzureKeyVaultCertificateKeys + }; + }; + + const syncCertificates = async (pkiSync: TPkiSyncWithCredentials, certificateMap: TCertificateMap) => { + const { accessToken } = await getAzureConnectionAccessToken(pkiSync.connection.id, appConnectionDAL, kmsService); + + // Cast destination config to Azure Key Vault config + const destinationConfig = pkiSync.destinationConfig as TAzureKeyVaultPkiSyncConfig; + + const { vaultCertificates, disabledAzureKeyVaultCertificateKeys } = await $getAzureKeyVaultCertificates( + accessToken, + destinationConfig.vaultBaseUrl, + pkiSync.id + ); + + const setCertificates: { + key: string; + cert: string; + privateKey: string; + certificateChain?: string; + }[] = []; + + // Track which certificates should exist in Azure Key Vault + const activeCertificateNames = Object.keys(certificateMap); + + // Iterate through certificates to sync to Azure Key Vault + Object.entries(certificateMap).forEach(([certName, { cert, privateKey, certificateChain }]) => { + if (disabledAzureKeyVaultCertificateKeys.includes(certName)) { + return; + } + + const existingCert = vaultCertificates[certName]; + const shouldUpdateCert = !existingCert || existingCert.cert !== cert; + + if (shouldUpdateCert) { + setCertificates.push({ + key: certName, + cert, + privateKey, + certificateChain + }); + } + }); + + // Identify expired/removed certificates that need to be cleaned up from Azure Key Vault + // Only remove certificates that were managed by Infisical (match naming schema) + const certificatesToRemove = Object.keys(vaultCertificates).filter( + (vaultCertName) => + isInfisicalManagedCertificate(vaultCertName, pkiSync) && + !activeCertificateNames.includes(vaultCertName) && + !disabledAzureKeyVaultCertificateKeys.includes(vaultCertName) + ); + + // Upload certificates to Azure Key Vault with rate limiting + const uploadResults = await executeWithConcurrencyLimit( + setCertificates, + async ({ key, cert, privateKey, certificateChain }) => { + try { + // Combine private key, certificate, and certificate chain in PEM format for Azure Key Vault + let combinedPem = ""; + + if (privateKey) { + combinedPem = privateKey.trim(); + } + + if (combinedPem) { + combinedPem = `${combinedPem}\n${cert.trim()}`; + } else { + combinedPem = cert.trim(); + } + + if (certificateChain) { + const trimmedChain = certificateChain.trim(); + if (trimmedChain) { + combinedPem = `${combinedPem}\n${trimmedChain}`; + } + } + + // Convert to base64 for Azure Key Vault import + const base64Cert = Buffer.from(combinedPem).toString("base64"); + + // Parse certificate to extract X.509 properties and key properties + const x509Props = parseCertificateX509Props(cert); + const keyProps = parseCertificateKeyProps(cert); + + // Build key_props based on key type + const keyPropsConfig = { + exportable: true, + reuse_key: false, + ...keyProps + }; + + const importData = { + value: base64Cert, + policy: { + key_props: keyPropsConfig, + secret_props: { + contentType: "application/x-pem-file" + }, + x509_props: x509Props + }, + attributes: { + enabled: true, + exportable: true + } + }; + + const response = await request.post( + `${destinationConfig.vaultBaseUrl}/certificates/${encodeURIComponent(key)}/import?api-version=7.4`, + importData, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); + + return { key, success: true, response: response.data as unknown }; + } catch (error) { + if (error instanceof AxiosError) { + const errorMessage = + error.response?.data && typeof error.response.data === "object" && "error" in error.response.data + ? (error.response.data as { error?: { message?: string } }).error?.message || error.message + : error.message; + + // Check if the error is due to certificate in deleted but recoverable state + const isDeletedButRecoverable = + errorMessage.includes("deleted but recoverable state") || errorMessage.includes("name cannot be reused"); + + if (isDeletedButRecoverable) { + logger.warn( + { certificateKey: key, syncId: pkiSync.id }, + "Certificate exists in deleted but recoverable state in Azure Key Vault - skipping upload" + ); + return { key, success: false, skipped: true, reason: "Certificate in deleted but recoverable state" }; + } + + throw new PkiSyncError({ + message: `Failed to upload certificate ${key} to Azure Key Vault: ${errorMessage}`, + cause: error, + context: { + certificateKey: key, + statusCode: error.response?.status, + responseData: error.response?.data + } + }); + } + throw error; + } + }, + { operation: "upload-certificates", syncId: pkiSync.id } + ); + + const results = uploadResults; + const failedUploads = results.filter((result) => result.status === "rejected"); + const fulfilledResults = results.filter((result) => result.status === "fulfilled"); + + // Separate successful uploads from skipped certificates + const successfulUploads = fulfilledResults.filter( + (result) => result.status === "fulfilled" && result.value.success + ); + const skippedUploads = fulfilledResults.filter((result) => result.status === "fulfilled" && result.value.skipped); + + // Remove expired/removed certificates from Azure Key Vault + let removedCertificates = 0; + let failedRemovals = 0; + + if (certificatesToRemove.length > 0) { + const removeResults = await executeWithConcurrencyLimit( + certificatesToRemove, + async (certName) => { + try { + await request.delete( + `${destinationConfig.vaultBaseUrl}/certificates/${encodeURIComponent(certName)}?api-version=7.4`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + return { key: certName, success: true }; + } catch (error) { + // If certificate doesn't exist (404), consider it as successfully removed + if (error instanceof AxiosError && error.response?.status === 404) { + return { key: certName, success: true, alreadyRemoved: true }; + } + + logger.error( + { error, syncId: pkiSync.id, certificateName: certName }, + "Failed to remove expired/removed certificate from Azure Key Vault" + ); + + // Don't throw here - we want to continue with other operations + return { key: certName, success: false, error: error as Error }; + } + }, + { operation: "remove-certificates", syncId: pkiSync.id } + ); + const successfulRemovals = removeResults.filter( + (result) => result.status === "fulfilled" && result.value.success + ); + removedCertificates = successfulRemovals.length; + failedRemovals = removeResults.length - removedCertificates; + + if (failedRemovals > 0) { + logger.warn( + { + syncId: pkiSync.id, + failedRemovals, + successfulRemovals: removedCertificates + }, + "Some expired/removed certificates could not be removed from Azure Key Vault" + ); + } + } + + // Collect detailed information for UI feedback + const details: { + failedUploads?: Array<{ name: string; error: string }>; + failedRemovals?: Array<{ name: string; error: string }>; + skippedCertificates?: Array<{ name: string; reason: string }>; + } = {}; + + // Collect skipped certificate details + if (skippedUploads.length > 0) { + details.skippedCertificates = skippedUploads.map((result) => { + const certificateName = result.status === "fulfilled" ? result.value.key : "unknown"; + return { + name: certificateName, + reason: "Azure Key Vault constraints or certificate already up to date" + }; + }); + } + + // Collect failed upload details + if (failedUploads.length > 0) { + details.failedUploads = failedUploads.map((failure, index) => { + const certificateName = setCertificates[index]?.key || "unknown"; + let errorMessage = "Unknown error"; + + if (failure.status === "rejected") { + errorMessage = (failure.reason as Error)?.message || "Unknown error"; + } + + return { + name: certificateName, + error: errorMessage + }; + }); + + logger.error( + { + syncId: pkiSync.id, + failedUploads: details.failedUploads, + failedCount: failedUploads.length + }, + "Some certificates failed to upload to Azure Key Vault" + ); + } + + // Collect failed removal details + if (failedRemovals > 0) { + const failedRemovalNames = certificatesToRemove.slice(-failedRemovals); + details.failedRemovals = failedRemovalNames.map((certName) => ({ + name: certName, + error: "Failed to remove from Azure Key Vault" + })); + + logger.warn( + { + syncId: pkiSync.id, + failedRemovals: details.failedRemovals, + successfulRemovals: removedCertificates + }, + "Some expired/removed certificates could not be removed from Azure Key Vault" + ); + } + + return { + uploaded: successfulUploads.length, + removed: removedCertificates, + failedRemovals, + skipped: Object.keys(certificateMap).length - setCertificates.length, + details: Object.keys(details).length > 0 ? details : undefined + }; + }; + + const removeCertificates = async (pkiSync: TPkiSyncWithCredentials, certificateNames: string[]) => { + const { accessToken } = await getAzureConnectionAccessToken(pkiSync.connection.id, appConnectionDAL, kmsService); + + // Cast destination config to Azure Key Vault config + const destinationConfig = pkiSync.destinationConfig as TAzureKeyVaultPkiSyncConfig; + + // Only remove certificates that are managed by Infisical (match naming schema) + const infisicalManagedCertNames = certificateNames.filter((certName) => + isInfisicalManagedCertificate(certName, pkiSync) + ); + + const results = await executeWithConcurrencyLimit( + infisicalManagedCertNames, + async (certName) => { + try { + const response = await request.delete( + `${destinationConfig.vaultBaseUrl}/certificates/${encodeURIComponent(certName)}?api-version=7.4`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + return { key: certName, success: true, response: response.data as unknown }; + } catch (error) { + if (error instanceof AxiosError) { + // If certificate doesn't exist (404), consider it as successfully removed + if (error.response?.status === 404) { + return { key: certName, success: true, alreadyRemoved: true }; + } + + throw new PkiSyncError({ + message: `Failed to remove certificate ${certName} from Azure Key Vault`, + cause: error, + context: { + certificateKey: certName, + statusCode: error.response?.status, + responseData: error.response?.data + } + }); + } + throw error; + } + }, + { operation: "remove-specific-certificates", syncId: pkiSync.id } + ); + const failedRemovals = results.filter((result) => result.status === "rejected"); + + if (failedRemovals.length > 0) { + const failedReasons = failedRemovals.map((failure) => { + if (failure.status === "rejected") { + return (failure.reason as Error)?.message || "Unknown error"; + } + return "Unknown error"; + }); + + throw new PkiSyncError({ + message: `Failed to remove ${failedRemovals.length} certificate(s) from Azure Key Vault`, + context: { + failedReasons, + totalCertificates: infisicalManagedCertNames.length, + failedCount: failedRemovals.length + } + }); + } + + return { + removed: infisicalManagedCertNames.length - failedRemovals.length, + failed: failedRemovals.length, + skipped: certificateNames.length - infisicalManagedCertNames.length + }; + }; + + return { + syncCertificates, + removeCertificates + }; +}; diff --git a/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts new file mode 100644 index 000000000..ef6347e82 --- /dev/null +++ b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts @@ -0,0 +1,74 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; +import { PkiSyncSchema } from "@app/services/pki-sync/pki-sync-schemas"; + +import { AZURE_KEY_VAULT_CERTIFICATE_NAMING } from "./azure-key-vault-pki-sync-constants"; + +export const AzureKeyVaultPkiSyncConfigSchema = z.object({ + vaultBaseUrl: z.string().url() +}); + +const AzureKeyVaultPkiSyncOptionsSchema = z.object({ + canImportCertificates: z.boolean().default(false), + canRemoveCertificates: z.boolean().default(true), + certificateNameSchema: z + .string() + .optional() + .refine( + (schema) => { + if (!schema) return true; + + const testName = schema + .replace(new RE2("\\{\\{certificateId\\}\\}", "g"), "") + .replace(new RE2("\\{\\{environment\\}\\}", "g"), ""); + + const hasForbiddenChars = AZURE_KEY_VAULT_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS.split("").some((char) => + testName.includes(char) + ); + + return AZURE_KEY_VAULT_CERTIFICATE_NAMING.NAME_PATTERN.test(testName) && !hasForbiddenChars; + }, + { + message: + "Certificate name schema must result in names that contain only alphanumeric characters and hyphens (a-z, A-Z, 0-9, -) and be 1-127 characters long when compiled for Azure Key Vault" + } + ) +}); + +export const AzureKeyVaultPkiSyncSchema = PkiSyncSchema.extend({ + destination: z.literal(PkiSync.AzureKeyVault), + destinationConfig: AzureKeyVaultPkiSyncConfigSchema, + syncOptions: AzureKeyVaultPkiSyncOptionsSchema +}); + +export const CreateAzureKeyVaultPkiSyncSchema = z.object({ + name: z.string().trim().min(1).max(64), + description: z.string().optional(), + isAutoSyncEnabled: z.boolean().default(true), + destinationConfig: AzureKeyVaultPkiSyncConfigSchema, + syncOptions: AzureKeyVaultPkiSyncOptionsSchema.optional().default({}), + subscriberId: z.string().optional(), + connectionId: z.string(), + projectId: z.string().trim().min(1) +}); + +export const UpdateAzureKeyVaultPkiSyncSchema = z.object({ + name: z.string().trim().min(1).max(64).optional(), + description: z.string().optional(), + isAutoSyncEnabled: z.boolean().optional(), + destinationConfig: AzureKeyVaultPkiSyncConfigSchema.optional(), + syncOptions: AzureKeyVaultPkiSyncOptionsSchema.optional(), + subscriberId: z.string().optional(), + connectionId: z.string().optional() +}); + +export const AzureKeyVaultPkiSyncListItemSchema = z.object({ + name: z.literal("Azure Key Vault"), + connection: z.literal(AppConnection.AzureKeyVault), + destination: z.literal(PkiSync.AzureKeyVault), + canImportCertificates: z.literal(false), + canRemoveCertificates: z.literal(false) +}); diff --git a/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-types.ts b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-types.ts new file mode 100644 index 000000000..2e69534be --- /dev/null +++ b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-types.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +import { TAzureKeyVaultConnection } from "@app/services/app-connection/azure-key-vault"; + +import { + AzureKeyVaultPkiSyncConfigSchema, + AzureKeyVaultPkiSyncSchema, + CreateAzureKeyVaultPkiSyncSchema, + UpdateAzureKeyVaultPkiSyncSchema +} from "./azure-key-vault-pki-sync-schemas"; + +export type GetAzureKeyVaultCertificate = { + id: string; + value: string; + attributes: { + enabled: boolean; + created: number; + updated: number; + recoveryLevel: string; + tags?: Record; + }; + x5t?: string; + contentType?: string; + key?: string; + cer?: string; +}; + +export type TAzureKeyVaultPkiSyncConfig = z.infer; + +export type TAzureKeyVaultPkiSync = z.infer; + +export type TAzureKeyVaultPkiSyncInput = z.infer; + +export type TAzureKeyVaultPkiSyncUpdate = z.infer; + +export type TAzureKeyVaultPkiSyncWithCredentials = TAzureKeyVaultPkiSync & { + connection: TAzureKeyVaultConnection; +}; diff --git a/backend/src/services/pki-sync/azure-key-vault/index.ts b/backend/src/services/pki-sync/azure-key-vault/index.ts new file mode 100644 index 000000000..cc9c64f9a --- /dev/null +++ b/backend/src/services/pki-sync/azure-key-vault/index.ts @@ -0,0 +1,4 @@ +export * from "./azure-key-vault-pki-sync-constants"; +export * from "./azure-key-vault-pki-sync-fns"; +export * from "./azure-key-vault-pki-sync-schemas"; +export * from "./azure-key-vault-pki-sync-types"; diff --git a/backend/src/services/pki-sync/pki-sync-cleanup-queue.ts b/backend/src/services/pki-sync/pki-sync-cleanup-queue.ts new file mode 100644 index 000000000..f6fd32e33 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-cleanup-queue.ts @@ -0,0 +1,94 @@ +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +import { TPkiSyncDALFactory } from "./pki-sync-dal"; +import { TPkiSyncQueueFactory } from "./pki-sync-queue"; + +type TPkiSyncCleanupQueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; +}; + +export type TPkiSyncCleanupQueueServiceFactory = ReturnType; + +export const pkiSyncCleanupQueueServiceFactory = ({ + queueService, + pkiSyncDAL, + pkiSyncQueue +}: TPkiSyncCleanupQueueServiceFactoryDep) => { + const appCfg = getConfig(); + + const syncExpiredCertificatesForPkiSyncs = async () => { + try { + const pkiSyncsWithExpiredCerts = await pkiSyncDAL.findPkiSyncsWithExpiredCertificates(); + + if (pkiSyncsWithExpiredCerts.length === 0) { + logger.info("No PKI syncs found with certificates that expired the previous day"); + return; + } + + logger.info( + `Found ${pkiSyncsWithExpiredCerts.length} PKI sync(s) with certificates that expired the previous day` + ); + + // Trigger sync for each PKI sync that has expired certificates + for (const { id: syncId, subscriberId } of pkiSyncsWithExpiredCerts) { + try { + // eslint-disable-next-line no-await-in-loop + await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ + syncId + }); + logger.info( + `Successfully queued PKI sync ${syncId} for subscriber ${subscriberId} due to expired certificates` + ); + } catch (error) { + logger.error(error, `Failed to queue PKI sync ${syncId} for subscriber ${subscriberId}`); + } + } + } catch (error) { + logger.error(error, "Failed to sync expired certificates for PKI syncs"); + throw error; + } + }; + + const init = async () => { + if (appCfg.isSecondaryInstance) { + return; + } + + await queueService.stopRepeatableJob( + QueueName.PkiSyncCleanup, + QueueJobs.PkiSyncCleanup, + { pattern: "0 0 * * *", utc: true }, + QueueName.PkiSyncCleanup // just a job id + ); + + await queueService.startPg( + QueueJobs.PkiSyncCleanup, + async () => { + try { + logger.info(`${QueueName.PkiSyncCleanup}: queue task started`); + await syncExpiredCertificatesForPkiSyncs(); + logger.info(`${QueueName.PkiSyncCleanup}: queue task completed`); + } catch (error) { + logger.error(error, `${QueueName.PkiSyncCleanup}: PKI sync cleanup failed`); + throw error; + } + }, + { + batchSize: 1, + workerCount: 1, + pollingIntervalSeconds: 120 + } + ); + + await queueService.schedulePg(QueueJobs.PkiSyncCleanup, "0 0 * * *", undefined, { tz: "UTC" }); + }; + + return { + init, + syncExpiredCertificatesForPkiSyncs + }; +}; diff --git a/backend/src/services/pki-sync/pki-sync-dal.ts b/backend/src/services/pki-sync/pki-sync-dal.ts new file mode 100644 index 000000000..460bbfc42 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-dal.ts @@ -0,0 +1,314 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TPkiSyncs } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex"; + +import { PkiSync } from "./pki-sync-enums"; + +export type TPkiSyncDALFactory = ReturnType; + +type PkiSyncFindFilter = Parameters>[0]; + +const basePkiSyncQuery = ({ filter, db, tx }: { db: TDbClient; filter?: PkiSyncFindFilter; tx?: Knex }) => { + const query = (tx || db.replicaNode())(TableName.PkiSync) + .leftJoin(TableName.AppConnection, `${TableName.PkiSync}.connectionId`, `${TableName.AppConnection}.id`) + .select(selectAllTableCols(TableName.PkiSync)) + .select( + // app connection fields + db.ref("name").withSchema(TableName.AppConnection).as("appConnectionName"), + db.ref("app").withSchema(TableName.AppConnection).as("appConnectionApp"), + db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("appConnectionEncryptedCredentials"), + db.ref("orgId").withSchema(TableName.AppConnection).as("appConnectionOrgId"), + db.ref("projectId").withSchema(TableName.AppConnection).as("appConnectionProjectId"), + db.ref("method").withSchema(TableName.AppConnection).as("appConnectionMethod"), + db.ref("description").withSchema(TableName.AppConnection).as("appConnectionDescription"), + db.ref("version").withSchema(TableName.AppConnection).as("appConnectionVersion"), + db.ref("gatewayId").withSchema(TableName.AppConnection).as("appConnectionGatewayId"), + db.ref("createdAt").withSchema(TableName.AppConnection).as("appConnectionCreatedAt"), + db.ref("updatedAt").withSchema(TableName.AppConnection).as("appConnectionUpdatedAt"), + db + .ref("isPlatformManagedCredentials") + .withSchema(TableName.AppConnection) + .as("appConnectionIsPlatformManagedCredentials") + ); + + if (filter) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.PkiSync, filter))); + } + + return query; +}; + +const basePkiSyncWithSubscriberQuery = ({ + filter, + db, + tx +}: { + db: TDbClient; + filter?: PkiSyncFindFilter; + tx?: Knex; +}) => { + const query = (tx || db.replicaNode())(TableName.PkiSync) + .leftJoin(TableName.AppConnection, `${TableName.PkiSync}.connectionId`, `${TableName.AppConnection}.id`) + .leftJoin(TableName.PkiSubscriber, `${TableName.PkiSync}.subscriberId`, `${TableName.PkiSubscriber}.id`) + .select(selectAllTableCols(TableName.PkiSync)) + .select( + // app connection fields + db.ref("name").withSchema(TableName.AppConnection).as("appConnectionName"), + db.ref("app").withSchema(TableName.AppConnection).as("appConnectionApp"), + db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("appConnectionEncryptedCredentials"), + db.ref("orgId").withSchema(TableName.AppConnection).as("appConnectionOrgId"), + db.ref("projectId").withSchema(TableName.AppConnection).as("appConnectionProjectId"), + db.ref("method").withSchema(TableName.AppConnection).as("appConnectionMethod"), + db.ref("description").withSchema(TableName.AppConnection).as("appConnectionDescription"), + db.ref("version").withSchema(TableName.AppConnection).as("appConnectionVersion"), + db.ref("gatewayId").withSchema(TableName.AppConnection).as("appConnectionGatewayId"), + db.ref("createdAt").withSchema(TableName.AppConnection).as("appConnectionCreatedAt"), + db.ref("updatedAt").withSchema(TableName.AppConnection).as("appConnectionUpdatedAt"), + db + .ref("isPlatformManagedCredentials") + .withSchema(TableName.AppConnection) + .as("appConnectionIsPlatformManagedCredentials"), + // pki subscriber fields + db.ref("id").withSchema(TableName.PkiSubscriber).as("pkiSubscriberId"), + db.ref("name").withSchema(TableName.PkiSubscriber).as("subscriberName") + ); + + if (filter) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.PkiSync, filter))); + } + + return query; +}; + +const expandPkiSync = (pkiSync: Awaited>[number]) => { + const { + appConnectionName, + appConnectionApp, + appConnectionEncryptedCredentials, + appConnectionOrgId, + appConnectionProjectId, + appConnectionMethod, + appConnectionDescription, + appConnectionVersion, + appConnectionGatewayId, + appConnectionCreatedAt, + appConnectionUpdatedAt, + appConnectionIsPlatformManagedCredentials, + ...el + } = pkiSync; + + return { + ...el, + destination: el.destination as PkiSync, + destinationConfig: el.destinationConfig as Record, + syncOptions: el.syncOptions as Record, + appConnectionName, + appConnectionApp, + connection: { + id: el.connectionId, + name: appConnectionName, + app: appConnectionApp, + encryptedCredentials: appConnectionEncryptedCredentials, + orgId: appConnectionOrgId, + projectId: appConnectionProjectId, + method: appConnectionMethod, + description: appConnectionDescription, + version: appConnectionVersion, + gatewayId: appConnectionGatewayId, + createdAt: appConnectionCreatedAt, + updatedAt: appConnectionUpdatedAt, + isPlatformManagedCredentials: appConnectionIsPlatformManagedCredentials + } + }; +}; + +const expandPkiSyncWithSubscriber = (pkiSync: Awaited>[number]) => { + const { + appConnectionName, + appConnectionApp, + appConnectionEncryptedCredentials, + appConnectionOrgId, + appConnectionProjectId, + appConnectionMethod, + appConnectionDescription, + appConnectionVersion, + appConnectionGatewayId, + appConnectionCreatedAt, + appConnectionUpdatedAt, + appConnectionIsPlatformManagedCredentials, + pkiSubscriberId, + subscriberName, + ...el + } = pkiSync; + + return { + ...el, + destination: el.destination as PkiSync, + destinationConfig: el.destinationConfig as Record, + syncOptions: el.syncOptions as Record, + appConnectionName, + appConnectionApp, + connection: { + id: el.connectionId, + name: appConnectionName, + app: appConnectionApp, + encryptedCredentials: appConnectionEncryptedCredentials, + orgId: appConnectionOrgId, + projectId: appConnectionProjectId, + method: appConnectionMethod, + description: appConnectionDescription, + version: appConnectionVersion, + gatewayId: appConnectionGatewayId, + createdAt: appConnectionCreatedAt, + updatedAt: appConnectionUpdatedAt, + isPlatformManagedCredentials: appConnectionIsPlatformManagedCredentials + }, + subscriber: pkiSubscriberId && subscriberName ? { id: pkiSubscriberId, name: subscriberName } : null + }; +}; + +export const pkiSyncDALFactory = (db: TDbClient) => { + const pkiSyncOrm = ormify(db, TableName.PkiSync); + + const findByProjectId = async (projectId: string, tx?: Knex) => { + try { + const pkiSyncs = await basePkiSyncQuery({ filter: { projectId }, db, tx }); + return pkiSyncs.map(expandPkiSync); + } catch (error) { + throw new DatabaseError({ error, name: "Find By Project ID - PKI Sync" }); + } + }; + + const findByProjectIdWithSubscribers = async (projectId: string, tx?: Knex) => { + try { + const pkiSyncs = await basePkiSyncWithSubscriberQuery({ filter: { projectId }, db, tx }); + return pkiSyncs.map(expandPkiSyncWithSubscriber); + } catch (error) { + throw new DatabaseError({ error, name: "Find By Project ID With Subscribers - PKI Sync" }); + } + }; + + const findBySubscriberId = async (subscriberId: string, tx?: Knex) => { + try { + const pkiSyncs = await basePkiSyncQuery({ filter: { subscriberId }, db, tx }); + return pkiSyncs.map(expandPkiSync); + } catch (error) { + throw new DatabaseError({ error, name: "Find By Subscriber ID - PKI Sync" }); + } + }; + + const findByIdAndProjectId = async (id: string, projectId: string, tx?: Knex) => { + try { + const pkiSync = await basePkiSyncQuery({ filter: { id, projectId }, db, tx }).first(); + return pkiSync ? expandPkiSync(pkiSync) : undefined; + } catch (error) { + throw new DatabaseError({ error, name: "Find By ID and Project ID - PKI Sync" }); + } + }; + + const findByNameAndProjectId = async (name: string, projectId: string, tx?: Knex) => { + try { + const pkiSync = await basePkiSyncQuery({ filter: { name, projectId }, db, tx }).first(); + return pkiSync ? expandPkiSync(pkiSync) : undefined; + } catch (error) { + throw new DatabaseError({ error, name: "Find By Name and Project ID - PKI Sync" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const pkiSync = await basePkiSyncQuery({ filter: { id }, db, tx }).first(); + return pkiSync ? expandPkiSync(pkiSync) : undefined; + } catch (error) { + throw new DatabaseError({ error, name: "Find By ID - PKI Sync" }); + } + }; + + const findOne = async (filter: Parameters<(typeof pkiSyncOrm)["findOne"]>[0], tx?: Knex) => { + try { + const pkiSync = await basePkiSyncQuery({ filter, db, tx }).first(); + return pkiSync ? expandPkiSync(pkiSync) : undefined; + } catch (error) { + throw new DatabaseError({ error, name: "Find One - PKI Sync" }); + } + }; + + const find = async (filter: Parameters<(typeof pkiSyncOrm)["find"]>[0], tx?: Knex) => { + try { + const pkiSyncs = await basePkiSyncQuery({ filter, db, tx }); + return pkiSyncs.map(expandPkiSync); + } catch (error) { + throw new DatabaseError({ error, name: "Find - PKI Sync" }); + } + }; + + const create = async (data: Parameters<(typeof pkiSyncOrm)["create"]>[0]) => { + const pkiSync = (await pkiSyncOrm.transaction(async (tx) => { + const sync = await pkiSyncOrm.create(data, tx); + return basePkiSyncQuery({ filter: { id: sync.id }, db, tx }).first(); + }))!; + + return expandPkiSync(pkiSync); + }; + + const updateById = async (syncId: string, data: Parameters<(typeof pkiSyncOrm)["updateById"]>[1]) => { + const pkiSync = (await pkiSyncOrm.transaction(async (tx) => { + const sync = await pkiSyncOrm.updateById(syncId, data, tx); + return basePkiSyncQuery({ filter: { id: sync.id }, db, tx }).first(); + }))!; + + return expandPkiSync(pkiSync); + }; + + const findPkiSyncsWithExpiredCertificates = async (): Promise> => { + try { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const pkiSyncs = (await db + .replicaNode()(TableName.PkiSync) + .select(`${TableName.PkiSync}.id`, `${TableName.PkiSync}.subscriberId`) + .innerJoin( + TableName.Certificate, + `${TableName.PkiSync}.subscriberId`, + `${TableName.Certificate}.pkiSubscriberId` + ) + .where(`${TableName.Certificate}.notAfter`, ">=", yesterday) + .where(`${TableName.Certificate}.notAfter`, "<", today) + .whereNotNull(`${TableName.Certificate}.pkiSubscriberId`) + .whereNotNull(`${TableName.PkiSync}.subscriberId`) + .groupBy(`${TableName.PkiSync}.id`, `${TableName.PkiSync}.subscriberId`)) as Array<{ + id: string; + subscriberId: string; + }>; + + return pkiSyncs; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI syncs with expired certificates" }); + } + }; + + return { + ...pkiSyncOrm, + findByProjectId, + findByProjectIdWithSubscribers, + findBySubscriberId, + findByIdAndProjectId, + findByNameAndProjectId, + findById, + findOne, + find, + create, + updateById, + findPkiSyncsWithExpiredCertificates + }; +}; diff --git a/backend/src/services/pki-sync/pki-sync-enums.ts b/backend/src/services/pki-sync/pki-sync-enums.ts new file mode 100644 index 000000000..fadd70914 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-enums.ts @@ -0,0 +1,16 @@ +export enum PkiSync { + AzureKeyVault = "azure-key-vault" +} + +export enum PkiSyncStatus { + Pending = "pending", + Running = "running", + Succeeded = "succeeded", + Failed = "failed" +} + +export enum PkiSyncAction { + SyncCertificates = "sync-certificates", + ImportCertificates = "import-certificates", + RemoveCertificates = "remove-certificates" +} diff --git a/backend/src/services/pki-sync/pki-sync-errors.ts b/backend/src/services/pki-sync/pki-sync-errors.ts new file mode 100644 index 000000000..80877d13b --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-errors.ts @@ -0,0 +1,25 @@ +export class PkiSyncError extends Error { + public context?: Record; + + public cause?: Error; + + public shouldRetry: boolean; + + constructor({ + message, + cause, + context, + shouldRetry = true + }: { + message: string; + cause?: Error; + context?: Record; + shouldRetry?: boolean; + }) { + super(message); + this.name = "PkiSyncError"; + this.cause = cause; + this.context = context; + this.shouldRetry = shouldRetry; + } +} diff --git a/backend/src/services/pki-sync/pki-sync-fns.ts b/backend/src/services/pki-sync/pki-sync-fns.ts new file mode 100644 index 000000000..343afa626 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-fns.ts @@ -0,0 +1,223 @@ +import * as handlebars from "handlebars"; +import { z, ZodSchema } from "zod"; + +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { BadRequestError } from "@app/lib/errors"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +import { AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION } from "./azure-key-vault/azure-key-vault-pki-sync-constants"; +import { azureKeyVaultPkiSyncFactory } from "./azure-key-vault/azure-key-vault-pki-sync-fns"; +import { PkiSync } from "./pki-sync-enums"; +import { TCertificateMap, TPkiSyncWithCredentials } from "./pki-sync-types"; + +const ENTERPRISE_PKI_SYNCS: PkiSync[] = []; + +const PKI_SYNC_LIST_OPTIONS = { + [PkiSync.AzureKeyVault]: AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION +}; + +export const enterprisePkiSyncCheck = async ( + licenseService: Pick, + orgId: string, + pkiSyncDestination: PkiSync, + errorMessage?: string +) => { + const plan = await licenseService.getPlan(orgId); + + if (!plan.enterpriseCertificateSyncs && ENTERPRISE_PKI_SYNCS.includes(pkiSyncDestination)) { + throw new BadRequestError({ + message: errorMessage || "Failed to create PKI sync due to plan restriction. Upgrade plan to create PKI sync." + }); + } +}; + +export const listPkiSyncOptions = () => { + return Object.values(PKI_SYNC_LIST_OPTIONS).sort((a, b) => a.name.localeCompare(b.name)); +}; + +export const getPkiSyncProviderCapabilities = (destination: PkiSync) => { + const providerOption = PKI_SYNC_LIST_OPTIONS[destination]; + if (!providerOption) { + throw new BadRequestError({ message: `Unsupported PKI sync destination: ${destination}` }); + } + + return { + canImportCertificates: providerOption.canImportCertificates, + canRemoveCertificates: providerOption.canRemoveCertificates + }; +}; + +export const matchesSchema = (schema: T, data: unknown): data is z.infer => { + return schema.safeParse(data).success; +}; + +export const parsePkiSyncErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === "string") { + return error; + } + + return "An unknown error occurred during PKI sync operation"; +}; + +export const applyCertificateNameSchema = ( + certificateMap: TCertificateMap, + environment: string, + schema?: string +): TCertificateMap => { + if (!schema) return certificateMap; + + const processedCertificateMap: TCertificateMap = {}; + + for (const [certificateId, value] of Object.entries(certificateMap)) { + const newName = handlebars.compile(schema)({ + certificateId, + environment + }); + + processedCertificateMap[newName] = value; + } + + return processedCertificateMap; +}; + +export const stripCertificateNameSchema = ( + certificateMap: TCertificateMap, + environment: string, + schema?: string +): TCertificateMap => { + if (!schema) return certificateMap; + + const compiledSchemaPattern = handlebars.compile(schema)({ + certificateId: "{{certificateId}}", + environment + }); + + const parts = compiledSchemaPattern.split("{{certificateId}}"); + const prefix = parts[0]; + const suffix = parts[parts.length - 1]; + + const strippedMap: TCertificateMap = {}; + + for (const [name, value] of Object.entries(certificateMap)) { + if (!name.startsWith(prefix) || !name.endsWith(suffix)) { + // eslint-disable-next-line no-continue + continue; + } + + const strippedName = name.slice(prefix.length, name.length - suffix.length); + strippedMap[strippedName] = value; + } + + return strippedMap; +}; + +export const matchesCertificateNameSchema = (name: string, environment: string, schema?: string): boolean => { + if (!schema) return true; + + const compiledSchemaPattern = handlebars.compile(schema)({ + certificateId: "{{certificateId}}", + environment + }); + + if (!compiledSchemaPattern.includes("{{certificateId}}")) { + return name === compiledSchemaPattern; + } + + const parts = compiledSchemaPattern.split("{{certificateId}}"); + const prefix = parts[0]; + const suffix = parts[parts.length - 1]; + + if (prefix === "" && suffix === "") return true; + + // If prefix is empty, name must end with suffix + if (prefix === "") return name.endsWith(suffix); + + // If suffix is empty, name must start with prefix + if (suffix === "") return name.startsWith(prefix); + + // Name must start with prefix and end with suffix + return name.startsWith(prefix) && name.endsWith(suffix); +}; + +const isAzureKeyVaultPkiSync = (pkiSync: TPkiSyncWithCredentials): boolean => { + return pkiSync.destination === PkiSync.AzureKeyVault; +}; + +export const PkiSyncFns = { + getCertificates: async ( + pkiSync: TPkiSyncWithCredentials, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + dependencies: { + appConnectionDAL: Pick; + kmsService: Pick; + } + ): Promise => { + switch (pkiSync.destination) { + case PkiSync.AzureKeyVault: { + throw new Error( + "Azure Key Vault does not support importing certificates into Infisical (private keys cannot be extracted)" + ); + } + default: + throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); + } + }, + + syncCertificates: async ( + pkiSync: TPkiSyncWithCredentials, + certificateMap: TCertificateMap, + dependencies: { + appConnectionDAL: Pick; + kmsService: Pick; + } + ): Promise<{ + uploaded: number; + removed?: number; + failedRemovals?: number; + skipped: number; + details?: { + failedUploads?: Array<{ name: string; error: string }>; + failedRemovals?: Array<{ name: string; error: string }>; + skippedCertificates?: Array<{ name: string; reason: string }>; + }; + }> => { + switch (pkiSync.destination) { + case PkiSync.AzureKeyVault: { + if (!isAzureKeyVaultPkiSync(pkiSync)) { + throw new Error("Invalid Azure Key Vault PKI sync configuration"); + } + const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); + return azureKeyVaultPkiSync.syncCertificates(pkiSync, certificateMap); + } + default: + throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); + } + }, + + removeCertificates: async ( + pkiSync: TPkiSyncWithCredentials, + certificateNames: string[], + dependencies: { + appConnectionDAL: Pick; + kmsService: Pick; + } + ): Promise => { + switch (pkiSync.destination) { + case PkiSync.AzureKeyVault: { + if (!isAzureKeyVaultPkiSync(pkiSync)) { + throw new Error("Invalid Azure Key Vault PKI sync configuration"); + } + const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); + await azureKeyVaultPkiSync.removeCertificates(pkiSync, certificateNames); + break; + } + default: + throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); + } + } +}; diff --git a/backend/src/services/pki-sync/pki-sync-maps.ts b/backend/src/services/pki-sync/pki-sync-maps.ts new file mode 100644 index 000000000..b667416c6 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-maps.ts @@ -0,0 +1,11 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { PkiSync } from "./pki-sync-enums"; + +export const PKI_SYNC_NAME_MAP: Record = { + [PkiSync.AzureKeyVault]: "Azure Key Vault" +}; + +export const PKI_SYNC_CONNECTION_MAP: Record = { + [PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault +}; diff --git a/backend/src/services/pki-sync/pki-sync-queue.ts b/backend/src/services/pki-sync/pki-sync-queue.ts new file mode 100644 index 000000000..5967a6c97 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-queue.ts @@ -0,0 +1,753 @@ +/* eslint-disable no-await-in-loop */ +import opentelemetry from "@opentelemetry/api"; +import * as x509 from "@peculiar/x509"; +import { AxiosError } from "axios"; +import { Job } from "bullmq"; +import handlebars from "handlebars"; + +import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal"; +import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "../certificate/certificate-dal"; +import { getCertificateCredentials } from "../certificate/certificate-fns"; +import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; +import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; +import { getCaCertChain } from "../certificate-authority/certificate-authority-fns"; +import { TPkiSyncDALFactory } from "./pki-sync-dal"; +import { PkiSyncStatus } from "./pki-sync-enums"; +import { PkiSyncError } from "./pki-sync-errors"; +import { enterprisePkiSyncCheck, parsePkiSyncErrorMessage, PkiSyncFns } from "./pki-sync-fns"; +import { + TCertificateMap, + TPkiSyncImportCertificatesDTO, + TPkiSyncRaw, + TPkiSyncRemoveCertificatesDTO, + TPkiSyncSyncCertificatesDTO, + TPkiSyncWithCredentials, + TQueuePkiSyncImportCertificatesByIdDTO, + TQueuePkiSyncRemoveCertificatesByIdDTO, + TQueuePkiSyncSyncCertificatesByIdDTO +} from "./pki-sync-types"; + +export type TPkiSyncQueueFactory = ReturnType; + +type TPkiSyncQueueFactoryDep = { + queueService: Pick; + kmsService: Pick< + TKmsServiceFactory, + "createCipherPairWithDataKey" | "decryptWithKmsKey" | "generateKmsKey" | "encryptWithKmsKey" + >; + appConnectionDAL: Pick; + keyStore: Pick; + pkiSyncDAL: Pick; + auditLogService: Pick; + projectDAL: TProjectDALFactory; + licenseService: Pick; + certificateDAL: Pick< + TCertificateDALFactory, + "findLatestActiveCertForSubscriber" | "findAllActiveCertsForSubscriber" | "create" + >; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; +}; + +type PkiSyncActionJob = Job< + TQueuePkiSyncSyncCertificatesByIdDTO | TQueuePkiSyncImportCertificatesByIdDTO | TQueuePkiSyncRemoveCertificatesByIdDTO +>; + +const JITTER_MS = 10 * 1000; +const REQUEUE_MS = 30 * 1000; +const REQUEUE_LIMIT = 30; +const CONNECTION_CONCURRENCY_LIMIT = 3; + +const getRequeueDelay = (failureCount?: number) => { + const jitter = Math.random() * JITTER_MS; + if (!failureCount) return jitter; + return REQUEUE_MS + jitter; +}; + +export const pkiSyncQueueFactory = ({ + queueService, + kmsService, + appConnectionDAL, + keyStore, + pkiSyncDAL, + auditLogService, + projectDAL, + licenseService, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL +}: TPkiSyncQueueFactoryDep) => { + const appCfg = getConfig(); + + const integrationMeter = opentelemetry.metrics.getMeter("PkiSyncs"); + const syncCertificatesErrorHistogram = integrationMeter.createHistogram("pki_sync_sync_certificates_errors", { + description: "PKI Sync - sync certificates errors", + unit: "1" + }); + const importCertificatesErrorHistogram = integrationMeter.createHistogram("pki_sync_import_certificates_errors", { + description: "PKI Sync - import certificates errors", + unit: "1" + }); + const removeCertificatesErrorHistogram = integrationMeter.createHistogram("pki_sync_remove_certificates_errors", { + description: "PKI Sync - remove certificates errors", + unit: "1" + }); + + const $isConnectionConcurrencyLimitReached = async (connectionId: string) => { + const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId)); + + if (!concurrencyCount) return false; + + const count = Number.parseInt(concurrencyCount, 10); + + if (Number.isNaN(count)) return false; + + return count >= CONNECTION_CONCURRENCY_LIMIT; + }; + + const $incrementConnectionConcurrencyCount = async (connectionId: string) => { + const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId)); + + const currentCount = Number.parseInt(concurrencyCount || "0", 10); + + const incrementedCount = Number.isNaN(currentCount) ? 1 : currentCount + 1; + + await keyStore.setItemWithExpiry( + KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId), + (REQUEUE_MS * REQUEUE_LIMIT) / 1000, // in seconds + incrementedCount + ); + }; + + const $decrementConnectionConcurrencyCount = async (connectionId: string) => { + const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId)); + + const currentCount = Number.parseInt(concurrencyCount || "0", 10); + + const decrementedCount = Math.max(0, Number.isNaN(currentCount) ? 0 : currentCount - 1); + + await keyStore.setItemWithExpiry( + KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId), + (REQUEUE_MS * REQUEUE_LIMIT) / 1000, // in seconds + decrementedCount + ); + }; + + const $getInfisicalCertificates = async ( + pkiSync: TPkiSyncRaw | TPkiSyncWithCredentials + ): Promise => { + const { projectId, subscriberId } = pkiSync; + + if (!subscriberId) { + throw new PkiSyncError({ + message: "Invalid PKI Sync source configuration: subscriber no longer exists. Please update source subscriber.", + shouldRetry: false + }); + } + + const certificateMap: TCertificateMap = {}; + + try { + // Get all active certificates for the subscriber (not just the latest) + const certificates = await certificateDAL.findAllActiveCertsForSubscriber({ + subscriberId + }); + + for (const certificate of certificates) { + try { + // Get the certificate body and decrypt the certificate data + const certBody = await certificateBodyDAL.findOne({ certId: certificate.id }); + + if (certBody) { + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ + projectId: certificate.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKeyId + }); + + const decryptedCert = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificate + }); + + const certObj = new x509.X509Certificate(decryptedCert); + const certificatePem = certObj.toString("pem"); + + // Get private key using getCertificateCredentials - handle cases where private key doesn't exist + let certPrivateKey: string | undefined; + try { + const credentials = await getCertificateCredentials({ + certId: certificate.id, + projectId: certificate.projectId, + certificateSecretDAL, + projectDAL, + kmsService + }); + certPrivateKey = credentials.certPrivateKey; + } catch (credError) { + logger.warn( + { certificateId: certificate.id, subscriberId, error: credError }, + "Certificate private key not found - certificate may be imported or key was not stored" + ); + // Continue without private key - some providers may only need the certificate + certPrivateKey = undefined; + } + + let certificateChain: string | undefined; + try { + if (certBody.encryptedCertificateChain) { + const decryptedCertChain = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificateChain + }); + certificateChain = decryptedCertChain.toString(); + } else if (certificate.caCertId) { + const { caCert, caCertChain } = await getCaCertChain({ + caCertId: certificate.caCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + certificateChain = `${caCert}\n${caCertChain}`.trim(); + } + } catch (chainError) { + logger.warn( + { certificateId: certificate.id, subscriberId, error: chainError }, + "Certificate chain not found or could not be decrypted - certificate may be imported or chain was not stored" + ); + // Continue without certificate chain + certificateChain = undefined; + } + + let certificateName: string; + const syncOptions = pkiSync.syncOptions as { certificateNameSchema?: string } | undefined; + const certificateNameSchema = syncOptions?.certificateNameSchema; + + if (certificateNameSchema) { + const environment = "global"; + certificateName = handlebars.compile(certificateNameSchema)({ + certificateId: certificate.id.replace(/-/g, ""), + environment + }); + } else { + certificateName = `Infisical-${certificate.id.replace(/-/g, "")}`; + } + + certificateMap[certificateName] = { + cert: certificatePem, + privateKey: certPrivateKey || "", + certificateChain + }; + } else { + logger.warn({ certificateId: certificate.id, subscriberId }, "Certificate body not found for certificate"); + } + } catch (error) { + logger.error( + { error, subscriberId, certificateId: certificate.id }, + "Failed to decrypt certificate for PKI sync" + ); + // Continue with other certificates + } + } + } catch (error) { + logger.error( + error, + `Failed to fetch certificate for subscriber [subscriberId=${subscriberId}] [projectId=${projectId}]` + ); + throw new PkiSyncError({ + message: `Failed to fetch certificate for PKI subscriber: ${error instanceof Error ? error.message : String(error)}`, + shouldRetry: true + }); + } + + return certificateMap; + }; + + const queuePkiSyncSyncCertificatesById = async (payload: TQueuePkiSyncSyncCertificatesByIdDTO) => + queueService.queue(QueueName.PkiSync, QueueJobs.PkiSyncSyncCertificates, payload, { + delay: getRequeueDelay(payload.failedToAcquireLockCount), // this is for delaying re-queued jobs if sync is locked + attempts: 5, + backoff: { + type: "exponential", + delay: 3000 + }, + removeOnComplete: true, + removeOnFail: true + }); + + const queuePkiSyncImportCertificatesById = async (payload: TQueuePkiSyncImportCertificatesByIdDTO) => + queueService.queue(QueueName.PkiSync, QueueJobs.PkiSyncImportCertificates, payload, { + attempts: 5, + backoff: { + type: "exponential", + delay: 3000 + }, + removeOnComplete: true, + removeOnFail: true + }); + + const queuePkiSyncRemoveCertificatesById = async (payload: TQueuePkiSyncRemoveCertificatesByIdDTO) => + queueService.queue(QueueName.PkiSync, QueueJobs.PkiSyncRemoveCertificates, payload, { + attempts: 5, + backoff: { + type: "exponential", + delay: 3000 + }, + removeOnComplete: true, + removeOnFail: true + }); + + const $importCertificates = async (): Promise => { + throw new Error("Certificate import functionality is not implemented"); + }; + + const $handleSyncCertificatesJob = async (job: TPkiSyncSyncCertificatesDTO, pkiSync: TPkiSyncRaw) => { + const { + data: { syncId, auditLogInfo } + } = job; + + await enterprisePkiSyncCheck( + licenseService, + pkiSync.connection.orgId, + pkiSync.destination, + "Failed to sync certificates due to plan restriction. Upgrade plan to access enterprise PKI syncs." + ); + + await pkiSyncDAL.updateById(syncId, { + syncStatus: PkiSyncStatus.Running + }); + + logger.info( + `PkiSync Sync [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]` + ); + + let isSynced = false; + let syncMessage: string | null = null; + let isFinalAttempt = job.attemptsStarted === job.opts.attempts; + + try { + const { + connection: { orgId, encryptedCredentials, projectId: appConnectionProjectId } + } = pkiSync; + + const credentials = await decryptAppConnectionCredentials({ + orgId, + encryptedCredentials, + kmsService, + projectId: appConnectionProjectId + }); + + const pkiSyncWithCredentials = { + ...pkiSync, + connection: { + ...pkiSync.connection, + credentials + } + } as TPkiSyncWithCredentials; + + const certificateMap = await $getInfisicalCertificates(pkiSync); + + const syncResult = await PkiSyncFns.syncCertificates(pkiSyncWithCredentials, certificateMap, { + appConnectionDAL, + kmsService + }); + + logger.info( + { + syncId: pkiSync.id, + uploaded: syncResult.uploaded || 0, + removed: syncResult.removed || 0, + failedRemovals: syncResult.failedRemovals || 0, + skipped: syncResult.skipped || 0 + }, + "PKI sync operation completed with certificate cleanup" + ); + + isSynced = true; + } catch (err) { + logger.error( + err, + `PkiSync Sync Error [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]` + ); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + syncCertificatesErrorHistogram.record(1, { + version: 1, + destination: pkiSync.destination, + syncId: pkiSync.id, + projectId: pkiSync.projectId, + type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError", + status: err instanceof AxiosError ? err.response?.status : undefined, + name: err instanceof Error ? err.name : undefined + }); + } + + syncMessage = parsePkiSyncErrorMessage(err); + + if (err instanceof PkiSyncError && !err.shouldRetry) { + isFinalAttempt = true; + } else { + throw err; + } + } finally { + const ranAt = new Date(); + const syncStatus = isSynced ? PkiSyncStatus.Succeeded : PkiSyncStatus.Failed; + + await auditLogService.createAuditLog({ + projectId: pkiSync.projectId, + ...(auditLogInfo ?? { + actor: { + type: ActorType.PLATFORM, + metadata: {} + } + }), + event: { + type: EventType.PKI_SYNC_SYNC_CERTIFICATES, + metadata: { + syncId: pkiSync.id, + syncMessage, + jobId: job.id!, + jobRanAt: ranAt + } + } + }); + + if (isSynced || isFinalAttempt) { + await pkiSyncDAL.updateById(pkiSync.id, { + syncStatus, + lastSyncJobId: job.id, + lastSyncMessage: syncMessage, + lastSyncedAt: isSynced ? ranAt : undefined + }); + } + } + }; + + const $handleImportCertificatesJob = async (job: TPkiSyncImportCertificatesDTO, pkiSync: TPkiSyncRaw) => { + const { + data: { syncId, auditLogInfo } + } = job; + + await pkiSyncDAL.updateById(syncId, { + importStatus: PkiSyncStatus.Running + }); + + logger.info( + `PkiSync Import [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]` + ); + + let isSuccess = false; + let importMessage: string | null = null; + let isFinalAttempt = job.attemptsStarted === job.opts.attempts; + + try { + await $importCertificates(); + + isSuccess = true; + } catch (err) { + logger.error( + err, + `PkiSync Import Error [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]` + ); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + importCertificatesErrorHistogram.record(1, { + version: 1, + destination: pkiSync.destination, + syncId: pkiSync.id, + projectId: pkiSync.projectId, + type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError", + status: err instanceof AxiosError ? err.response?.status : undefined, + name: err instanceof Error ? err.name : undefined + }); + } + + importMessage = parsePkiSyncErrorMessage(err); + + if (err instanceof PkiSyncError && !err.shouldRetry) { + isFinalAttempt = true; + } else { + throw err; + } + } finally { + const ranAt = new Date(); + const importStatus = isSuccess ? PkiSyncStatus.Succeeded : PkiSyncStatus.Failed; + + await auditLogService.createAuditLog({ + projectId: pkiSync.projectId, + ...(auditLogInfo ?? { + actor: { + type: ActorType.PLATFORM, + metadata: {} + } + }), + event: { + type: EventType.PKI_SYNC_IMPORT_CERTIFICATES, + metadata: { + syncId: pkiSync.id, + importMessage, + jobId: job.id!, + jobRanAt: ranAt + } + } + }); + + if (isSuccess || isFinalAttempt) { + await pkiSyncDAL.updateById(pkiSync.id, { + importStatus, + lastImportJobId: job.id, + lastImportMessage: importMessage, + lastImportedAt: isSuccess ? ranAt : undefined + }); + } + } + }; + + const $handleRemoveCertificatesJob = async (job: TPkiSyncRemoveCertificatesDTO, pkiSync: TPkiSyncRaw) => { + const { + data: { syncId, auditLogInfo, deleteSyncOnComplete } + } = job; + + await enterprisePkiSyncCheck( + licenseService, + pkiSync.connection.orgId, + pkiSync.destination, + "Failed to remove certificates due to plan restriction. Upgrade plan to access enterprise PKI syncs." + ); + + await pkiSyncDAL.updateById(syncId, { + removeStatus: PkiSyncStatus.Running + }); + + logger.info( + `PkiSync Remove [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]` + ); + + let isSuccess = false; + let removeMessage: string | null = null; + let isFinalAttempt = job.attemptsStarted === job.opts.attempts; + + try { + const { + connection: { orgId, encryptedCredentials, projectId: appConnectionProjectId } + } = pkiSync; + + const credentials = await decryptAppConnectionCredentials({ + orgId, + encryptedCredentials, + kmsService, + projectId: appConnectionProjectId + }); + + const certificateMap = await $getInfisicalCertificates(pkiSync); + + await PkiSyncFns.removeCertificates( + { + ...pkiSync, + connection: { + ...pkiSync.connection, + credentials + } + } as TPkiSyncWithCredentials, + Object.keys(certificateMap), + { + appConnectionDAL, + kmsService + } + ); + + isSuccess = true; + } catch (err) { + logger.error( + err, + `PkiSync Remove Error [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]` + ); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + removeCertificatesErrorHistogram.record(1, { + version: 1, + destination: pkiSync.destination, + syncId: pkiSync.id, + projectId: pkiSync.projectId, + type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError", + status: err instanceof AxiosError ? err.response?.status : undefined, + name: err instanceof Error ? err.name : undefined + }); + } + + removeMessage = parsePkiSyncErrorMessage(err); + + if (err instanceof PkiSyncError && !err.shouldRetry) { + isFinalAttempt = true; + } else { + throw err; + } + } finally { + const ranAt = new Date(); + const removeStatus = isSuccess ? PkiSyncStatus.Succeeded : PkiSyncStatus.Failed; + + await auditLogService.createAuditLog({ + projectId: pkiSync.projectId, + ...(auditLogInfo ?? { + actor: { + type: ActorType.PLATFORM, + metadata: {} + } + }), + event: { + type: EventType.PKI_SYNC_REMOVE_CERTIFICATES, + metadata: { + syncId: pkiSync.id, + removeMessage, + jobId: job.id!, + jobRanAt: ranAt + } + } + }); + + if (isSuccess || isFinalAttempt) { + if (isSuccess && deleteSyncOnComplete) { + await pkiSyncDAL.deleteById(pkiSync.id); + } else { + await pkiSyncDAL.updateById(pkiSync.id, { + removeStatus, + lastRemoveJobId: job.id, + lastRemoveMessage: removeMessage, + lastRemovedAt: isSuccess ? ranAt : undefined + }); + } + } + } + }; + + const $handleAcquireLockFailure = async (job: PkiSyncActionJob) => { + const { syncId } = job.data; + + switch (job.name) { + case QueueJobs.PkiSyncSyncCertificates: { + const { failedToAcquireLockCount = 0, ...rest } = job.data as TQueuePkiSyncSyncCertificatesByIdDTO; + + if (failedToAcquireLockCount < REQUEUE_LIMIT) { + await queuePkiSyncSyncCertificatesById({ ...rest, failedToAcquireLockCount: failedToAcquireLockCount + 1 }); + return; + } + + await pkiSyncDAL.updateById(syncId, { + syncStatus: PkiSyncStatus.Failed, + lastSyncMessage: + "Failed to run job. This typically happens when a sync is already in progress. Please try again.", + lastSyncJobId: job.id + }); + + break; + } + case QueueJobs.PkiSyncImportCertificates: { + await pkiSyncDAL.updateById(syncId, { + importStatus: PkiSyncStatus.Failed, + lastImportMessage: + "Failed to run job. This typically happens when a sync is already in progress. Please try again.", + lastImportJobId: job.id + }); + + break; + } + case QueueJobs.PkiSyncRemoveCertificates: { + await pkiSyncDAL.updateById(syncId, { + removeStatus: PkiSyncStatus.Failed, + lastRemoveMessage: + "Failed to run job. This typically happens when a sync is already in progress. Please try again.", + lastRemoveJobId: job.id + }); + + break; + } + default: + throw new Error(`Unhandled PKI Sync Job ${String(job.name)}`); + } + }; + + queueService.start(QueueName.PkiSync, async (job) => { + const { syncId } = job.data; + + const pkiSync = await pkiSyncDAL.findById(syncId); + + if (!pkiSync) throw new Error(`Cannot find PKI sync with ID ${syncId}`); + + const { connectionId } = pkiSync; + + if (job.name === QueueJobs.PkiSyncSyncCertificates) { + const isConcurrentLimitReached = await $isConnectionConcurrencyLimitReached(connectionId); + + if (isConcurrentLimitReached) { + await $handleAcquireLockFailure(job as PkiSyncActionJob); + + return; + } + } + + let lock: Awaited>; + + try { + lock = await keyStore.acquireLock( + [KeyStorePrefixes.PkiSyncLock(syncId)], + // PKI syncs can take excessive amounts of time so we need to keep it locked + 5 * 60 * 1000 + ); + } catch (e) { + await $handleAcquireLockFailure(job as PkiSyncActionJob); + + return; + } + + try { + switch (job.name) { + case QueueJobs.PkiSyncSyncCertificates: { + await $incrementConnectionConcurrencyCount(connectionId); + await $handleSyncCertificatesJob(job as TPkiSyncSyncCertificatesDTO, pkiSync); + break; + } + case QueueJobs.PkiSyncImportCertificates: + await $handleImportCertificatesJob(job as TPkiSyncImportCertificatesDTO, pkiSync); + break; + case QueueJobs.PkiSyncRemoveCertificates: + await $handleRemoveCertificatesJob(job as TPkiSyncRemoveCertificatesDTO, pkiSync); + break; + default: + throw new Error(`Unhandled PKI Sync Job ${String(job.name)}`); + } + } finally { + if (job.name === QueueJobs.PkiSyncSyncCertificates) { + await $decrementConnectionConcurrencyCount(connectionId); + } + + await lock.release(); + } + }); + + return { + queuePkiSyncSyncCertificatesById, + queuePkiSyncImportCertificatesById, + queuePkiSyncRemoveCertificatesById + }; +}; diff --git a/backend/src/services/pki-sync/pki-sync-schemas.ts b/backend/src/services/pki-sync/pki-sync-schemas.ts new file mode 100644 index 000000000..95023002e --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-schemas.ts @@ -0,0 +1,61 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { PkiSync } from "./pki-sync-enums"; + +// Schema for PKI sync options configuration +export const PkiSyncOptionsSchema = z.object({ + canImportCertificates: z.boolean(), + canRemoveCertificates: z.boolean().optional(), + certificateNameSchema: z + .string() + .optional() + .refine( + (val) => { + if (!val) return true; + + const allowedPlaceholdersRegexPart = ["{{certificateId}}"] + .map((p) => p.replace(new RE2(/[-/\\^$*+?.()|[\]{}]/g), "\\$&")) // Escape regex special characters + .join("|"); + + const allowedContentRegex = new RE2(`^([a-zA-Z0-9_\\-/]|${allowedPlaceholdersRegexPart})*$`); + const contentIsValid = allowedContentRegex.test(val); + + if (val.trim()) { + const certificateIdRegex = new RE2(/\{\{certificateId\}\}/); + const certificateIdIsPresent = certificateIdRegex.test(val); + return contentIsValid && certificateIdIsPresent; + } + + return contentIsValid; + }, + { + message: + "Certificate name schema must include exactly one {{certificateId}} placeholder. Only alphanumeric characters (a-z, A-Z, 0-9), dashes (-), underscores (_), and slashes (/) are allowed besides the placeholders." + } + ) +}); + +// Schema for destination-specific configurations +export const PkiSyncDestinationConfigSchema = z.object({ + destination: z.nativeEnum(PkiSync), + config: z.record(z.unknown()) +}); + +// Base PKI sync schema for API responses +export const PkiSyncSchema = z.object({ + id: z.string().uuid(), + name: z.string().max(255), + description: z.string().nullable().optional(), + destination: z.nativeEnum(PkiSync), + isAutoSyncEnabled: z.boolean(), + destinationConfig: z.record(z.unknown()), + syncOptions: z.record(z.unknown()), + projectId: z.string().uuid(), + subscriberId: z.string().uuid().nullable().optional(), + connectionId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + syncStatus: z.string().nullable().optional(), + lastSyncedAt: z.date().nullable().optional() +}); diff --git a/backend/src/services/pki-sync/pki-sync-service.ts b/backend/src/services/pki-sync/pki-sync-service.ts new file mode 100644 index 000000000..f92c9e19f --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-service.ts @@ -0,0 +1,447 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionPkiSyncActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; +import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; + +import { TPkiSyncDALFactory } from "./pki-sync-dal"; +import { PkiSync, PkiSyncStatus } from "./pki-sync-enums"; +import { enterprisePkiSyncCheck, getPkiSyncProviderCapabilities, listPkiSyncOptions } from "./pki-sync-fns"; +import { PKI_SYNC_CONNECTION_MAP, PKI_SYNC_NAME_MAP } from "./pki-sync-maps"; +import { TPkiSyncQueueFactory } from "./pki-sync-queue"; +import { + TCreatePkiSyncDTO, + TDeletePkiSyncDTO, + TFindPkiSyncByIdDTO, + TListPkiSyncsByProjectId, + TPkiSync, + TTriggerPkiSyncImportCertificatesByIdDTO, + TTriggerPkiSyncRemoveCertificatesByIdDTO, + TTriggerPkiSyncSyncCertificatesByIdDTO, + TUpdatePkiSyncDTO +} from "./pki-sync-types"; + +const getDestinationAppType = (destination: PkiSync): AppConnection => { + const appConnection = PKI_SYNC_CONNECTION_MAP[destination]; + if (!appConnection) { + throw new BadRequestError({ + message: `Unsupported PKI sync destination: ${destination}` + }); + } + return appConnection; +}; + +type TPkiSyncServiceFactoryDep = { + pkiSyncDAL: Pick< + TPkiSyncDALFactory, + "findById" | "findByProjectIdWithSubscribers" | "findByNameAndProjectId" | "create" | "updateById" | "deleteById" + >; + pkiSubscriberDAL: Pick; + appConnectionService: Pick; + permissionService: Pick; + licenseService: Pick; + pkiSyncQueue: Pick< + TPkiSyncQueueFactory, + "queuePkiSyncSyncCertificatesById" | "queuePkiSyncImportCertificatesById" | "queuePkiSyncRemoveCertificatesById" + >; +}; + +export type TPkiSyncServiceFactory = ReturnType; + +export const pkiSyncServiceFactory = ({ + pkiSyncDAL, + pkiSubscriberDAL, + appConnectionService, + permissionService, + licenseService, + pkiSyncQueue +}: TPkiSyncServiceFactoryDep) => { + const createPkiSync = async ( + { + name, + description, + destination, + isAutoSyncEnabled = true, + destinationConfig, + syncOptions = {}, + subscriberId, + connectionId, + projectId + }: Omit, + actor: OrgServiceActor + ): Promise => { + await enterprisePkiSyncCheck(licenseService, actor.orgId, destination); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId + }); + + let subscriber; + if (subscriberId) { + subscriber = await pkiSubscriberDAL.findById(subscriberId); + if (!subscriber || subscriber.projectId !== projectId) { + throw new NotFoundError({ message: "PKI subscriber not found" }); + } + } + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Create, + subscriber + ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: subscriber.name }) + : ProjectPermissionSub.PkiSyncs + ); + + // Get the destination app type based on PKI sync destination + const destinationApp = getDestinationAppType(destination); + + // Validates permission to connect and app is valid for sync destination + await appConnectionService.connectAppConnectionById(destinationApp, connectionId, actor); + + const providerCapabilities = getPkiSyncProviderCapabilities(destination); + const resolvedSyncOptions = { + ...providerCapabilities, + ...syncOptions + }; + + try { + const pkiSync = await pkiSyncDAL.create({ + name, + description, + destination, + isAutoSyncEnabled, + destinationConfig, + syncOptions: resolvedSyncOptions, + subscriberId, + connectionId, + projectId, + ...(isAutoSyncEnabled && { syncStatus: PkiSyncStatus.Pending }) + }); + + if (pkiSync.isAutoSyncEnabled) { + await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id }); + } + + return pkiSync as TPkiSync; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === "23505") { + throw new BadRequestError({ + message: `A PKI Sync with the name "${name}" already exists for the project with ID "${projectId}"` + }); + } + throw err; + } + }; + + const updatePkiSync = async ( + { + id, + name, + description, + isAutoSyncEnabled, + destinationConfig, + syncOptions, + subscriberId, + connectionId + }: Omit, + actor: OrgServiceActor + ): Promise => { + const pkiSync = await pkiSyncDAL.findById(id); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId: pkiSync.projectId + }); + + let currentSubscriber; + if (pkiSync.subscriberId) { + currentSubscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId); + } + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Edit, + currentSubscriber + ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: currentSubscriber.name }) + : ProjectPermissionSub.PkiSyncs + ); + + if (name && name !== pkiSync.name) { + const existingPkiSync = await pkiSyncDAL.findByNameAndProjectId(name, pkiSync.projectId); + if (existingPkiSync) { + throw new BadRequestError({ message: "PKI sync with this name already exists" }); + } + } + + if (subscriberId) { + const subscriber = await pkiSubscriberDAL.findById(subscriberId); + if (!subscriber || subscriber.projectId !== pkiSync.projectId) { + throw new NotFoundError({ message: "PKI subscriber not found" }); + } + } + + if (connectionId && connectionId !== pkiSync.connectionId) { + const destinationApp = getDestinationAppType(pkiSync.destination); + await appConnectionService.connectAppConnectionById(destinationApp, connectionId, actor); + } + + let resolvedSyncOptions = syncOptions; + if (syncOptions) { + const providerCapabilities = getPkiSyncProviderCapabilities(pkiSync.destination); + + if (syncOptions.canImportCertificates && !providerCapabilities.canImportCertificates) { + throw new BadRequestError({ + message: `Certificate import is not supported for ${PKI_SYNC_NAME_MAP[pkiSync.destination]} PKI sync destination` + }); + } + + if (syncOptions.canRemoveCertificates && !providerCapabilities.canRemoveCertificates) { + throw new BadRequestError({ + message: `Certificate removal cannot be enabled for ${PKI_SYNC_NAME_MAP[pkiSync.destination]} PKI sync destination` + }); + } + + resolvedSyncOptions = { + ...providerCapabilities, + ...syncOptions + }; + } + + const updatedPkiSync = await pkiSyncDAL.updateById(id, { + name, + description, + isAutoSyncEnabled, + destinationConfig, + syncOptions: resolvedSyncOptions, + subscriberId, + connectionId + }); + + return updatedPkiSync as TPkiSync; + }; + + const deletePkiSync = async ( + { id }: Omit, + actor: OrgServiceActor + ) => { + const pkiSync = await pkiSyncDAL.findById(id); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId: pkiSync.projectId + }); + + let pkiSyncSubscriber; + if (pkiSync.subscriberId) { + pkiSyncSubscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId); + } + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Delete, + pkiSyncSubscriber + ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: pkiSyncSubscriber.name }) + : ProjectPermissionSub.PkiSyncs + ); + + return pkiSyncDAL.deleteById(id); + }; + + const listPkiSyncsByProjectId = async ( + { projectId }: TListPkiSyncsByProjectId, + actor: OrgServiceActor + ): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionPkiSyncActions.Read, ProjectPermissionSub.PkiSyncs); + + const pkiSyncsWithSubscribers = await pkiSyncDAL.findByProjectIdWithSubscribers(projectId); + + return pkiSyncsWithSubscribers as TPkiSync[]; + }; + + const findPkiSyncById = async ({ id, projectId }: TFindPkiSyncByIdDTO, actor: OrgServiceActor) => { + const pkiSync = await pkiSyncDAL.findById(id); + if (!pkiSync || (projectId && pkiSync.projectId !== projectId)) { + throw new NotFoundError({ + message: `Could not find PKI Sync with ID "${id}"` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId: pkiSync.projectId + }); + + let findSubscriber; + if (pkiSync.subscriberId) { + findSubscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId); + } + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Read, + findSubscriber + ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: findSubscriber.name }) + : ProjectPermissionSub.PkiSyncs + ); + + const result = { + ...pkiSync, + subscriber: findSubscriber ? { id: findSubscriber.id, name: findSubscriber.name } : null + } as TPkiSync; + + return result; + }; + + const triggerPkiSyncSyncCertificatesById = async ( + { id }: Omit, + actor: OrgServiceActor + ) => { + const pkiSync = await pkiSyncDAL.findById(id); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId: pkiSync.projectId + }); + + let syncSubscriber; + if (pkiSync.subscriberId) { + syncSubscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId); + } + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.SyncCertificates, + syncSubscriber + ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: syncSubscriber.name }) + : ProjectPermissionSub.PkiSyncs + ); + + await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: id }); + + return { message: "PKI sync job added to queue successfully" }; + }; + + const triggerPkiSyncImportCertificatesById = async ( + { id }: Omit, + actor: OrgServiceActor + ) => { + const pkiSync = await pkiSyncDAL.findById(id); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId: pkiSync.projectId + }); + + // Check if the PKI sync destination supports importing certificates + const syncOptions = listPkiSyncOptions().find((option) => option.destination === pkiSync.destination); + if (!syncOptions?.canImportCertificates) { + throw new BadRequestError({ + message: `Certificate import is not supported for ${pkiSync.destination} PKI sync destination` + }); + } + + let importSubscriber; + if (pkiSync.subscriberId) { + importSubscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId); + } + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.ImportCertificates, + importSubscriber + ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: importSubscriber.name }) + : ProjectPermissionSub.PkiSyncs + ); + + await pkiSyncQueue.queuePkiSyncImportCertificatesById({ syncId: id }); + + return { message: "PKI sync import job added to queue successfully" }; + }; + + const triggerPkiSyncRemoveCertificatesById = async ( + { id }: Omit, + actor: OrgServiceActor + ) => { + const pkiSync = await pkiSyncDAL.findById(id); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId: pkiSync.projectId + }); + + let removeSubscriber; + if (pkiSync.subscriberId) { + removeSubscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId); + } + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.RemoveCertificates, + removeSubscriber + ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: removeSubscriber.name }) + : ProjectPermissionSub.PkiSyncs + ); + + await pkiSyncQueue.queuePkiSyncRemoveCertificatesById({ syncId: id }); + + return { message: "PKI sync remove job added to queue successfully" }; + }; + + const getPkiSyncOptions = () => { + return listPkiSyncOptions(); + }; + + return { + createPkiSync, + updatePkiSync, + deletePkiSync, + listPkiSyncsByProjectId, + findPkiSyncById, + triggerPkiSyncSyncCertificatesById, + triggerPkiSyncImportCertificatesById, + triggerPkiSyncRemoveCertificatesById, + getPkiSyncOptions + }; +}; diff --git a/backend/src/services/pki-sync/pki-sync-types.ts b/backend/src/services/pki-sync/pki-sync-types.ts new file mode 100644 index 000000000..bf750beee --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-types.ts @@ -0,0 +1,169 @@ +import { Job } from "bullmq"; + +import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types"; +import { QueueJobs } from "@app/queue"; +import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema"; + +import { TPkiSyncDALFactory } from "./pki-sync-dal"; +import { PkiSync } from "./pki-sync-enums"; + +export type TPkiSync = { + id: string; + name: string; + description?: string; + destination: PkiSync; + isAutoSyncEnabled: boolean; + destinationConfig: Record; + syncOptions: Record; + projectId: string; + subscriberId?: string; + connectionId: string; + createdAt: Date; + updatedAt: Date; + syncStatus?: string; + lastSyncJobId?: string; + lastSyncMessage?: string; + lastSyncedAt?: Date; + importStatus?: string; + lastImportJobId?: string; + lastImportMessage?: string; + lastImportedAt?: Date; + removeStatus?: string; + lastRemoveJobId?: string; + lastRemoveMessage?: string; + lastRemovedAt?: Date; + appConnectionName: string; + appConnectionApp: string; + connection: { + id: string; + name: string; + app: string; + encryptedCredentials: unknown; + orgId: string; + projectId?: string; + method: string; + description?: string; + version: number; + gatewayId?: string; + createdAt: Date; + updatedAt: Date; + isPlatformManagedCredentials?: boolean; + }; + subscriber?: { + id: string; + name: string; + } | null; +}; + +export type TPkiSyncWithCredentials = TPkiSync & { + connection: { + id: string; + name: string; + app: string; + credentials: Record; + orgId: string; + }; +}; + +export type TPkiSyncListItem = TPkiSync & { + appConnectionName: string; + appConnectionApp: string; +}; + +export type TCertificateMap = Record; + +export type TCreatePkiSyncDTO = { + name: string; + description?: string; + destination: PkiSync; + isAutoSyncEnabled?: boolean; + destinationConfig: Record; + syncOptions?: Record; + subscriberId?: string; + connectionId: string; + projectId: string; + auditLogInfo: AuditLogInfo; + resourceMetadata?: ResourceMetadataDTO; +}; + +export type TUpdatePkiSyncDTO = { + id: string; + projectId?: string; + name?: string; + description?: string; + isAutoSyncEnabled?: boolean; + destinationConfig?: Record; + syncOptions?: Record; + subscriberId?: string; + connectionId?: string; + auditLogInfo: AuditLogInfo; + resourceMetadata?: ResourceMetadataDTO; +}; + +export type TDeletePkiSyncDTO = { + id: string; + projectId?: string; + auditLogInfo: AuditLogInfo; +}; + +export type TListPkiSyncsByProjectId = { + projectId: string; +}; + +export type TFindPkiSyncByIdDTO = { + id: string; + projectId?: string; +}; + +export type TTriggerPkiSyncSyncCertificatesByIdDTO = { + id: string; + projectId?: string; + auditLogInfo: AuditLogInfo; +}; + +export type TTriggerPkiSyncImportCertificatesByIdDTO = { + id: string; + projectId?: string; + auditLogInfo: AuditLogInfo; +}; + +export type TTriggerPkiSyncRemoveCertificatesByIdDTO = { + id: string; + projectId?: string; + auditLogInfo: AuditLogInfo; +}; + +export type TPkiSyncRaw = NonNullable>>; + +export type TQueuePkiSyncSyncCertificatesByIdDTO = { + syncId: string; + failedToAcquireLockCount?: number; + auditLogInfo?: AuditLogInfo; +}; + +export type TQueuePkiSyncImportCertificatesByIdDTO = { + syncId: string; + auditLogInfo?: AuditLogInfo; +}; + +export type TQueuePkiSyncRemoveCertificatesByIdDTO = { + syncId: string; + auditLogInfo?: AuditLogInfo; + deleteSyncOnComplete?: boolean; +}; + +export type TPkiSyncSyncCertificatesDTO = Job< + TQueuePkiSyncSyncCertificatesByIdDTO, + void, + QueueJobs.PkiSyncSyncCertificates +>; +export type TPkiSyncImportCertificatesDTO = Job< + TQueuePkiSyncImportCertificatesByIdDTO, + void, + QueueJobs.PkiSyncImportCertificates +>; +export type TPkiSyncRemoveCertificatesDTO = Job< + TQueuePkiSyncRemoveCertificatesByIdDTO, + void, + QueueJobs.PkiSyncRemoveCertificates +>; diff --git a/backend/src/services/pki-sync/pki-sync-utils.ts b/backend/src/services/pki-sync/pki-sync-utils.ts new file mode 100644 index 000000000..a81864a17 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-utils.ts @@ -0,0 +1,27 @@ +import { logger } from "@app/lib/logger"; + +import { TPkiSyncDALFactory } from "./pki-sync-dal"; +import { TPkiSyncQueueFactory } from "./pki-sync-queue"; + +export const triggerAutoSyncForSubscriber = async ( + subscriberId: string, + dependencies: { + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; + } +) => { + try { + const pkiSyncs = await dependencies.pkiSyncDAL.find({ + subscriberId, + isAutoSyncEnabled: true + }); + + // Queue sync jobs for each auto sync enabled PKI sync + const syncPromises = pkiSyncs.map((pkiSync) => + dependencies.pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id }) + ); + await Promise.all(syncPromises); + } catch (error) { + logger.error(error, `Failed to trigger auto sync for subscriber ${subscriberId}:`); + } +}; diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 7cf665141..301ce1bc0 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -18,6 +18,8 @@ import { ms } from "@app/lib/ms"; import { TUserGroupMembershipDALFactory } from "../../ee/services/group/user-group-membership-dal"; import { ActorType } from "../auth/auth-type"; import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; +import { TNotificationServiceFactory } from "../notification/notification-service"; +import { NotificationType } from "../notification/notification-types"; import { TOrgDALFactory } from "../org/org-dal"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; @@ -41,7 +43,10 @@ import { import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal"; type TProjectMembershipServiceFactoryDep = { - permissionService: Pick; + permissionService: Pick< + TPermissionServiceFactory, + "getProjectPermission" | "getProjectPermissionByRole" | "invalidateProjectPermissionCache" + >; smtpService: TSmtpService; projectBotDAL: TProjectBotDALFactory; projectMembershipDAL: TProjectMembershipDALFactory; @@ -56,6 +61,7 @@ type TProjectMembershipServiceFactoryDep = { projectUserAdditionalPrivilegeDAL: Pick; secretReminderRecipientsDAL: Pick; groupProjectDAL: TGroupProjectDALFactory; + notificationService: Pick; }; export type TProjectMembershipServiceFactory = ReturnType; @@ -74,7 +80,8 @@ export const projectMembershipServiceFactory = ({ projectDAL, projectKeyDAL, secretReminderRecipientsDAL, - licenseService + licenseService, + notificationService }: TProjectMembershipServiceFactoryDep) => { const getProjectMemberships = async ({ actorId, @@ -235,7 +242,19 @@ export const projectMembershipServiceFactory = ({ ); }); + await permissionService.invalidateProjectPermissionCache(projectId); + if (sendEmails) { + await notificationService.createUserNotifications( + orgMembers.map((member) => ({ + userId: member.userId, + orgId: project.orgId, + type: NotificationType.PROJECT_INVITATION, + title: "Project Invitation", + body: `You've been invited to join the project **${project.name}**.` + })) + ); + const appCfg = getConfig(); await smtpService.sendMail({ template: SmtpTemplates.WorkspaceInvite, @@ -357,6 +376,8 @@ export const projectMembershipServiceFactory = ({ return projectUserMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); }); + await permissionService.invalidateProjectPermissionCache(projectId); + return updatedRoles; }; @@ -400,6 +421,9 @@ export const projectMembershipServiceFactory = ({ ); return deletedMembership; }); + + await permissionService.invalidateProjectPermissionCache(projectId); + return membership; }; @@ -501,6 +525,9 @@ export const projectMembershipServiceFactory = ({ return deletedMemberships; }); + + await permissionService.invalidateProjectPermissionCache(projectId); + return memberships; }; diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts index dd0eecc68..f30da21f5 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -35,7 +35,10 @@ type TProjectRoleServiceFactoryDep = { identityDAL: Pick; userDAL: Pick; projectDAL: Pick; - permissionService: Pick; + permissionService: Pick< + TPermissionServiceFactory, + "getProjectPermission" | "getUserProjectPermission" | "invalidateProjectPermissionCache" + >; identityProjectMembershipRoleDAL: TIdentityProjectMembershipRoleDALFactory; projectUserMembershipRoleDAL: TProjectUserMembershipRoleDALFactory; }; @@ -162,6 +165,8 @@ export const projectRoleServiceFactory = ({ }); if (!updatedRole) throw new NotFoundError({ message: "Project role not found", name: "Update role" }); + await permissionService.invalidateProjectPermissionCache(projectRole.projectId); + return { ...updatedRole, permissions: unpackPermissions(updatedRole.permissions) }; }; @@ -197,6 +202,8 @@ export const projectRoleServiceFactory = ({ const deletedRole = await projectRoleDAL.deleteById(roleId); if (!deletedRole) throw new NotFoundError({ message: "Project role not found", name: "Delete role" }); + await permissionService.invalidateProjectPermissionCache(projectRole.projectId); + return { ...deletedRole, permissions: unpackPermissions(deletedRole.permissions) }; }; diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index 2766db379..d64977f8b 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -399,6 +399,7 @@ export const projectDALFactory = (db: TDbClient) => { name?: string; sortBy?: SearchProjectSortBy; sortDir?: SortDirection; + projectIds?: string[]; }) => { const { limit = 20, offset = 0, sortBy = SearchProjectSortBy.NAME, sortDir = SortDirection.ASC } = dto; @@ -454,6 +455,11 @@ export const projectDALFactory = (db: TDbClient) => { if (dto.name) { void query.whereILike(`${TableName.Project}.name`, `%${dto.name}%`); } + + if (dto.projectIds?.length) { + void query.whereIn(`${TableName.Project}.id`, dto.projectIds); + } + const docs = await query; return { docs, totalCount: Number(docs?.[0]?.count ?? 0) }; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index d59f20bc6..8dbba2b4f 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -57,6 +57,8 @@ import { TKmsServiceFactory } from "../kms/kms-service"; import { validateMicrosoftTeamsChannelsSchema } from "../microsoft-teams/microsoft-teams-fns"; import { TMicrosoftTeamsIntegrationDALFactory } from "../microsoft-teams/microsoft-teams-integration-dal"; import { TProjectMicrosoftTeamsConfigDALFactory } from "../microsoft-teams/project-microsoft-teams-config-dal"; +import { TNotificationServiceFactory } from "../notification/notification-service"; +import { NotificationType } from "../notification/notification-types"; import { TOrgDALFactory } from "../org/org-dal"; import { TPkiAlertDALFactory } from "../pki-alert/pki-alert-dal"; import { TPkiCollectionDALFactory } from "../pki-collection/pki-collection-dal"; @@ -183,6 +185,7 @@ type TProjectServiceFactoryDep = { >; projectTemplateService: TProjectTemplateServiceFactory; reminderService: Pick; + notificationService: Pick; }; export type TProjectServiceFactory = ReturnType; @@ -227,7 +230,8 @@ export const projectServiceFactory = ({ projectTemplateService, groupProjectDAL, smtpService, - reminderService + reminderService, + notificationService }: TProjectServiceFactoryDep) => { /* * Create workspace. Make user the admin @@ -237,8 +241,8 @@ export const projectServiceFactory = ({ actorId, actorOrgId, actorAuthMethod, - workspaceName, - workspaceDescription, + projectName: workspaceName, + projectDescription: workspaceDescription, slug: projectSlug, kmsKeyId, tx: trx, @@ -254,7 +258,13 @@ export const projectServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); + + if ( + permission.cannot(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace) && + permission.cannot(OrgPermissionActions.Create, OrgPermissionSubjects.Project) + ) { + throw new ForbiddenRequestError({ message: "You don't have permission to create a project" }); + } const results = await (trx || projectDAL).transaction(async (tx) => { await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.CreateProject(organization.id)]); @@ -591,7 +601,8 @@ export const projectServiceFactory = ({ secretSharing: update.secretSharing, defaultProduct: update.defaultProduct, showSnapshotsLegacy: update.showSnapshotsLegacy, - secretDetectionIgnoreValues: update.secretDetectionIgnoreValues + secretDetectionIgnoreValues: update.secretDetectionIgnoreValues, + pitVersionLimit: update.pitVersionLimit }); return updatedProject; @@ -684,19 +695,21 @@ export const projectServiceFactory = ({ actorOrgId, actorAuthMethod, auditLogsRetentionDays, - workspaceSlug + filter }: TUpdateAuditLogsRetentionDTO) => { - const project = await projectDAL.findProjectBySlug(workspaceSlug, actorOrgId); + const project = await projectDAL.findProjectByFilter(filter); + const projectId = project.id; + if (!project) { throw new NotFoundError({ - message: `Project with slug '${workspaceSlug}' not found` + message: `Project not found` }); } const { hasRole } = await permissionService.getProjectPermission({ actor, actorId, - projectId: project.id, + projectId, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.Any @@ -1806,7 +1819,8 @@ export const projectServiceFactory = ({ limit, type, orderBy, - orderDirection + orderDirection, + projectIds }: TSearchProjectsDTO) => { // check user belong to org await permissionService.getOrgPermission( @@ -1822,6 +1836,7 @@ export const projectServiceFactory = ({ offset, name, type, + projectIds, orgId: permission.orgId, actor: permission.type, actorId: permission.id, @@ -1913,6 +1928,21 @@ export const projectServiceFactory = ({ projectTypeUrl = "cert-management"; } + const callbackPath = `/projects/${projectTypeUrl}/${project.id}/access-management?selectedTab=members&requesterEmail=${userDetails.email}`; + + await notificationService.createUserNotifications( + projectMembers + .filter((member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin)) + .map((member) => ({ + userId: member.userId, + orgId: project.orgId, + type: NotificationType.PROJECT_ACCESS_REQUEST, + title: "Project Access Request", + body: `**${userDetails.firstName} ${userDetails.lastName}** (${userDetails.email}) has requested access to the project **${project.name}**.`, + link: callbackPath + })) + ); + await smtpService.sendMail({ template: SmtpTemplates.ProjectAccessRequest, recipients: filteredProjectMembers, @@ -1923,7 +1953,7 @@ export const projectServiceFactory = ({ projectName: project?.name, orgName: org?.name, note: comment, - callback_url: `${appCfg.SITE_URL}/projects/${projectTypeUrl}/${project.id}/access-management?selectedTab=members&requesterEmail=${userDetails.email}` + callback_url: `${appCfg.SITE_URL}${callbackPath}` } }); }; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index ceef78f6a..74c7e95f4 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -42,12 +42,13 @@ export type TCreateProjectDTO = { actorAuthMethod: ActorAuthMethod; actorId: string; actorOrgId?: string; - workspaceName: string; - workspaceDescription?: string; + projectName: string; + projectDescription?: string; slug?: string; kmsKeyId?: string; createDefaultEnvs?: boolean; template?: string; + pitVersionLimit?: number; tx?: Knex; type?: ProjectType; }; @@ -78,7 +79,7 @@ export type TUpdateProjectVersionLimitDTO = { export type TUpdateAuditLogsRetentionDTO = { auditLogsRetentionDays: number; - workspaceSlug: string; + filter: Filter; } & Omit; export type TUpdateProjectNameDTO = { @@ -90,6 +91,7 @@ export type TUpdateProjectDTO = { update: { name?: string; description?: string; + pitVersionLimit?: number; autoCapitalization?: boolean; hasDeleteProtection?: boolean; defaultProduct?: ProjectType; @@ -221,6 +223,7 @@ export type TSearchProjectsDTO = { limit?: number; offset?: number; orderBy?: SearchProjectSortBy; + projectIds?: string[]; orderDirection?: SortDirection; }; diff --git a/backend/src/services/secret-sync/gitlab/gitlab-sync-fns.ts b/backend/src/services/secret-sync/gitlab/gitlab-sync-fns.ts index 3c152d853..7bf45d47c 100644 --- a/backend/src/services/secret-sync/gitlab/gitlab-sync-fns.ts +++ b/backend/src/services/secret-sync/gitlab/gitlab-sync-fns.ts @@ -53,6 +53,7 @@ const getValidAccessToken = async ( connection.credentials.refreshToken, connection.id, connection.orgId, + connection.projectId, appConnectionDAL, kmsService, connection.credentials.instanceUrl diff --git a/backend/src/services/secret-sync/heroku/heroku-sync-fns.ts b/backend/src/services/secret-sync/heroku/heroku-sync-fns.ts index d2f0817db..5f2375979 100644 --- a/backend/src/services/secret-sync/heroku/heroku-sync-fns.ts +++ b/backend/src/services/secret-sync/heroku/heroku-sync-fns.ts @@ -32,6 +32,7 @@ const getValidAuthToken = async ( connection.credentials.refreshToken, connection.id, connection.orgId, + connection.projectId, appConnectionDAL, kmsService ); diff --git a/backend/src/services/secret-sync/secret-sync-dal.ts b/backend/src/services/secret-sync/secret-sync-dal.ts index e50593f10..57c6581ce 100644 --- a/backend/src/services/secret-sync/secret-sync-dal.ts +++ b/backend/src/services/secret-sync/secret-sync-dal.ts @@ -31,6 +31,7 @@ const baseSecretSyncQuery = ({ filter, db, tx }: { db: TDbClient; filter?: Secre db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"), db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"), db.ref("gatewayId").withSchema(TableName.AppConnection).as("connectionGatewayId"), + db.ref("projectId").withSchema(TableName.AppConnection).as("connectionProjectId"), db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"), db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"), db @@ -67,6 +68,7 @@ const expandSecretSync = ( connectionVersion, connectionIsPlatformManagedCredentials, connectionGatewayId, + connectionProjectId, ...el } = secretSync; @@ -86,7 +88,8 @@ const expandSecretSync = ( updatedAt: connectionUpdatedAt, version: connectionVersion, isPlatformManagedCredentials: connectionIsPlatformManagedCredentials, - gatewayId: connectionGatewayId + gatewayId: connectionGatewayId, + projectId: connectionProjectId }, folder: folder ? { diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index f75d84eda..63faf3b03 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -61,6 +61,8 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal"; import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; +import { TNotificationServiceFactory } from "../notification/notification-service"; +import { NotificationType } from "../notification/notification-types"; export type TSecretSyncQueueFactory = ReturnType; @@ -100,6 +102,7 @@ type TSecretSyncQueueFactoryDep = { licenseService: Pick; gatewayService: Pick; gatewayV2Service: Pick; + notificationService: Pick; }; type SecretSyncActionJob = Job< @@ -142,7 +145,8 @@ export const secretSyncQueueFactory = ({ folderCommitService, licenseService, gatewayService, - gatewayV2Service + gatewayV2Service, + notificationService }: TSecretSyncQueueFactoryDep) => { const appCfg = getConfig(); @@ -484,13 +488,14 @@ export const secretSyncQueueFactory = ({ try { const { - connection: { orgId, encryptedCredentials } + connection: { orgId, encryptedCredentials, projectId } } = secretSync; const credentials = await decryptAppConnectionCredentials({ orgId, encryptedCredentials, - kmsService + kmsService, + projectId }); const secretSyncWithCredentials = { @@ -624,13 +629,14 @@ export const secretSyncQueueFactory = ({ try { const { - connection: { orgId, encryptedCredentials } + connection: { orgId, encryptedCredentials, projectId } } = secretSync; const credentials = await decryptAppConnectionCredentials({ orgId, encryptedCredentials, - kmsService + kmsService, + projectId }); await $importSecrets( @@ -744,13 +750,14 @@ export const secretSyncQueueFactory = ({ try { const { - connection: { orgId, encryptedCredentials } + connection: { orgId, encryptedCredentials, projectId } } = secretSync; const credentials = await decryptAppConnectionCredentials({ orgId, encryptedCredentials, - kmsService + kmsService, + projectId }); const secretMap = await $getInfisicalSecrets(secretSync); @@ -895,6 +902,19 @@ export const secretSyncQueueFactory = ({ break; } + const syncPath = `/projects/secret-management/${projectId}/integrations/secret-syncs/${destination}/${secretSync.id}`; + + await notificationService.createUserNotifications( + projectAdmins.map((admin) => ({ + userId: admin.userId, + orgId: project.orgId, + type: NotificationType.SECRET_SYNC_FAILED, + title: `Secret Sync Failed to ${actionLabel} Secrets`, + body: `Your **${syncDestination}** sync **${name}** failed to complete${failureMessage ? `: \`${failureMessage}\`` : ""}`, + link: syncPath + })) + ); + await smtpService.sendMail({ recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean), template: SmtpTemplates.SecretSyncFailed, @@ -907,7 +927,7 @@ export const secretSyncQueueFactory = ({ secretPath: folder?.path, environment: environment?.name, projectName: project.name, - syncUrl: `${appCfg.SITE_URL}/projects/secret-management/${projectId}/integrations/secret-syncs/${destination}/${secretSync.id}` + syncUrl: `${appCfg.SITE_URL}${syncPath}` } }); }; diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index 3fdb7fea6..ecd7d04a5 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -41,7 +41,7 @@ import { TSecretSyncQueueFactory } from "./secret-sync-queue"; type TSecretSyncServiceFactoryDep = { secretSyncDAL: TSecretSyncDALFactory; secretImportDAL: TSecretImportDALFactory; - appConnectionService: Pick; + appConnectionService: Pick; permissionService: Pick; projectBotService: Pick; folderDAL: Pick; @@ -267,7 +267,11 @@ export const secretSyncServiceFactory = ({ const destinationApp = SECRET_SYNC_CONNECTION_MAP[params.destination]; // validates permission to connect and app is valid for sync destination - await appConnectionService.connectAppConnectionById(destinationApp, params.connectionId, actor); + await appConnectionService.validateAppConnectionUsageById( + destinationApp, + { connectionId: params.connectionId, projectId }, + actor + ); try { const secretSync = await secretSyncDAL.create({ @@ -362,7 +366,11 @@ export const secretSyncServiceFactory = ({ const destinationApp = SECRET_SYNC_CONNECTION_MAP[secretSync.destination as SecretSync]; // validates permission to connect and app is valid for sync destination - await appConnectionService.connectAppConnectionById(destinationApp, params.connectionId, actor); + await appConnectionService.validateAppConnectionUsageById( + destinationApp, + { connectionId: params.connectionId, projectId: secretSync.projectId }, + actor + ); } if ( diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index 93afb3b55..d8220e4f5 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -446,9 +446,10 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { } }) .where((bd) => { - void bd - .whereNull(`${TableName.SecretV2}.userId`) - .orWhere({ [`${TableName.SecretV2}.userId` as "userId"]: userId || null }); + void bd.whereNull(`${TableName.SecretV2}.userId`); + // scott: removing this as we don't need to count overrides + // and there is currently a bug when you move secrets that doesn't move the override so this can skew count + // .orWhere({ [`${TableName.SecretV2}.userId` as "userId"]: userId || null }); }) .countDistinct(`${TableName.SecretV2}.key`); diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index 7aee91273..6b284ddee 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -597,19 +597,27 @@ export const expandSecretReferencesFactory = ({ return secretCache[cacheKey][secretKey] || { value: "", tags: [] }; } - const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); - if (!folder) return { value: "", tags: [] }; - const secrets = await secretDAL.findByFolderId({ folderId: folder.id }); + try { + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) return { value: "", tags: [] }; + const secrets = await secretDAL.findByFolderId({ folderId: folder.id }); - const decryptedSecret = secrets.reduce>((prev, secret) => { - // eslint-disable-next-line no-param-reassign - prev[secret.key] = { value: decryptSecret(secret.encryptedValue) || "", tags: secret.tags?.map((el) => el.slug) }; - return prev; - }, {}); + const decryptedSecret = secrets.reduce>((prev, secret) => { + // eslint-disable-next-line no-param-reassign + prev[secret.key] = { + value: decryptSecret(secret.encryptedValue) || "", + tags: secret.tags?.map((el) => el.slug) + }; + return prev; + }, {}); - secretCache[cacheKey] = decryptedSecret; + secretCache[cacheKey] = decryptedSecret; - return secretCache[cacheKey][secretKey] || { value: "", tags: [] }; + return secretCache[cacheKey][secretKey] || { value: "", tags: [] }; + } catch (error) { + secretCache[cacheKey] = {}; + return { value: "", tags: [] }; + } }; const recursivelyExpandSecret = async (dto: { @@ -622,11 +630,16 @@ export const expandSecretReferencesFactory = ({ const stackTrace = { ...dto, key: "root", children: [] } as TSecretReferenceTraceNode; if (!dto.value) return { expandedValue: "", stackTrace }; - const stack = [{ ...dto, depth: 0, trace: stackTrace }]; + + // Track visited secrets to prevent circular references + const createSecretId = (env: string, secretPath: string, key: string) => `${env}:${secretPath}:${key}`; + + const currentSecretId = createSecretId(dto.environment, dto.secretPath, dto.secretKey); + const stack = [{ ...dto, depth: 0, trace: stackTrace, visitedSecrets: new Set([currentSecretId]) }]; let expandedValue = dto.value; while (stack.length) { - const { value, secretPath, environment, depth, trace } = stack.pop()!; + const { value, secretPath, environment, depth, trace, visitedSecrets } = stack.pop()!; // eslint-disable-next-line no-continue if (depth > MAX_SECRET_REFERENCE_DEPTH) continue; @@ -664,6 +677,7 @@ export const expandSecretReferencesFactory = ({ }); const cacheKey = getCacheUniqueKey(environment, secretPath); + if (!secretCache[cacheKey]) secretCache[cacheKey] = {}; secretCache[cacheKey][secretKey] = referredValue; referencedSecretValue = referredValue.value; @@ -683,6 +697,7 @@ export const expandSecretReferencesFactory = ({ }); const cacheKey = getCacheUniqueKey(secretReferenceEnvironment, secretReferencePath); + if (!secretCache[cacheKey]) secretCache[cacheKey] = {}; secretCache[cacheKey][secretReferenceKey] = referedValue; referencedSecretValue = referedValue.value; @@ -700,17 +715,27 @@ export const expandSecretReferencesFactory = ({ trace }; - const shouldExpandMore = INTERPOLATION_TEST_REGEX.test(referencedSecretValue); + // Check for circular reference + const referencedSecretId = createSecretId( + referencedSecretEnvironmentSlug, + referencedSecretPath, + referencedSecretKey + ); + const isCircular = visitedSecrets.has(referencedSecretId); + + const newVisitedSecrets = new Set([...visitedSecrets, referencedSecretId]); + + const shouldExpandMore = INTERPOLATION_TEST_REGEX.test(referencedSecretValue) && !isCircular; if (dto.shouldStackTrace) { const stackTraceNode = { ...node, children: [], key: referencedSecretKey, trace: null }; trace?.children.push(stackTraceNode); // if stack trace this would be child node if (shouldExpandMore) { - stack.push({ ...node, trace: stackTraceNode }); + stack.push({ ...node, trace: stackTraceNode, visitedSecrets: newVisitedSecrets }); } } else if (shouldExpandMore) { // if no stack trace is needed we just keep going with root node - stack.push(node); + stack.push({ ...node, visitedSecrets: newVisitedSecrets }); } if (referencedSecretValue) { @@ -757,7 +782,7 @@ export const expandSecretReferencesFactory = ({ }; export const reshapeBridgeSecret = ( - workspaceId: string, + projectId: string, environment: string, secretPath: string, secret: Omit & { @@ -784,7 +809,8 @@ export const reshapeBridgeSecret = ( ) => ({ secretKey: secret.key, secretPath, - workspace: workspaceId, + workspace: projectId, + projectId, environment, secretComment: secret.comment || "", version: secret.version, diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 9db535fbe..de8f930ed 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -159,17 +159,14 @@ export const secretV2BridgeServiceFactory = ({ const uniqueReferenceEnvironmentSlugs = Array.from(new Set(references.map((el) => el.environment))); const referencesEnvironments = await projectEnvDAL.findBySlugs(projectId, uniqueReferenceEnvironmentSlugs, tx); - if (referencesEnvironments.length !== uniqueReferenceEnvironmentSlugs.length) - throw new BadRequestError({ - message: `Referenced environment not found. Missing ${diff( - uniqueReferenceEnvironmentSlugs, - referencesEnvironments.map((el) => el.slug) - ).join(",")}` - }); - + // Filter out references to non-existent environments const referencesEnvironmentGroupBySlug = groupBy(referencesEnvironments, (i) => i.slug); + const validEnvironmentReferences = references.filter((el) => referencesEnvironmentGroupBySlug[el.environment]); + + if (validEnvironmentReferences.length === 0) return; + const referredFolders = await folderDAL.findByManySecretPath( - references.map((el) => ({ + validEnvironmentReferences.map((el) => ({ secretPath: el.secretPath, envId: referencesEnvironmentGroupBySlug[el.environment][0].id })), @@ -177,58 +174,71 @@ export const secretV2BridgeServiceFactory = ({ ); const referencesFolderGroupByPath = groupBy(referredFolders.filter(Boolean), (i) => `${i?.envId}-${i?.path}`); + + // Find only references that have valid folders (don't throw for missing paths) + const validReferences = validEnvironmentReferences.filter((el) => { + const folderId = + referencesFolderGroupByPath[`${referencesEnvironmentGroupBySlug[el.environment][0].id}-${el.secretPath}`]?.[0] + ?.id; + return folderId; + }); + + if (validReferences.length === 0) return; + const referredSecrets = await secretDAL.find( { $complex: { operator: "or", - value: references.map((el) => { - const folderId = - referencesFolderGroupByPath[ - `${referencesEnvironmentGroupBySlug[el.environment][0].id}-${el.secretPath}` - ][0]?.id; - if (!folderId) throw new BadRequestError({ message: `Referenced path ${el.secretPath} doesn't exist` }); + value: validReferences + .map((el) => { + const folderGroup = + referencesFolderGroupByPath[ + `${referencesEnvironmentGroupBySlug[el.environment][0].id}-${el.secretPath}` + ]; + if (!folderGroup || !folderGroup[0]) return null; - return { - operator: "and", - value: [ - { - operator: "eq", - field: "folderId", - value: folderId - }, - { - operator: "eq", - field: `${TableName.SecretV2}.key` as "key", - value: el.secretKey - } - ] - }; - }) + const folderId = folderGroup[0].id; + + return { + operator: "and", + value: [ + { + operator: "eq", + field: "folderId", + value: folderId + }, + { + operator: "eq", + field: `${TableName.SecretV2}.key` as "key", + value: el.secretKey + } + ] + }; + }) + .filter((query) => query !== null) as Array<{ + operator: "and"; + value: Array<{ + operator: "eq"; + field: "folderId" | "key"; + value: string; + }>; + }> } }, { tx } ); - if ( - referredSecrets.length !== - new Set(references.map(({ secretKey, secretPath, environment }) => `${secretKey}.${secretPath}.${environment}`)) - .size // only count unique references - ) - throw new BadRequestError({ - message: `Referenced secret(s) not found: ${diff( - references.map((el) => el.secretKey), - referredSecrets.map((el) => el.key) - ).join(",")}` - }); - - const referredSecretsGroupBySecretKey = groupBy(referredSecrets, (i) => i.key); - references.forEach((el) => { - throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { - environment: el.environment, - secretPath: el.secretPath, - secretName: el.secretKey, - secretTags: referredSecretsGroupBySecretKey[el.secretKey][0]?.tags?.map((i) => i.slug) - }); + // Only check permissions for secrets that actually exist + referredSecrets.forEach((secret) => { + const reference = validReferences.find((ref) => ref.secretKey === secret.key); + if (reference) { + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { + environment: reference.environment, + secretPath: reference.secretPath, + secretName: reference.secretKey, + secretTags: secret.tags?.map((i) => i.slug) + }); + } }); return referredSecrets; @@ -478,15 +488,16 @@ export const secretV2BridgeServiceFactory = ({ secret = sharedSecretToModify; } - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSecretActions.Edit, - subject(ProjectPermissionSub.Secrets, { - environment, - secretPath, - secretName: inputSecret.secretName, - secretTags: secret.tags.map((el) => el.slug) - }) - ); + if (secret.type !== SecretType.Personal) + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretActions.Edit, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName: inputSecret.secretName, + secretTags: secret.tags.map((el) => el.slug) + }) + ); // validate tags // fetch all tags and if not same count throw error meaning one was invalid tags @@ -497,17 +508,18 @@ export const secretV2BridgeServiceFactory = ({ const tagsToCheck = inputSecret.tagIds ? newTags : secret.tags; // now check with new ids - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSecretActions.Edit, - subject(ProjectPermissionSub.Secrets, { - environment, - secretPath, - secretName: inputSecret.secretName, - ...(tagsToCheck.length && { - secretTags: tagsToCheck.map((el) => el.slug) + if (secret.type !== SecretType.Personal) + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretActions.Edit, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName: inputSecret.secretName, + ...(tagsToCheck.length && { + secretTags: tagsToCheck.map((el) => el.slug) + }) }) - }) - ); + ); if (inputSecret.newSecretName) { const doesNewNameSecretExist = await secretDAL.findOne({ @@ -546,6 +558,14 @@ export const secretV2BridgeServiceFactory = ({ ); } + if (secretValue) { + const { nestedReferences, localReferences } = getAllSecretReferences(secretValue); + const allSecretReferences = nestedReferences.concat( + localReferences.map((el) => ({ secretKey: el, secretPath, environment })) + ); + await $validateSecretReferences(projectId, permission, allSecretReferences); + } + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId @@ -706,15 +726,17 @@ export const secretV2BridgeServiceFactory = ({ }) }); if (!secretToDelete) throw new NotFoundError({ message: "Secret not found" }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSecretActions.Delete, - subject(ProjectPermissionSub.Secrets, { - environment, - secretPath, - secretName: secretToDelete.key, - secretTags: secretToDelete.tags?.map((el) => el.slug) - }) - ); + + if (secretToDelete.type !== SecretType.Personal) + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretActions.Delete, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName: secretToDelete.key, + secretTags: secretToDelete.tags?.map((el) => el.slug) + }) + ); try { const deletedSecret = await secretDAL.transaction(async (tx) => { @@ -1658,7 +1680,7 @@ export const secretV2BridgeServiceFactory = ({ await scanSecretPolicyViolations(projectId, secretPath, inputSecrets, project.secretDetectionIgnoreValues || []); // get all tags - const sanitizedTagIds = inputSecrets.flatMap(({ tagIds = [] }) => tagIds); + const sanitizedTagIds = [...new Set(inputSecrets.flatMap(({ tagIds = [] }) => tagIds))]; const tags = sanitizedTagIds.length ? await secretTagDAL.findManyTagsById(projectId, sanitizedTagIds) : []; if (tags.length !== sanitizedTagIds.length) throw new NotFoundError({ message: `Tag not found. Found ${tags.map((el) => el.slug).join(",")}` }); @@ -1906,7 +1928,7 @@ export const secretV2BridgeServiceFactory = ({ }); // get all tags - const sanitizedTagIds = secretsToUpdate.flatMap(({ tagIds = [] }) => tagIds); + const sanitizedTagIds = [...new Set(secretsToUpdate.flatMap(({ tagIds = [] }) => tagIds))]; const tags = sanitizedTagIds.length ? await secretTagDAL.findManyTagsById(projectId, sanitizedTagIds, tx) : []; if (tags.length !== sanitizedTagIds.length) throw new NotFoundError({ message: "Tag not found" }); const tagsGroupByID = groupBy(tags, (i) => i.id); @@ -2333,7 +2355,8 @@ export const secretV2BridgeServiceFactory = ({ actorAuthMethod, limit = 20, offset = 0, - secretId + secretId, + secretVersions: secretVersionsFilter }: TGetSecretVersionsDTO) => { const secret = await secretDAL.findById(secretId); @@ -2370,6 +2393,7 @@ export const secretV2BridgeServiceFactory = ({ const secretVersions = await secretVersionDAL.findVersionsBySecretIdWithActors({ secretId, projectId: folder.projectId, + secretVersions: secretVersionsFilter, findOpt: { offset, limit, @@ -2939,7 +2963,7 @@ export const secretV2BridgeServiceFactory = ({ secretKey: secretName }); - return { tree: stackTrace, value: expandedValue }; + return { tree: stackTrace, value: expandedValue, secret }; }; const getAccessibleSecrets = async ({ @@ -3155,6 +3179,7 @@ export const secretV2BridgeServiceFactory = ({ getSecretById, getAccessibleSecrets, getSecretVersionsByIds, - findSecretIdsByFolderIdAndKeys + findSecretIdsByFolderIdAndKeys, + $validateSecretReferences }; }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index fc7d468ff..5e2ffc1a0 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -159,6 +159,7 @@ export type TGetSecretVersionsDTO = Omit & { limit?: number; offset?: number; secretId: string; + secretVersions?: string[]; }; export type TSecretReference = { environment: string; secretPath: string; secretKey: string }; diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 6bafa3ba6..3a5ccc667 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -2568,7 +2568,8 @@ export const secretServiceFactory = ({ actorAuthMethod, limit = 20, offset = 0, - secretId + secretId, + secretVersions: filterSecretVersions }: TGetSecretVersionsDTO) => { const secretVersionV2 = await secretV2BridgeService .getSecretVersions({ @@ -2578,7 +2579,8 @@ export const secretServiceFactory = ({ actorAuthMethod, limit, offset, - secretId + secretId, + secretVersions: filterSecretVersions }) .catch((err) => { if ((err as Error).message === "BadRequest: Failed to find secret") { @@ -2970,14 +2972,23 @@ export const secretServiceFactory = ({ actor, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + projectId: inputProjectId }: TMoveSecretsDTO) => { - const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + let project; + if (projectSlug) { + project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + } else if (inputProjectId) { + project = await projectDAL.findById(inputProjectId); + } + if (!project) { throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); } + + const projectId = project.id; if (project.version === ProjectVersion.V3) { return secretV2BridgeService.moveSecrets({ sourceEnvironment, @@ -3170,7 +3181,7 @@ export const secretServiceFactory = ({ }); } const destinationFolderPolicy = await secretApprovalPolicyService.getSecretApprovalPolicy( - project.id, + projectId, destinationFolder.environment.slug, destinationFolder.path ); @@ -3257,7 +3268,7 @@ export const secretServiceFactory = ({ } if (locallyUpdatedSecrets.length) { await fnSecretBulkUpdate({ - projectId: project.id, + projectId, folderId: destinationFolder.id, secretVersionDAL, secretDAL, @@ -3300,7 +3311,7 @@ export const secretServiceFactory = ({ const locallyDeletedSecrets = decryptedSourceSecrets.map((el) => ({ ...el, operation: SecretOperations.Delete })); const sourceFolderPolicy = await secretApprovalPolicyService.getSecretApprovalPolicy( - project.id, + projectId, sourceFolder.environment.slug, sourceFolder.path ); diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index cd341a8b5..d8c778d7e 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -331,6 +331,7 @@ export type TGetSecretVersionsDTO = Omit & { limit?: number; offset?: number; secretId: string; + secretVersions?: string[]; }; export type TSecretReference = { environment: string; secretPath: string }; @@ -534,7 +535,8 @@ export type TSyncSecretsDTO = { }); export type TMoveSecretsDTO = { - projectSlug: string; + projectId?: string; + projectSlug?: string; sourceEnvironment: string; sourceSecretPath: string; destinationEnvironment: string; diff --git a/backend/src/services/smtp/emails/OAuthPasswordResetTemplate.tsx b/backend/src/services/smtp/emails/OAuthPasswordResetTemplate.tsx new file mode 100644 index 000000000..97fc8d7e3 --- /dev/null +++ b/backend/src/services/smtp/emails/OAuthPasswordResetTemplate.tsx @@ -0,0 +1,80 @@ +import { Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; + +interface OAuthPasswordResetTemplateProps extends Omit { + email: string; + lastLoginMethod?: string | null; + isCloud: boolean; +} + +export const OAuthPasswordResetTemplate = ({ + email, + lastLoginMethod, + isCloud, + siteUrl +}: OAuthPasswordResetTemplateProps) => { + const getAuthMethodDisplayName = (method: string) => { + return method + .split("-") + .map((word) => { + const upperWord = word.toUpperCase(); + if (["SAML", "LDAP", "OIDC", "SSO"].includes(upperWord)) { + return upperWord; + } + return word.charAt(0).toUpperCase() + word.slice(1); + }) + .join(" "); + }; + + const getAuthMethodMessage = () => { + if (lastLoginMethod) { + const displayName = getAuthMethodDisplayName(lastLoginMethod); + return `Please continue by signing in with ${displayName}.`; + } + return "Please continue using the same authentication method you previously used to sign in (e.g., SSO, SAML, OAuth, or another configured provider)."; + }; + return ( + + + Password Reset Not Available + +
+ + Password reset is not available for this account. + + + A password reset was requested for your Infisical account ({email}), but password login has not been enabled + for your account. + + {getAuthMethodMessage()} + + If you did not initiate this request, please contact{" "} + {isCloud ? ( + <> + us immediately at support@infisical.com + + ) : ( + "your administrator immediately" + )} + . + +
+
+ ); +}; + +export default OAuthPasswordResetTemplate; + +OAuthPasswordResetTemplate.PreviewProps = { + email: "user@example.com", + lastLoginMethod: "github", + isCloud: true, + siteUrl: "https://infisical.com" +} as OAuthPasswordResetTemplateProps; diff --git a/backend/src/services/smtp/emails/index.ts b/backend/src/services/smtp/emails/index.ts index 71c338def..066744596 100644 --- a/backend/src/services/smtp/emails/index.ts +++ b/backend/src/services/smtp/emails/index.ts @@ -7,6 +7,7 @@ export * from "./ExternalImportStartedTemplate"; export * from "./ExternalImportSucceededTemplate"; export * from "./IntegrationSyncFailedTemplate"; export * from "./NewDeviceLoginTemplate"; +export * from "./OAuthPasswordResetTemplate"; export * from "./OrgAdminBreakglassAccessTemplate"; export * from "./OrgAdminProjectGrantAccessTemplate"; export * from "./OrganizationAssignmentTemplate"; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 224f78265..d64582fe0 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -16,6 +16,7 @@ import { ExternalImportSucceededTemplate, IntegrationSyncFailedTemplate, NewDeviceLoginTemplate, + OAuthPasswordResetTemplate, OrgAdminBreakglassAccessTemplate, OrgAdminProjectGrantAccessTemplate, OrganizationAssignmentTemplate, @@ -63,6 +64,7 @@ export enum SmtpTemplates { NewDeviceJoin = "newDevice", OrgInvite = "organizationInvitation", OrgAssignment = "organizationAssignment", + OAuthPasswordReset = "oAuthPasswordReset", ResetPassword = "passwordReset", SetupPassword = "passwordSetup", SecretLeakIncident = "secretLeakIncident", @@ -121,6 +123,7 @@ const EmailTemplateMap: Record> = { [SmtpTemplates.OrgAdminProjectDirectAccess]: OrgAdminProjectGrantAccessTemplate, [SmtpTemplates.ProjectAccessRequest]: ProjectAccessRequestTemplate, [SmtpTemplates.SecretApprovalRequestNeedsReview]: SecretApprovalRequestNeedsReviewTemplate, + [SmtpTemplates.OAuthPasswordReset]: OAuthPasswordResetTemplate, [SmtpTemplates.ResetPassword]: PasswordResetTemplate, [SmtpTemplates.SetupPassword]: PasswordSetupTemplate, [SmtpTemplates.PkiExpirationAlert]: PkiExpirationAlertTemplate, diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index 7e2027f25..de466614a 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -32,7 +32,8 @@ export enum PostHogEventTypes { IssueSshHostHostCert = "Issue SSH Host Host Certificate", SignCert = "Sign PKI Certificate", IssueCert = "Issue PKI Certificate", - InvalidateCache = "Invalidate Cache" + InvalidateCache = "Invalidate Cache", + NotificationUpdated = "Notification Updated" } export type TSecretModifiedEvent = { @@ -46,7 +47,7 @@ export type TSecretModifiedEvent = { properties: { numberOfSecrets: number; environment: string; - workspaceId: string; + projectId: string; secretPath: string; channel?: string; userAgent?: string; @@ -232,6 +233,14 @@ export type TInvalidateCacheEvent = { }; }; +export type TNotificationUpdatedEvent = { + event: PostHogEventTypes.NotificationUpdated; + properties: { + notificationId: string; + isRead?: boolean; + }; +}; + export type TPostHogEvent = { distinctId: string; organizationId?: string } & ( | TSecretModifiedEvent | TAdminInitEvent @@ -251,4 +260,5 @@ export type TPostHogEvent = { distinctId: string; organizationId?: string } & ( | TSignCertificateEvent | TIssueCertificateEvent | TInvalidateCacheEvent + | TNotificationUpdatedEvent ); diff --git a/backend/src/services/totp/totp-service.ts b/backend/src/services/totp/totp-service.ts index 591a66ed6..193a27d90 100644 --- a/backend/src/services/totp/totp-service.ts +++ b/backend/src/services/totp/totp-service.ts @@ -131,15 +131,20 @@ export const totpServiceFactory = ({ totpConfigDAL, kmsService, userDAL }: TTotp secret }); - if (isValid) { - await totpConfigDAL.updateById(totpConfig.id, { - isVerified: true - }); - } else { + if (!isValid) { throw new BadRequestError({ message: "Invalid TOTP token" }); } + + await totpConfigDAL.updateById(totpConfig.id, { + isVerified: true + }); + + const recoveryCodes = decryptWithRoot(totpConfig.encryptedRecoveryCodes).toString().split(","); + return { + recoveryCodes + }; }; const verifyUserTotp = async ({ userId, totp }: TVerifyUserTotpDTO) => { diff --git a/backend/src/services/upgrade-path/github-client.ts b/backend/src/services/upgrade-path/github-client.ts new file mode 100644 index 000000000..44aacca6d --- /dev/null +++ b/backend/src/services/upgrade-path/github-client.ts @@ -0,0 +1,242 @@ +/* eslint-disable no-await-in-loop */ +import RE2 from "re2"; + +import { getConfig } from "@app/lib/config/env"; + +import { FormattedRelease, GitHubApiError, GitHubRelease } from "./types"; + +interface GitHubClientConfig { + token?: string; + timeout: number; + maxRetries: number; + retryDelay: number; + maxPagesPerRequest: number; + perPage: number; +} + +interface RateLimitInfo { + remaining: number; + reset: Date; + used: number; + limit: number; +} + +const getDefaultConfig = (): GitHubClientConfig => ({ + token: getConfig().GITHUB_API_TOKEN, + timeout: 30000, + maxRetries: 3, + retryDelay: 1000, + maxPagesPerRequest: 10, + perPage: 100 +}); + +const getHeaders = (token?: string): Record => { + const headers: Record = { + Accept: "application/vnd.github.v3+json", + "User-Agent": "Infisical-Upgrade-Path-Tool/1.0", + "X-GitHub-Api-Version": "2022-11-28" + }; + + if (token) { + headers.Authorization = `token ${token}`; + } + + return headers; +}; + +const delay = (ms: number): Promise => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +}; + +const isMainInfisicalRelease = (tagName: string): boolean => { + if ( + tagName.startsWith("infisical-cli/") || + tagName.startsWith("infisical-k8-operator/") || + tagName.startsWith("infisical-k8s-operator/") + ) { + return false; + } + + const patterns = [ + new RE2(/^v\d+\.\d+\.\d+/), + new RE2(/^\d+\.\d+\.\d+/), + new RE2(/^infisical\/v?\d+\.\d+\.\d+/), + new RE2(/^infisical\/v?\d+\.\d+\.\d+[-\w]*/) + ]; + + return patterns.some((pattern) => pattern.test(tagName)); +}; + +const normalizeVersion = (tagName: string): string => { + const versionMatch = tagName.match(new RE2(/(\d+\.\d+\.\d+(?:\.\d+)?)/)); + if (versionMatch) { + return `v${versionMatch[1]}`; + } + + if (tagName.startsWith("infisical/")) { + const withoutPrefix = tagName.replace(new RE2(/^infisical\//), ""); + return withoutPrefix.replace(new RE2(/-[a-zA-Z]+$/), ""); + } + return tagName.replace(new RE2(/-[a-zA-Z]+$/), ""); +}; + +const compareVersions = (v1: string, v2: string): number => { + const normalize = (v: string) => { + const versionMatch = v.match(new RE2(/(\d+\.\d+\.\d+(?:\.\d+)?)/)); + if (versionMatch) { + return versionMatch[1]; + } + if (v.startsWith("infisical/")) { + return v.replace(new RE2(/^infisical\/v?/), "").replace(new RE2(/-[a-zA-Z]+$/), ""); + } + return v.replace(new RE2(/^v/), "").replace(new RE2(/-[a-zA-Z]+$/), ""); + }; + + const clean1 = normalize(v1); + const clean2 = normalize(v2); + + const parts1 = clean1.split(".").map(Number); + const parts2 = clean2.split(".").map(Number); + + const maxLength = Math.max(parts1.length, parts2.length); + while (parts1.length < maxLength) parts1.push(0); + while (parts2.length < maxLength) parts2.push(0); + + for (let i = 0; i < maxLength; i += 1) { + if (parts1[i] > parts2[i]) return 1; + if (parts1[i] < parts2[i]) return -1; + } + return 0; +}; + +const isVersionAtLeastMinimum = (tagName: string, minimumVersion = "0.147.0"): boolean => { + return compareVersions(tagName, minimumVersion) >= 0; +}; + +const makeRequest = async ( + url: string, + config: GitHubClientConfig, + retryCount = 0 +): Promise<{ data: T; rateLimit: RateLimitInfo }> => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.timeout); + + try { + const response = await fetch(url, { + headers: getHeaders(config.token), + signal: controller.signal + }); + + clearTimeout(timeout); + + const rateLimit: RateLimitInfo = { + remaining: parseInt(response.headers.get("X-RateLimit-Remaining") || "0", 10), + reset: new Date(parseInt(response.headers.get("X-RateLimit-Reset") || "0", 10) * 1000), + used: parseInt(response.headers.get("X-RateLimit-Used") || "0", 10), + limit: parseInt(response.headers.get("X-RateLimit-Limit") || "5000", 10) + }; + + if (!response.ok) { + const error: GitHubApiError = new Error(`GitHub API error: ${response.status}`); + error.status = response.status; + error.headers = response.headers; + + if (response.status === 403) { + const resetTime = rateLimit.reset.toISOString(); + error.message = `GitHub API rate limit exceeded. Remaining: ${rateLimit.remaining}, Reset at: ${resetTime}. ${ + !config.token ? "Consider setting GITHUB_TOKEN environment variable." : "" + }`; + } + + if (retryCount < config.maxRetries && (response.status >= 500 || response.status === 403)) { + await delay(config.retryDelay * 2 ** retryCount); + return await makeRequest(url, config, retryCount + 1); + } + + throw error; + } + + const data = (await response.json()) as T; + return { data, rateLimit }; + } catch (error) { + clearTimeout(timeout); + + if (error instanceof Error && error.name === "AbortError") { + if (retryCount < config.maxRetries) { + await delay(config.retryDelay * 2 ** retryCount); + return await makeRequest(url, config, retryCount + 1); + } + throw new Error(`Request timeout after ${config.timeout}ms`); + } + + if (retryCount < config.maxRetries && !(error as GitHubApiError).status) { + await delay(config.retryDelay * 2 ** retryCount); + return await makeRequest(url, config, retryCount + 1); + } + + throw error; + } +}; + +export const fetchReleases = async (includePrerelease = false): Promise => { + const config = getDefaultConfig(); + const allReleases: GitHubRelease[] = []; + let page = 1; + let hasMorePages = true; + let reachedMinimumVersion = false; + + const maxConcurrentRequests = Math.min(3, config.maxPagesPerRequest); + + while (hasMorePages && page <= config.maxPagesPerRequest && !reachedMinimumVersion) { + const requests: Promise<{ data: GitHubRelease[]; rateLimit: RateLimitInfo }>[] = []; + + for (let i = 0; i < maxConcurrentRequests && page <= config.maxPagesPerRequest; i += 1, page += 1) { + const url = `https://api.github.com/repos/Infisical/infisical/releases?page=${page}&per_page=${config.perPage}`; + requests.push(makeRequest(url, config)); + } + + const results = await Promise.allSettled(requests); + let hasData = false; + + for (const result of results) { + if (result.status === "fulfilled") { + const { data } = result.value; + if (data.length > 0) { + for (const release of data) { + if (!release.draft && isMainInfisicalRelease(release.tag_name)) { + if (isVersionAtLeastMinimum(release.tag_name)) { + allReleases.push(release); + } else { + reachedMinimumVersion = true; + break; + } + } + } + hasData = true; + } + } + } + + if (!hasData || results.every((r) => r.status === "fulfilled" && r.value.data.length < config.perPage)) { + hasMorePages = false; + } + } + + const formattedReleases = allReleases + .map( + (release): FormattedRelease => ({ + tagName: release.tag_name, + normalizedTagName: normalizeVersion(release.tag_name), + name: release.name, + body: release.body, + publishedAt: release.published_at, + prerelease: release.prerelease, + draft: release.draft + }) + ) + .sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()); + + return formattedReleases.filter((release) => includePrerelease || !release.prerelease); +}; diff --git a/backend/src/services/upgrade-path/index.ts b/backend/src/services/upgrade-path/index.ts new file mode 100644 index 000000000..1000e2bfa --- /dev/null +++ b/backend/src/services/upgrade-path/index.ts @@ -0,0 +1,2 @@ +export type { TUpgradePathService, TUpgradePathServiceFactory } from "./upgrade-path-service"; +export { upgradePathServiceFactory } from "./upgrade-path-service"; diff --git a/backend/src/services/upgrade-path/types.ts b/backend/src/services/upgrade-path/types.ts new file mode 100644 index 000000000..83d3d1546 --- /dev/null +++ b/backend/src/services/upgrade-path/types.ts @@ -0,0 +1,66 @@ +export interface GitHubRelease { + tag_name: string; + name: string; + body: string; + published_at: string; + prerelease: boolean; + draft: boolean; +} + +export interface FormattedRelease { + tagName: string; + normalizedTagName: string; + name: string; + body: string; + publishedAt: string; + prerelease: boolean; + draft: boolean; +} + +export interface BreakingChange { + title: string; + description: string; + action: string; +} + +export interface VersionConfig { + breaking_changes?: BreakingChange[]; + db_schema_changes?: string; + notes?: string; +} + +export interface UpgradePathConfig { + versions?: Record; +} + +export interface UpgradePathResult { + path: Array<{ + version: string; + name: string; + publishedAt: string; + prerelease: boolean; + }>; + breakingChanges: Array<{ + version: string; + changes: BreakingChange[]; + }>; + features: Array<{ + version: string; + name: string; + body: string; + publishedAt: string; + }>; + hasDbMigration: boolean; + config: Record; +} + +export interface GitHubApiError extends Error { + status?: number; + headers?: Headers; +} + +export interface CacheEntry { + data: T; + timestamp: number; + ttl: number; +} diff --git a/backend/src/services/upgrade-path/upgrade-path-schemas.ts b/backend/src/services/upgrade-path/upgrade-path-schemas.ts new file mode 100644 index 000000000..a283505c7 --- /dev/null +++ b/backend/src/services/upgrade-path/upgrade-path-schemas.ts @@ -0,0 +1,24 @@ +import RE2 from "re2"; +import { z } from "zod"; + +export const versionSchema = z + .string() + .min(1) + .max(50) + .regex(new RE2(/^[a-zA-Z0-9._/-]+$/), "Invalid version format"); + +export const breakingChangeSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().min(1).max(1000), + action: z.string().min(1).max(500) +}); + +export const versionConfigSchema = z.object({ + breaking_changes: z.array(breakingChangeSchema).optional(), + db_schema_changes: z.string().max(1000).optional(), + notes: z.string().max(2000).optional() +}); + +export const upgradePathConfigSchema = z.object({ + versions: z.record(versionSchema, versionConfigSchema).optional().nullable() +}); diff --git a/backend/src/services/upgrade-path/upgrade-path-service.ts b/backend/src/services/upgrade-path/upgrade-path-service.ts new file mode 100644 index 000000000..45c602d78 --- /dev/null +++ b/backend/src/services/upgrade-path/upgrade-path-service.ts @@ -0,0 +1,259 @@ +import { readFile } from "fs/promises"; +import * as yaml from "js-yaml"; +import * as path from "path"; +import RE2 from "re2"; +import { z } from "zod"; + +import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { logger } from "@app/lib/logger"; + +import { fetchReleases } from "./github-client"; +import { BreakingChange, FormattedRelease, UpgradePathConfig, UpgradePathResult, VersionConfig } from "./types"; +import { versionConfigSchema, versionSchema } from "./upgrade-path-schemas"; + +export type TUpgradePathServiceFactory = { + keyStore: TKeyStoreFactory; +}; +export type TUpgradePathService = ReturnType; + +interface CalculateUpgradePathParams { + fromVersion: string; + toVersion: string; +} + +export const upgradePathServiceFactory = ({ keyStore }: TUpgradePathServiceFactory) => { + const sanitizeCacheKey = (key: string): string => { + return key.replace(new RE2(/[^a-zA-Z0-9\-:._]/g), "_"); + }; + const getGitHubReleases = async (): Promise => { + const cacheKey = "upgrade-path:releases"; + + try { + const cached = await keyStore.getItem(cacheKey); + if (cached) { + const cachedReleases = JSON.parse(cached) as FormattedRelease[]; + if (cachedReleases.length > 0) { + return cachedReleases; + } + } + } catch (error) { + logger.error(error, "Failed to retrieve releases from cache"); + } + + try { + const releases = await fetchReleases(false); + const filteredReleases = releases.filter((v) => !v.tagName.includes("nightly")); + + await keyStore.setItemWithExpiry(cacheKey, 24 * 60 * 60, JSON.stringify(filteredReleases)); + return filteredReleases; + } catch (error) { + throw new Error(`GitHub releases unavailable: ${error instanceof Error ? error.message : "Unknown error"}`); + } + }; + + const getUpgradePathConfig = async (): Promise>> => { + const cacheKey = "upgrade-path:config"; + + try { + const cached = await keyStore.getItem(cacheKey); + if (cached) return JSON.parse(cached) as Record; + } catch (error) { + logger.error(error, "Failed to retrieve config from cache"); + } + + try { + const yamlPath = path.join(__dirname, "..", "..", "..", "upgrade-path.yaml"); + const resolvedPath = path.resolve(yamlPath); + const expectedBaseDir = path.resolve(__dirname, "..", "..", ".."); + if (!resolvedPath.startsWith(expectedBaseDir)) { + throw new Error("Invalid configuration file path"); + } + + const yamlContent = await readFile(yamlPath, "utf8"); + + if (yamlContent.length > 1024 * 1024) { + throw new Error("Config file too large"); + } + + const config = yaml.load(yamlContent, { schema: yaml.FAILSAFE_SCHEMA }) as UpgradePathConfig; + const versionConfig = config?.versions || {}; + + await keyStore.setItemWithExpiry(cacheKey, 24 * 60 * 60, JSON.stringify(versionConfig)); + return versionConfig; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + const empty = {}; + await keyStore.setItemWithExpiry(cacheKey, 24 * 60 * 60, JSON.stringify(empty)); + return empty; + } + throw new Error(`Config load failed: ${error instanceof Error ? error.message : "Unknown error"}`); + } + }; + + const normalizeVersion = (version: string): string => { + const versionRegex = new RE2(/(\d+\.\d+\.\d+(?:\.\d+)?)/); + const versionMatch = version.match(versionRegex); + if (versionMatch) { + return versionMatch[1]; + } + + if (version.startsWith("infisical/")) { + return version.replace(new RE2(/^infisical\/v?/), "").replace(new RE2(/-[a-zA-Z]+$/), ""); + } + return version.replace(new RE2(/^v/), "").replace(new RE2(/-[a-zA-Z]+$/), ""); + }; + + const validateParams = (params: CalculateUpgradePathParams) => { + const { fromVersion, toVersion } = params; + + versionSchema.parse(fromVersion); + versionSchema.parse(toVersion); + + if (fromVersion === toVersion) { + throw new Error("Versions cannot be identical"); + } + + if (fromVersion.includes("nightly") || toVersion.includes("nightly")) { + throw new Error("Nightly releases are not supported for upgrade path calculation"); + } + + return { fromVersion, toVersion }; + }; + + const calculateUpgradePath = async (params: CalculateUpgradePathParams): Promise => { + const { fromVersion, toVersion } = validateParams(params); + const cacheKey = sanitizeCacheKey(`upgrade-path:${fromVersion}:${toVersion}`); + + try { + const cached = await keyStore.getItem(cacheKey); + if (cached) return JSON.parse(cached) as UpgradePathResult; + } catch (error) { + logger.error(error, "Failed to retrieve upgrade path from cache"); + } + + const [releases, config] = await Promise.all([getGitHubReleases(), getUpgradePathConfig()]); + + const cleanFrom = normalizeVersion(fromVersion); + const cleanTo = normalizeVersion(toVersion); + + const compareVersions = (v1: string, v2: string): number => { + const normalize = (v: string) => normalizeVersion(v); + const clean1 = normalize(v1); + const clean2 = normalize(v2); + + const parts1 = clean1.split(".").map(Number); + const parts2 = clean2.split(".").map(Number); + + const maxLength = Math.max(parts1.length, parts2.length); + while (parts1.length < maxLength) parts1.push(0); + while (parts2.length < maxLength) parts2.push(0); + + for (let i = 0; i < maxLength; i += 1) { + if (parts1[i] > parts2[i]) return 1; + if (parts1[i] < parts2[i]) return -1; + } + return 0; + }; + + if (compareVersions(cleanFrom, cleanTo) >= 0) { + throw new Error("fromVersion must be older than toVersion"); + } + + const fromIdx = releases.findIndex((r) => normalizeVersion(r.normalizedTagName) === cleanFrom); + const toIdx = releases.findIndex((r) => normalizeVersion(r.normalizedTagName) === cleanTo); + + let upgradePath: FormattedRelease[] = []; + const filteredPath: FormattedRelease[] = []; + + if (fromIdx !== -1 && toIdx !== -1) { + if (fromIdx <= toIdx) throw new Error("Invalid version order"); + upgradePath = releases.slice(toIdx, fromIdx + 1).reverse(); + const [first, last] = [upgradePath[0], upgradePath[upgradePath.length - 1]]; + + filteredPath.push(first); + if (last !== first) filteredPath.push(last); + } + + const breakingChanges: Array<{ version: string; changes: BreakingChange[] }> = []; + const features: Array<{ version: string; name: string; body: string; publishedAt: string }> = []; + let hasDbMigration = false; + + const isVersionInRange = (version: string, fromVer: string, toVer: string): boolean => { + const versionComp = compareVersions(version, fromVer); + const toVersionComp = compareVersions(version, toVer); + return versionComp > 0 && toVersionComp < 0; + }; + + Object.keys(config).forEach((configVersion) => { + const versionConfig = config[configVersion]; + if (versionConfig?.breaking_changes?.length) { + if (isVersionInRange(configVersion, cleanFrom, cleanTo)) { + breakingChanges.push({ + version: configVersion, + changes: versionConfig.breaking_changes + }); + } + } + }); + for (let i = 0; i < upgradePath.length; i += 1) { + const version = upgradePath[i]; + const isFromVersion = normalizeVersion(version.normalizedTagName) === cleanFrom; + + if (!isFromVersion) { + const versionNumber = normalizeVersion(version.tagName); + const possibleKeys = [ + version.tagName, + version.normalizedTagName, + versionNumber, + `v${versionNumber}`, + version.tagName.replace(new RE2(/^infisical\//), ""), + version.tagName.replace(new RE2(/^infisical\/v?/), "").replace(new RE2(/-[a-zA-Z]+$/), "") + ]; + + for (const key of possibleKeys) { + const versionConfig = config[key]; + if ( + versionConfig?.db_schema_changes && + typeof versionConfig.db_schema_changes === "string" && + versionConfig.db_schema_changes.trim() + ) { + hasDbMigration = true; + break; + } + } + } + + // Collect release notes and features + if (version.body) { + features.push({ + version: version.tagName, + name: version.name, + body: version.body, + publishedAt: version.publishedAt + }); + } + } + + const result: UpgradePathResult = { + path: filteredPath.map((r) => ({ + version: r.tagName, + name: r.name, + publishedAt: r.publishedAt, + prerelease: r.prerelease + })), + breakingChanges, + features, + hasDbMigration, + config + }; + + await keyStore.setItemWithExpiry(cacheKey, 60 * 60, JSON.stringify(result)); + return result; + }; + + return { + getGitHubReleases, + getUpgradePathConfig, + calculateUpgradePath: (fromVersion: string, toVersion: string) => calculateUpgradePath({ fromVersion, toVersion }) + }; +}; diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index f97e98fa3..b8058f327 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -294,11 +294,18 @@ export const userServiceFactory = ({ // Delete all user aliases since the email is changing await userAliasDAL.delete({ userId }, tx); + // Ensure EMAIL auth method is included if not already present + const currentAuthMethods = user.authMethods || []; + const updatedAuthMethods = currentAuthMethods.includes(AuthMethod.EMAIL) + ? currentAuthMethods + : [...currentAuthMethods, AuthMethod.EMAIL]; + const updatedUser = await userDAL.updateById( userId, { email: newEmail.toLowerCase(), - username: newEmail.toLowerCase() + username: newEmail.toLowerCase(), + authMethods: updatedAuthMethods }, tx ); diff --git a/backend/src/services/webhook/webhook-fns.ts b/backend/src/services/webhook/webhook-fns.ts index d7e07ae28..e04b5097b 100644 --- a/backend/src/services/webhook/webhook-fns.ts +++ b/backend/src/services/webhook/webhook-fns.ts @@ -94,6 +94,7 @@ export const getWebhookPayload = (event: TWebhookPayloads) => { event: event.type, project: { workspaceId: projectId, + projectId, projectName, environment, secretPath @@ -147,6 +148,7 @@ export const getWebhookPayload = (event: TWebhookPayloads) => { event: event.type, project: { workspaceId: projectId, + projectId, projectName, environment, secretPath, diff --git a/backend/upgrade-path.yaml b/backend/upgrade-path.yaml new file mode 100644 index 000000000..4ccb7e775 --- /dev/null +++ b/backend/upgrade-path.yaml @@ -0,0 +1,26 @@ +# Upgrade Path Configuration File +# +# This file defines breaking changes and database migration information for Infisical versions. +# Used by the upgrade path tool to help users understand what changes are required between versions. +# +# Expected format: +# versions: +# "version_key": # Can be "v1.2.3", "1.2.3", or "infisical/v1.2.3-postgres" +# breaking_changes: # Optional: list of breaking changes for this version +# - title: "Short descriptive title" +# description: "Detailed description of what changed" +# action: "Specific steps users need to take" +# db_schema_changes: "Optional: Description of database changes and migration details" +# notes: "Optional: Additional notes or important information about this version" +# +# Example: +# versions: +# "v1.2.3": +# breaking_changes: +# - title: "API Endpoint Changes" +# description: "Authentication endpoints have been restructured" +# action: "Update all API calls to use new /auth/v2/ endpoints" +# db_schema_changes: "Major schema restructuring with table reorganization. Extended migration time: 3 minutes." +# notes: "Critical update requiring maintenance window. Test thoroughly before production deployment." + +versions: \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/redis/available.mdx b/docs/api-reference/endpoints/app-connections/redis/available.mdx new file mode 100644 index 000000000..6b0389d3f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/redis/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/redis/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/redis/create.mdx b/docs/api-reference/endpoints/app-connections/redis/create.mdx new file mode 100644 index 000000000..b203048d6 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/redis/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/redis" +--- + + + Check out the configuration docs for [Redis Connections](/integrations/app-connections/redis) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/redis/delete.mdx b/docs/api-reference/endpoints/app-connections/redis/delete.mdx new file mode 100644 index 000000000..bf8178a23 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/redis/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/redis/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/redis/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/redis/get-by-id.mdx new file mode 100644 index 000000000..9879fff7f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/redis/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/redis/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/redis/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/redis/get-by-name.mdx new file mode 100644 index 000000000..42807f221 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/redis/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/redis/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/redis/list.mdx b/docs/api-reference/endpoints/app-connections/redis/list.mdx new file mode 100644 index 000000000..913da1679 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/redis/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/redis" +--- diff --git a/docs/api-reference/endpoints/app-connections/redis/update.mdx b/docs/api-reference/endpoints/app-connections/redis/update.mdx new file mode 100644 index 000000000..e2414b971 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/redis/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/redis/{connectionId}" +--- + + + Check out the configuration docs for [Redis Connections](/integrations/app-connections/redis) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-syncs/list.mdx b/docs/api-reference/endpoints/certificate-syncs/list.mdx new file mode 100644 index 000000000..6de2c2d1b --- /dev/null +++ b/docs/api-reference/endpoints/certificate-syncs/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List PKI Syncs" +openapi: "GET /api/v1/pki/syncs" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-syncs/options.mdx b/docs/api-reference/endpoints/certificate-syncs/options.mdx new file mode 100644 index 000000000..ab2d11e48 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-syncs/options.mdx @@ -0,0 +1,4 @@ +--- +title: "Options" +openapi: "GET /api/v1/pki/syncs/options" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/deprecated/environments/create.mdx b/docs/api-reference/endpoints/deprecated/environments/create.mdx new file mode 100644 index 000000000..826dcce3d --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/environments/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/workspace/{workspaceId}/environments" +--- diff --git a/docs/api-reference/endpoints/deprecated/environments/delete.mdx b/docs/api-reference/endpoints/deprecated/environments/delete.mdx new file mode 100644 index 000000000..903e58d2a --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/environments/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/workspace/{workspaceId}/environments/{id}" +--- diff --git a/docs/api-reference/endpoints/deprecated/environments/update.mdx b/docs/api-reference/endpoints/deprecated/environments/update.mdx new file mode 100644 index 000000000..f93968668 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/environments/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/workspace/{workspaceId}/environments/{id}" +--- diff --git a/docs/api-reference/endpoints/deprecated/folders/create.mdx b/docs/api-reference/endpoints/deprecated/folders/create.mdx new file mode 100644 index 000000000..e1ff3004a --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/folders/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/folders" +--- diff --git a/docs/api-reference/endpoints/deprecated/folders/delete.mdx b/docs/api-reference/endpoints/deprecated/folders/delete.mdx new file mode 100644 index 000000000..a106cc2eb --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/folders/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/folders/{folderIdOrName}" +--- diff --git a/docs/api-reference/endpoints/deprecated/folders/get-by-id.mdx b/docs/api-reference/endpoints/deprecated/folders/get-by-id.mdx new file mode 100644 index 000000000..db3c4d0cc --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/folders/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/folders/{id}" +--- diff --git a/docs/api-reference/endpoints/deprecated/folders/list.mdx b/docs/api-reference/endpoints/deprecated/folders/list.mdx new file mode 100644 index 000000000..f40f93273 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/folders/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/folders" +--- diff --git a/docs/api-reference/endpoints/deprecated/folders/update.mdx b/docs/api-reference/endpoints/deprecated/folders/update.mdx new file mode 100644 index 000000000..c54778e94 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/folders/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/folders/{folderId}" +--- diff --git a/docs/api-reference/endpoints/organizations/workspaces.mdx b/docs/api-reference/endpoints/deprecated/organizations/projects.mdx similarity index 100% rename from docs/api-reference/endpoints/organizations/workspaces.mdx rename to docs/api-reference/endpoints/deprecated/organizations/projects.mdx diff --git a/docs/api-reference/endpoints/deprecated/project-groups/create.mdx b/docs/api-reference/endpoints/deprecated/project-groups/create.mdx new file mode 100644 index 000000000..6dd7f1a4f --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-groups/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Project Membership" +openapi: "POST /api/v2/workspace/{projectId}/groups/{groupIdOrName}" +--- diff --git a/docs/api-reference/endpoints/deprecated/project-groups/delete.mdx b/docs/api-reference/endpoints/deprecated/project-groups/delete.mdx new file mode 100644 index 000000000..07db40d7f --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-groups/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete Project Membership" +openapi: "DELETE /api/v2/workspace/{projectId}/groups/{groupId}" +--- diff --git a/docs/api-reference/endpoints/deprecated/project-groups/get-by-id.mdx b/docs/api-reference/endpoints/deprecated/project-groups/get-by-id.mdx new file mode 100644 index 000000000..611f39059 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-groups/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Project Membership" +openapi: "GET /api/v2/workspace/{projectId}/groups/{groupId}" +--- diff --git a/docs/api-reference/endpoints/deprecated/project-groups/list.mdx b/docs/api-reference/endpoints/deprecated/project-groups/list.mdx new file mode 100644 index 000000000..1488fb6ae --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-groups/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List Project Memberships" +openapi: "GET /api/v2/workspace/{projectId}/groups" +--- diff --git a/docs/api-reference/endpoints/deprecated/project-groups/update.mdx b/docs/api-reference/endpoints/deprecated/project-groups/update.mdx new file mode 100644 index 000000000..8b963a1d8 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-groups/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update Project Membership" +openapi: "PATCH /api/v2/workspace/{projectId}/groups/{groupId}" +--- diff --git a/docs/api-reference/endpoints/deprecated/project-identities/add-identity-membership.mdx b/docs/api-reference/endpoints/deprecated/project-identities/add-identity-membership.mdx new file mode 100644 index 000000000..285b1d1c4 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-identities/add-identity-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Identity Membership" +openapi: "POST /api/v2/workspace/{projectId}/identity-memberships/{identityId}" +--- diff --git a/docs/api-reference/endpoints/deprecated/project-identities/delete-identity-membership.mdx b/docs/api-reference/endpoints/deprecated/project-identities/delete-identity-membership.mdx new file mode 100644 index 000000000..e2b266626 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-identities/delete-identity-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete Identity Membership" +openapi: "DELETE /api/v2/workspace/{projectId}/identity-memberships/{identityId}" +--- diff --git a/docs/api-reference/endpoints/deprecated/project-identities/get-by-id.mdx b/docs/api-reference/endpoints/deprecated/project-identities/get-by-id.mdx new file mode 100644 index 000000000..37f4192d7 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-identities/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Identity by ID" +openapi: "GET /api/v2/workspace/{projectId}/identity-memberships/{identityId}" +--- diff --git a/docs/api-reference/endpoints/deprecated/project-identities/list-identity-memberships.mdx b/docs/api-reference/endpoints/deprecated/project-identities/list-identity-memberships.mdx new file mode 100644 index 000000000..e5162e693 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-identities/list-identity-memberships.mdx @@ -0,0 +1,4 @@ +--- +title: "List Identity Memberships" +openapi: "GET /api/v2/workspace/{projectId}/identity-memberships" +--- diff --git a/docs/api-reference/endpoints/deprecated/project-identities/update-identity-membership.mdx b/docs/api-reference/endpoints/deprecated/project-identities/update-identity-membership.mdx new file mode 100644 index 000000000..667cf7eb3 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-identities/update-identity-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Update Identity Membership" +openapi: "PATCH /api/v2/workspace/{projectId}/identity-memberships/{identityId}" +--- diff --git a/docs/api-reference/endpoints/deprecated/project-roles/create.mdx b/docs/api-reference/endpoints/deprecated/project-roles/create.mdx new file mode 100644 index 000000000..97570ec36 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-roles/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v2/workspace/{projectId}/roles" +--- + + + You can read more about the permissions field in the [permissions + documentation](/internals/permissions). + + diff --git a/docs/api-reference/endpoints/deprecated/project-roles/delete.mdx b/docs/api-reference/endpoints/deprecated/project-roles/delete.mdx new file mode 100644 index 000000000..41edfa7c3 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-roles/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/workspace/{projectId}/roles/{roleId}" +--- diff --git a/docs/api-reference/endpoints/deprecated/project-roles/get-by-slug.mdx b/docs/api-reference/endpoints/deprecated/project-roles/get-by-slug.mdx new file mode 100644 index 000000000..dfc5c582a --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-roles/get-by-slug.mdx @@ -0,0 +1,4 @@ +--- +title: "Get By Slug" +openapi: "GET /api/v2/workspace/{projectId}/roles/slug/{roleSlug}" +--- diff --git a/docs/api-reference/endpoints/deprecated/project-roles/list.mdx b/docs/api-reference/endpoints/deprecated/project-roles/list.mdx new file mode 100644 index 000000000..8d8dc10c1 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-roles/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/workspace/{projectId}/roles" +--- diff --git a/docs/api-reference/endpoints/deprecated/project-roles/update.mdx b/docs/api-reference/endpoints/deprecated/project-roles/update.mdx new file mode 100644 index 000000000..662d5e617 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-roles/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/workspace/{projectId}/roles/{roleId}" +--- diff --git a/docs/api-reference/endpoints/deprecated/project-users/delete-membership.mdx b/docs/api-reference/endpoints/deprecated/project-users/delete-membership.mdx new file mode 100644 index 000000000..1995e4726 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-users/delete-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete User Membership" +openapi: "DELETE /api/v1/workspace/{workspaceId}/memberships/{membershipId}" +--- diff --git a/docs/api-reference/endpoints/deprecated/project-users/get-by-username.mdx b/docs/api-reference/endpoints/deprecated/project-users/get-by-username.mdx new file mode 100644 index 000000000..ec69d4947 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-users/get-by-username.mdx @@ -0,0 +1,4 @@ +--- +title: "Get By Username" +openapi: "POST /api/v1/workspace/{workspaceId}/memberships/details" +--- diff --git a/docs/api-reference/endpoints/project-users/invite-member-to-workspace.mdx b/docs/api-reference/endpoints/deprecated/project-users/invite-member-to-project.mdx similarity index 100% rename from docs/api-reference/endpoints/project-users/invite-member-to-workspace.mdx rename to docs/api-reference/endpoints/deprecated/project-users/invite-member-to-project.mdx diff --git a/docs/api-reference/endpoints/deprecated/project-users/memberships.mdx b/docs/api-reference/endpoints/deprecated/project-users/memberships.mdx new file mode 100644 index 000000000..3c4735f94 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-users/memberships.mdx @@ -0,0 +1,4 @@ +--- +title: "Get User Memberships" +openapi: "GET /api/v1/workspace/{workspaceId}/memberships" +--- diff --git a/docs/api-reference/endpoints/project-users/remove-member-from-workspace.mdx b/docs/api-reference/endpoints/deprecated/project-users/remove-member-from-project.mdx similarity index 100% rename from docs/api-reference/endpoints/project-users/remove-member-from-workspace.mdx rename to docs/api-reference/endpoints/deprecated/project-users/remove-member-from-project.mdx diff --git a/docs/api-reference/endpoints/deprecated/project-users/update-membership.mdx b/docs/api-reference/endpoints/deprecated/project-users/update-membership.mdx new file mode 100644 index 000000000..9a7aa600f --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-users/update-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Update User Membership" +openapi: "PATCH /api/v1/workspace/{workspaceId}/memberships/{membershipId}" +--- diff --git a/docs/api-reference/endpoints/workspaces/create-workspace.mdx b/docs/api-reference/endpoints/deprecated/projects/create-project.mdx similarity index 100% rename from docs/api-reference/endpoints/workspaces/create-workspace.mdx rename to docs/api-reference/endpoints/deprecated/projects/create-project.mdx diff --git a/docs/api-reference/endpoints/workspaces/delete-workspace.mdx b/docs/api-reference/endpoints/deprecated/projects/delete-project.mdx similarity index 100% rename from docs/api-reference/endpoints/workspaces/delete-workspace.mdx rename to docs/api-reference/endpoints/deprecated/projects/delete-project.mdx diff --git a/docs/api-reference/endpoints/workspaces/get-workspace-by-slug.mdx b/docs/api-reference/endpoints/deprecated/projects/get-project-by-slug.mdx similarity index 100% rename from docs/api-reference/endpoints/workspaces/get-workspace-by-slug.mdx rename to docs/api-reference/endpoints/deprecated/projects/get-project-by-slug.mdx diff --git a/docs/api-reference/endpoints/workspaces/get-workspace.mdx b/docs/api-reference/endpoints/deprecated/projects/get-project.mdx similarity index 94% rename from docs/api-reference/endpoints/workspaces/get-workspace.mdx rename to docs/api-reference/endpoints/deprecated/projects/get-project.mdx index edd0a0276..a27d41b43 100644 --- a/docs/api-reference/endpoints/workspaces/get-workspace.mdx +++ b/docs/api-reference/endpoints/deprecated/projects/get-project.mdx @@ -1,4 +1,4 @@ --- title: "Get Project" openapi: "GET /api/v1/workspace/{workspaceId}" ---- \ No newline at end of file +--- diff --git a/docs/api-reference/endpoints/workspaces/workspace-key.mdx b/docs/api-reference/endpoints/deprecated/projects/project-key.mdx similarity index 100% rename from docs/api-reference/endpoints/workspaces/workspace-key.mdx rename to docs/api-reference/endpoints/deprecated/projects/project-key.mdx diff --git a/docs/api-reference/endpoints/workspaces/update-workspace.mdx b/docs/api-reference/endpoints/deprecated/projects/project-workspace.mdx similarity index 100% rename from docs/api-reference/endpoints/workspaces/update-workspace.mdx rename to docs/api-reference/endpoints/deprecated/projects/project-workspace.mdx diff --git a/docs/api-reference/endpoints/workspaces/rollback-snapshot.mdx b/docs/api-reference/endpoints/deprecated/projects/rollback-snapshot.mdx similarity index 100% rename from docs/api-reference/endpoints/workspaces/rollback-snapshot.mdx rename to docs/api-reference/endpoints/deprecated/projects/rollback-snapshot.mdx diff --git a/docs/api-reference/endpoints/workspaces/secret-snapshots.mdx b/docs/api-reference/endpoints/deprecated/projects/secret-snapshots.mdx similarity index 100% rename from docs/api-reference/endpoints/workspaces/secret-snapshots.mdx rename to docs/api-reference/endpoints/deprecated/projects/secret-snapshots.mdx diff --git a/docs/api-reference/endpoints/deprecated/projects/update-project.mdx b/docs/api-reference/endpoints/deprecated/projects/update-project.mdx new file mode 100644 index 000000000..699e3e3af --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/projects/update-project.mdx @@ -0,0 +1,4 @@ +--- +title: "Update Project" +openapi: "PATCH /api/v1/workspace/{workspaceId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/deprecated/secret-imports/create.mdx b/docs/api-reference/endpoints/deprecated/secret-imports/create.mdx new file mode 100644 index 000000000..3abfb320f --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/secret-imports/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-imports" +--- diff --git a/docs/api-reference/endpoints/deprecated/secret-imports/delete.mdx b/docs/api-reference/endpoints/deprecated/secret-imports/delete.mdx new file mode 100644 index 000000000..cfa5960b1 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/secret-imports/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-imports/{secretImportId}" +--- diff --git a/docs/api-reference/endpoints/deprecated/secret-imports/list.mdx b/docs/api-reference/endpoints/deprecated/secret-imports/list.mdx new file mode 100644 index 000000000..580d4be8d --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/secret-imports/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-imports" +--- diff --git a/docs/api-reference/endpoints/deprecated/secret-imports/update.mdx b/docs/api-reference/endpoints/deprecated/secret-imports/update.mdx new file mode 100644 index 000000000..f21133223 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/secret-imports/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-imports/{secretImportId}" +--- diff --git a/docs/api-reference/endpoints/deprecated/secret-tags/create.mdx b/docs/api-reference/endpoints/deprecated/secret-tags/create.mdx new file mode 100644 index 000000000..82d0eed17 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/secret-tags/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/workspace/{projectId}/tags" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/deprecated/secret-tags/delete.mdx b/docs/api-reference/endpoints/deprecated/secret-tags/delete.mdx new file mode 100644 index 000000000..cc98f03c2 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/secret-tags/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/workspace/{projectId}/tags/{tagId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/deprecated/secret-tags/get-by-id.mdx b/docs/api-reference/endpoints/deprecated/secret-tags/get-by-id.mdx new file mode 100644 index 000000000..de02fe133 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/secret-tags/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get By ID" +openapi: "GET /api/v1/workspace/{projectId}/tags/{tagId}" +--- diff --git a/docs/api-reference/endpoints/deprecated/secret-tags/get-by-slug.mdx b/docs/api-reference/endpoints/deprecated/secret-tags/get-by-slug.mdx new file mode 100644 index 000000000..91eab730f --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/secret-tags/get-by-slug.mdx @@ -0,0 +1,4 @@ +--- +title: "Get By Slug" +openapi: "GET /api/v1/workspace/{projectId}/tags/slug/{tagSlug}" +--- diff --git a/docs/api-reference/endpoints/deprecated/secret-tags/list.mdx b/docs/api-reference/endpoints/deprecated/secret-tags/list.mdx new file mode 100644 index 000000000..c4a940f77 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/secret-tags/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/workspace/{projectId}/tags" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/deprecated/secret-tags/update.mdx b/docs/api-reference/endpoints/deprecated/secret-tags/update.mdx new file mode 100644 index 000000000..b9c290db8 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/secret-tags/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/workspace/{projectId}/tags/{tagId}" +--- diff --git a/docs/api-reference/endpoints/secrets/attach-tags.mdx b/docs/api-reference/endpoints/deprecated/secrets/attach-tags.mdx similarity index 100% rename from docs/api-reference/endpoints/secrets/attach-tags.mdx rename to docs/api-reference/endpoints/deprecated/secrets/attach-tags.mdx diff --git a/docs/api-reference/endpoints/deprecated/secrets/create-many.mdx b/docs/api-reference/endpoints/deprecated/secrets/create-many.mdx new file mode 100644 index 000000000..227c5470d --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/secrets/create-many.mdx @@ -0,0 +1,5 @@ +--- +title: "Bulk Create" +openapi: "POST /api/v3/secrets/batch/raw" +--- + diff --git a/docs/api-reference/endpoints/deprecated/secrets/create.mdx b/docs/api-reference/endpoints/deprecated/secrets/create.mdx new file mode 100644 index 000000000..afee0a719 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/secrets/create.mdx @@ -0,0 +1,5 @@ +--- +title: "Create" +openapi: "POST /api/v3/secrets/raw/{secretName}" +--- + diff --git a/docs/api-reference/endpoints/deprecated/secrets/delete-many.mdx b/docs/api-reference/endpoints/deprecated/secrets/delete-many.mdx new file mode 100644 index 000000000..57c8588d8 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/secrets/delete-many.mdx @@ -0,0 +1,5 @@ +--- +title: "Bulk Delete" +openapi: "DELETE /api/v3/secrets/batch/raw" +--- + diff --git a/docs/api-reference/endpoints/deprecated/secrets/delete.mdx b/docs/api-reference/endpoints/deprecated/secrets/delete.mdx new file mode 100644 index 000000000..ef3abc722 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/secrets/delete.mdx @@ -0,0 +1,5 @@ +--- +title: "Delete" +openapi: "DELETE /api/v3/secrets/raw/{secretName}" +--- + diff --git a/docs/api-reference/endpoints/secrets/detach-tags.mdx b/docs/api-reference/endpoints/deprecated/secrets/detach-tags.mdx similarity index 100% rename from docs/api-reference/endpoints/secrets/detach-tags.mdx rename to docs/api-reference/endpoints/deprecated/secrets/detach-tags.mdx diff --git a/docs/api-reference/endpoints/deprecated/secrets/list.mdx b/docs/api-reference/endpoints/deprecated/secrets/list.mdx new file mode 100644 index 000000000..4808f6690 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/secrets/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v3/secrets/raw" +--- diff --git a/docs/api-reference/endpoints/deprecated/secrets/read.mdx b/docs/api-reference/endpoints/deprecated/secrets/read.mdx new file mode 100644 index 000000000..6af308e0f --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/secrets/read.mdx @@ -0,0 +1,5 @@ +--- +title: "Retrieve" +openapi: "GET /api/v3/secrets/raw/{secretName}" +--- + diff --git a/docs/api-reference/endpoints/deprecated/secrets/update-many.mdx b/docs/api-reference/endpoints/deprecated/secrets/update-many.mdx new file mode 100644 index 000000000..7586d91bc --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/secrets/update-many.mdx @@ -0,0 +1,5 @@ +--- +title: "Bulk Update" +openapi: "PATCH /api/v3/secrets/batch/raw" +--- + diff --git a/docs/api-reference/endpoints/deprecated/secrets/update.mdx b/docs/api-reference/endpoints/deprecated/secrets/update.mdx new file mode 100644 index 000000000..ce68c492e --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/secrets/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v3/secrets/raw/{secretName}" +--- diff --git a/docs/api-reference/endpoints/environments/create.mdx b/docs/api-reference/endpoints/environments/create.mdx index 826dcce3d..c7f9b0658 100644 --- a/docs/api-reference/endpoints/environments/create.mdx +++ b/docs/api-reference/endpoints/environments/create.mdx @@ -1,4 +1,4 @@ --- title: "Create" -openapi: "POST /api/v1/workspace/{workspaceId}/environments" +openapi: "POST /api/v1/projects/{projectId}/environments" --- diff --git a/docs/api-reference/endpoints/environments/delete.mdx b/docs/api-reference/endpoints/environments/delete.mdx index 903e58d2a..2d2419255 100644 --- a/docs/api-reference/endpoints/environments/delete.mdx +++ b/docs/api-reference/endpoints/environments/delete.mdx @@ -1,4 +1,4 @@ --- title: "Delete" -openapi: "DELETE /api/v1/workspace/{workspaceId}/environments/{id}" +openapi: "DELETE /api/v1/projects/{projectId}/environments/{id}" --- diff --git a/docs/api-reference/endpoints/environments/update.mdx b/docs/api-reference/endpoints/environments/update.mdx index f93968668..1583f5220 100644 --- a/docs/api-reference/endpoints/environments/update.mdx +++ b/docs/api-reference/endpoints/environments/update.mdx @@ -1,4 +1,4 @@ --- title: "Update" -openapi: "PATCH /api/v1/workspace/{workspaceId}/environments/{id}" +openapi: "PATCH /api/v1/projects/{projectId}/environments/{id}" --- diff --git a/docs/api-reference/endpoints/folders/create.mdx b/docs/api-reference/endpoints/folders/create.mdx index e1ff3004a..13c4abea2 100644 --- a/docs/api-reference/endpoints/folders/create.mdx +++ b/docs/api-reference/endpoints/folders/create.mdx @@ -1,4 +1,4 @@ --- title: "Create" -openapi: "POST /api/v1/folders" +openapi: "POST /api/v2/folders" --- diff --git a/docs/api-reference/endpoints/folders/delete.mdx b/docs/api-reference/endpoints/folders/delete.mdx index a106cc2eb..71ba9684c 100644 --- a/docs/api-reference/endpoints/folders/delete.mdx +++ b/docs/api-reference/endpoints/folders/delete.mdx @@ -1,4 +1,4 @@ --- title: "Delete" -openapi: "DELETE /api/v1/folders/{folderIdOrName}" +openapi: "DELETE /api/v2/folders/{folderIdOrName}" --- diff --git a/docs/api-reference/endpoints/folders/get-by-id.mdx b/docs/api-reference/endpoints/folders/get-by-id.mdx index db3c4d0cc..ebfda35da 100644 --- a/docs/api-reference/endpoints/folders/get-by-id.mdx +++ b/docs/api-reference/endpoints/folders/get-by-id.mdx @@ -1,4 +1,4 @@ --- title: "Get by ID" -openapi: "GET /api/v1/folders/{id}" +openapi: "GET /api/v2/folders/{id}" --- diff --git a/docs/api-reference/endpoints/folders/list.mdx b/docs/api-reference/endpoints/folders/list.mdx index f40f93273..1d3a10ed8 100644 --- a/docs/api-reference/endpoints/folders/list.mdx +++ b/docs/api-reference/endpoints/folders/list.mdx @@ -1,4 +1,4 @@ --- title: "List" -openapi: "GET /api/v1/folders" +openapi: "GET /api/v2/folders" --- diff --git a/docs/api-reference/endpoints/folders/update.mdx b/docs/api-reference/endpoints/folders/update.mdx index c54778e94..969833a04 100644 --- a/docs/api-reference/endpoints/folders/update.mdx +++ b/docs/api-reference/endpoints/folders/update.mdx @@ -1,4 +1,4 @@ --- title: "Update" -openapi: "PATCH /api/v1/folders/{folderId}" +openapi: "PATCH /api/v2/folders/{folderId}" --- diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/create.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/create.mdx new file mode 100644 index 000000000..1a464cd1e --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Azure Key Vault PKI Sync" +openapi: "POST /api/v1/pki/syncs/azure-key-vault" +--- diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/delete.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/delete.mdx new file mode 100644 index 000000000..a08b2664d --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete Azure Key Vault PKI Sync" +openapi: "DELETE /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}" +--- diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/get-by-id.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/get-by-id.mdx new file mode 100644 index 000000000..0976a9dd1 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Azure Key Vault PKI Sync by ID" +openapi: "GET /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}" +--- diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/list.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/list.mdx new file mode 100644 index 000000000..b21f5bc33 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List Azure Key Vault PKI Syncs" +openapi: "GET /api/v1/pki/syncs/azure-key-vault" +--- diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/remove-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/remove-certificates.mdx new file mode 100644 index 000000000..817f545c0 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/remove-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Certificates from Azure Key Vault" +openapi: "POST /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}/remove-certificates" +--- diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/sync-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/sync-certificates.mdx new file mode 100644 index 000000000..ca8faced5 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/sync-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Certificates to Azure Key Vault" +openapi: "POST /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}/sync" +--- diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/update.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/update.mdx new file mode 100644 index 000000000..bc0e903cf --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update Azure Key Vault PKI Sync" +openapi: "PATCH /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}" +--- diff --git a/docs/api-reference/endpoints/pki/syncs/get-by-id.mdx b/docs/api-reference/endpoints/pki/syncs/get-by-id.mdx new file mode 100644 index 000000000..6ec710ec8 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get PKI Sync by ID" +openapi: "GET /api/v1/pki/syncs/{pkiSyncId}" +--- diff --git a/docs/api-reference/endpoints/pki/syncs/list.mdx b/docs/api-reference/endpoints/pki/syncs/list.mdx new file mode 100644 index 000000000..4b1f1972a --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List PKI Syncs" +openapi: "GET /api/v1/pki/syncs" +--- diff --git a/docs/api-reference/endpoints/pki/syncs/options.mdx b/docs/api-reference/endpoints/pki/syncs/options.mdx new file mode 100644 index 000000000..a328b0832 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/options.mdx @@ -0,0 +1,4 @@ +--- +title: "List PKI Sync Options" +openapi: "GET /api/v1/pki/syncs/options" +--- diff --git a/docs/api-reference/endpoints/project-groups/create.mdx b/docs/api-reference/endpoints/project-groups/create.mdx index 6dd7f1a4f..c40c506a6 100644 --- a/docs/api-reference/endpoints/project-groups/create.mdx +++ b/docs/api-reference/endpoints/project-groups/create.mdx @@ -1,4 +1,4 @@ --- title: "Create Project Membership" -openapi: "POST /api/v2/workspace/{projectId}/groups/{groupIdOrName}" +openapi: "POST /api/v1/projects/{projectId}/groups/{groupIdOrName}" --- diff --git a/docs/api-reference/endpoints/project-groups/delete.mdx b/docs/api-reference/endpoints/project-groups/delete.mdx index 07db40d7f..450e05501 100644 --- a/docs/api-reference/endpoints/project-groups/delete.mdx +++ b/docs/api-reference/endpoints/project-groups/delete.mdx @@ -1,4 +1,4 @@ --- title: "Delete Project Membership" -openapi: "DELETE /api/v2/workspace/{projectId}/groups/{groupId}" +openapi: "DELETE /api/v1/projects/{projectId}/groups/{groupId}" --- diff --git a/docs/api-reference/endpoints/project-groups/get-by-id.mdx b/docs/api-reference/endpoints/project-groups/get-by-id.mdx index 611f39059..89aa6ebac 100644 --- a/docs/api-reference/endpoints/project-groups/get-by-id.mdx +++ b/docs/api-reference/endpoints/project-groups/get-by-id.mdx @@ -1,4 +1,4 @@ --- title: "Get Project Membership" -openapi: "GET /api/v2/workspace/{projectId}/groups/{groupId}" +openapi: "GET /api/v1/projects/{projectId}/groups/{groupId}" --- diff --git a/docs/api-reference/endpoints/project-groups/list.mdx b/docs/api-reference/endpoints/project-groups/list.mdx index 1488fb6ae..90b4f957a 100644 --- a/docs/api-reference/endpoints/project-groups/list.mdx +++ b/docs/api-reference/endpoints/project-groups/list.mdx @@ -1,4 +1,4 @@ --- title: "List Project Memberships" -openapi: "GET /api/v2/workspace/{projectId}/groups" +openapi: "GET /api/v1/projects/{projectId}/groups" --- diff --git a/docs/api-reference/endpoints/project-groups/update.mdx b/docs/api-reference/endpoints/project-groups/update.mdx index 8b963a1d8..029815673 100644 --- a/docs/api-reference/endpoints/project-groups/update.mdx +++ b/docs/api-reference/endpoints/project-groups/update.mdx @@ -1,4 +1,4 @@ --- title: "Update Project Membership" -openapi: "PATCH /api/v2/workspace/{projectId}/groups/{groupId}" +openapi: "PATCH /api/v1/projects/{projectId}/groups/{groupId}" --- diff --git a/docs/api-reference/endpoints/project-identities/add-identity-membership.mdx b/docs/api-reference/endpoints/project-identities/add-identity-membership.mdx index 285b1d1c4..8b0122efd 100644 --- a/docs/api-reference/endpoints/project-identities/add-identity-membership.mdx +++ b/docs/api-reference/endpoints/project-identities/add-identity-membership.mdx @@ -1,4 +1,4 @@ --- title: "Create Identity Membership" -openapi: "POST /api/v2/workspace/{projectId}/identity-memberships/{identityId}" +openapi: "POST /api/v1/projects/{projectId}/identity-memberships/{identityId}" --- diff --git a/docs/api-reference/endpoints/project-identities/delete-identity-membership.mdx b/docs/api-reference/endpoints/project-identities/delete-identity-membership.mdx index e2b266626..10c56e5ec 100644 --- a/docs/api-reference/endpoints/project-identities/delete-identity-membership.mdx +++ b/docs/api-reference/endpoints/project-identities/delete-identity-membership.mdx @@ -1,4 +1,4 @@ --- title: "Delete Identity Membership" -openapi: "DELETE /api/v2/workspace/{projectId}/identity-memberships/{identityId}" +openapi: "DELETE /api/v1/projects/{projectId}/identity-memberships/{identityId}" --- diff --git a/docs/api-reference/endpoints/project-identities/get-by-id.mdx b/docs/api-reference/endpoints/project-identities/get-by-id.mdx index 37f4192d7..801b0d422 100644 --- a/docs/api-reference/endpoints/project-identities/get-by-id.mdx +++ b/docs/api-reference/endpoints/project-identities/get-by-id.mdx @@ -1,4 +1,4 @@ --- title: "Get Identity by ID" -openapi: "GET /api/v2/workspace/{projectId}/identity-memberships/{identityId}" +openapi: "GET /api/v1/projects/{projectId}/identity-memberships/{identityId}" --- diff --git a/docs/api-reference/endpoints/project-identities/list-identity-memberships.mdx b/docs/api-reference/endpoints/project-identities/list-identity-memberships.mdx index e5162e693..8673350fa 100644 --- a/docs/api-reference/endpoints/project-identities/list-identity-memberships.mdx +++ b/docs/api-reference/endpoints/project-identities/list-identity-memberships.mdx @@ -1,4 +1,4 @@ --- title: "List Identity Memberships" -openapi: "GET /api/v2/workspace/{projectId}/identity-memberships" +openapi: "GET /api/v1/projects/{projectId}/identity-memberships" --- diff --git a/docs/api-reference/endpoints/project-identities/update-identity-membership.mdx b/docs/api-reference/endpoints/project-identities/update-identity-membership.mdx index 667cf7eb3..ef34363d6 100644 --- a/docs/api-reference/endpoints/project-identities/update-identity-membership.mdx +++ b/docs/api-reference/endpoints/project-identities/update-identity-membership.mdx @@ -1,4 +1,4 @@ --- title: "Update Identity Membership" -openapi: "PATCH /api/v2/workspace/{projectId}/identity-memberships/{identityId}" +openapi: "PATCH /api/v1/projects/{projectId}/identity-memberships/{identityId}" --- diff --git a/docs/api-reference/endpoints/project-roles/create.mdx b/docs/api-reference/endpoints/project-roles/create.mdx index 97570ec36..1d3f9eb51 100644 --- a/docs/api-reference/endpoints/project-roles/create.mdx +++ b/docs/api-reference/endpoints/project-roles/create.mdx @@ -1,10 +1,9 @@ --- title: "Create" -openapi: "POST /api/v2/workspace/{projectId}/roles" +openapi: "POST /api/v1/projects/{projectId}/roles" --- You can read more about the permissions field in the [permissions documentation](/internals/permissions). - diff --git a/docs/api-reference/endpoints/project-roles/delete.mdx b/docs/api-reference/endpoints/project-roles/delete.mdx index 41edfa7c3..7b162c6da 100644 --- a/docs/api-reference/endpoints/project-roles/delete.mdx +++ b/docs/api-reference/endpoints/project-roles/delete.mdx @@ -1,4 +1,4 @@ --- title: "Delete" -openapi: "DELETE /api/v2/workspace/{projectId}/roles/{roleId}" +openapi: "DELETE /api/v1/projects/{projectId}/roles/{roleId}" --- diff --git a/docs/api-reference/endpoints/project-roles/get-by-slug.mdx b/docs/api-reference/endpoints/project-roles/get-by-slug.mdx index dfc5c582a..adfac8cd3 100644 --- a/docs/api-reference/endpoints/project-roles/get-by-slug.mdx +++ b/docs/api-reference/endpoints/project-roles/get-by-slug.mdx @@ -1,4 +1,4 @@ --- title: "Get By Slug" -openapi: "GET /api/v2/workspace/{projectId}/roles/slug/{roleSlug}" +openapi: "GET /api/v1/projects/{projectId}/roles/slug/{roleSlug}" --- diff --git a/docs/api-reference/endpoints/project-roles/list.mdx b/docs/api-reference/endpoints/project-roles/list.mdx index 8d8dc10c1..df6164370 100644 --- a/docs/api-reference/endpoints/project-roles/list.mdx +++ b/docs/api-reference/endpoints/project-roles/list.mdx @@ -1,4 +1,4 @@ --- title: "List" -openapi: "GET /api/v2/workspace/{projectId}/roles" +openapi: "GET /api/v1/projects/{projectId}/roles" --- diff --git a/docs/api-reference/endpoints/project-roles/update.mdx b/docs/api-reference/endpoints/project-roles/update.mdx index 662d5e617..56ac34f8b 100644 --- a/docs/api-reference/endpoints/project-roles/update.mdx +++ b/docs/api-reference/endpoints/project-roles/update.mdx @@ -1,4 +1,4 @@ --- title: "Update" -openapi: "PATCH /api/v2/workspace/{projectId}/roles/{roleId}" +openapi: "PATCH /api/v1/projects/{projectId}/roles/{roleId}" --- diff --git a/docs/api-reference/endpoints/project-users/delete-membership.mdx b/docs/api-reference/endpoints/project-users/delete-membership.mdx index 1995e4726..5c87e94af 100644 --- a/docs/api-reference/endpoints/project-users/delete-membership.mdx +++ b/docs/api-reference/endpoints/project-users/delete-membership.mdx @@ -1,4 +1,4 @@ --- title: "Delete User Membership" -openapi: "DELETE /api/v1/workspace/{workspaceId}/memberships/{membershipId}" +openapi: "DELETE /api/v1/projects/{projectId}/memberships/{membershipId}" --- diff --git a/docs/api-reference/endpoints/project-users/get-by-username.mdx b/docs/api-reference/endpoints/project-users/get-by-username.mdx index ec69d4947..5e4916b8e 100644 --- a/docs/api-reference/endpoints/project-users/get-by-username.mdx +++ b/docs/api-reference/endpoints/project-users/get-by-username.mdx @@ -1,4 +1,4 @@ --- title: "Get By Username" -openapi: "POST /api/v1/workspace/{workspaceId}/memberships/details" +openapi: "POST /api/v1/projects/{projectId}/memberships/details" --- diff --git a/docs/api-reference/endpoints/project-users/invite-member-to-project.mdx b/docs/api-reference/endpoints/project-users/invite-member-to-project.mdx new file mode 100644 index 000000000..c6fda8a09 --- /dev/null +++ b/docs/api-reference/endpoints/project-users/invite-member-to-project.mdx @@ -0,0 +1,4 @@ +--- +title: "Invite Member" +openapi: "POST /api/v1/projects/{projectId}/memberships" +--- diff --git a/docs/api-reference/endpoints/project-users/memberships.mdx b/docs/api-reference/endpoints/project-users/memberships.mdx index 3c4735f94..dd79667fe 100644 --- a/docs/api-reference/endpoints/project-users/memberships.mdx +++ b/docs/api-reference/endpoints/project-users/memberships.mdx @@ -1,4 +1,4 @@ --- title: "Get User Memberships" -openapi: "GET /api/v1/workspace/{workspaceId}/memberships" +openapi: "GET /api/v1/projects/{projectId}/memberships" --- diff --git a/docs/api-reference/endpoints/project-users/remove-member-from-project.mdx b/docs/api-reference/endpoints/project-users/remove-member-from-project.mdx new file mode 100644 index 000000000..8646aa158 --- /dev/null +++ b/docs/api-reference/endpoints/project-users/remove-member-from-project.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Member" +openapi: "DELETE /api/v1/projects/{projectId}/memberships" +--- diff --git a/docs/api-reference/endpoints/project-users/update-membership.mdx b/docs/api-reference/endpoints/project-users/update-membership.mdx index 9a7aa600f..5f1edcf0a 100644 --- a/docs/api-reference/endpoints/project-users/update-membership.mdx +++ b/docs/api-reference/endpoints/project-users/update-membership.mdx @@ -1,4 +1,4 @@ --- title: "Update User Membership" -openapi: "PATCH /api/v1/workspace/{workspaceId}/memberships/{membershipId}" +openapi: "PATCH /api/v1/projects/{projectId}/memberships/{membershipId}" --- diff --git a/docs/api-reference/endpoints/projects/create-project.mdx b/docs/api-reference/endpoints/projects/create-project.mdx new file mode 100644 index 000000000..7ee1caff8 --- /dev/null +++ b/docs/api-reference/endpoints/projects/create-project.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Project" +openapi: "POST /api/v1/projects" +--- diff --git a/docs/api-reference/endpoints/projects/delete-project.mdx b/docs/api-reference/endpoints/projects/delete-project.mdx new file mode 100644 index 000000000..eff96ded5 --- /dev/null +++ b/docs/api-reference/endpoints/projects/delete-project.mdx @@ -0,0 +1,8 @@ +--- +title: "Delete Project" +openapi: "DELETE /api/v1/projects/{projectId}" +--- + + + This operation is irreversible. All data associated with the project will be deleted. Please use with caution. + diff --git a/docs/api-reference/endpoints/projects/get-project-by-slug.mdx b/docs/api-reference/endpoints/projects/get-project-by-slug.mdx new file mode 100644 index 000000000..9bcccc31e --- /dev/null +++ b/docs/api-reference/endpoints/projects/get-project-by-slug.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Project By Slug" +openapi: "GET /api/v1/projects/slug/{slug}" +--- diff --git a/docs/api-reference/endpoints/projects/get-project.mdx b/docs/api-reference/endpoints/projects/get-project.mdx new file mode 100644 index 000000000..6ed92833e --- /dev/null +++ b/docs/api-reference/endpoints/projects/get-project.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Project" +openapi: "GET /api/v1/projects/{projectId}" +--- diff --git a/docs/api-reference/endpoints/projects/list-projects.mdx b/docs/api-reference/endpoints/projects/list-projects.mdx new file mode 100644 index 000000000..5017023a1 --- /dev/null +++ b/docs/api-reference/endpoints/projects/list-projects.mdx @@ -0,0 +1,4 @@ +--- +title: "List Projects" +openapi: "GET /api/v1/projects" +--- diff --git a/docs/api-reference/endpoints/projects/rollback-snapshot.mdx b/docs/api-reference/endpoints/projects/rollback-snapshot.mdx new file mode 100644 index 000000000..527c861b2 --- /dev/null +++ b/docs/api-reference/endpoints/projects/rollback-snapshot.mdx @@ -0,0 +1,4 @@ +--- +title: "Roll Back to Snapshot" +openapi: "POST /api/v1/secret-snapshot/{secretSnapshotId}/rollback" +--- diff --git a/docs/api-reference/endpoints/projects/secret-snapshots.mdx b/docs/api-reference/endpoints/projects/secret-snapshots.mdx new file mode 100644 index 000000000..182e61507 --- /dev/null +++ b/docs/api-reference/endpoints/projects/secret-snapshots.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Snapshots" +openapi: "GET /api/v1/projects/{projectId}/secret-snapshots" +--- diff --git a/docs/api-reference/endpoints/projects/update-project.mdx b/docs/api-reference/endpoints/projects/update-project.mdx new file mode 100644 index 000000000..99fcc7b76 --- /dev/null +++ b/docs/api-reference/endpoints/projects/update-project.mdx @@ -0,0 +1,4 @@ +--- +title: "Update Project" +openapi: "PATCH /api/v1/projects/{projectId}" +--- diff --git a/docs/api-reference/endpoints/secret-imports/create.mdx b/docs/api-reference/endpoints/secret-imports/create.mdx index 3abfb320f..f072e59a4 100644 --- a/docs/api-reference/endpoints/secret-imports/create.mdx +++ b/docs/api-reference/endpoints/secret-imports/create.mdx @@ -1,4 +1,4 @@ --- title: "Create" -openapi: "POST /api/v1/secret-imports" +openapi: "POST /api/v2/secret-imports" --- diff --git a/docs/api-reference/endpoints/secret-imports/delete.mdx b/docs/api-reference/endpoints/secret-imports/delete.mdx index cfa5960b1..d0b35426b 100644 --- a/docs/api-reference/endpoints/secret-imports/delete.mdx +++ b/docs/api-reference/endpoints/secret-imports/delete.mdx @@ -1,4 +1,4 @@ --- title: "Delete" -openapi: "DELETE /api/v1/secret-imports/{secretImportId}" +openapi: "DELETE /api/v2/secret-imports/{secretImportId}" --- diff --git a/docs/api-reference/endpoints/secret-imports/list.mdx b/docs/api-reference/endpoints/secret-imports/list.mdx index 580d4be8d..1cf3e1386 100644 --- a/docs/api-reference/endpoints/secret-imports/list.mdx +++ b/docs/api-reference/endpoints/secret-imports/list.mdx @@ -1,4 +1,4 @@ --- title: "List" -openapi: "GET /api/v1/secret-imports" +openapi: "GET /api/v2/secret-imports" --- diff --git a/docs/api-reference/endpoints/secret-imports/update.mdx b/docs/api-reference/endpoints/secret-imports/update.mdx index f21133223..146fcd76b 100644 --- a/docs/api-reference/endpoints/secret-imports/update.mdx +++ b/docs/api-reference/endpoints/secret-imports/update.mdx @@ -1,4 +1,4 @@ --- title: "Update" -openapi: "PATCH /api/v1/secret-imports/{secretImportId}" +openapi: "PATCH /api/v2/secret-imports/{secretImportId}" --- diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/create.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/create.mdx new file mode 100644 index 000000000..8bce0dc4f --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-rotations/redis-credentials" +--- + + + Check out the configuration docs for [Redis + Credentials Rotations](/documentation/platform/secret-rotation/redis-credentials) to learn how to obtain the + required parameters. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/delete.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/delete.mdx new file mode 100644 index 000000000..28d9e3a6b --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-rotations/redis-credentials/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-id.mdx new file mode 100644 index 000000000..a3ec932e0 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-rotations/redis-credentials/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-name.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-name.mdx new file mode 100644 index 000000000..2dc50d581 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-rotations/redis-credentials/rotation-name/{rotationName}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-generated-credentials-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-generated-credentials-by-id.mdx new file mode 100644 index 000000000..c0002edd6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-generated-credentials-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Credentials by ID" +openapi: "GET /api/v2/secret-rotations/redis-credentials/{rotationId}/generated-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/list.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/list.mdx new file mode 100644 index 000000000..bc72d101e --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-rotations/redis-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/rotate-secrets.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/rotate-secrets.mdx new file mode 100644 index 000000000..53c2c7651 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/rotate-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Rotate Secrets" +openapi: "POST /api/v2/secret-rotations/redis-credentials/{rotationId}/rotate-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/update.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/update.mdx new file mode 100644 index 000000000..4817a0f65 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-rotations/redis-credentials/{rotationId}" +--- + + + Check out the configuration docs for [Redis + Credentials Rotations](/documentation/platform/secret-rotation/redis-credentials) to learn how to obtain the + required parameters. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-tags/create.mdx b/docs/api-reference/endpoints/secret-tags/create.mdx index 82d0eed17..d4c587979 100644 --- a/docs/api-reference/endpoints/secret-tags/create.mdx +++ b/docs/api-reference/endpoints/secret-tags/create.mdx @@ -1,4 +1,4 @@ --- title: "Create" -openapi: "POST /api/v1/workspace/{projectId}/tags" ---- \ No newline at end of file +openapi: "POST /api/v1/projects/{projectId}/tags" +--- diff --git a/docs/api-reference/endpoints/secret-tags/delete.mdx b/docs/api-reference/endpoints/secret-tags/delete.mdx index cc98f03c2..121d6816c 100644 --- a/docs/api-reference/endpoints/secret-tags/delete.mdx +++ b/docs/api-reference/endpoints/secret-tags/delete.mdx @@ -1,4 +1,4 @@ --- title: "Delete" -openapi: "DELETE /api/v1/workspace/{projectId}/tags/{tagId}" ---- \ No newline at end of file +openapi: "DELETE /api/v1/projects/{projectId}/tags/{tagId}" +--- diff --git a/docs/api-reference/endpoints/secret-tags/get-by-id.mdx b/docs/api-reference/endpoints/secret-tags/get-by-id.mdx index de02fe133..7e1fa8d7f 100644 --- a/docs/api-reference/endpoints/secret-tags/get-by-id.mdx +++ b/docs/api-reference/endpoints/secret-tags/get-by-id.mdx @@ -1,4 +1,4 @@ --- title: "Get By ID" -openapi: "GET /api/v1/workspace/{projectId}/tags/{tagId}" +openapi: "GET /api/v1/projects/{projectId}/tags/{tagId}" --- diff --git a/docs/api-reference/endpoints/secret-tags/get-by-slug.mdx b/docs/api-reference/endpoints/secret-tags/get-by-slug.mdx index 91eab730f..c012c97d4 100644 --- a/docs/api-reference/endpoints/secret-tags/get-by-slug.mdx +++ b/docs/api-reference/endpoints/secret-tags/get-by-slug.mdx @@ -1,4 +1,4 @@ --- title: "Get By Slug" -openapi: "GET /api/v1/workspace/{projectId}/tags/slug/{tagSlug}" +openapi: "GET /api/v1/projects/{projectId}/tags/slug/{tagSlug}" --- diff --git a/docs/api-reference/endpoints/secret-tags/list.mdx b/docs/api-reference/endpoints/secret-tags/list.mdx index c4a940f77..b7e1eca74 100644 --- a/docs/api-reference/endpoints/secret-tags/list.mdx +++ b/docs/api-reference/endpoints/secret-tags/list.mdx @@ -1,4 +1,4 @@ --- title: "List" -openapi: "GET /api/v1/workspace/{projectId}/tags" ---- \ No newline at end of file +openapi: "GET /api/v1/projects/{projectId}/tags" +--- diff --git a/docs/api-reference/endpoints/secret-tags/update.mdx b/docs/api-reference/endpoints/secret-tags/update.mdx index b9c290db8..b20ce49f4 100644 --- a/docs/api-reference/endpoints/secret-tags/update.mdx +++ b/docs/api-reference/endpoints/secret-tags/update.mdx @@ -1,4 +1,4 @@ --- title: "Update" -openapi: "PATCH /api/v1/workspace/{projectId}/tags/{tagId}" +openapi: "PATCH /api/v1/projects/{projectId}/tags/{tagId}" --- diff --git a/docs/api-reference/endpoints/secrets/create-many.mdx b/docs/api-reference/endpoints/secrets/create-many.mdx index 227c5470d..e3d30aef8 100644 --- a/docs/api-reference/endpoints/secrets/create-many.mdx +++ b/docs/api-reference/endpoints/secrets/create-many.mdx @@ -1,5 +1,4 @@ --- title: "Bulk Create" -openapi: "POST /api/v3/secrets/batch/raw" +openapi: "POST /api/v4/secrets/batch" --- - diff --git a/docs/api-reference/endpoints/secrets/create.mdx b/docs/api-reference/endpoints/secrets/create.mdx index afee0a719..c04dcf9f4 100644 --- a/docs/api-reference/endpoints/secrets/create.mdx +++ b/docs/api-reference/endpoints/secrets/create.mdx @@ -1,5 +1,4 @@ --- title: "Create" -openapi: "POST /api/v3/secrets/raw/{secretName}" +openapi: "POST /api/v4/secrets/{secretName}" --- - diff --git a/docs/api-reference/endpoints/secrets/delete-many.mdx b/docs/api-reference/endpoints/secrets/delete-many.mdx index 57c8588d8..49491c3b9 100644 --- a/docs/api-reference/endpoints/secrets/delete-many.mdx +++ b/docs/api-reference/endpoints/secrets/delete-many.mdx @@ -1,5 +1,4 @@ --- title: "Bulk Delete" -openapi: "DELETE /api/v3/secrets/batch/raw" +openapi: "DELETE /api/v4/secrets/batch" --- - diff --git a/docs/api-reference/endpoints/secrets/delete.mdx b/docs/api-reference/endpoints/secrets/delete.mdx index ef3abc722..f71a851e8 100644 --- a/docs/api-reference/endpoints/secrets/delete.mdx +++ b/docs/api-reference/endpoints/secrets/delete.mdx @@ -1,5 +1,4 @@ --- title: "Delete" -openapi: "DELETE /api/v3/secrets/raw/{secretName}" +openapi: "DELETE /api/v4/secrets/{secretName}" --- - diff --git a/docs/api-reference/endpoints/secrets/list.mdx b/docs/api-reference/endpoints/secrets/list.mdx index 4808f6690..6a1f38d68 100644 --- a/docs/api-reference/endpoints/secrets/list.mdx +++ b/docs/api-reference/endpoints/secrets/list.mdx @@ -1,4 +1,4 @@ --- title: "List" -openapi: "GET /api/v3/secrets/raw" +openapi: "GET /api/v4/secrets" --- diff --git a/docs/api-reference/endpoints/secrets/read.mdx b/docs/api-reference/endpoints/secrets/read.mdx index 6af308e0f..10e725ba5 100644 --- a/docs/api-reference/endpoints/secrets/read.mdx +++ b/docs/api-reference/endpoints/secrets/read.mdx @@ -1,5 +1,4 @@ --- title: "Retrieve" -openapi: "GET /api/v3/secrets/raw/{secretName}" +openapi: "GET /api/v4/secrets/{secretName}" --- - diff --git a/docs/api-reference/endpoints/secrets/update-many.mdx b/docs/api-reference/endpoints/secrets/update-many.mdx index 7586d91bc..9aca17148 100644 --- a/docs/api-reference/endpoints/secrets/update-many.mdx +++ b/docs/api-reference/endpoints/secrets/update-many.mdx @@ -1,5 +1,4 @@ --- title: "Bulk Update" -openapi: "PATCH /api/v3/secrets/batch/raw" +openapi: "PATCH /api/v4/secrets/batch" --- - diff --git a/docs/api-reference/endpoints/secrets/update.mdx b/docs/api-reference/endpoints/secrets/update.mdx index ce68c492e..435f3e0c4 100644 --- a/docs/api-reference/endpoints/secrets/update.mdx +++ b/docs/api-reference/endpoints/secrets/update.mdx @@ -1,4 +1,4 @@ --- title: "Update" -openapi: "PATCH /api/v3/secrets/raw/{secretName}" +openapi: "PATCH /api/v4/secrets/{secretName}" --- diff --git a/docs/cli/commands/gateway.mdx b/docs/cli/commands/gateway.mdx index 99d0e1086..bddbf614c 100644 --- a/docs/cli/commands/gateway.mdx +++ b/docs/cli/commands/gateway.mdx @@ -9,7 +9,7 @@ description: "Run the Infisical gateway or manage its systemd service" infisical gateway start --name= --relay= --auth-method= ``` - + ```bash sudo infisical gateway systemd install --token= --domain= --name= --relay= ``` @@ -25,29 +25,29 @@ The gateway system uses SSH reverse tunnels over TCP, eliminating firewall compl **Deprecation and Migration Notice:** The legacy `infisical gateway` command (v1) will be removed in a future release. Please migrate to `infisical gateway start` (Gateway v2). -If you are moving from Gateway v1 to Gateway v2, this is NOT a drop-in switch. Gateway v2 creates new gateway instances with new gateway IDs. You must update any existing resources that reference gateway IDs (for example: dynamic secret configs, app connections, or other gateway-bound resources) to point to the new Gateway v2 gateway ID. Until you update those references, traffic will continue to target the old v1 gateway. +If you are moving from Gateway v1 to Gateway v2, this is NOT a drop-in switch. Gateway v2 creates new gateway instances with new gateway IDs. You must update any existing resources that reference gateway IDs (for example: dynamic secret configs, app connections, or other gateway-bound resources) to point to the new Gateway v2 gateway resource. Until you update those references, traffic will continue to target the old v1 gateway. ## Subcommands & flags - Run the Infisical gateway component within your VPC. The gateway establishes an SSH reverse tunnel to the specified relay server and provides secure access to private resources. + Run the Infisical gateway component within your the network where your target resources are located. The gateway establishes an SSH reverse tunnel to the specified relay server and provides secure access to private resources within your network. ```bash infisical gateway start --relay= --name= --auth-method= ``` -The gateway component: +Once started, the gateway component will: -- Establishes outbound SSH reverse tunnels to relay servers (no inbound firewall rules needed) -- Authenticates using SSH certificates issued by Infisical -- Automatically reconnects if the connection is lost -- Provides access to private resources within your network +- Establish outbound SSH reverse tunnels to relay servers (no inbound firewall rules needed) +- Authenticate using SSH certificates issued by Infisical +- Automatically reconnect if the connection is lost +- Provide access to private resources within your network ### Authentication -The Infisical CLI supports multiple authentication methods. Below are the available authentication methods, with their respective flags. +The Relay supports multiple authentication methods. Below are the available authentication methods, with their respective flags. @@ -361,12 +361,12 @@ sudo systemctl disable infisical-gateway # Disable auto-start on boot -## Legacy Gateway Commands (Deprecated) +## Legacy Gateway Commands **This command is deprecated and will be removed in a future release.** - + Please migrate to `infisical gateway start` for the new TCP-based SSH tunnel architecture. **Migration required:** If you are currently using Gateway v1 (via `infisical gateway`), moving to Gateway v2 is not in-place. Gateway v2 provisions new gateway instances with new gateway IDs. Update any resources that reference a gateway ID (for example: dynamic secret configs, app connections, or other gateway-bound resources) to use the new Gateway v2 gateway ID. Until you update those references, traffic will continue to target the old v1 gateway. @@ -593,7 +593,7 @@ The Infisical CLI supports multiple authentication methods. Below are the availa **This command is deprecated and will be removed in a future release.** - + Please migrate to `infisical gateway systemd install` for the new TCP-based SSH tunnel architecture with enhanced security and better performance. **Migration required:** If you previously installed Gateway v1 via `infisical gateway install`, moving to Gateway v2 is not in-place. Gateway v2 provisions new gateway instances with new gateway IDs. Update any resources that reference a gateway ID (for example: dynamic secret configs, app connections, or other gateway-bound resources) to use the new Gateway v2 gateway ID. Until you update those references, traffic will continue to target the old v1 gateway. diff --git a/docs/cli/commands/relay.mdx b/docs/cli/commands/relay.mdx index 46a061da3..b377b9ce2 100644 --- a/docs/cli/commands/relay.mdx +++ b/docs/cli/commands/relay.mdx @@ -6,88 +6,70 @@ description: "Relay-related commands for Infisical" ```bash - infisical relay start --type= --host= --name= --auth-method= + infisical relay start --host= --name= --auth-method= + ``` + + + ```bash + # Install systemd service + sudo infisical relay systemd install --host= --name= --token= + + # Uninstall systemd service + sudo infisical relay systemd uninstall ``` ## Description -Relay-related commands for Infisical that provide identity-aware relay infrastructure for routing encrypted traffic: - -- **Relay**: Identity-aware server that routes encrypted traffic (can be instance-wide or organization-specific) - -The relay system uses SSH reverse tunnels over TCP, eliminating firewall complexity and providing excellent performance for enterprise environments. +Relay-related commands for Infisical that provide identity-aware relay infrastructure for routing encrypted traffic. Relays are organization-deployed servers that route encrypted traffic between Infisical and your gateways. ## Subcommands & flags - Run the Infisical relay component. The relay handles network traffic routing and can operate in different modes. + Run the Infisical relay component. The relay handles network traffic routing between Infisical and your gateways. ```bash -infisical relay start --type= --host= --name= --auth-method= +infisical relay start --host= --name= --auth-method= ``` ### Flags - - The type of relay to run. Must be either 'instance' or 'org'. - - - **`instance`**: Shared relay server that can be used by all organizations on your Infisical instance. Set up by the instance administrator. Uses `INFISICAL_RELAY_AUTH_SECRET` environment variable for authentication, which must be configured by the instance admin. - - **`org`**: Dedicated relay server that individual organizations deploy and manage in their own infrastructure. Provides enhanced security, custom geographic placement, and compliance benefits. Uses standard Infisical authentication methods. - - ```bash - # Organization relay (customer-deployed) - infisical relay start --type=org --host=192.168.1.100 --name=my-org-relay - - # Instance relay (configured by instance admin) - INFISICAL_RELAY_AUTH_SECRET= infisical relay start --type=instance --host=10.0.1.50 --name=shared-relay - ``` - - - The host (IP address or hostname) of the instance where the relay is deployed. This must be a static public IP or resolvable hostname that gateways can reach. ```bash # Example with IP address - infisical relay start --host=203.0.113.100 --type=org --name=my-relay + infisical relay start --host=203.0.113.100 --name=my-relay # Example with hostname - infisical relay start --host=relay.example.com --type=org --name=my-relay + infisical relay start --host=relay.example.com --name=my-relay ``` - The name of the relay. + The name of the relay. This is an arbitrary identifier for your relay instance. ```bash # Example - infisical relay start --name=my-relay --type=org --host=192.168.1.100 + infisical relay start --name=my-relay --host=192.168.1.100 ``` ### Authentication -**Organization Relays (`--type=org`):** -Deploy your own relay server in your infrastructure for enhanced security and reduced latency. Supports all standard Infisical authentication methods documented below. - -**Instance Relays (`--type=instance`):** -Shared relay servers that serve all organizations on your Infisical instance. For Infisical Cloud, these are already running and ready to use. For self-hosted deployments, they're set up by the instance administrator. Authentication is handled via the `INFISICAL_RELAY_AUTH_SECRET` environment variable. +Relays support all standard Infisical authentication methods. Choose the authentication method that best fits your environment and set the corresponding flags when starting the relay. ```bash -# Organization relay with Universal Auth (customer-deployed) -infisical relay start --type=org --host=192.168.1.100 --name=my-org-relay --auth-method=universal-auth --client-id= --client-secret= - -# Instance relay (configured by instance admin) -INFISICAL_RELAY_AUTH_SECRET= infisical relay start --type=instance --host=10.0.1.50 --name=shared-relay +# Example with Universal Auth +infisical relay start --host=192.168.1.100 --name=my-relay --auth-method=universal-auth --client-id= --client-secret= ``` -### Authentication Methods +### Available Authentication Methods -The Infisical CLI supports multiple authentication methods for organization relays. Below are the available authentication methods, with their respective flags. +The Infisical CLI supports multiple authentication methods for relays. Below are the available authentication methods, with their respective flags. @@ -108,7 +90,7 @@ The Infisical CLI supports multiple authentication methods for organization rela ```bash - infisical relay start --auth-method=universal-auth --client-id= --client-secret= --type=org --host= --name= + infisical relay start --auth-method=universal-auth --client-id= --client-secret= --host= --name= ``` @@ -132,7 +114,7 @@ The Infisical CLI supports multiple authentication methods for organization rela ```bash - infisical relay start --auth-method=kubernetes --machine-identity-id= --type=org --host= --name= + infisical relay start --auth-method=kubernetes --machine-identity-id= --host= --name= ``` @@ -153,7 +135,7 @@ The Infisical CLI supports multiple authentication methods for organization rela ```bash - infisical relay start --auth-method=azure --machine-identity-id= --type=org --host= --name= + infisical relay start --auth-method=azure --machine-identity-id= --host= --name= ``` @@ -174,7 +156,7 @@ The Infisical CLI supports multiple authentication methods for organization rela ```bash - infisical relay start --auth-method=gcp-id-token --machine-identity-id= --type=org --host= --name= + infisical relay start --auth-method=gcp-id-token --machine-identity-id= --host= --name= ``` @@ -196,7 +178,7 @@ The Infisical CLI supports multiple authentication methods for organization rela ```bash - infisical relay start --auth-method=gcp-iam --machine-identity-id= --service-account-key-file-path= --type=org --host= --name= + infisical relay start --auth-method=gcp-iam --machine-identity-id= --service-account-key-file-path= --host= --name= ``` @@ -215,7 +197,7 @@ The Infisical CLI supports multiple authentication methods for organization rela ```bash - infisical relay start --auth-method=aws-iam --machine-identity-id= --type=org --host= --name= + infisical relay start --auth-method=aws-iam --machine-identity-id= --host= --name= ``` @@ -237,7 +219,7 @@ The Infisical CLI supports multiple authentication methods for organization rela ```bash - infisical relay start --auth-method=oidc-auth --machine-identity-id= --jwt= --type=org --host= --name= + infisical relay start --auth-method=oidc-auth --machine-identity-id= --jwt= --host= --name= ``` @@ -261,7 +243,7 @@ The Infisical CLI supports multiple authentication methods for organization rela ```bash - infisical relay start --auth-method=jwt-auth --jwt= --machine-identity-id= --type=org --host= --name= + infisical relay start --auth-method=jwt-auth --jwt= --machine-identity-id= --host= --name= ``` @@ -277,30 +259,132 @@ The Infisical CLI supports multiple authentication methods for organization rela ```bash - infisical relay start --token= --type=org --host= --name= + infisical relay start --token= --host= --name= ``` -### Deployment Considerations + -**When to use Instance Relays (`--type=instance`):** + + Manage systemd service for Infisical relay. This allows you to install and run the relay as a systemd service on Linux systems. + ### Requirements + - **Operating System**: Linux only (systemd is not supported on other operating systems) + - **Privileges**: Root/sudo privileges required for both install and uninstall operations + - **Systemd**: The system must be running systemd as the init system -- You want to get started quickly without setting up your own relay infrastructure -- You're using Infisical Cloud and want to leverage the existing relay infrastructure -- You're on a self-hosted instance where the admin has already set up shared relays -- You don't need custom geographic placement of relay servers -- You don't have specific compliance requirements that require dedicated infrastructure -- You want to minimize operational overhead by using shared infrastructure +```bash +infisical relay systemd +``` -**When to use Organization Relays (`--type=org`):** +### Subcommands -- You need lower latency by deploying relay servers closer to your resources -- You have security requirements that mandate running infrastructure in your own environment -- You have compliance requirements such as data sovereignty or air-gapped environments -- You need custom network policies or specific networking configurations -- You have high-scale performance requirements that shared infrastructure can't meet -- You want full control over your relay infrastructure and its configuration + + Install and enable systemd service for the relay. Must be run with sudo on Linux systems. + +```bash +sudo infisical relay systemd install --host= --name= --token= [flags] +``` + +#### Flags + + + The host (IP address or hostname) of the instance where the relay is deployed. This must be a static public IP or resolvable hostname that gateways can reach. + +```bash +# Example with IP address +sudo infisical relay systemd install --host=203.0.113.100 --name=my-relay --token= + +# Example with hostname +sudo infisical relay systemd install --host=relay.example.com --name=my-relay --token= +``` + + + + + The name of the relay. + +```bash +# Example +sudo infisical relay systemd install --name=my-relay --host=192.168.1.100 --token= +``` + + + + + Connect with Infisical using machine identity access token. + +```bash +# Example +sudo infisical relay systemd install --token= --host= --name= +``` + + + + + Domain of your self-hosted Infisical instance. Optional flag for specifying a custom domain. + +```bash +# Example +sudo infisical relay systemd install --domain=http://localhost:8080 --token= --host= --name= +``` + + + +#### Examples + +```bash +# Install relay with token authentication +sudo infisical relay systemd install --host=192.168.1.100 --name=my-relay --token= + +# Install with custom domain +sudo infisical relay systemd install --domain=http://localhost:8080 --token= --host= --name= +``` + +#### Post-installation + +After successful installation, the service will be enabled but not started. To start the service: + +```bash +sudo systemctl start infisical-relay +``` + +To check the service status: + +```bash +sudo systemctl status infisical-relay +``` + +To view service logs: + +```bash +sudo journalctl -u infisical-relay -f +``` + + + + + Uninstall and remove systemd service for the relay. Must be run with sudo on Linux systems. + +```bash +sudo infisical relay systemd uninstall +``` + +#### Examples + +```bash +# Uninstall the relay systemd service +sudo infisical relay systemd uninstall +``` + +#### What it does + +- Stops the `infisical-relay` systemd service if it's running +- Disables the service from starting on boot +- Removes the systemd service file +- Cleans up the service configuration + + diff --git a/docs/docs.json b/docs/docs.json index 285e11a60..210c5145c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -134,6 +134,7 @@ "integrations/app-connections/oracledb", "integrations/app-connections/postgres", "integrations/app-connections/railway", + "integrations/app-connections/redis", "integrations/app-connections/render", "integrations/app-connections/supabase", "integrations/app-connections/teamcity", @@ -173,8 +174,9 @@ "group": "Gateway", "pages": [ "documentation/platform/gateways/overview", - "documentation/platform/gateways/gateway-security", - "documentation/platform/gateways/networking", + "documentation/platform/gateways/gateway-deployment", + "documentation/platform/gateways/relay-deployment", + "documentation/platform/gateways/security", { "group": "Gateway (Deprecated)", "pages": [ @@ -441,7 +443,8 @@ "documentation/platform/secret-rotation/mysql-credentials", "documentation/platform/secret-rotation/okta-client-secret", "documentation/platform/secret-rotation/oracledb-credentials", - "documentation/platform/secret-rotation/postgres-credentials" + "documentation/platform/secret-rotation/postgres-credentials", + "documentation/platform/secret-rotation/redis-credentials" ] }, { @@ -452,6 +455,7 @@ "documentation/platform/dynamic-secrets/aws-elasticache", "documentation/platform/dynamic-secrets/aws-iam", "documentation/platform/dynamic-secrets/azure-entra-id", + "documentation/platform/dynamic-secrets/azure-sql-database", "documentation/platform/dynamic-secrets/cassandra", "documentation/platform/dynamic-secrets/couchbase", "documentation/platform/dynamic-secrets/elastic-search", @@ -711,6 +715,18 @@ "documentation/platform/pki/pki-issuer", "documentation/platform/pki/integration-guides/gloo-mesh" ] + }, + { + "group": "Certificate Syncs", + "pages": [ + "documentation/platform/pki/certificate-syncs/overview", + { + "group": "Syncs", + "pages": [ + "documentation/platform/pki/certificate-syncs/azure-key-vault" + ] + } + ] } ] } @@ -1009,28 +1025,55 @@ "api-reference/endpoints/organizations/delete-membership", "api-reference/endpoints/organizations/bulk-delete-memberships", "api-reference/endpoints/organizations/list-identity-memberships", - "api-reference/endpoints/organizations/workspaces" + { + "group": "Legacy", + "pages": [ + "api-reference/endpoints/deprecated/organizations/projects" + ] + } ] }, { "group": "Projects", "pages": [ - "api-reference/endpoints/workspaces/get-workspace-by-slug", - "api-reference/endpoints/workspaces/create-workspace", - "api-reference/endpoints/workspaces/delete-workspace", - "api-reference/endpoints/workspaces/get-workspace", - "api-reference/endpoints/workspaces/update-workspace", - "api-reference/endpoints/workspaces/secret-snapshots" + "api-reference/endpoints/projects/get-project-by-slug", + "api-reference/endpoints/projects/create-project", + "api-reference/endpoints/projects/delete-project", + "api-reference/endpoints/projects/get-project", + "api-reference/endpoints/projects/list-projects", + "api-reference/endpoints/projects/update-project", + "api-reference/endpoints/projects/secret-snapshots", + { + "group": "Legacy", + "pages": [ + "api-reference/endpoints/deprecated/projects/get-project-by-slug", + "api-reference/endpoints/deprecated/projects/create-project", + "api-reference/endpoints/deprecated/projects/delete-project", + "api-reference/endpoints/deprecated/projects/get-project", + "api-reference/endpoints/deprecated/projects/update-project", + "api-reference/endpoints/deprecated/projects/secret-snapshots" + ] + } ] }, { "group": "Project Users", "pages": [ - "api-reference/endpoints/project-users/invite-member-to-workspace", - "api-reference/endpoints/project-users/remove-member-from-workspace", + "api-reference/endpoints/project-users/invite-member-to-project", + "api-reference/endpoints/project-users/remove-member-from-project", "api-reference/endpoints/project-users/memberships", "api-reference/endpoints/project-users/get-by-username", - "api-reference/endpoints/project-users/update-membership" + "api-reference/endpoints/project-users/update-membership", + { + "group": "Legacy", + "pages": [ + "api-reference/endpoints/deprecated/project-users/invite-member-to-project", + "api-reference/endpoints/deprecated/project-users/remove-member-from-project", + "api-reference/endpoints/deprecated/project-users/memberships", + "api-reference/endpoints/deprecated/project-users/get-by-username", + "api-reference/endpoints/deprecated/project-users/update-membership" + ] + } ] }, { @@ -1040,7 +1083,17 @@ "api-reference/endpoints/project-groups/delete", "api-reference/endpoints/project-groups/get-by-id", "api-reference/endpoints/project-groups/list", - "api-reference/endpoints/project-groups/update" + "api-reference/endpoints/project-groups/update", + { + "group": "Legacy", + "pages": [ + "api-reference/endpoints/deprecated/project-groups/create", + "api-reference/endpoints/deprecated/project-groups/delete", + "api-reference/endpoints/deprecated/project-groups/get-by-id", + "api-reference/endpoints/deprecated/project-groups/list", + "api-reference/endpoints/deprecated/project-groups/update" + ] + } ] }, { @@ -1050,7 +1103,17 @@ "api-reference/endpoints/project-identities/list-identity-memberships", "api-reference/endpoints/project-identities/get-by-id", "api-reference/endpoints/project-identities/update-identity-membership", - "api-reference/endpoints/project-identities/delete-identity-membership" + "api-reference/endpoints/project-identities/delete-identity-membership", + { + "group": "Legacy", + "pages": [ + "api-reference/endpoints/deprecated/project-identities/add-identity-membership", + "api-reference/endpoints/deprecated/project-identities/list-identity-memberships", + "api-reference/endpoints/deprecated/project-identities/get-by-id", + "api-reference/endpoints/deprecated/project-identities/update-identity-membership", + "api-reference/endpoints/deprecated/project-identities/delete-identity-membership" + ] + } ] }, { @@ -1060,7 +1123,17 @@ "api-reference/endpoints/project-roles/update", "api-reference/endpoints/project-roles/delete", "api-reference/endpoints/project-roles/get-by-slug", - "api-reference/endpoints/project-roles/list" + "api-reference/endpoints/project-roles/list", + { + "group": "Legacy", + "pages": [ + "api-reference/endpoints/deprecated/project-roles/create", + "api-reference/endpoints/deprecated/project-roles/update", + "api-reference/endpoints/deprecated/project-roles/delete", + "api-reference/endpoints/deprecated/project-roles/get-by-slug", + "api-reference/endpoints/deprecated/project-roles/list" + ] + } ] }, { @@ -1078,7 +1151,15 @@ "pages": [ "api-reference/endpoints/environments/create", "api-reference/endpoints/environments/update", - "api-reference/endpoints/environments/delete" + "api-reference/endpoints/environments/delete", + { + "group": "Legacy", + "pages": [ + "api-reference/endpoints/deprecated/environments/create", + "api-reference/endpoints/deprecated/environments/update", + "api-reference/endpoints/deprecated/environments/delete" + ] + } ] }, { @@ -1088,7 +1169,17 @@ "api-reference/endpoints/folders/get-by-id", "api-reference/endpoints/folders/create", "api-reference/endpoints/folders/update", - "api-reference/endpoints/folders/delete" + "api-reference/endpoints/folders/delete", + { + "group": "Legacy", + "pages": [ + "api-reference/endpoints/deprecated/folders/list", + "api-reference/endpoints/deprecated/folders/get-by-id", + "api-reference/endpoints/deprecated/folders/create", + "api-reference/endpoints/deprecated/folders/update", + "api-reference/endpoints/deprecated/folders/delete" + ] + } ] }, { @@ -1099,7 +1190,18 @@ "api-reference/endpoints/secret-tags/get-by-slug", "api-reference/endpoints/secret-tags/create", "api-reference/endpoints/secret-tags/update", - "api-reference/endpoints/secret-tags/delete" + "api-reference/endpoints/secret-tags/delete", + { + "group": "Legacy", + "pages": [ + "api-reference/endpoints/deprecated/secret-tags/list", + "api-reference/endpoints/deprecated/secret-tags/get-by-id", + "api-reference/endpoints/deprecated/secret-tags/get-by-slug", + "api-reference/endpoints/deprecated/secret-tags/create", + "api-reference/endpoints/deprecated/secret-tags/update", + "api-reference/endpoints/deprecated/secret-tags/delete" + ] + } ] }, { @@ -1113,8 +1215,21 @@ "api-reference/endpoints/secrets/create-many", "api-reference/endpoints/secrets/update-many", "api-reference/endpoints/secrets/delete-many", - "api-reference/endpoints/secrets/attach-tags", - "api-reference/endpoints/secrets/detach-tags" + { + "group": "Legacy", + "pages": [ + "api-reference/endpoints/deprecated/secrets/list", + "api-reference/endpoints/deprecated/secrets/create", + "api-reference/endpoints/deprecated/secrets/read", + "api-reference/endpoints/deprecated/secrets/update", + "api-reference/endpoints/deprecated/secrets/delete", + "api-reference/endpoints/deprecated/secrets/create-many", + "api-reference/endpoints/deprecated/secrets/update-many", + "api-reference/endpoints/deprecated/secrets/delete-many", + "api-reference/endpoints/deprecated/secrets/attach-tags", + "api-reference/endpoints/deprecated/secrets/detach-tags" + ] + } ] }, { @@ -1144,7 +1259,16 @@ "api-reference/endpoints/secret-imports/list", "api-reference/endpoints/secret-imports/create", "api-reference/endpoints/secret-imports/update", - "api-reference/endpoints/secret-imports/delete" + "api-reference/endpoints/secret-imports/delete", + { + "group": "Legacy", + "pages": [ + "api-reference/endpoints/deprecated/secret-imports/list", + "api-reference/endpoints/deprecated/secret-imports/create", + "api-reference/endpoints/deprecated/secret-imports/update", + "api-reference/endpoints/deprecated/secret-imports/delete" + ] + } ] }, { @@ -1268,6 +1392,19 @@ "api-reference/endpoints/secret-rotations/postgres-credentials/rotate-secrets", "api-reference/endpoints/secret-rotations/postgres-credentials/update" ] + }, + { + "group": "Redis Credentials", + "pages": [ + "api-reference/endpoints/secret-rotations/redis-credentials/create", + "api-reference/endpoints/secret-rotations/redis-credentials/delete", + "api-reference/endpoints/secret-rotations/redis-credentials/get-by-id", + "api-reference/endpoints/secret-rotations/redis-credentials/get-by-name", + "api-reference/endpoints/secret-rotations/redis-credentials/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/redis-credentials/list", + "api-reference/endpoints/secret-rotations/redis-credentials/rotate-secrets", + "api-reference/endpoints/secret-rotations/redis-credentials/update" + ] } ] }, @@ -1745,6 +1882,18 @@ "api-reference/endpoints/app-connections/railway/delete" ] }, + { + "group": "Redis", + "pages": [ + "api-reference/endpoints/app-connections/redis/list", + "api-reference/endpoints/app-connections/redis/available", + "api-reference/endpoints/app-connections/redis/get-by-id", + "api-reference/endpoints/app-connections/redis/get-by-name", + "api-reference/endpoints/app-connections/redis/create", + "api-reference/endpoints/app-connections/redis/update", + "api-reference/endpoints/app-connections/redis/delete" + ] + }, { "group": "Render", "pages": [ @@ -2367,6 +2516,26 @@ "api-reference/endpoints/pki-alerts/update", "api-reference/endpoints/pki-alerts/delete" ] + }, + { + "group": "Certificate Syncs", + "pages": [ + "api-reference/endpoints/pki/syncs/list", + "api-reference/endpoints/pki/syncs/get-by-id", + "api-reference/endpoints/pki/syncs/options", + { + "group": "Azure Key Vault", + "pages": [ + "api-reference/endpoints/pki/syncs/azure-key-vault/list", + "api-reference/endpoints/pki/syncs/azure-key-vault/get-by-id", + "api-reference/endpoints/pki/syncs/azure-key-vault/create", + "api-reference/endpoints/pki/syncs/azure-key-vault/update", + "api-reference/endpoints/pki/syncs/azure-key-vault/delete", + "api-reference/endpoints/pki/syncs/azure-key-vault/sync-certificates", + "api-reference/endpoints/pki/syncs/azure-key-vault/remove-certificates" + ] + } + ] } ] }, diff --git a/docs/documentation/platform/auth-methods/email-password.mdx b/docs/documentation/platform/auth-methods/email-password.mdx index f5cb0e6f5..5a433a566 100644 --- a/docs/documentation/platform/auth-methods/email-password.mdx +++ b/docs/documentation/platform/auth-methods/email-password.mdx @@ -29,6 +29,9 @@ You can update your account email address: 1. Open the `Personal Settings` menu. 2. Navigate to the `Authentication` tab. 3. In the `Change Email` section, enter your new email address. + +If you don't currently have Email authentication enabled, it will be automatically activated when you change your email. You may disable it in the authentication settings after logging in with your new email if needed. + ![change email section](../../../images/auth-methods/personal-settings-authentication-change-email-password.png) 4. Click `Send Verification Code` to receive an 6-digit verification code at your new email address. 5. Check your new email inbox and enter the verification code. diff --git a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx index 28b177c5f..c1fc85257 100644 --- a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx +++ b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx @@ -3,49 +3,93 @@ title: "AWS IAM" description: "Learn how to dynamically generate AWS IAM Users." --- -The Infisical AWS IAM dynamic secret allows you to generate AWS IAM Users on demand based on a configured AWS policy. Infisical supports several authentication methods to connect to your AWS account, including assuming an IAM Role, using IAM Roles for Service Accounts (IRSA) on EKS, or static Access Keys. +The Infisical AWS IAM dynamic secret allows you to generate AWS IAM Users and temporary credentials on demand based on a configured AWS policy. Infisical supports several authentication methods to connect to your AWS account, including assuming an IAM Role, using IAM Roles for Service Accounts (IRSA) on EKS, or static Access Keys. + +## AWS STS Duration Limits + +When using **Temporary Credentials**, AWS STS has specific maximum duration limits: + +- **AssumeRole operations**: Maximum 1 hour (3600 seconds) when using temporary credentials +- **GetSessionToken operations** (Access Key & IRSA): Maximum 12 hours (43200 seconds) + + +**Automatic Duration Adjustment**: If you specify a TTL that exceeds these AWS limits, Infisical will automatically use the maximum allowed duration instead of failing the operation. This ensures your dynamic secrets work reliably within AWS constraints. + ## Prerequisite -Infisical needs an AWS IAM principal (a user or a role) with the required permissions to create and manage other IAM users. This principal will be responsible for the lifecycle of the dynamically generated users. +Infisical needs an AWS IAM principal (a user or a role) with the required permissions to create and manage other IAM users and temporary credentials. This principal will be responsible for the lifecycle of the dynamically generated users and temporary credentials. -```json -{ - "Version": "2012-10-17", - "Statement": [ + + + Required permissions for creating temporary IAM users: + + ```json { - "Effect": "Allow", - "Action": [ - "iam:AttachUserPolicy", - "iam:CreateAccessKey", - "iam:CreateUser", - "iam:DeleteAccessKey", - "iam:DeleteUser", - "iam:DeleteUserPolicy", - "iam:DetachUserPolicy", - "iam:GetUser", - "iam:ListAccessKeys", - "iam:ListAttachedUserPolicies", - "iam:ListGroupsForUser", - "iam:ListUserPolicies", - "iam:PutUserPolicy", - "iam:AddUserToGroup", - "iam:RemoveUserFromGroup", - "iam:TagUser" - ], - "Resource": ["*"] + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:AttachUserPolicy", + "iam:CreateAccessKey", + "iam:CreateUser", + "iam:DeleteAccessKey", + "iam:DeleteUser", + "iam:DeleteUserPolicy", + "iam:DetachUserPolicy", + "iam:GetUser", + "iam:ListAccessKeys", + "iam:ListAttachedUserPolicies", + "iam:ListGroupsForUser", + "iam:ListUserPolicies", + "iam:PutUserPolicy", + "iam:AddUserToGroup", + "iam:RemoveUserFromGroup", + "iam:TagUser" + ], + "Resource": ["*"] + } + ] } - ] -} -``` + ``` -To minimize managing user access you can attach a resource in format + To minimize managing user access you can attach a resource in format -> arn:aws:iam::\:user/\ + > arn:aws:iam::\:user/\ -Replace **\** with your AWS account id and **\** with a path to minimize managing user access. + Replace **\** with your AWS account id and **\** with a path to minimize managing user access. + + + + Required permissions for Access Key and Assume Role methods: + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "sts:GetSessionToken", + "sts:AssumeRole" + ], + "Resource": ["*"] + } + ] + } + ``` + + + To minimize managing user access you can attach a resource in format + + > arn:aws:iam::\:user/\ + + Replace **\** with your AWS account id and **\** with a path to minimize managing user access. + + @@ -101,6 +145,9 @@ Replace **\** with your AWS account id and **\** w 2. Select **AWS Account** as the **Trusted Entity Type**. 3. Select **Another AWS Account** and provide the appropriate Infisical AWS Account ID: use **381492033652** for the **US region**, and **345594589636** for the **EU region**. This restricts the role to be assumed only by Infisical. If self-hosting, provide your AWS account number instead. + + **For Dedicated Instances**: Your AWS account ID differs from the one provided above. Please reach out to Infisical support to obtain your AWS account ID. + 4. (Recommended) Enable "Require external ID" and input your **Project ID** to strengthen security and mitigate the [confused deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html). 5. Assign permission as shared in prerequisite. @@ -170,43 +217,76 @@ Replace **\** with your AWS account id and **\** w Select *Assume Role* method. - - The ARN of the AWS Role to assume. + + Choose the credential generation approach: + - **IAM User (Default)**: Creates new temporary IAM users in your AWS account + - **Temporary Credentials**: Generates temporary credentials from your role connection - - [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. + + The ARN of the AWS Role to assume. The AWS data center region. - - The IAM Policy ARN of the [AWS Permissions Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) to attach to IAM users created in the role. - + + + + [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. + - - The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas - + + The IAM Policy ARN of the [AWS Permissions Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) to attach to IAM users created in the role. + - - The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas - + + The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas. + - - The AWS IAM inline policy that should be attached to the created users. - Multiple values can be provided by separating them with commas - + + The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas. + - - Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + The AWS IAM inline policy that should be attached to the created users. Multiple values can be provided by separating them with commas. + - Allowed template variables are + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - - `{{randomUsername}}`: Random username string - - `{{unixTimestamp}}`: Current Unix timestamp - + Allowed template variables are: + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are: + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + + + Tags to be added to the created IAM User resource. + + + + + When **Credential Type** is set to **Temporary Credentials**: + + + No additional configuration parameters are required. The generated credentials will: + - Inherit the permissions of the assumed role + - Include an AWS Session Token + - Be valid for the duration specified in Default TTL + + + + **Duration Limit**: AssumeRole temporary credentials are limited to 1 hour maximum by AWS. TTL values exceeding this limit will be automatically adjusted to 1 hour. + + + @@ -232,6 +312,18 @@ Replace **\** with your AWS account id and **\** w Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + **Credentials format depends on your chosen credential type:** + + **IAM User credential type:** + - AWS Username + - AWS Access Key ID + - AWS Secret Access Key + + **Temporary Credentials credential type:** + - AWS Access Key ID + - AWS Secret Access Key + - AWS Session Token + ![Provision Lease](/images/platform/dynamic-secrets/lease-values-aws-iam.png) @@ -342,36 +434,75 @@ Replace **\** with your AWS account id and **\** w Select *IRSA* method. + + Choose the credential generation approach: + - **IAM User**: Creates new temporary IAM users in your AWS account + - **Temporary Credentials**: Generates temporary credentials from your IRSA role connection + The ARN of the AWS IAM Role for the service account to assume. - - [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. - + The AWS data center region. - - The IAM Policy ARN of the [AWS Permissions Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) to attach to IAM users created in the role. - - - The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas - - - The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas - - - The AWS IAM inline policy that should be attached to the created users. - Multiple values can be provided by separating them with commas - - - Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - Allowed template variables are + + + + [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. + - - `{{randomUsername}}`: Random username string - - `{{unixTimestamp}}`: Current Unix timestamp - + + The IAM Policy ARN of the [AWS Permissions Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) to attach to IAM users created in the role. + + + + The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas. + + + + The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas. + + + + The AWS IAM inline policy that should be attached to the created users. Multiple values can be provided by separating them with commas. + + + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + Allowed template variables are: + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are: + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + + + Tags to be added to the created IAM User resource. + + + + + When **Credential Type** is set to **Temporary Credentials**: + + + No additional configuration parameters are required. The generated credentials will: + - Inherit the permissions of the assumed IRSA role + - Include an AWS Session Token + - Be valid for the duration specified in Default TTL + + + + **Duration Limit**: IRSA temporary credentials support up to 12 hours maximum via GetSessionToken. TTL values exceeding this limit will be automatically adjusted. + + + After submitting the form, you will see a dynamic secret created in the dashboard. @@ -429,6 +560,12 @@ Replace **\** with your AWS account id and **\** w Select *Access Key* method. + + Choose the credential generation approach: + - **IAM User**: Creates new temporary IAM users in your AWS account + - **Temporary Credentials**: Generates temporary credentials from your access key connection + + The managing AWS IAM User Access Key @@ -437,43 +574,66 @@ Replace **\** with your AWS account id and **\** w The managing AWS IAM User Secret Key - - [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. - - The AWS data center region. - - The IAM Policy ARN of the [AWS Permissions Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) to attach to IAM users created in the role. - + + + + [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. + - - The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas - + + The IAM Policy ARN of the [AWS Permissions Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) to attach to IAM users created in the role. + - - The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas - + + The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas. + - - The AWS IAM inline policy that should be attached to the created users. - Multiple values can be provided by separating them with commas - + + The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas. + - - Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + The AWS IAM inline policy that should be attached to the created users. Multiple values can be provided by separating them with commas. + - Allowed template variables are + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - - `{{randomUsername}}`: Random username string - - `{{unixTimestamp}}`: Current Unix timestamp - + Allowed template variables are: + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters - - Tags to be added to the created IAM User resource. - + Allowed template functions are: + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + + + Tags to be added to the created IAM User resource. + + + + + When **Credential Type** is set to **Temporary Credentials**: + + + No additional configuration parameters are required. The generated credentials will: + - Inherit the permissions of your access key connection + - Include an AWS Session Token + - Be valid for the duration specified in Default TTL + + + + **Duration Limit**: Access Key temporary credentials support up to 12 hours maximum via GetSessionToken. TTL values exceeding this limit will be automatically adjusted. + + + @@ -500,6 +660,18 @@ Replace **\** with your AWS account id and **\** w Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + **Credentials format depends on your chosen credential type:** + + **IAM User credential type:** + - AWS Username + - AWS Access Key ID + - AWS Secret Access Key + + **Temporary Credentials credential type:** + - AWS Access Key ID + - AWS Secret Access Key + - AWS Session Token + ![Provision Lease](/images/platform/dynamic-secrets/lease-values-aws-iam.png) diff --git a/docs/documentation/platform/dynamic-secrets/azure-sql-database.mdx b/docs/documentation/platform/dynamic-secrets/azure-sql-database.mdx new file mode 100644 index 000000000..f92ea7232 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/azure-sql-database.mdx @@ -0,0 +1,184 @@ +--- +title: "Azure SQL Database" +description: "Learn how to dynamically generate Azure SQL Database user credentials." +--- + +The Infisical Azure SQL Database dynamic secret allows you to generate Azure SQL Database user credentials on demand based on configured roles. + +## How Azure SQL Database Authentication Works + +Azure SQL Database uses a two-tier authentication system that differs from traditional SQL Server: + +1. **Master Database**: Contains server-level logins that can authenticate to the Azure SQL Database server +2. **User Databases**: Individual databases that contain database users mapped to server logins + +When creating dynamic credentials for Azure SQL Database, Infisical performs a two-step process: +1. **Create Login in Master Database**: Creates a server-level login with the specified password +2. **Create User in Target Database**: Creates a database user mapped to the login and grants the necessary permissions + +This architecture ensures proper security isolation and follows Azure SQL Database best practices. + +## Prerequisite + +Create a user with the required permissions in your Azure SQL Database instance. This user will be used to create new accounts on-demand. + +The user needs: +- `loginmanager` role in the master database (to create logins) +- `db_owner` role in the target database (to create users and grant permissions) + +## Set up Dynamic Secrets with Azure SQL Database + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/azure-sql-database/add-dynamic-secret-button.png) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) + + + + Maximum time-to-live for a generated secret + + + + List of key/value metadata pairs + + + + Azure SQL Database server hostname (e.g., myserver.database.windows.net) + + + + Database port (typically 1433 for Azure SQL Database) + + + + Username that will be used to create dynamic secrets (must have loginmanager role in master and db_owner in target database) + + + + Password that will be used to create dynamic secrets + + + + Name of the target database where users will be created and granted permissions + + + + Enable SSL encryption for the database connection (recommended for Azure SQL Database) + + + + SSL certificate authority certificate. For Azure SQL Database, this is typically not required as Azure manages the certificates. + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/azure-sql-database/create-dynamic-secret-form.png) + + + + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/azure-sql-database/modify-sql-statements-azure-sql.png) + + Azure SQL Database dynamic secrets use predefined SQL statements that follow Azure's security best practices: + + + SQL statement executed in the master database to create a server-level login. This login allows authentication to the Azure SQL Database server. + + + + SQL statement executed in the target database to create a database user and grant permissions. The user is mapped to the login created in the master database. + + + + SQL statements executed when a lease expires or is manually revoked. The system intelligently routes DROP USER commands to the target database and DROP LOGIN commands to the master database for proper cleanup. + + + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + Allowed template variables are: + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are: + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` + + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + If this step fails, ensure your user has the proper permissions in both the master database (`loginmanager` role) and target database (`db_owner` role). + + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) + + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret. + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) + + + + +## Audit or Revoke Leases + +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you to see the expiration time of the lease or delete the lease before its set time to live. + +When a lease is revoked or expires, Infisical automatically: +1. **Drops the user** from the target database +2. **Drops the login** from the master database + +This ensures complete cleanup and prevents orphaned credentials. + +![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) + +## Renew Leases + +To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** button as illustrated below. +![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + diff --git a/docs/documentation/platform/gateways/gateway-deployment.mdx b/docs/documentation/platform/gateways/gateway-deployment.mdx new file mode 100644 index 000000000..40b98965a --- /dev/null +++ b/docs/documentation/platform/gateways/gateway-deployment.mdx @@ -0,0 +1,265 @@ +--- +title: "Gateway Deployment" +description: "Complete guide to deploying Infisical Gateways including network configuration and firewall requirements" +--- + +Infisical Gateways enables secure communication between your private resources and the Infisical platform without exposing inbound ports in your network. +This guide covers everything you need to deploy and configure Infisical Gateways. + +## Deployment Steps + +To successfully deploy an Infisical Gateway for use, follow these steps in order. + + + + Create a machine identity with the correct permissions to create and manage gateways. This identity is used by the gateway to authenticate with Infisical and should be provisioned in advance. + The gateway supports several [machine identity auth methods](/documentation/platform/identities/machine-identities), as listed below. Choose the one that best fits your environment and set the corresponding environment variables when deploying the gateway. + + + + Simple and secure authentication using client ID and client secret. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=universal-auth` + - `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID=` + - `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET=` + + + + Direct authentication using a machine identity access token. + + **Environment Variables:** + - `INFISICAL_TOKEN=` + + + + Authentication using Kubernetes service account tokens. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=kubernetes` + - `INFISICAL_MACHINE_IDENTITY_ID=` + + + + Authentication using AWS IAM roles. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=aws-iam` + - `INFISICAL_MACHINE_IDENTITY_ID=` + + + + Authentication using GCP identity tokens. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=gcp-id-token` + - `INFISICAL_MACHINE_IDENTITY_ID=` + + + + Authentication using GCP service account keys. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=gcp-iam` + - `INFISICAL_MACHINE_IDENTITY_ID=` + - `INFISICAL_GCP_SERVICE_ACCOUNT_KEY_FILE_PATH=` + + + + Authentication using Azure managed identity. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=azure` + - `INFISICAL_MACHINE_IDENTITY_ID=` + + + + Authentication using OIDC identity tokens. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=oidc-auth` + - `INFISICAL_MACHINE_IDENTITY_ID=` + - `INFISICAL_JWT=` + + + + Authentication using JWT tokens. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=jwt-auth` + - `INFISICAL_MACHINE_IDENTITY_ID=` + - `INFISICAL_JWT=` + + + + + Ensure a relay server is running and accessible before you deploy any gateways. You have two options: + - **Managed relay (Infisical Cloud, US/EU only):** Managed relays are only available for Infisical Cloud instances in the US and EU regions. If you are using Infisical Cloud in these regions, you can use the provided managed relay. + - **Self-hosted relay:** For all other cases, including all self-hosted and dedicated enterprise instances of Infisical, you must deploy your own relay server. You can also choose to deploy your own relay server when using Infisical Cloud if you require reduced geographic proximity to your target resources for lower latency or to reduce network congestion. For setup instructions, see the Relay Deployment Guide. + + + Make sure the Infisical CLI is installed on the machine or environment where you plan to deploy the gateway. The CLI is required for gateway installation and management. + + See the [CLI Installation Guide](/cli/overview) for instructions. + + + Ensure your network and firewall settings allow the gateway to connect to all required services. All connections are outbound only; no inbound ports need to be opened. + + | Protocol | Destination | Port | Purpose | + | -------- | ------------------------------------ | ---- | ------------------------------------------ | + | TCP | Relay Server IP/Hostname | 2222 | SSH reverse tunnel establishment | + | TCP | Infisical instance host (US/EU, other) | 443 | API communication and certificate requests | + + For managed relays, allow outbound traffic to the provided relay server IP/hostname. For self-hosted relays, allow outbound traffic to your own relay server address. + + If you are in a corporate environment with strict egress filtering, ensure outbound TCP 2222 to relay servers and outbound HTTPS 443 to Infisical API endpoints are allowed. + + + The Infisical CLI is used to install and start the gateway in your chosen environment. The CLI provides commands for both production and development scenarios, and supports a variety of options/flags to configure your deployment. + + To view all available flags and equivalent environment variables for gateway deployment, see the [Gateway CLI Command Reference](/cli/commands/gateway). + + + For production deployments on Linux servers, install the Gateway as a systemd service so that it runs securely in the background and automatically restarts on failure or system reboot: + ```bash + sudo infisical gateway systemd install --token --domain --name --relay + sudo systemctl start infisical-gateway + ``` + + + + + The systemd install command requires a Linux operating system with root/sudo + privileges. + + + + + For production deployments on Kubernetes clusters, install the Gateway using the Infisical Helm chart: + + #### Install the latest Helm Chart repository + + ```bash + helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' + helm repo update + ``` + + #### Create a Kubernetes Secret + + The gateway supports all identity authentication methods through environment variables: + + ```bash + kubectl create secret generic infisical-gateway-environment \ + --from-literal=INFISICAL_AUTH_METHOD=universal-auth \ + --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= \ + --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= \ + --from-literal=INFISICAL_RELAY_NAME= \ + --from-literal=INFISICAL_GATEWAY_NAME= + ``` + + #### Install the Gateway + + ```bash + helm install infisical-gateway infisical-helm-charts/infisical-gateway + ``` + + + + For development or testing environments: + + ```bash + infisical gateway start --token --relay= --name= + ``` + + + + + + After deployment, verify your gateway is working: + + 1. **Check logs** for "Gateway started successfully" message indicating the gateway is running and connected to the relay + + 2. **Verify registration** in the Infisical by visiting the Gateways section of your organization. The new gateway should appear with a recent heartbeat timestamp. + + 3. **Test connectivity** by creating a resource in Infisical that uses the gateway to access a private service. Verify the resource can successfully connect through the gateway. + + + + + + +## Frequently Asked Questions + +No inbound ports need to be opened for gateways. The gateway only makes outbound connections: + +- **Outbound SSH** to relay servers on port 2222 +- **Outbound HTTPS** to Infisical API endpoints on port 443 +- **SSH reverse tunnels** handle all communication - no return traffic configuration needed + +This design maintains security by avoiding the need for inbound firewall rules that could expose your network to external threats. + + + + +Test relay connectivity and outbound API access from the gateway: + +1. Test SSH port to relay: + ```bash + nc -zv 2222 + ``` +2. Test outbound API access (replace with your Infisical domain if different): + ```bash + curl -I https://app.infisical.com + ``` + + + +If the gateway cannot connect to the relay: + +1. Verify the relay server is running and accessible +2. Check firewall rules allow outbound connections on port 2222 +3. Confirm the relay name matches exactly +4. Test SSH port to relay: + ```bash + nc -zv 2222 + ``` + + + +If you encounter authentication failures: + +1. Verify machine identity credentials are correct +2. Check token expiration and renewal +3. Ensure authentication method is properly configured + + + +Check gateway logs for detailed error information: + +- **systemd service:** + ```bash + sudo journalctl -u infisical-gateway -f + ``` +- **Kubernetes:** + ```bash + kubectl logs deployment/infisical-gateway + ``` +- **Local installation:** Logs appear in the terminal where you started the gateway + + + +For systemd-based installations, the gateway's configuration file is stored at `/etc/infisical/gateway.conf`. You may reference or inspect this file for troubleshooting advanced configuration issues. + + + +The gateway is designed to handle network interruptions gracefully: + +- **Automatic reconnection**: The gateway will automatically attempt to reconnect to relay servers if the SSH connection is lost +- **Connection retry logic**: Built-in retry mechanisms handle temporary network outages without manual intervention +- **Persistent SSH tunnels**: SSH connections are automatically re-established when connectivity is restored +- **Certificate rotation**: The gateway handles certificate renewal automatically during reconnection +- **Graceful degradation**: The gateway logs connection issues and continues attempting to restore connectivity + +No manual intervention is typically required during network interruptions. + + diff --git a/docs/documentation/platform/gateways/networking.mdx b/docs/documentation/platform/gateways/networking.mdx deleted file mode 100644 index 2b068a535..000000000 --- a/docs/documentation/platform/gateways/networking.mdx +++ /dev/null @@ -1,178 +0,0 @@ ---- -title: "Networking" -description: "Network configuration and firewall requirements for Infisical Gateway" ---- - -The Infisical Gateway requires outbound network connectivity to establish secure SSH reverse tunnels with relay servers. -This page outlines the required ports, protocols, and firewall configurations needed for optimal gateway usage. - -## Network Architecture - -The gateway uses SSH reverse tunnels to establish secure connections with end-to-end encryption: - -1. **Gateway** connects outbound to **Relay Servers** using SSH over TCP -2. **Infisical platform** establishes mTLS connections with gateways for application traffic -3. **Relay Servers** route the doubly-encrypted traffic (mTLS payload within SSH tunnels) between the platform and gateways -4. **Double encryption** ensures relay servers cannot access application data - only the platform and gateway can decrypt traffic - -## Required Network Connectivity - -### Outbound Connections (Required) - -The gateway requires the following outbound connectivity: - -| Protocol | Destination | Ports | Purpose | -| -------- | ------------------------------------ | ----- | ------------------------------------------ | -| TCP | Relay Servers | 2222 | SSH reverse tunnel establishment | -| TCP | app.infisical.com / eu.infisical.com | 443 | API communication and certificate requests | - -### Relay Server Connectivity - -**For Instance Relays (Infisical Cloud):** Your firewall must allow outbound connectivity to Infisical-managed relay servers. - -**For Organization Relays:** Your firewall must allow outbound connectivity to your own relay server IP addresses or hostnames. - -**For Self-hosted Instance Relays:** Your firewall must allow outbound connectivity to relay servers configured by your instance administrator. - - - - Infisical provides multiple managed relay servers with static IP addresses. - You can whitelist these IPs ahead of time based on which relay server you - choose to connect to. **Firewall requirements:** Allow outbound TCP - connections to the desired relay server IP on port 2222. - - - You control the relay server IP addresses or hostnames when deploying your - own organization relays. **Firewall requirements:** Allow outbound TCP - connections to your relay server IP or hostname on port 2222. For example, - if your relay is at `203.0.113.100` or `relay.example.com`, allow TCP to - `203.0.113.100:2222` or `relay.example.com:2222`. - - - Contact your instance administrator for the relay server IP addresses or - hostnames configured for your deployment. **Firewall requirements:** Allow - outbound TCP connections to instance relay servers on port 2222. - - - -## Protocol Details - -### SSH over TCP - -The gateway uses SSH reverse tunnels for primary communication: - -- **Port 2222**: SSH connection to relay servers -- **Built-in features**: Automatic reconnection, certificate-based authentication, encrypted tunneling -- **Encryption**: SSH with certificate-based authentication and key exchange - -## Firewall Configuration for SSH - -The gateway uses standard SSH over TCP, making firewall configuration straightforward. - -### TCP Connection Handling - -SSH connections over TCP are stateful and handled seamlessly by all modern firewalls: - -- **Established connections** are automatically tracked -- **Return traffic** is allowed for established outbound connections -- **No special configuration** needed for connection tracking -- **Standard SSH protocol** that enterprise firewalls handle well - -### Simplified Firewall Rules - -Since SSH uses TCP, you only need simple outbound rules: - -1. **Allow outbound TCP** to relay servers (IP addresses or hostnames) on port 2222 -2. **Allow outbound HTTPS** to Infisical API endpoints on port 443 -3. **No inbound rules required** - all connections are outbound only - -## Common Network Scenarios - -### Corporate Firewalls - -For corporate environments with strict egress filtering: - -1. **Allow outbound TCP** to relay servers (IP addresses or hostnames) on port 2222 -2. **Allow outbound HTTPS** to the Infisical API server on port 443 -3. **No inbound rules required** - all connections are outbound only -4. **Standard TCP rules** - simple and straightforward configuration - -### Cloud Environments (AWS/GCP/Azure) - -Configure security groups to allow: - -- **Outbound TCP** to relay servers (IP addresses or hostnames) on port 2222 -- **Outbound HTTPS** to app.infisical.com/eu.infisical.com on port 443 -- **No inbound rules required** - SSH reverse tunnels are outbound only - -## Frequently Asked Questions - - -The gateway is designed to handle network interruptions gracefully: - -- **Automatic reconnection**: The gateway will automatically attempt to reconnect to relay servers if the SSH connection is lost -- **Connection retry logic**: Built-in retry mechanisms handle temporary network outages without manual intervention -- **Persistent SSH tunnels**: SSH connections are automatically re-established when connectivity is restored -- **Certificate rotation**: The gateway handles certificate renewal automatically during reconnection -- **Graceful degradation**: The gateway logs connection issues and continues attempting to restore connectivity - -No manual intervention is typically required during network interruptions. - - - - -SSH over TCP provides several advantages for enterprise gateway communication: - -- **Firewall-friendly**: TCP is stateful and handled seamlessly by all enterprise firewalls -- **Standard protocol**: SSH is a well-established protocol that network teams are familiar with -- **Certificate-based security**: Uses SSH certificates for strong authentication without shared secrets -- **Automatic tunneling**: SSH reverse tunnels handle all the complexity of secure communication -- **Enterprise compatibility**: Works reliably across all enterprise network configurations - -TCP's reliability and firewall compatibility make it ideal for enterprise environments where network policies are strictly managed. - - - - -No inbound ports need to be opened. The gateway only makes outbound connections: - -- **Outbound SSH** to relay servers on port 2222 -- **Outbound HTTPS** to Infisical API endpoints on port 443 -- **SSH reverse tunnels** handle all communication - no return traffic configuration needed - -This design maintains security by avoiding the need for inbound firewall rules that could expose your network to external threats. - - - - -If your firewall has strict outbound restrictions: - -1. **Work with your network team** to allow outbound TCP connections on port 2222 to relay servers (IP addresses or hostnames) -2. **Allow standard SSH traffic** - most enterprises already have SSH policies in place -3. **Consider network policy exceptions** for the gateway host if needed -4. **Monitor firewall logs** to identify which specific rules are blocking traffic - - - - -The gateway connects to **one relay server**: - -- **Single SSH connection**: Each gateway establishes one SSH reverse tunnel to its assigned relay server -- **Named relay assignment**: Gateways connect to the specific relay server specified by `--relay` -- **Automatic reconnection**: If the relay connection is lost, the gateway automatically reconnects to the same relay -- **Certificate-based authentication**: Each connection uses SSH certificates issued by Infisical for secure authentication - - - -No, relay servers cannot decrypt any traffic passing through them due to end-to-end encryption: - -- **Client-to-Gateway mTLS (via TLS-pinned tunnel)**: Clients connect via a proxy that establishes a TLS-pinned tunnel to the gateway; mTLS between the client and gateway is negotiated inside this tunnel, encrypting all application traffic -- **SSH tunnel encryption**: The mTLS-encrypted traffic is then transmitted through SSH reverse tunnels to relay servers -- **Double encryption**: Traffic is encrypted twice - once by client mTLS and again by SSH tunnels -- **Relay only routes traffic**: The relay server only routes the doubly-encrypted traffic without access to either encryption layer -- **No data storage**: Relay servers do not store any traffic or sensitive information -- **Certificate isolation**: Each connection uses unique certificates, ensuring complete tenant isolation - -The relay infrastructure is designed as a secure routing mechanism where only the client and gateway can decrypt the actual application traffic. - - diff --git a/docs/documentation/platform/gateways/overview.mdx b/docs/documentation/platform/gateways/overview.mdx index b8ea0102a..c04f2f7d3 100644 --- a/docs/documentation/platform/gateways/overview.mdx +++ b/docs/documentation/platform/gateways/overview.mdx @@ -1,19 +1,14 @@ --- -title: "Gateway" +title: "Gateway Overview" sidebarTitle: "Overview" description: "How to access private network resources from Infisical" --- ![Architecture Overview](../../../images/platform/gateways/gateway-highlevel-diagram.png) -The Infisical Gateway provides secure access to private resources within your network without needing direct inbound connections to your environment. This method keeps your resources fully protected from external access while enabling Infisical to securely interact with resources like databases. - -**Architecture Components:** - -- **Gateway**: Lightweight agent deployed within your VPCs that provides access to private resources -- **Relay**: Infrastructure that routes encrypted traffic (instance-wide or organization-specific) - -Common use cases include generating dynamic credentials or rotating credentials for private databases. +The Infisical Gateway provides secure access to private resources within your network without needing direct inbound connections to your environment. +This is particularly useful when Infisical isn't hosted within the same network as the resources it needs to reach. +This method keeps your resources fully protected from external access while enabling Infisical to securely interact with resources like databases. Gateway is a paid feature available under the Enterprise Tier for Infisical @@ -22,428 +17,62 @@ Common use cases include generating dynamic credentials or rotating credentials license. +## Core Components + +The Gateway system consists of two primary components working together to enable secure network access: + + + + A Gateway is a lightweight service that you deploy within your own network infrastructure to provide secure access to your private resources. Think of it as a secure bridge between Infisical and your internal systems. + + Gateways must be deployed within the same network where your target resources are located, with direct network connectivity to the private resources you want Infisical to access. + For different networks, regions, or isolated environments, you'll need to deploy separate gateways. + + **Core Functions:** + - **Network Placement**: Deployed within your VPCs, data centers, or on-premises infrastructure where your private resources live + - **Connection Model**: Only makes outbound connections to Infisical's relay servers, so no inbound firewall rules are needed + - **Security Method**: Uses SSH reverse tunnels with certificate-based authentication for maximum security + - **Resource Access**: Acts as a proxy to connect Infisical to your private databases, APIs, and other services + + + + A Relay Server is the routing infrastructure that enables secure communication between the Infisical platform and your deployed gateways. It acts as an intermediary that never sees your actual data. + + **Core Functions:** + - **Traffic Routing**: Routes encrypted traffic between the Infisical platform and your gateways without storing or inspecting the data + - **Network Isolation**: Enables secure communication without requiring direct network connections between Infisical and your private infrastructure + - **Authentication Management**: Validates SSH certificates and manages secure routing between authenticated gateways + + **Deployment Options:** + To reduce operational overhead, Infisical Cloud (US/EU) provides managed relay infrastructure, though organizations can also deploy their own relays for reduced latency. + - **Infisical Managed**: Use pre-deployed relays in select regions, shared across all Infisical Cloud organizations. Each organization traffic is isolated and encrypted. + - **Self-Deployed**: Deploy your own dedicated relay servers geographically close to your infrastructure for reduced latency. + + + ## How It Works The Gateway system uses SSH reverse tunnels for secure, firewall-friendly connectivity: 1. **Gateway Registration**: The gateway establishes an outbound SSH reverse tunnel to a relay server using SSH certificates issued by Infisical -2. **Relay Routing**: The relay server routes encrypted traffic between the Infisical platform and gateways -3. **Resource Access**: The Infisical platform connects to your private resources through the established gateway connections - -**Key Benefits:** - -- **No inbound firewall rules needed** - all connections are outbound from your network -- **Firewall-friendly** - uses standard SSH over TCP -- **Certificate-based authentication** provides enhanced security -- **Automatic reconnection** if connections are lost - -## Deployment - -The Infisical Gateway is integrated into the Infisical CLI under the `gateway` command, making it simple to deploy and manage. -You can install the Gateway in all the same ways you install the Infisical CLI—whether via npm, Docker, or a binary. -For detailed installation instructions, refer to the Infisical [CLI Installation instructions](/cli/overview). - -**Prerequisites:** - -1. **Relay Server**: Before deploying gateways, you need a running relay server: - - **Infisical Cloud**: Instance relays are already available - no setup needed - - **Self-hosted**: Instance admin must set up shared instance relays, or organizations can deploy their own -2. **Machine Identity**: Configure a machine identity with appropriate permissions to create and manage gateways - -Once authenticated, the Gateway establishes an SSH reverse tunnel to the specified relay server, allowing secure access to your private resources. - -### Get started - - - - 1. Navigate to **Organization Access Control** in your Infisical dashboard. - 2. Create a dedicated machine identity for your Gateway. - 3. **Best Practice:** Assign a unique identity to each Gateway for better security and management. - ![Create Gateway Identity](../../../images/platform/gateways/create-identity-for-gateway.png) - - - - You'll need to choose an authentication method to initiate communication with Infisical. View the available machine identity authentication methods [here](/documentation/platform/identities/machine-identities). - - - - You have two options for relay infrastructure: - - - - **Infisical Cloud:** Instance relays are already running and available - **no setup required**. You can immediately proceed to deploy gateways using these shared relays. - - **Self-hosted:** If your instance admin has set up shared instance relays, you can use them directly. If not, the instance admin can set them up: - ```bash - # Instance admin sets up shared relay (one-time setup) - export INFISICAL_RELAY_AUTH_SECRET= - infisical relay start --type=instance --ip= --name= - ``` - - - **Available for all users:** Deploy your own dedicated relay infrastructure for enhanced control: - ```bash - # Deploy organization-specific relay - infisical relay start --type=org --ip= --name= --auth-method=universal-auth --client-id= --client-secret= - ``` - - **When to choose this:** - - You need lower latency (deploy closer to your resources) - - Enhanced security requirements - - Compliance needs (data sovereignty, air-gapped environments) - - Custom network policies - - - - - - Use the Infisical CLI to deploy the Gateway. You can run it directly or install it as a systemd service for production: - - - - For production deployments on Linux, install the Gateway as a systemd service: - - - **Gateway v2:** The `infisical gateway systemd install` command deploys the new Gateway v2 component. - - If you are migrating from Gateway v1 (legacy `infisical gateway install` command), this is not in-place. Gateway v2 provisions new gateway instances with new gateway IDs. Update any resources that reference a gateway ID (for example: dynamic secret configs, app connections, or other gateway-bound resources) to use the new Gateway v2 gateway ID. - - - ```bash - sudo infisical gateway systemd install --token --domain --name --relay - sudo systemctl start infisical-gateway - ``` - This will install and start the Gateway as a secure systemd service that: - - Runs with restricted privileges: - - Runs as root user (required for secure token management) - - Restricted access to home directories - - Private temporary directory - - Automatically restarts on failure - - Starts on system boot - - Manages token and domain configuration securely in `/etc/infisical/gateway.conf` - - - The install command requires: - - Linux operating system - - Root/sudo privileges - - Systemd - - - - - - The Gateway can be installed via [Helm](https://helm.sh/). Helm is a package manager for Kubernetes that allows you to define, install, and upgrade Kubernetes applications. - - For production deployments on Kubernetes, install the Gateway using the Infisical Helm chart: - - ### Install the latest Helm Chart repository - ```bash - helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' - ``` - - ### Update the Helm Chart repository - ```bash - helm repo update - ``` - - ### Create a Kubernetes Secret containing gateway environment variables - - The gateway supports all identity authentication methods through the use of environment variables. - The environment variables must be set in the `infisical-gateway-environment` Kubernetes secret. - - - #### Supported authentication methods - - - - The Universal Auth method is a simple and secure way to authenticate with Infisical. It requires a client ID and a client secret to authenticate with Infisical. - - - - - Your machine identity client ID. - - - Your machine identity client secret. - - - The authentication method to use. Must be `universal-auth` when using Universal Auth. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment \ - --from-literal=INFISICAL_AUTH_METHOD=universal-auth \ - --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= \ - --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= \ - --from-literal=INFISICAL_RELAY_NAME= \ - --from-literal=INFISICAL_GATEWAY_NAME= - ``` - - - - The Native Kubernetes method is used to authenticate with Infisical when running in a Kubernetes environment. It requires a service account token to authenticate with Infisical. - - - - - Your machine identity ID. - - - Path to the Kubernetes service account token to use. Default: `/var/run/secrets/kubernetes.io/serviceaccount/token`. - - - The authentication method to use. Must be `kubernetes` when using Native Kubernetes. - - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=kubernetes --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - - The Native Azure method is used to authenticate with Infisical when running in an Azure environment. - - - - - Your machine identity ID. - - - The authentication method to use. Must be `azure` when using Native Azure. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=azure --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - The Native GCP ID Token method is used to authenticate with Infisical when running in a GCP environment. - - - - - Your machine identity ID. - - - The authentication method to use. Must be `gcp-id-token` when using Native GCP ID Token. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=gcp-id-token --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - - The GCP IAM method is used to authenticate with Infisical with a GCP service account key. - - - - - Your machine identity ID. - - - Path to your GCP service account key file _(Must be in JSON format!)_ - - - The authentication method to use. Must be `gcp-iam` when using GCP IAM. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=gcp-iam --from-literal=INFISICAL_MACHINE_IDENTITY_ID= --from-literal=INFISICAL_GCP_SERVICE_ACCOUNT_KEY_FILE_PATH= - ``` - - - - - The AWS IAM method is used to authenticate with Infisical with an AWS IAM role while running in an AWS environment like EC2, Lambda, etc. - - - - - Your machine identity ID. - - - The authentication method to use. Must be `aws-iam` when using Native AWS IAM. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=aws-iam --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - - The OIDC Auth method is used to authenticate with Infisical via identity tokens with OIDC. - - - - - Your machine identity ID. - - - The OIDC JWT from the identity provider. - - - The authentication method to use. Must be `oidc-auth` when using OIDC Auth. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=oidc-auth --from-literal=INFISICAL_MACHINE_IDENTITY_ID= --from-literal=INFISICAL_JWT= - ``` - - - - The JWT Auth method is used to authenticate with Infisical via a JWT token. - - - - - The JWT token to use for authentication. - - - Your machine identity ID. - - - The authentication method to use. Must be `jwt-auth` when using JWT Auth. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=jwt-auth --from-literal=INFISICAL_JWT= --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - You can use the `INFISICAL_TOKEN` environment variable to authenticate with Infisical with a raw machine identity access token. - - - - - The machine identity access token to use for authentication. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_TOKEN= - ``` - - - - - #### Required environment variables - - In addition to the authentication method above, you **must** include these required variables: - - - - The name of the relay server that this gateway should connect to. - - - The name of this gateway instance. - - - - **Complete example with required variables:** - ```bash - kubectl create secret generic infisical-gateway-environment \ - --from-literal=INFISICAL_AUTH_METHOD=universal-auth \ - --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= \ - --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= \ - --from-literal=INFISICAL_RELAY_NAME= \ - --from-literal=INFISICAL_GATEWAY_NAME= - ``` - - #### Other environment variables - - - - The API URL to use for the gateway. By default, `INFISICAL_API_URL` is set to `https://app.infisical.com`. - - - - ### Install the Infisical Gateway Helm Chart - - **Version mapping:** Helm chart versions `>= 1.0.0` contain the new Gateway v2 component. Helm chart versions `<= 0.0.5` contain the legacy Gateway v1 component. - - If you are moving from Gateway v1 (chart `<= 0.0.5`) to Gateway v2 (chart `>= 1.0.0`), this is not in-place. Gateway v2 provisions new gateway instances with new gateway IDs. Update any resources that reference a gateway ID (for example: dynamic secret configs, app connections, or other gateway-bound resources) to use the new Gateway v2 gateway ID. - - - ```bash - helm install infisical-gateway infisical-helm-charts/infisical-gateway - ``` - - ### Check the gateway logs - After installing the gateway, you can check the logs to ensure it's running as expected. - - ```bash - kubectl logs deployment/infisical-gateway - ``` - - You should see the following output which indicates the gateway is running as expected. - ```bash - $ kubectl logs deployment/infisical-gateway - 12:43AM INF Starting gateway - 12:43AM INF Starting gateway certificate renewal goroutine - 12:43AM INF Successfully registered gateway and received certificates - 12:43AM INF Connecting to relay server infisical-start on 152.42.218.156:2222... - 12:43AM INF Relay connection established for gateway - 12:43AM INF Received incoming connection, starting TLS handshake - 12:43AM INF TLS handshake completed successfully - 12:43AM INF Negotiated ALPN protocol: infisical-ping - 12:43AM INF Starting ping handler - 12:43AM INF Ping handler completed - 12:43AM INF Gateway is reachable by Infisical - ``` - - - - - For development or testing, you can run the Gateway directly. Log in with your machine identity and start the Gateway in one command: - ```bash - infisical gateway start --token $(infisical login --method=universal-auth --client-id=<> --client-secret=<> --plain) --relay= --name= - ``` - - Alternatively, if you already have the token, use it directly with the `--token` flag: - ```bash - infisical gateway start --token --relay= --name= - ``` - - Or set it as an environment variable: - ```bash - export INFISICAL_TOKEN= - infisical gateway start --relay= --name= - ``` - - - - For detailed information about the gateway commands and their options, see the [gateway command documentation](/cli/commands/gateway). - - - **Requirements:** - - Ensure the deployed Gateway has network access to the private resources you intend to connect with Infisical - - The gateway must be able to reach the relay server (outbound connection only) - - Replace `` with the name of your relay server and `` with a unique name for this gateway - - - - - - To confirm your Gateway is working, check the deployment status by looking for the message **"Gateway started successfully"** in the Gateway logs. This indicates the Gateway is running properly. Next, verify its registration by opening your Infisical dashboard, navigating to **Organization Access Control**, and selecting the **Gateways** tab. Your newly deployed Gateway should appear in the list. - ![Gateway List](../../../images/platform/gateways/gateway-list.png) - - +2. **Persistent Connection**: The gateway maintains an open TCP connection with the relay server, creating a secure channel for incoming requests +3. **Request Routing**: When Infisical needs to access your resources, requests are routed through the relay server to the already-established gateway connection +4. **Resource Access**: The gateway receives the routed requests and connects to your private resources on behalf of Infisical + +## Getting Started + +Ready to set up your gateway? Follow the guides below. + + + + Deploy and configure your gateway within your network infrastructure. + + + Set up relay servers if using self-deployed infrastructure. + + + + + Learn about the security model and implementation best practices. + + \ No newline at end of file diff --git a/docs/documentation/platform/gateways/relay-deployment.mdx b/docs/documentation/platform/gateways/relay-deployment.mdx new file mode 100644 index 000000000..adf5fdb9d --- /dev/null +++ b/docs/documentation/platform/gateways/relay-deployment.mdx @@ -0,0 +1,243 @@ +--- +title: "Relay Deployment" +description: "How to deploy Infisical Relay Servers" +--- + +Infisical Relay is a secure routing layer that allows Infisical to connect to your private network resources, such as databases or internal APIs, without exposing them to the public internet. +The relay acts as an intermediary, forwarding encrypted traffic between Infisical and your deployed gateways. This ensures that your sensitive data remains protected and never leaves your network unencrypted. +With this architecture, you can achieve secure, firewall-friendly access across network boundaries, making it possible for Infisical to interact with resources even in highly restricted environments. + +Before diving in, it's important to determine whether you actually need to deploy your own relay server or if you can use Infisical's managed infrastructure. + +## Do You Need to Deploy a Relay? + +Not all users need to deploy their own relay servers. Infisical provides managed relay infrastructure in US/EU regions for Infisical Cloud users, which requires no setup or maintenance. You only need to deploy a relay if you: + +- Are self-hosting Infisical +- Have a dedicated enterprise instance of Infisical (managed by Infisical) +- Require closer geographic proximity to target resources than managed relays provide for lower latency and reduced network congestion when accessing resources through the relay +- Need full control over relay infrastructure and traffic routing + +If you are using Infisical Cloud and do not have specific requirements, you can use the managed relays provided by Infisical and skip the rest of this guide. + +## Deployment Steps + +To successfully deploy an Infisical Relay for use, follow these steps in order. + + + + Create a machine identity with the correct permissions to create and manage relays. This identity is used by the relay to authenticate with Infisical and should be provisioned in advance. + The relay supports several [machine identity auth methods](/documentation/platform/identities/machine-identities) for authentication, as listed below. Choose the one that best fits your environment and set the corresponding environment variables when deploying the relay. + + + + Simple and secure authentication using client ID and client secret. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=universal-auth` + - `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID=` + - `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET=` + + + + Direct authentication using a machine identity access token. + + **Environment Variables:** + - `INFISICAL_TOKEN=` + + + + Authentication using Kubernetes service account tokens. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=kubernetes` + - `INFISICAL_MACHINE_IDENTITY_ID=` + + + + Authentication using AWS IAM roles. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=aws-iam` + - `INFISICAL_MACHINE_IDENTITY_ID=` + + + + Authentication using GCP identity tokens. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=gcp-id-token` + - `INFISICAL_MACHINE_IDENTITY_ID=` + + + + Authentication using GCP service account keys. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=gcp-iam` + - `INFISICAL_MACHINE_IDENTITY_ID=` + - `INFISICAL_GCP_SERVICE_ACCOUNT_KEY_FILE_PATH=` + + + + Authentication using Azure managed identity. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=azure` + - `INFISICAL_MACHINE_IDENTITY_ID=` + + + + Authentication using OIDC identity tokens. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=oidc-auth` + - `INFISICAL_MACHINE_IDENTITY_ID=` + - `INFISICAL_JWT=` + + + + Authentication using JWT tokens. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=jwt-auth` + - `INFISICAL_MACHINE_IDENTITY_ID=` + - `INFISICAL_JWT=` + + + + + + Install the Infisical CLI on the server where you plan to deploy the relay. The CLI is required for relay installation and management. + + See the [CLI Installation Guide](/cli/overview) for instructions. + + This server must have a static IP address or DNS name to be identifiable by the Infisical platform. + + + + + Ensure your network and firewall settings allow the server to accept inbound connections and make outbound connections: + + **Inbound Connections Rules:** + | Protocol | Source | Port | Purpose | + | -------- | ------------------ | ---- | -------------------------------- | + | TCP | Gateways | 2222 | SSH reverse tunnel establishment | + | TCP | Infisical instance host (US/EU, other) | 8443 | Platform-to-relay communication | + + **Outbound Connections Rules:** + | Protocol | Destination | Port | Purpose | + | -------- | ------------------------------------ | ---- | ------------------------------------------ | + | TCP | Infisical instance host (US/EU, other) | 443 | API communication and certificate requests | + + + + + The Infisical CLI is used to install and start the relay in your chosen environment. The CLI provides commands for both production and development scenarios, and supports a variety of options/flags to configure your deployment. + + To view all available flags and equivalent environment variables for relay deployment, see the [Relay CLI Command Reference](/cli/commands/relay). + + + For production deployments on Linux servers, install the Relay as a systemd service. This installation method only supports [Token Auth](/documentation/platform/identities/token-auth) at the moment. + + Once you have a [Token Auth](/documentation/platform/identities/token-auth) token, set the following environment variables for relay authentication: + + ```bash + export INFISICAL_TOKEN= + ``` + + + The systemd install command requires a Linux operating system with root/sudo privileges. + + + ```bash + sudo infisical relay systemd install \ + --token \ + --name \ + --domain \ + --host + + # Start the relay service + sudo systemctl start infisical-relay + sudo systemctl enable infisical-relay + ``` + + + + For non-Linux systems or when you need more control over the relay process: + + ```bash + infisical relay start \ + --type= \ + --host= \ + --name= \ + --auth-method= + ``` + + This method supports all [machine identity auth methods](/documentation/platform/identities/machine-identities) and runs in the foreground. Suitable for production use on non-Linux systems or development environments. + Set the appropriate environment variables for your chosen auth method as described in Step 1 before running the relay start command. + + + + + + + +## Frequently Asked Questions + + +No, relay servers cannot decrypt any traffic passing through them due to end-to-end encryption: + +- **Client-to-Gateway mTLS (via TLS-pinned tunnel)**: Clients connect via a proxy that establishes a TLS-pinned tunnel to the gateway; mTLS between the client and gateway is negotiated inside this tunnel, encrypting all application traffic +- **SSH tunnel encryption**: The mTLS-encrypted traffic is then transmitted through SSH reverse tunnels to relay servers +- **Double encryption**: Traffic is encrypted twice - once by client mTLS and again by SSH tunnels +- **Relay only routes traffic**: The relay server only routes the doubly-encrypted traffic without access to either encryption layer + +The relay infrastructure is designed as a secure routing mechanism where only the client and gateway can decrypt the actual application traffic. + + + + +Deploying your own relay provides several advantages: + +- **Dedicated resources**: Full control over relay infrastructure and performance +- **Lower latency**: Deploy closer to your gateways for optimal performance +- **Compliance**: Meet specific data routing and compliance requirements +- **Custom network policies**: Implement organization-specific network configurations +- **Geographic proximity**: Reduce network congestion and improve response times to access resources +- **High availability**: Deploy multiple relays for redundancy and load distribution + +Organization-deployed relays give you complete control over your secure communication infrastructure. + + + + +For detailed troubleshooting: + +**Platform cannot connect to relay:** + +- Check firewall rules allow inbound TCP with TLS on port 8443 +- Test connectivity: `openssl s_client -connect :8443` + +**Test network connectivity:** + +```bash +# Test outbound API access from relay. Replace URL with your Infisical instance if self-hosted +curl -I https://app.infisical.com + +# Test TCP with TLS port from platform +openssl s_client -connect :8443 +``` + + + +Relay server outages affect gateway connectivity: + +- **Gateway reconnection**: Gateways will automatically attempt to reconnect when the relay comes back online +- **Service interruption**: While the relay is down, the Infisical platform cannot reach gateways through that relay. As a result, any secrets or resources accessed via those gateways will be temporarily unavailable until connectivity is restored. +- **Multiple relays**: Deploy multiple relay servers for redundancy and high availability +- **Automatic restart**: Use systemd or container orchestration to automatically restart failed relay services + +For production environments, consider deploying multiple relay servers to avoid single points of failure. + + diff --git a/docs/documentation/platform/gateways/gateway-security.mdx b/docs/documentation/platform/gateways/security.mdx similarity index 91% rename from docs/documentation/platform/gateways/gateway-security.mdx rename to docs/documentation/platform/gateways/security.mdx index 6962c627e..dc70aa36f 100644 --- a/docs/documentation/platform/gateways/gateway-security.mdx +++ b/docs/documentation/platform/gateways/security.mdx @@ -1,13 +1,9 @@ --- -title: "Gateway Security Architecture" -sidebarTitle: "Architecture" -description: "Understand the security model and tenant isolation of Infisical's Gateway" +title: "Security Architecture" +description: "Security model, tenant isolation, and best practices for Infisical Gateways and Relays" --- -# Gateway Security Architecture - The Infisical Gateway enables secure access to private resources using SSH reverse tunnels, certificate-based authentication, and a comprehensive PKI (Public Key Infrastructure) system. The architecture provides end-to-end encryption and complete tenant isolation through multiple certificate authorities. -This document explains the internal security architecture and how tenant isolation is maintained. ## Security Model Overview @@ -82,16 +78,16 @@ The platform establishes secure direct connections with gateways through a **TLS 2. **Connection Flow**: ``` - Platform ←→ [SSH Reverse Tunnel] ←→ Gateway + Platform ←→ [TCP with TLS] ←→ Relay ←→ [SSH Reverse Tunnel] ←→ Gateway ``` - Gateway maintains persistent outbound SSH tunnel to relay server - - Platform connects directly to gateway through this tunnel - - TLS handshake occurs over the SSH tunnel, establishing mTLS connection - - Application traffic flows through the TLS-pinned tunnel + - Platform connects to relay server using TCP with TLS + - Relay routes encrypted traffic between platform and gateway + - TLS handshake occurs between platform and gateway through the relay + - Application traffic flows through the TLS-pinned tunnel via relay routing 3. **Security Benefits**: - - **No inbound connections**: Gateway never needs to accept incoming connections - **Certificate-based authentication**: Uses Organization Gateway certificates for mutual TLS - **Double encryption**: TLS traffic within SSH tunnel provides layered security @@ -132,7 +128,6 @@ The architecture provides tenant isolation through multiple certificate authorit - Ephemeral certificate validation ensures time-bound access 2. **Network Isolation**: - - Each organization's traffic flows through isolated certificate-authenticated channels - Relay servers route traffic based on certificate validation without content access - Gateway validates all incoming connections against Organization Gateway Client CA diff --git a/docs/documentation/platform/identities/ldap-auth/general.mdx b/docs/documentation/platform/identities/ldap-auth/general.mdx index 01395b68c..f49643474 100644 --- a/docs/documentation/platform/identities/ldap-auth/general.mdx +++ b/docs/documentation/platform/identities/ldap-auth/general.mdx @@ -34,24 +34,33 @@ To create and manage LDAP auth templates, see our [Machine Identity Auth Templat To configure LDAP auth for your identity, press the **Add Auth Method** button on the identity's page. ![Add auth method](/images/platform/identities/ldap/identities-org-add-auth-method.png) - + Now select **LDAP Auth** from the list of available auth methods for the identity. ![Select LDAP auth](/images/platform/identities/ldap/identities-org-add-auth-method-modal.png) - - + + After selecting **LDAP Auth**, you'll see the form you need to fill out to configure LDAP auth for your identity. The following fields are available: + **Configuration Tab** - `URL`: The LDAP server to connect to such as `ldap://ldap.your-org.com`, `ldaps://ldap.myorg.com:636` _(for connection over SSL/TLS)_, etc. - `Bind DN`: The DN to bind to the LDAP server with. - `Bind Pass`: The password to bind to the LDAP server with. - `Search Base / DN`: Base DN under which to perform user search such as `ou=Users,dc=acme,dc=com`. - `User Search Filter`: Template used to construct the LDAP user search filter such as `(uid={{username}})`; use literal `{{username}}` to have the given username used in the search. The default is `(uid={{username}})` which is compatible with several common directory schemas. - `Required Attributes`: A key/value pair of attributes that must be present in the LDAP user entry for them to be authenticated. As an example, if you set key `uid` to value `user1,user2,user3`, then only users with `uid` of `user1`, `user2`, or `user3` will be able to login with this identity. Each value is a comma separated list of attributes. - - `CA Certificate`: The CA certificate to use when verifying the LDAP server certificate. This field is optional but recommended. - `Access Token TTL` _(default is 2592000 equivalent to 30 days)_: The lifetime for an access token in seconds. This value will be referenced at renewal time. - `Access Token Max TTL` _(default is 2592000 equivalent to 30 days)_: The maximum lifetime for an access token in seconds. This value will be referenced at renewal time. - `Access Token Max Number of Uses` _(default is 0)_: The maximum number of times that an access token can be used; a value of 0 implies infinite number of uses. + + **Lockout Tab** + - `Lockout` _(enabled by default)_: The lockout feature will temporarily block login attempts after X consecutive login failures. + - `Lockout Threshold` _(default is 3)_: The amount of times login must fail before locking the identity auth method. + - `Lockout Duration` _(default is 5 minutes)_: How long an identity auth method lockout lasts. + - `Lockout Counter Reset` _(default is 30 seconds)_: How long to wait from the most recent failed login until resetting the lockout counter. + + **Advanced Tab** + - `CA Certificate`: The CA certificate to use when verifying the LDAP server certificate. This field is optional but recommended. - `Access Token Trusted IPs`: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the 0.0.0.0/0, allowing usage from any network address. Once you've filled out the form, press **Add** to save your changes. @@ -91,3 +100,13 @@ To create and manage LDAP auth templates, see our [Machine Identity Auth Templat + +**FAQ** + + + + You can reset (remove) all lockouts for an identity auth method by clicking into the auth method and pressing **Reset All Lockouts**. + + ![ldap reset lockouts](/images/platform/identities/ldap-reset-lockouts.png) + + diff --git a/docs/documentation/platform/identities/machine-identities.mdx b/docs/documentation/platform/identities/machine-identities.mdx index b4a18708c..7e40f85f9 100644 --- a/docs/documentation/platform/identities/machine-identities.mdx +++ b/docs/documentation/platform/identities/machine-identities.mdx @@ -38,6 +38,12 @@ To interact with various resources in Infisical, Machine Identities can authenti - [GCP Auth](/documentation/platform/identities/gcp-auth): A GCP-native authentication method for GCP resources (e.g. Compute Engine, App Engine, Cloud Run, Google Kubernetes Engine, IAM service accounts, etc.). - [OIDC Auth](/documentation/platform/identities/oidc-auth): A platform-agnostic, JWT-based authentication method for workloads using an OpenID Connect identity provider. +## Identity Lockout + +Lockout is a feature that prevents brute-force attacks on identity login endpoints. Auth methods that support lockout include: [Universal Auth](/documentation/platform/identities/universal-auth), [LDAP Auth](/documentation/platform/identities/ldap-auth/general). + +Supported auth methods have lockout enabled by default. If triggered, lockout temporarily disables the login endpoint for 5 minutes after 3 consecutive failed login attempts within a 30-second window. Lockout can be configured and disabled in the identity auth method settings. + ## FAQ @@ -51,15 +57,15 @@ You can learn more about how to do this in the CLI quickstart [here](/cli/usage) A service token is a project-level authentication method that is being deprecated in favor of identities. The service token method will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). - + Amongst many differences, identities provide broader access over the Infisical API, utilizes the same permission system as user identities, and come with a significantly larger number of configurable authentication and security features. - + If you're looking for a simple authentication method, similar to service tokens, that can be bound onto an identity, we recommend checking out [Token Auth](/documentation/platform/identities/token-auth). There are a few reasons for why this might happen: - + - You have insufficient organization permissions to create, read, update, delete identities. - The identity you are trying to read, update, or delete is more privileged than yourself. - The role you are trying to create an identity for or update an identity to is more privileged than yours. diff --git a/docs/documentation/platform/identities/universal-auth.mdx b/docs/documentation/platform/identities/universal-auth.mdx index 51721f7bf..3a87f6da9 100644 --- a/docs/documentation/platform/identities/universal-auth.mdx +++ b/docs/documentation/platform/identities/universal-auth.mdx @@ -4,7 +4,7 @@ description: "Learn how to authenticate to Infisical from any platform or enviro --- **Universal Auth** is a platform-agnostic authentication method that can be configured for a [machine identity](/documentation/platform/identities/machine-identities) to authenticate from any platform/environment using a Client ID and Client Secret. -This authentication method supports setting token periods, which can help [overcome secret zero](#solving-secret-zero-with-periodic-tokens). +This authentication method supports setting token periods, which can help [overcome secret zero](#solving-secret-zero-with-periodic-tokens). ## Diagram @@ -65,17 +65,32 @@ using the Universal Auth authentication method. By default, the identity has been configured with Universal Auth. If you wish, you can edit the Universal Auth configuration details by pressing to edit the **Authentication** section. + Here's some guidance on each field: + + **Configuration Tab** + ![identities organization create universal auth method 1](/images/platform/identities/identities-org-create-universal-auth-method-1.png) + + - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an access token in seconds. This value will be referenced at renewal time. + - Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an access token in seconds. This value will be referenced at renewal time. + - Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses. + - Access Token Period (optional, default is `0`): If set, the access token becomes a renewable, non-expiring token for the specified period (in seconds). TTL and Max TTL are ignored when this is set. This is ideal for "secret zero" scenarios, where a workload needs to bootstrap itself securely without hard-coded static secrets. + + **Lockout Tab** + ![identities organization create universal auth method 2](/images/platform/identities/identities-org-create-universal-auth-method-2.png) - Here's some more guidance on each field: + - Lockout (enabled by default): The lockout feature will temporarily block login attempts after X consecutive login failures. + - Lockout Threshold (default is `3`): The amount of times login must fail before locking the identity auth method. + - Lockout Duration (default is `5 minutes`): How long an identity auth method lockout lasts. + - Lockout Counter Reset (default is `30 seconds`): How long to wait from the most recent failed login until resetting the lockout counter. + + **Advanced Tab** + + ![identities organization create universal auth method 3](/images/platform/identities/identities-org-create-universal-auth-method-3.png) - - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an acccess token in seconds. This value will be referenced at renewal time. - - Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an acccess token in seconds. This value will be referenced at renewal time. - - Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses. - Client Secret Trusted IPs: The IPs or CIDR ranges that the **Client Secret** can be used from together with the **Client ID** to get back an access token. By default, **Client Secrets** are given the `0.0.0.0/0`, allowing usage from any network address. - Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. - - Access Token Period (optional, default is `0`): If set, the access token becomes a renewable, non-expiring token for the specified period (in seconds). TTL and Max TTL are ignored when this is set. This is ideal for "secret zero" scenarios, where a workload needs to bootstrap itself securely without hard-coded static secrets. Restricting **Client Secret** and access token usage to specific trusted IPs is a paid feature. @@ -202,6 +217,10 @@ This approach allows your workload to securely bootstrap and maintain access to A token can be renewed any number of times where each call to renew it can extend the token's lifetime by increments of the access token's TTL. Regardless of how frequently an access token is renewed, its lifespan remains bound to the maximum TTL determined at its creation. + + + You can reset (remove) all lockouts for an identity auth method by clicking into the auth method and pressing **Reset All Lockouts**. + ![ua reset lockouts](/images/platform/identities/ua-reset-lockouts.png) diff --git a/docs/documentation/platform/kms-configuration/aws-kms.mdx b/docs/documentation/platform/kms-configuration/aws-kms.mdx index b4631b1c3..b57efca22 100644 --- a/docs/documentation/platform/kms-configuration/aws-kms.mdx +++ b/docs/documentation/platform/kms-configuration/aws-kms.mdx @@ -23,6 +23,9 @@ Before you begin, you'll first need to choose a method of authentication with AW 2. Select **AWS Account** as the **Trusted Entity Type**. 3. Select **Another AWS Account** and provide the appropriate Infisical AWS Account ID: use **381492033652** for the **US region**, and **345594589636** for the **EU region**. This restricts the role to be assumed only by Infisical. If you are self-hosting, provide the AWS account number where Infisical is hosted. + + **For Dedicated Instances**: Your AWS account ID differs from the one provided above. Please reach out to Infisical support to obtain your AWS account ID. + 4. Optionally, enable **Require external ID** and enter your Infisical **project ID** to further enhance security. diff --git a/docs/documentation/platform/pki/certificate-syncs/azure-key-vault.mdx b/docs/documentation/platform/pki/certificate-syncs/azure-key-vault.mdx new file mode 100644 index 000000000..6190ab3c3 --- /dev/null +++ b/docs/documentation/platform/pki/certificate-syncs/azure-key-vault.mdx @@ -0,0 +1,150 @@ +--- +title: "Azure Key Vault" +description: "Learn how to configure an Azure Key Vault Certificate Sync for Infisical PKI." +--- + +**Prerequisites:** + + - Set up and configure a [Certificate Authority](/documentation/platform/pki/overview) + - Create an [Azure Key Vault Connection](/integrations/app-connections/azure-key-vault) + - Ensure your network security policies allow incoming requests from Infisical to this certificate sync provider, if network restrictions apply. + + + The Azure Key Vault Certificate Sync requires the following certificate permissions to be set on the user / service principal + for Infisical to sync certificates to Azure Key Vault: `certificates/list`, `certificates/get`, `certificates/import`, `certificates/delete`. + + Any role with these permissions would work such as the **Key Vault Certificates Officer** role. + + + + Certificates synced to Azure Key Vault will be stored as certificate objects, preserving both the certificate and private key components. + + + + + 1. Navigate to **Project** > **Integrations** and select the **Certificate Syncs** tab. Click on the **Add Sync** button. + ![Certificate Syncs Tab](/images/certificate-syncs/general/certificate-sync-tab.png) + + 2. Select the **Azure Key Vault** option. + ![Select Key Vault](/images/certificate-syncs/azure-key-vault/select-key-vault-option.png) + + 3. Configure the **Source** from where certificates should be retrieved, then click **Next**. + ![Configure Source](/images/certificate-syncs/azure-key-vault/vault-source.png) + + - **PKI Subscriber**: The PKI subscriber to retrieve certificates from. + + 4. Configure the **Destination** to where certificates should be deployed, then click **Next**. + ![Configure Destination](/images/certificate-syncs/azure-key-vault/vault-destination.png) + + - **Azure Connection**: The Azure Connection to authenticate with. + - **Vault Base URL**: The URL of your Azure Key Vault. +

+ + 5. Configure the **Sync Options** to specify how certificates should be synced, then click **Next**. + ![Configure Options](/images/certificate-syncs/azure-key-vault/vault-options.png) + + - **Auto-Sync Enabled**: If enabled, certificates will automatically be synced from the source PKI subscriber when changes occur. Disable to enforce manual syncing only. + - **Enable Certificate Removal**: If enabled, Infisical will remove expired certificates from the destination during sync operations. Disable this option if you intend to manage certificate cleanup manually. + - **Certificate Name Schema** (Optional): Customize how certificate names are generated in Azure Key Vault. Use `{{certificateId}}` as a placeholder for the certificate ID. If not specified, defaults to `Infisical-{{certificateId}}`. + + + **Azure Key Vault Soft Delete**: When certificates are removed from Azure Key Vault, they are placed in a soft-deleted state rather than being permanently deleted. This means: + - Subsequent syncs will not re-add these soft-deleted certificates automatically + - To resync removed certificates, you must either manually **purge** them from Azure Key Vault or **recover** them through the Azure portal/CLI + + + 6. Configure the **Details** of your Azure Key Vault Certificate Sync, then click **Next**. + ![Configure Details](/images/certificate-syncs/azure-key-vault/vault-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your Azure Key Vault Certificate Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/certificate-syncs/azure-key-vault/vault-review.png) + + 8. If enabled, your Azure Key Vault Certificate Sync will begin syncing your certificates to the destination endpoint. + ![Sync Certificates](/images/certificate-syncs/azure-key-vault/vault-synced.png) + + + + To create an **Azure Key Vault Certificate Sync**, make an API request to the [Create Azure Key Vault Certificate Sync](/api-reference/endpoints/pki/syncs/azure-key-vault/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/pki/syncs/azure-key-vault \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-key-vault-cert-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example certificate sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "subscriberId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "destination": "azure-key-vault", + "isAutoSyncEnabled": true, + "syncOptions": { + "canRemoveCertificates": true, + "certificateNameSchema": "myapp-{{certificateId}}" + }, + "destinationConfig": { + "vaultBaseUrl": "https://my-key-vault.vault.azure.net" + } + }' + ``` + + ### Sample response + + ```json Response + { + "pkiSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-key-vault-cert-sync", + "description": "an example certificate sync", + "destination": "azure-key-vault", + "isAutoSyncEnabled": true, + "destinationConfig": { + "vaultBaseUrl": "https://my-key-vault.vault.azure.net" + }, + "syncOptions": { + "canRemoveCertificates": true, + "certificateNameSchema": "myapp-{{certificateId}}" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "subscriberId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-01-01T00:00:00.000Z", + "updatedAt": "2023-01-01T00:00:00.000Z" + } + } + ``` + + + +## Certificate Management + +Your Azure Key Vault Certificate Sync will: + +- **Automatic Deployment**: Deploy new certificates issued by your PKI subscriber to Azure Key Vault +- **Certificate Updates**: Update certificates in Azure Key Vault when renewals occur +- **Expiration Handling**: Optionally remove expired certificates from Azure Key Vault (if enabled) +- **Format Preservation**: Maintain certificate format and metadata during sync operations + + + Azure Key Vault Certificate Syncs support both automatic and manual synchronization modes. When auto-sync is enabled, certificates are automatically deployed as they are issued or renewed. + + +## Manual Certificate Sync + +You can manually trigger certificate synchronization from your PKI subscriber to Azure Key Vault using the sync certificates functionality. This is useful for: + +- Initial setup when you have existing certificates to deploy +- One-time sync of specific certificates +- Testing certificate sync configurations +- Force sync after making changes + +To manually sync certificates, use the [Sync Certificates](/api-reference/endpoints/pki/syncs/azure-key-vault/sync-certificates) API endpoint or the manual sync option in the Infisical UI. + + +Azure Key Vault does not support importing certificates back into Infisical due to security limitations where private keys cannot be extracted from Azure Key Vault. + \ No newline at end of file diff --git a/docs/documentation/platform/pki/certificate-syncs/overview.mdx b/docs/documentation/platform/pki/certificate-syncs/overview.mdx new file mode 100644 index 000000000..db4844a92 --- /dev/null +++ b/docs/documentation/platform/pki/certificate-syncs/overview.mdx @@ -0,0 +1,142 @@ +--- +sidebarTitle: "Overview" +description: "Learn how to sync certificates from Infisical PKI to third-party services." +--- + +Certificate Syncs enable you to sync certificates from Infisical PKI to third-party services using [App Connections](/integrations/app-connections/overview). + + + Certificate Syncs are designed to automatically deploy certificates issued by your Certificate Authority to external services, ensuring your certificates are always up-to-date across your infrastructure. + + +## Concept + +Certificate Syncs are a project-level resource used to sync certificates, via an [App Connection](/integrations/app-connections/overview), from a particular PKI subscriber (source) +to a third-party service (destination). When new certificates are issued or existing certificates are renewed, changes will automatically be propagated to the destination, ensuring +your certificates are always current. + +
+ +

+ + ```mermaid + %%{init: {'flowchart': {'curve': 'linear'} } }%% + graph LR + A[App Connection] + B[Certificate Sync] + C[Certificate 1] + D[Certificate 2] + E[Certificate 3] + F[Third-Party Service] + G[Certificate 1] + H[Certificate 2] + I[Certificate 3] + J[PKI Subscriber] + + B --> A + C --> J + D --> J + E --> J + A --> F + F --> G + F --> H + F --> I + J --> B + + classDef default fill:#ffffff,stroke:#666,stroke-width:2px,rx:10px,color:black + classDef connection fill:#FFF2B2,stroke:#E6C34A,stroke-width:2px,color:black,rx:15px + classDef certificate fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px + classDef sync fill:#F4FFE6,stroke:#96D600,stroke-width:2px,color:black,rx:15px + classDef service fill:#E6E6FF,stroke:#6B4E96,stroke-width:2px,color:black,rx:15px + classDef subscriber fill:#FFE6E6,stroke:#D63F3F,stroke-width:2px,color:black,rx:15px + + class A connection + class B sync + class C,D,E,G,H,I certificate + class F service + class J subscriber + ``` + +
+ +## Workflow + +Configuring a Certificate Sync requires three components: a source PKI subscriber to retrieve certificates from, +a destination endpoint to deploy certificates to, and configuration options to determine how your certificates +should be synced. Follow these steps to start syncing: + + + For step-by-step guides on syncing to a particular third-party service, refer to the Certificate Syncs section in the Navigation Bar. + + +1. Create App Connection: If you have not already done so, create an [App Connection](/integrations/app-connections/overview) +via the UI or API for the third-party service you intend to sync certificates to. + +2. Create Certificate Sync: Configure a Certificate Sync in the desired project by specifying the following parameters via the UI or API: + - Source: The PKI subscriber you wish to retrieve certificates from. + - Destination: The App Connection to utilize and the destination endpoint to deploy certificates to. These can vary between services. + - Options: Customize how certificates should be synced, including: + - Whether certificates should be removed from the destination when they expire + - Certificate naming schema to control how certificate names are generated in the destination + + + Only certificates managed by Infisical will be affected during sync operations. Certificates not created or + managed by Infisical will remain untouched, and changes made to Infisical-managed certificates directly + in the destination service may be overwritten by future syncs. + + + + Some third-party services do not support removing expired certificates automatically. + + +3. Utilize Sync: Any new certificates issued or renewals from the source PKI subscriber will now automatically be propagated to the destination endpoint. + + + Infisical is continuously expanding its Certificate Sync third-party service support. If the service you need isn't available, + contact us at team@infisical.com to make a request. + + +## Certificate Naming + +Certificate Syncs support flexible certificate naming through configurable naming schemas. This allows you to customize how certificate names appear in your destination services. + +### Default Naming + +By default, certificates are named using the pattern `Infisical-{certificateId}` where `{certificateId}` is the unique identifier of the certificate with hyphens removed for compatibility with services like Azure Key Vault. + +### Custom Naming Schema + +You can customize certificate naming by providing a **Certificate Name Schema** when creating or updating a Certificate Sync. The schema supports the following placeholders: + +- `{{certificateId}}` - The unique certificate identifier (required) + +**Examples:** +- `myapp-{{certificateId}}` → `myapp-abc123def456` +- `ssl/{{certificateId}}` → `ssl/abc123def456` + +**Rules:** +- Must include exactly one `{{certificateId}}` placeholder +- Only alphanumeric characters, dashes (-), underscores (_), and slashes (/) are allowed +- Certificate names matching your schema will be managed by Infisical during sync operations + +## Certificate Management + +Certificate Syncs handle the full lifecycle of certificate management: + +- **Automatic Deployment**: New certificates are automatically deployed to configured destinations +- **Renewal Propagation**: Certificate renewals are seamlessly pushed to all connected services +- **Expiration Handling**: Expired certificates can be automatically removed from destinations (service-dependent) +- **Certificate Validation**: Certificates are validated before deployment to ensure integrity + +
+ ```mermaid + graph LR + A[Certificate Issued] -->|Deploy| B[Destination Service] + C[Certificate Renewed] -->|Update| B + D[Certificate Expired] -->|Remove| B + style B fill:#F4FFE6,stroke:#96D600,stroke-width:2px,color:black,rx:15px + style A fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px + style C fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px + style D fill:#FFE6E6,stroke:#D63F3F,stroke-width:2px,color:black,rx:15px + ``` +
\ No newline at end of file diff --git a/docs/documentation/platform/secret-rotation/redis-credentials.mdx b/docs/documentation/platform/secret-rotation/redis-credentials.mdx new file mode 100644 index 000000000..577d0e956 --- /dev/null +++ b/docs/documentation/platform/secret-rotation/redis-credentials.mdx @@ -0,0 +1,158 @@ +--- +title: "Redis Credentials Rotation" +description: "Learn how to automatically rotate Redis credentials." +--- + +## Prerequisites + +1. Create a [Redis Connection](/integrations/app-connections/redis) with the required **Secret Rotation** permissions +2. Ensure your network security policies allow incoming requests from Infisical to this rotation provider, if network restrictions apply. + +Create a Redis Credentials Rotation in Infisical + + + + 1. Navigate to your Secret Manager Project's Dashboard and select **Add Secret Rotation** from the actions dropdown. + ![Secret Manager Dashboard](/images/secret-rotations-v2/generic/add-secret-rotation.png) + + 2. Select the **Redis Credentials** option. + ![Select Redis Credentials](/images/secret-rotations-v2/redis-credentials/select-redis-credentials-option.png) + + 3. Select the **Redis Connection** to use and configure the rotation behavior. Then click **Next**. + ![Rotation Configuration](/images/secret-rotations-v2/redis-credentials/redis-credentials-configuration.png) + + - **Redis Connection** - the connection that will perform the rotation of the configured database user credentials. + - **Rotation Interval** - the interval, in days, that once elapsed will trigger a rotation. + - **Rotate At** - the local time of day when rotation should occur once the interval has elapsed. + - **Auto-Rotation Enabled** - whether secrets should automatically be rotated once the rotation interval has elapsed. Disable this option to manually rotate secrets or pause secret rotation. + + 4. Input the password requirements and permission scope for the Redis users that will be created for the rotation. Then click **Next**. + ![Rotation Parameters](/images/secret-rotations-v2/redis-credentials/redis-credentials-parameters.png) + + - **Permission Scope** - The scope of the Redis users that will be created for the rotation. This will default to `~* +@all` if not specified. + - **Password Requirements** - The requirements for the password of the Redis users that will be created for the rotation. + + 5. Specify the secret names that the active credentials should be mapped to. Then click **Next**. + ![Rotation Secrets Mapping](/images/secret-rotations-v2/redis-credentials/redis-credentials-secrets-mapping.png) + + - **Username** - the name of the secret that the active username will be mapped to. + - **Password** - the name of the secret that the active password will be mapped to. + + 6. Give your rotation a name and description (optional). Then click **Next**. + ![Rotation Details](/images/secret-rotations-v2/redis-credentials/redis-credentials-details.png) + + - **Name** - the name of the secret rotation configuration. Must be slug-friendly. + - **Description** (optional) - a description of this rotation configuration. + + 7. Review your configuration, then click **Create Secret Rotation**. + ![Rotation Review](/images/secret-rotations-v2/redis-credentials/redis-credentials-confirm.png) + + 8. Your **Redis Credentials** are now available for use via the mapped secrets. + ![Rotation Created](/images/secret-rotations-v2/redis-credentials/redis-credentials-created.png) + + + To create a Redis Credentials Rotation, make an API request to the [Create Redis + Credentials Rotation](/api-reference/endpoints/secret-rotations/redis-credentials/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-rotations/redis-credentials \ + --header 'Content-Type: application/json' \ + --data '{ + "name": my-redis-rotation", + "projectId": "", + "description": "", + "connectionId": "", + "environment": "dev|staging|prod", + "secretPath": "", + "isAutoRotationEnabled": true, + "rotationInterval": 2, + "rotateAtUtc": { + "hours": 11.5, + "minutes": 29.5 + }, + "parameters": { + "passwordRequirements": { + "length": 64, + "required": { + "digits": 1, + "lowercase": 1, + "uppercase": 1, + "symbols": 1 + }, + "allowedSymbols": "@!+" + }, + "permissionScope": "~* +@all" + }, + "secretsMapping": { + "username": "REDIS_USERNAME", + "password": "REDIS_PASSWORD" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretRotation": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-redis-rotation", + "description": "my database credentials rotation", + "isAutoRotationEnabled": true, + "activeIndex": 0, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "rotationInterval": 30, + "rotationStatus": "success", + "lastRotationAttemptedAt": "2023-11-07T05:31:56Z", + "lastRotatedAt": "2023-11-07T05:31:56Z", + "lastRotationJobId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "nextRotationAt": "2023-11-07T05:31:56Z", + "connection": { + "app": "redis", + "name": "my-redis-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/" + }, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "lastRotationMessage": null, + "type": "redis-credentials", + "parameters": { + "passwordRequirements": { + "length": 64, + "required": { + "digits": 1, + "lowercase": 1, + "uppercase": 1, + "symbols": 1 + }, + "allowedSymbols": "@!+" + }, + "permissionScope": "~* +@all" + }, + "secretsMapping": { + "username": "REDIS_USERNAME", + "password": "REDIS_PASSWORD" + } + } + } + ``` + + diff --git a/docs/documentation/platform/sso/google-saml.mdx b/docs/documentation/platform/sso/google-saml.mdx index 84888b2f9..639a11321 100644 --- a/docs/documentation/platform/sso/google-saml.mdx +++ b/docs/documentation/platform/sso/google-saml.mdx @@ -53,6 +53,13 @@ description: "Learn how to configure Google SAML for Infisical SSO." ![Google SAML attribute mapping](../../../images/sso/google-saml/attribute-mapping.png) + + If you want to sync Google groups to Infisical groups, you can also configure: + - **groups** -> **groups** + + This requires setting up group claims in Google Workspace. See the [Group Membership Mapping](#saml-group-membership-mapping) section below for details. + + Click **Finish**.
@@ -90,6 +97,34 @@ description: "Learn how to configure Google SAML for Infisical SSO." +## SAML Group Membership Mapping + +Automatically sync Google Workspace group memberships to Infisical. + + + + In your Google Admin console SAML app, go to **Attribute mapping** and add: + + - **Google groups**: Include all groups you want to include in the SAML claim. Only these groups will be synced to Infisical. + - **App attribute**: `groups` + + ![Google SAML groups attribute mapping](../../../images/sso/google-saml/groups-attribute-mapping.png) + + + + Back in Infisical, under Organization Settings, enable **SAML Group Membership Mapping** in the **Single Sign-On (SSO)** tab. + + ![Google SAML group membership mapping](../../../images/sso/google-saml/group-membership-mapping.png) + + + Once configured, Google groups will now be automatically synchronized when users log in through SAML. Users will be added to or removed from Infisical groups based on their current Google group memberships. + + + + +Group membership changes in the SAML provider only sync with Infisical when a user logs in via SAML. For example, if you remove a user from a group in the SAML provider, this change will not be reflected in Infisical until their next SAML login. To ensure this behavior, Infisical recommends enabling Enforce SAML SSO. + + If you are only using one organization on your Infisical instance, you can configure a default organization in the [Server Admin Console](../admin-panel/server-admin#default-organization) to expedite SAML login. diff --git a/docs/images/app-connections/general/add-connection.png b/docs/images/app-connections/general/add-connection.png index ad9d54716..b6dce69ac 100644 Binary files a/docs/images/app-connections/general/add-connection.png and b/docs/images/app-connections/general/add-connection.png differ diff --git a/docs/images/app-connections/redis/redis-app-connection-form.png b/docs/images/app-connections/redis/redis-app-connection-form.png new file mode 100644 index 000000000..05db36fd5 Binary files /dev/null and b/docs/images/app-connections/redis/redis-app-connection-form.png differ diff --git a/docs/images/app-connections/redis/redis-app-connection-generated.png b/docs/images/app-connections/redis/redis-app-connection-generated.png new file mode 100644 index 000000000..b3eb5af72 Binary files /dev/null and b/docs/images/app-connections/redis/redis-app-connection-generated.png differ diff --git a/docs/images/app-connections/redis/redis-app-connection-option.png b/docs/images/app-connections/redis/redis-app-connection-option.png new file mode 100644 index 000000000..46f3dea6d Binary files /dev/null and b/docs/images/app-connections/redis/redis-app-connection-option.png differ diff --git a/docs/images/certificate-syncs/azure-key-vault/select-key-vault-option.png b/docs/images/certificate-syncs/azure-key-vault/select-key-vault-option.png new file mode 100644 index 000000000..0a9093ee8 Binary files /dev/null and b/docs/images/certificate-syncs/azure-key-vault/select-key-vault-option.png differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-destination.png b/docs/images/certificate-syncs/azure-key-vault/vault-destination.png new file mode 100644 index 000000000..96295362b Binary files /dev/null and b/docs/images/certificate-syncs/azure-key-vault/vault-destination.png differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-details.png b/docs/images/certificate-syncs/azure-key-vault/vault-details.png new file mode 100644 index 000000000..cc2ebc691 Binary files /dev/null and b/docs/images/certificate-syncs/azure-key-vault/vault-details.png differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-options.png b/docs/images/certificate-syncs/azure-key-vault/vault-options.png new file mode 100644 index 000000000..980dad0ef Binary files /dev/null and b/docs/images/certificate-syncs/azure-key-vault/vault-options.png differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-review.png b/docs/images/certificate-syncs/azure-key-vault/vault-review.png new file mode 100644 index 000000000..4853be086 Binary files /dev/null and b/docs/images/certificate-syncs/azure-key-vault/vault-review.png differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-source.png b/docs/images/certificate-syncs/azure-key-vault/vault-source.png new file mode 100644 index 000000000..5ab8c5dc6 Binary files /dev/null and b/docs/images/certificate-syncs/azure-key-vault/vault-source.png differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-synced.png b/docs/images/certificate-syncs/azure-key-vault/vault-synced.png new file mode 100644 index 000000000..2bacd1a7b Binary files /dev/null and b/docs/images/certificate-syncs/azure-key-vault/vault-synced.png differ diff --git a/docs/images/certificate-syncs/general/certificate-sync-tab.png b/docs/images/certificate-syncs/general/certificate-sync-tab.png new file mode 100644 index 000000000..f97a10aa3 Binary files /dev/null and b/docs/images/certificate-syncs/general/certificate-sync-tab.png differ diff --git a/docs/images/integrations/heroku/integrations-heroku-config-credentials.png b/docs/images/integrations/heroku/integrations-heroku-config-credentials.png index e84aaca01..82588e7f1 100644 Binary files a/docs/images/integrations/heroku/integrations-heroku-config-credentials.png and b/docs/images/integrations/heroku/integrations-heroku-config-credentials.png differ diff --git a/docs/images/integrations/heroku/integrations-heroku-config-new-app-form.png b/docs/images/integrations/heroku/integrations-heroku-config-new-app-form.png index dee5a5336..bbc62f70a 100644 Binary files a/docs/images/integrations/heroku/integrations-heroku-config-new-app-form.png and b/docs/images/integrations/heroku/integrations-heroku-config-new-app-form.png differ diff --git a/docs/images/platform/dynamic-secrets/azure-sql-database/add-dynamic-secret-button.png b/docs/images/platform/dynamic-secrets/azure-sql-database/add-dynamic-secret-button.png new file mode 100644 index 000000000..3adbc8a9e Binary files /dev/null and b/docs/images/platform/dynamic-secrets/azure-sql-database/add-dynamic-secret-button.png differ diff --git a/docs/images/platform/dynamic-secrets/azure-sql-database/create-dynamic-secret-form.png b/docs/images/platform/dynamic-secrets/azure-sql-database/create-dynamic-secret-form.png new file mode 100644 index 000000000..3092ab7b3 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/azure-sql-database/create-dynamic-secret-form.png differ diff --git a/docs/images/platform/dynamic-secrets/azure-sql-database/modify-sql-statements-azure-sql.png b/docs/images/platform/dynamic-secrets/azure-sql-database/modify-sql-statements-azure-sql.png new file mode 100644 index 000000000..e04aae8c4 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/azure-sql-database/modify-sql-statements-azure-sql.png differ diff --git a/docs/images/platform/identities/identities-org-create-universal-auth-method-1.png b/docs/images/platform/identities/identities-org-create-universal-auth-method-1.png index d3fe6fbe0..eaef0c10c 100644 Binary files a/docs/images/platform/identities/identities-org-create-universal-auth-method-1.png and b/docs/images/platform/identities/identities-org-create-universal-auth-method-1.png differ diff --git a/docs/images/platform/identities/identities-org-create-universal-auth-method-2.png b/docs/images/platform/identities/identities-org-create-universal-auth-method-2.png index ea0fc9671..9aae59d04 100644 Binary files a/docs/images/platform/identities/identities-org-create-universal-auth-method-2.png and b/docs/images/platform/identities/identities-org-create-universal-auth-method-2.png differ diff --git a/docs/images/platform/identities/identities-org-create-universal-auth-method-3.png b/docs/images/platform/identities/identities-org-create-universal-auth-method-3.png new file mode 100644 index 000000000..c966afdb4 Binary files /dev/null and b/docs/images/platform/identities/identities-org-create-universal-auth-method-3.png differ diff --git a/docs/images/platform/identities/ldap-reset-lockouts.png b/docs/images/platform/identities/ldap-reset-lockouts.png new file mode 100644 index 000000000..c8aae2eff Binary files /dev/null and b/docs/images/platform/identities/ldap-reset-lockouts.png differ diff --git a/docs/images/platform/identities/ua-reset-lockouts.png b/docs/images/platform/identities/ua-reset-lockouts.png new file mode 100644 index 000000000..40993da1a Binary files /dev/null and b/docs/images/platform/identities/ua-reset-lockouts.png differ diff --git a/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-configuration.png b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-configuration.png new file mode 100644 index 000000000..280a8bed6 Binary files /dev/null and b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-configuration.png differ diff --git a/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-confirm.png b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-confirm.png new file mode 100644 index 000000000..efff5b168 Binary files /dev/null and b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-confirm.png differ diff --git a/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-created.png b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-created.png new file mode 100644 index 000000000..2e6dd994c Binary files /dev/null and b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-created.png differ diff --git a/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-details.png b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-details.png new file mode 100644 index 000000000..9a7552f7c Binary files /dev/null and b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-details.png differ diff --git a/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-parameters.png b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-parameters.png new file mode 100644 index 000000000..4d9db7dd4 Binary files /dev/null and b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-parameters.png differ diff --git a/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-secrets-mapping.png b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-secrets-mapping.png new file mode 100644 index 000000000..10d02a191 Binary files /dev/null and b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-secrets-mapping.png differ diff --git a/docs/images/secret-rotations-v2/redis-credentials/select-redis-credentials-option.png b/docs/images/secret-rotations-v2/redis-credentials/select-redis-credentials-option.png new file mode 100644 index 000000000..979a77b43 Binary files /dev/null and b/docs/images/secret-rotations-v2/redis-credentials/select-redis-credentials-option.png differ diff --git a/docs/images/self-hosting/helper/upgrade-path-tool.png b/docs/images/self-hosting/helper/upgrade-path-tool.png new file mode 100644 index 000000000..a8a538aaf Binary files /dev/null and b/docs/images/self-hosting/helper/upgrade-path-tool.png differ diff --git a/docs/images/sso/google-saml/group-membership-mapping.png b/docs/images/sso/google-saml/group-membership-mapping.png new file mode 100644 index 000000000..eb7ff67ec Binary files /dev/null and b/docs/images/sso/google-saml/group-membership-mapping.png differ diff --git a/docs/images/sso/google-saml/groups-attribute-mapping.png b/docs/images/sso/google-saml/groups-attribute-mapping.png new file mode 100644 index 000000000..16a494c3c Binary files /dev/null and b/docs/images/sso/google-saml/groups-attribute-mapping.png differ diff --git a/docs/integrations/app-connections/1password.mdx b/docs/integrations/app-connections/1password.mdx index 394d8bc23..bcd7e8ff7 100644 --- a/docs/integrations/app-connections/1password.mdx +++ b/docs/integrations/app-connections/1password.mdx @@ -53,7 +53,7 @@ Infisical supports the use of [Service Accounts](https://developer.1password.com - In your Infisical dashboard, go to **Organization Settings** and select the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) @@ -72,7 +72,7 @@ Infisical supports the use of [Service Accounts](https://developer.1password.com ![1Password Connection Modal](/images/app-connections/1password/app-connection-modal.png) - After clicking Create, your **1Password Connection** is established and ready to use with your Infisical projects. + After clicking Create, your **1Password Connection** is established and ready to use with your Infisical project. ![1Password Connection Created](/images/app-connections/1password/app-connection-created.png) @@ -90,6 +90,7 @@ Infisical supports the use of [Service Accounts](https://developer.1password.com --data '{ "name": "my-1password-connection", "method": "api-token", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "instanceUrl": "https://1pass.example.com", "apiToken": "" @@ -104,6 +105,7 @@ Infisical supports the use of [Service Accounts](https://developer.1password.com "appConnection": { "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", "name": "my-1password-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "description": null, "version": 1, "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", diff --git a/docs/integrations/app-connections/auth0.mdx b/docs/integrations/app-connections/auth0.mdx index 42e78cb66..91c5f5e60 100644 --- a/docs/integrations/app-connections/auth0.mdx +++ b/docs/integrations/app-connections/auth0.mdx @@ -42,7 +42,7 @@ Infisical supports the use of [Client Credentials](https://auth0.com/docs/get-st - 1. Navigate to the App Connections tab on the Organization Settings page. + 1. Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) 2. Select the **Auth0 Connection** option. @@ -67,6 +67,7 @@ Infisical supports the use of [Client Credentials](https://auth0.com/docs/get-st --data '{ "name": "my-auth0-connection", "method": "client-credentials", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "domain": "xxx-xxxxxxxxx.us.auth0.com", "clientId": "...", @@ -83,6 +84,7 @@ Infisical supports the use of [Client Credentials](https://auth0.com/docs/get-st "appConnection": { "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "name": "my-auth0-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "version": 1, "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "createdAt": "2023-11-07T05:31:56Z", diff --git a/docs/integrations/app-connections/aws.mdx b/docs/integrations/app-connections/aws.mdx index 195f98247..0d18856d2 100644 --- a/docs/integrations/app-connections/aws.mdx +++ b/docs/integrations/app-connections/aws.mdx @@ -56,6 +56,9 @@ Infisical supports two methods for connecting to AWS. 2. Select **AWS Account** as the **Trusted Entity Type**. 3. Select **Another AWS Account** and provide the appropriate Infisical AWS Account ID: use **381492033652** for the **US region**, and **345594589636** for the **EU region**. This restricts the role to be assumed only by Infisical. If self-hosting, provide your AWS account number instead. + + **For Dedicated Instances**: Your AWS account ID differs from the one provided above. Please reach out to Infisical support to obtain your AWS account ID. + 4. (Recommended) Enable "Require external ID" and input your **Organization ID** to strengthen security and mitigate the [confused deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html). @@ -184,7 +187,7 @@ Infisical supports two methods for connecting to AWS. - 1. Navigate to the App Connections tab on the Organization Settings page. + 1. Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) 2. Select the **AWS Connection** option. @@ -209,6 +212,7 @@ Infisical supports two methods for connecting to AWS. --data '{ "name": "my-aws-connection", "method": "assume-role", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "roleArn": "...", } @@ -222,6 +226,7 @@ Infisical supports two methods for connecting to AWS. "appConnection": { "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "name": "my-aws-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "version": 123, "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "createdAt": "2023-11-07T05:31:56Z", @@ -361,7 +366,7 @@ Infisical supports two methods for connecting to AWS. - 1. Navigate to the App Connections tab on the Organization Settings page. + 1. Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) 2. Select the **AWS Connection** option. @@ -386,6 +391,7 @@ Infisical supports two methods for connecting to AWS. --data '{ "name": "my-aws-connection", "method": "access-key", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "accessKeyId": "...", "secretKey": "..." @@ -400,6 +406,7 @@ Infisical supports two methods for connecting to AWS. "appConnection": { "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "name": "my-aws-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "version": 123, "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "createdAt": "2023-11-07T05:31:56Z", diff --git a/docs/integrations/app-connections/azure-app-configuration.mdx b/docs/integrations/app-connections/azure-app-configuration.mdx index 4efd19f30..cd9c707be 100644 --- a/docs/integrations/app-connections/azure-app-configuration.mdx +++ b/docs/integrations/app-connections/azure-app-configuration.mdx @@ -83,7 +83,7 @@ Infisical currently only supports two methods for connecting to Azure, which are - Navigate to the **App Connections** tab on the **Organization Settings** page. ![App Connections + Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) diff --git a/docs/integrations/app-connections/azure-client-secrets.mdx b/docs/integrations/app-connections/azure-client-secrets.mdx index 37e7d49b0..cb25fb596 100644 --- a/docs/integrations/app-connections/azure-client-secrets.mdx +++ b/docs/integrations/app-connections/azure-client-secrets.mdx @@ -94,7 +94,7 @@ Infisical currently only supports two methods for connecting to Azure, which are - Navigate to the **App Connections** tab on the **Organization Settings** page. ![App Connections + Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) diff --git a/docs/integrations/app-connections/azure-devops.mdx b/docs/integrations/app-connections/azure-devops.mdx index 4744a35c6..7eabff84b 100644 --- a/docs/integrations/app-connections/azure-devops.mdx +++ b/docs/integrations/app-connections/azure-devops.mdx @@ -117,7 +117,7 @@ Infisical currently supports three methods for connecting to Azure DevOps, which - Navigate to the **App Connections** tab on the **Organization Settings** page. ![App Connections + Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) diff --git a/docs/integrations/app-connections/azure-key-vault.mdx b/docs/integrations/app-connections/azure-key-vault.mdx index 866a1de82..b2989efae 100644 --- a/docs/integrations/app-connections/azure-key-vault.mdx +++ b/docs/integrations/app-connections/azure-key-vault.mdx @@ -83,7 +83,7 @@ Infisical currently only supports two methods for connecting to Azure, which are - Navigate to the **App Connections** tab on the **Organization Settings** page. ![App Connections + Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) diff --git a/docs/integrations/app-connections/bitbucket.mdx b/docs/integrations/app-connections/bitbucket.mdx index be4fdbee7..3b3385202 100644 --- a/docs/integrations/app-connections/bitbucket.mdx +++ b/docs/integrations/app-connections/bitbucket.mdx @@ -78,7 +78,7 @@ Infisical supports the use of [API Tokens](https://support.atlassian.com/bitbuck - In your Infisical dashboard, go to **Organization Settings** and select the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) @@ -95,7 +95,7 @@ Infisical supports the use of [API Tokens](https://support.atlassian.com/bitbuck ![Bitbucket Connection Modal](/images/app-connections/bitbucket/step-6.png) - After clicking Create, your **Bitbucket Connection** is established and ready to use with your Infisical projects. + After clicking Create, your **Bitbucket Connection** is established and ready to use with your Infisical project. ![Bitbucket Connection Created](/images/app-connections/bitbucket/step-7.png) @@ -113,6 +113,7 @@ Infisical supports the use of [API Tokens](https://support.atlassian.com/bitbuck --data '{ "name": "my-bitbucket-connection", "method": "api-token", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "email": "user@example.com", "apiToken": "" @@ -127,6 +128,7 @@ Infisical supports the use of [API Tokens](https://support.atlassian.com/bitbuck "appConnection": { "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", "name": "my-bitbucket-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "description": null, "version": 1, "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", diff --git a/docs/integrations/app-connections/camunda.mdx b/docs/integrations/app-connections/camunda.mdx index 68084cea3..7ad0f8ef9 100644 --- a/docs/integrations/app-connections/camunda.mdx +++ b/docs/integrations/app-connections/camunda.mdx @@ -50,8 +50,7 @@ Infisical supports connecting to Camunda APIs using [client credentials](https:/ - Navigate to the **App Connections** tab on the **Organization Settings** - page. ![App Connections + Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) diff --git a/docs/integrations/app-connections/checkly.mdx b/docs/integrations/app-connections/checkly.mdx index 38234744d..943a470c2 100644 --- a/docs/integrations/app-connections/checkly.mdx +++ b/docs/integrations/app-connections/checkly.mdx @@ -37,7 +37,7 @@ Infisical supports the use of [API Keys](https://app.checklyhq.com/settings/user - In your Infisical dashboard, go to **Organization Settings** and open the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) @@ -55,7 +55,7 @@ Infisical supports the use of [API Keys](https://app.checklyhq.com/settings/user ![Checkly Connection Modal](/images/app-connections/checkly/checkly-app-connection-form.png) - After submitting the form, your **Checkly Connection** will be successfully created and ready to use with your Infisical projects. + After submitting the form, your **Checkly Connection** will be successfully created and ready to use with your Infisical project. ![Checkly Connection Created](/images/app-connections/checkly/checkly-app-connection-generated.png) @@ -75,6 +75,7 @@ Infisical supports the use of [API Keys](https://app.checklyhq.com/settings/user --data '{ "name": "my-checkly-connection", "method": "api-key", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "apiKey": "[API KEY]" } @@ -88,6 +89,7 @@ Infisical supports the use of [API Keys](https://app.checklyhq.com/settings/user "appConnection": { "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", "name": "my-checkly-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "description": null, "version": 1, "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", diff --git a/docs/integrations/app-connections/cloudflare.mdx b/docs/integrations/app-connections/cloudflare.mdx index 241c737bc..33a9a992c 100644 --- a/docs/integrations/app-connections/cloudflare.mdx +++ b/docs/integrations/app-connections/cloudflare.mdx @@ -88,8 +88,7 @@ Infisical supports connecting to Cloudflare using API tokens and Account ID for - Navigate to the **App Connections** tab on the **Organization Settings** - page. ![App Connections + Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) diff --git a/docs/integrations/app-connections/databricks.mdx b/docs/integrations/app-connections/databricks.mdx index 38d125ea6..e0b861184 100644 --- a/docs/integrations/app-connections/databricks.mdx +++ b/docs/integrations/app-connections/databricks.mdx @@ -43,8 +43,7 @@ Infisical supports the use of [service principals](https://docs.databricks.com/e - Navigate to the **App Connections** tab on the **Organization Settings** - page. ![App Connections + Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) diff --git a/docs/integrations/app-connections/digital-ocean.mdx b/docs/integrations/app-connections/digital-ocean.mdx index 5b047f017..ff2eccfc1 100644 --- a/docs/integrations/app-connections/digital-ocean.mdx +++ b/docs/integrations/app-connections/digital-ocean.mdx @@ -45,7 +45,7 @@ Infisical supports the use of [API Tokens](https://cloud.digitalocean.com/accoun - In your Infisical dashboard, go to **Organization Settings** and open the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) @@ -63,7 +63,7 @@ Infisical supports the use of [API Tokens](https://cloud.digitalocean.com/accoun ![DigitalOcean Connection Modal](/images/app-connections/digital-ocean/app-connection-form.png) - After submitting the form, your **DigitalOcean Connection** will be successfully created and ready to use with your Infisical projects. + After submitting the form, your **DigitalOcean Connection** will be successfully created and ready to use with your Infisical project. ![DigitalOcean Connection Created](/images/app-connections/digital-ocean/app-connection-generated.png) @@ -82,6 +82,7 @@ Infisical supports the use of [API Tokens](https://cloud.digitalocean.com/accoun --data '{ "name": "my-digitalocean-connection", "method": "api-token", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "apiToken": "[API TOKEN]" } @@ -95,6 +96,7 @@ Infisical supports the use of [API Tokens](https://cloud.digitalocean.com/accoun "appConnection": { "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", "name": "my-digitalocean-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "description": null, "version": 1, "orgId": "abcdef12-3456-7890-abcd-ef1234567890", diff --git a/docs/integrations/app-connections/flyio.mdx b/docs/integrations/app-connections/flyio.mdx index e42756254..bf36ccc9b 100644 --- a/docs/integrations/app-connections/flyio.mdx +++ b/docs/integrations/app-connections/flyio.mdx @@ -30,7 +30,7 @@ Infisical supports the use of [Access Tokens](https://fly.io/docs/security/token - In your Infisical dashboard, go to **Organization Settings** and select the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) @@ -48,7 +48,7 @@ Infisical supports the use of [Access Tokens](https://fly.io/docs/security/token ![Fly.io Connection Modal](/images/app-connections/flyio/app-connection-modal.png) - After clicking Create, your **Fly.io Connection** is established and ready to use with your Infisical projects. + After clicking Create, your **Fly.io Connection** is established and ready to use with your Infisical project. ![Fly.io Connection Created](/images/app-connections/flyio/app-connection-created.png) @@ -66,6 +66,7 @@ Infisical supports the use of [Access Tokens](https://fly.io/docs/security/token --data '{ "name": "my-flyio-connection", "method": "access-token", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "accessToken": "[PRIVATE TOKEN]" } @@ -79,6 +80,7 @@ Infisical supports the use of [Access Tokens](https://fly.io/docs/security/token "appConnection": { "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", "name": "my-flyio-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "description": null, "version": 1, "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", diff --git a/docs/integrations/app-connections/gcp.mdx b/docs/integrations/app-connections/gcp.mdx index 129c26c2a..9458339af 100644 --- a/docs/integrations/app-connections/gcp.mdx +++ b/docs/integrations/app-connections/gcp.mdx @@ -82,8 +82,7 @@ Infisical supports [service account impersonation](https://cloud.google.com/iam/ - Navigate to the **App Connections** tab on the **Organization Settings** - page. ![App Connections + Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) diff --git a/docs/integrations/app-connections/github-radar.mdx b/docs/integrations/app-connections/github-radar.mdx index 376973efd..491070a8b 100644 --- a/docs/integrations/app-connections/github-radar.mdx +++ b/docs/integrations/app-connections/github-radar.mdx @@ -97,7 +97,7 @@ Infisical supports GitHub App installation for creating a GitHub Radar Connectio - Navigate to the **App Connections** tab on the **Organization Settings** page. + Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) diff --git a/docs/integrations/app-connections/github.mdx b/docs/integrations/app-connections/github.mdx index 9a952f815..e44fc405a 100644 --- a/docs/integrations/app-connections/github.mdx +++ b/docs/integrations/app-connections/github.mdx @@ -85,7 +85,7 @@ Infisical supports two methods for connecting to GitHub. - Navigate to the **App Connections** tab on the **Organization Settings** page. + Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) @@ -156,7 +156,7 @@ Infisical supports two methods for connecting to GitHub. - Navigate to the **App Connections** tab on the **Organization Settings** page. + Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) diff --git a/docs/integrations/app-connections/gitlab.mdx b/docs/integrations/app-connections/gitlab.mdx index 60e9236ca..4f7223d93 100644 --- a/docs/integrations/app-connections/gitlab.mdx +++ b/docs/integrations/app-connections/gitlab.mdx @@ -70,7 +70,7 @@ Infisical supports two methods for connecting to GitLab: **OAuth** and **Access - Navigate to the **App Connections** tab on the **Organization Settings** page. + Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) @@ -193,7 +193,7 @@ Infisical supports two methods for connecting to GitLab: **OAuth** and **Access - Navigate to the **App Connections** tab on the **Organization Settings** page. + Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) diff --git a/docs/integrations/app-connections/hashicorp-vault.mdx b/docs/integrations/app-connections/hashicorp-vault.mdx index c49b53ab8..7d5502fc5 100644 --- a/docs/integrations/app-connections/hashicorp-vault.mdx +++ b/docs/integrations/app-connections/hashicorp-vault.mdx @@ -131,7 +131,7 @@ Infisical supports two methods for connecting to Hashicorp Vault. - In your Infisical dashboard, go to **Organization Settings** and select the **App Connections** tab. + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) @@ -184,6 +184,7 @@ Infisical supports two methods for connecting to Hashicorp Vault. --data '{ "name": "my-vault-connection", "method": "app-role", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "instanceUrl": "https://vault.example.com", "roleId": "4797c4fa-7794-71f0-c8b1-7c87759df5bf", @@ -199,6 +200,7 @@ Infisical supports two methods for connecting to Hashicorp Vault. "appConnection": { "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "name": "my-vault-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "version": 1, "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "createdAt": "2025-04-01T05:31:56Z", diff --git a/docs/integrations/app-connections/heroku.mdx b/docs/integrations/app-connections/heroku.mdx index fc2d1fbcc..d0824741c 100644 --- a/docs/integrations/app-connections/heroku.mdx +++ b/docs/integrations/app-connections/heroku.mdx @@ -24,7 +24,7 @@ Infisical supports two methods for connecting to Heroku: **OAuth** and **Auth To ![Heroku config applications](/images/integrations/heroku/integrations-heroku-config-applications.png) ![Heroku config new app](/images/integrations/heroku/integrations-heroku-config-new-app.png) - Create the API client. As part of the form, set the **OAuth callback URL** to `https://your-domain.com/integrations/heroku/oauth2/callback`. + Create the API client. As part of the form, set the **OAuth callback URL** to `https://your-domain.com/organization/app-connections/heroku/oauth/callback`. The domain you defined in the OAuth callback URL should be equivalent to the `SITE_URL` configured in your Infisical instance. @@ -39,8 +39,8 @@ Infisical supports two methods for connecting to Heroku: **OAuth** and **Auth To Back in your Infisical instance, add two new environment variables for the credentials of your Heroku API client: - - `CLIENT_ID_HEROKU`: The **Client ID** of your Heroku API client. - - `CLIENT_SECRET_HEROKU`: The **Client Secret** of your Heroku API client. + - `INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID`: The **Client ID** of your Heroku API client. + - `INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET`: The **Client Secret** of your Heroku API client. Once added, restart your Infisical instance and use the Heroku Connection. @@ -51,7 +51,7 @@ Infisical supports two methods for connecting to Heroku: **OAuth** and **Auth To - Navigate to the **App Connections** tab on the **Organization Settings** page. + Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) @@ -93,7 +93,7 @@ Infisical supports two methods for connecting to Heroku: **OAuth** and **Auth To ![Heroku API Token](/images/app-connections/heroku/heroku-api-token.png) - Navigate to the **App Connections** tab on the **Organization Settings** page. + Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) diff --git a/docs/integrations/app-connections/humanitec.mdx b/docs/integrations/app-connections/humanitec.mdx index 570d3ba5d..669798eeb 100644 --- a/docs/integrations/app-connections/humanitec.mdx +++ b/docs/integrations/app-connections/humanitec.mdx @@ -53,7 +53,7 @@ Infisical supports connecting to Humanitec using a service user. ![Humanitec Connection Created](/images/app-connections/humanitec/humanitec-user-added.png) - Navigate to the **App Connections** tab on the **Organization Settings** page. + Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) diff --git a/docs/integrations/app-connections/ldap.mdx b/docs/integrations/app-connections/ldap.mdx index db0b596ce..f0afa1157 100644 --- a/docs/integrations/app-connections/ldap.mdx +++ b/docs/integrations/app-connections/ldap.mdx @@ -33,7 +33,7 @@ Depending on how you intend to use your LDAP connection, there may be additional - 1. Navigate to the App Connections tab on the Organization Settings page. + 1. Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) 2. Select the **LDAP Connection** option. @@ -58,6 +58,7 @@ Depending on how you intend to use your LDAP connection, there may be additional --data '{ "name": "my-ldap-connection", "method": "simple-bind", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "provider": "active-directory", "url": "ldaps://domain-or-ip:636", @@ -76,6 +77,7 @@ Depending on how you intend to use your LDAP connection, there may be additional "appConnection": { "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "name": "my-ldap-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "version": 1, "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "createdAt": "2023-11-07T05:31:56Z", diff --git a/docs/integrations/app-connections/mssql.mdx b/docs/integrations/app-connections/mssql.mdx index 7e940804d..77e8e676d 100644 --- a/docs/integrations/app-connections/mssql.mdx +++ b/docs/integrations/app-connections/mssql.mdx @@ -62,7 +62,7 @@ Infisical supports connecting to Microsoft SQL Server using database principals. - 1. Navigate to the App Connections tab on the Organization Settings page. + 1. Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) 2. Select the **Microsoft SQL Server Connection** option. @@ -96,6 +96,7 @@ Infisical supports connecting to Microsoft SQL Server using database principals. --data '{ "name": "my-mssql-connection", "method": "username-and-password", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "isPlatformManagedCredentials": true, "credentials": { "host": "123.4.5.6", @@ -115,7 +116,8 @@ Infisical supports connecting to Microsoft SQL Server using database principals. { "appConnection": { "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", - "name": "my-pg-connection", + "name": "my-mssql-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "version": 1, "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "createdAt": "2023-11-07T05:31:56Z", diff --git a/docs/integrations/app-connections/mysql.mdx b/docs/integrations/app-connections/mysql.mdx index 38a8a4e97..6055d77cc 100644 --- a/docs/integrations/app-connections/mysql.mdx +++ b/docs/integrations/app-connections/mysql.mdx @@ -52,7 +52,7 @@ Infisical supports connecting to MySQL using a database role. - 1. Navigate to the App Connections tab on the Organization Settings page. + 1. Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) 2. Select the **MySQL Connection** option. @@ -88,6 +88,7 @@ Infisical supports connecting to MySQL using a database role. "name": "my-mysql-connection", "method": "username-and-password", "isPlatformManagedCredentials": true, + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "host": "123.4.5.6", "port": 3306, @@ -107,6 +108,7 @@ Infisical supports connecting to MySQL using a database role. "appConnection": { "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "name": "my-mysql-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "version": 1, "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "createdAt": "2023-11-07T05:31:56Z", diff --git a/docs/integrations/app-connections/netlify.mdx b/docs/integrations/app-connections/netlify.mdx index cd4dfb1b5..d2a62113c 100644 --- a/docs/integrations/app-connections/netlify.mdx +++ b/docs/integrations/app-connections/netlify.mdx @@ -35,7 +35,7 @@ Infisical supports the use of [Personal Access Tokens](https://docs.netlify.com/ - In your Infisical dashboard, go to **Organization Settings** and open the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) @@ -53,7 +53,7 @@ Infisical supports the use of [Personal Access Tokens](https://docs.netlify.com/ ![Netlify Connection Modal](/images/app-connections/netlify/app-connection-form.png) - After submitting the form, your **Netlify Connection** will be successfully created and ready to use with your Infisical projects. + After submitting the form, your **Netlify Connection** will be successfully created and ready to use with your Infisical project. ![Netlify Connection Created](/images/app-connections/netlify/app-connection-generated.png) @@ -72,6 +72,7 @@ Infisical supports the use of [Personal Access Tokens](https://docs.netlify.com/ --data '{ "name": "my-netlify-connection", "method": "access-token", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "accessToken": "[ACCESS TOKEN]" } @@ -86,6 +87,7 @@ Infisical supports the use of [Personal Access Tokens](https://docs.netlify.com/ "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", "name": "my-netlify-connection", "description": null, + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "version": 1, "orgId": "abcdef12-3456-7890-abcd-ef1234567890", "createdAt": "2025-07-19T10:15:00.000Z", diff --git a/docs/integrations/app-connections/oci.mdx b/docs/integrations/app-connections/oci.mdx index 58fb3c1d3..10d5fabaa 100644 --- a/docs/integrations/app-connections/oci.mdx +++ b/docs/integrations/app-connections/oci.mdx @@ -117,7 +117,7 @@ Infisical supports the use of [API Signing Key Authentication](https://docs.orac - In your Infisical dashboard, go to **Organization Settings** and select the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) @@ -139,7 +139,7 @@ Infisical supports the use of [API Signing Key Authentication](https://docs.orac ![OCI Connection Modal](/images/app-connections/oci/app-connection-modal.png) - After clicking Create, your **OCI Connection** is established and ready to use with your Infisical projects. + After clicking Create, your **OCI Connection** is established and ready to use with your Infisical project. ![OCI Connection Created](/images/app-connections/oci/app-connection-created.png) @@ -157,6 +157,7 @@ Infisical supports the use of [API Signing Key Authentication](https://docs.orac --data '{ "name": "my-oci-connection", "method": "access-key", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "userOcid": "ocid1.user.oc1..aaaaaaaagrp35tbkvvad4y2j7sug7xonua7dl2gfp4at2u5i5xj4ghnitg3a", "tenancyOcid": "ocid1.tenancy.oc1..aaaaaaaaotfma465m4zumfe2ua64mj2m5dwmlw2llh4g4dnfttnakiifonta", @@ -174,6 +175,7 @@ Infisical supports the use of [API Signing Key Authentication](https://docs.orac "appConnection": { "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", "name": "my-oci-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "description": null, "version": 1, "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", diff --git a/docs/integrations/app-connections/okta.mdx b/docs/integrations/app-connections/okta.mdx index 3c1295cf8..cb5edecbd 100644 --- a/docs/integrations/app-connections/okta.mdx +++ b/docs/integrations/app-connections/okta.mdx @@ -31,7 +31,7 @@ Infisical supports the use of [API Tokens](https://developer.okta.com/docs/guide - In your Infisical dashboard, go to **Organization Settings** and select the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) @@ -48,7 +48,7 @@ Infisical supports the use of [API Tokens](https://developer.okta.com/docs/guide ![Connection Modal](/images/app-connections/okta/step-4.png) - After clicking Create, your **Okta Connection** is established and ready to use with your Infisical projects. + After clicking Create, your **Okta Connection** is established and ready to use with your Infisical project. ![Connection Created](/images/app-connections/okta/step-5.png) @@ -66,6 +66,7 @@ Infisical supports the use of [API Tokens](https://developer.okta.com/docs/guide --data '{ "name": "my-okta-connection", "method": "api-token", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "instanceUrl": "https://example.okta.com", "apiToken": "" @@ -80,6 +81,7 @@ Infisical supports the use of [API Tokens](https://developer.okta.com/docs/guide "appConnection": { "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", "name": "my-okta-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "description": null, "version": 1, "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", diff --git a/docs/integrations/app-connections/oracledb.mdx b/docs/integrations/app-connections/oracledb.mdx index 8cab6371b..47aa33356 100644 --- a/docs/integrations/app-connections/oracledb.mdx +++ b/docs/integrations/app-connections/oracledb.mdx @@ -62,7 +62,7 @@ Infisical supports connecting to OracleDB using a database user. - 1. Navigate to the App Connections tab on the Organization Settings page. + 1. Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) 2. Select the **OracleDB Connection** option. @@ -98,6 +98,7 @@ Infisical supports connecting to OracleDB using a database user. "name": "my-oracledb-connection", "method": "username-and-password", "isPlatformManagedCredentials": true, + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "host": "123.4.5.6", "port": 1521, @@ -117,6 +118,7 @@ Infisical supports connecting to OracleDB using a database user. "appConnection": { "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "name": "my-oracledb-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "version": 1, "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "createdAt": "2023-11-07T05:31:56Z", diff --git a/docs/integrations/app-connections/overview.mdx b/docs/integrations/app-connections/overview.mdx index e698c4302..8b1032e7d 100644 --- a/docs/integrations/app-connections/overview.mdx +++ b/docs/integrations/app-connections/overview.mdx @@ -3,12 +3,16 @@ sidebarTitle: "Overview" description: "Learn how to manage and configure third-party app connections with Infisical." --- -App Connections enable your organization to integrate Infisical with third-party services in a secure and versatile way. +App Connections enable you to integrate your Infisical projects with third-party services in a secure and versatile way. + + + App connections can also be created and managed independently in projects now. + ## Concept -App Connections are an organization-level resource used to establish connections with third-party applications -that can be used across Infisical projects. Example use cases include syncing secrets, generating dynamic secrets, and more. +App Connections can be used to establish connections with third-party applications +that can be used across multiple features. Example use cases include syncing secrets, rotating credentials, scanning repositories for secret leaks, and more.
diff --git a/docs/integrations/app-connections/postgres.mdx b/docs/integrations/app-connections/postgres.mdx index 239608905..dcb6c76d8 100644 --- a/docs/integrations/app-connections/postgres.mdx +++ b/docs/integrations/app-connections/postgres.mdx @@ -60,7 +60,7 @@ Infisical supports connecting to PostgreSQL using a database role. - 1. Navigate to the App Connections tab on the Organization Settings page. + 1. Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) 2. Select the **PostgreSQL Connection** option. @@ -95,6 +95,7 @@ Infisical supports connecting to PostgreSQL using a database role. "name": "my-pg-connection", "method": "username-and-password", "isPlatformManagedCredentials": true, + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "host": "123.4.5.6", "port": 5432, @@ -114,6 +115,7 @@ Infisical supports connecting to PostgreSQL using a database role. "appConnection": { "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "name": "my-pg-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "version": 1, "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "createdAt": "2023-11-07T05:31:56Z", diff --git a/docs/integrations/app-connections/railway.mdx b/docs/integrations/app-connections/railway.mdx index 7b53d02ad..b88b3fbf1 100644 --- a/docs/integrations/app-connections/railway.mdx +++ b/docs/integrations/app-connections/railway.mdx @@ -96,7 +96,7 @@ Infisical supports the use of [API Tokens](https://docs.railway.com/guides/publi - In your Infisical dashboard, go to **Organization Settings** and open the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) @@ -115,7 +115,7 @@ Infisical supports the use of [API Tokens](https://docs.railway.com/guides/publi ![Railway Connection Modal](/images/app-connections/railway/railway-app-connection-form.png)
- After submitting the form, your **Railway Connection** will be successfully created and ready to use with your Infisical projects. + After submitting the form, your **Railway Connection** will be successfully created and ready to use with your Infisical project. ![Railway Connection Created](/images/app-connections/railway/railway-app-connection-generated.png) @@ -134,6 +134,7 @@ Infisical supports the use of [API Tokens](https://docs.railway.com/guides/publi --data '{ "name": "my-railway-connection", "method": "team-token", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "apiToken": "[TEAM TOKEN]" } @@ -147,6 +148,7 @@ Infisical supports the use of [API Tokens](https://docs.railway.com/guides/publi "appConnection": { "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", "name": "my-railway-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "description": null, "version": 1, "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", diff --git a/docs/integrations/app-connections/redis.mdx b/docs/integrations/app-connections/redis.mdx new file mode 100644 index 000000000..224abbea8 --- /dev/null +++ b/docs/integrations/app-connections/redis.mdx @@ -0,0 +1,126 @@ +--- +title: "Redis Connection" +description: "Learn how to configure a Redis Connection for Infisical." +--- + +Infisical supports the use of Username & Password authentication to connect with Redis databases + +## Configure a Redis user for Infisical + + + + Infisical recommends creating a designated user in your Redis database for your connection. + + ```bash + ACL SETUSER user_manager on >[ENTER-YOUR-USER-PASSWORD] + ``` + + + + Depending on how you intend to use your Redis connection, you'll need to grant one or more of the following permissions. + + + To learn more about Redis's permission system, please visit their [documentation](https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/). + + + + + For Secret Rotations, your Infisical user will require the ability to set and delete users: + + ```bash + ACL SETUSER user_manager +acl|setuser +acl|deluser ~* + ``` + + + + + + +## Create Redis Connection in Infisical + + + + + + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click the **+ Add Connection** button and select the **Redis Connection** option from the available integrations. + + ![Select Redis Connection](/images/app-connections/redis/redis-app-connection-option.png) + + + Complete the Redis Connection form by entering: + - A descriptive name for the connection + - An optional description for future reference + - The Redis host URL for your database + - The Redis port for your Redis database + - The Redis username for your Redis database + - The Redis password for your Redis database + + You can optionally configure SSL/TLS for your Redis connection in the **SSL** section. + + + ![Redis Connection Modal](/images/app-connections/redis/redis-app-connection-form.png) + + + After clicking Create, your **Redis Connection** is established and ready to use with your Infisical project. + + ![Redis Connection Created](/images/app-connections/redis/redis-app-connection-generated.png) + + + + + To create a Redis Connection, make an API request to the [Create Redis Connection](/api-reference/endpoints/app-connections/redis/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/redis \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-redis-connection", + "method": "username-and-password", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", + "credentials": { + "host": "[REDIS HOST]", + "port": 6379, + "username": "[REDIS USERNAME]", + "password": "[REDIS PASSWORD]", + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", + "name": "my-redis-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", + "description": null, + "version": 1, + "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", + "createdAt": "2025-04-23T19:46:34.831Z", + "updatedAt": "2025-04-23T19:46:34.831Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "7c2d371dec195f82a6a0d5b41c970a229cfcaf88e894a5b6395e2dbd0280661f", + "app": "redis", + "method": "username-and-password", + credentials: { + "host": "", + "port": 6379, + "username": "", + "sslEnabled": true, + "sslRejectUnauthorized": false, + "sslCertificate": "" + } + } + } + ``` + + diff --git a/docs/integrations/app-connections/render.mdx b/docs/integrations/app-connections/render.mdx index 580ec953e..78e050135 100644 --- a/docs/integrations/app-connections/render.mdx +++ b/docs/integrations/app-connections/render.mdx @@ -33,8 +33,7 @@ Infisical supports connecting to Render using API keys for secure access to your - Navigate to the **App Connections** tab on the **Organization Settings** - page. ![App Connections + Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) diff --git a/docs/integrations/app-connections/supabase.mdx b/docs/integrations/app-connections/supabase.mdx index 9716b1526..80290cec9 100644 --- a/docs/integrations/app-connections/supabase.mdx +++ b/docs/integrations/app-connections/supabase.mdx @@ -34,7 +34,7 @@ Infisical supports the use of [Personal Access Tokens](https://supabase.com/dash - In your Infisical dashboard, go to **Organization Settings** and open the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) @@ -53,7 +53,7 @@ Infisical supports the use of [Personal Access Tokens](https://supabase.com/dash ![Supabase Connection Modal](/images/app-connections/supabase/app-connection-form.png) - After submitting the form, your **Supabase Connection** will be successfully created and ready to use with your Infisical projects. + After submitting the form, your **Supabase Connection** will be successfully created and ready to use with your Infisical project. ![Supabase Connection Created](/images/app-connections/supabase/app-connection-generated.png) @@ -73,6 +73,7 @@ Infisical supports the use of [Personal Access Tokens](https://supabase.com/dash --data '{ "name": "my-supabase-connection", "method": "access-token", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "accessToken": "[Access Token]", "instanceUrl": "https://api.supabase.com" @@ -87,6 +88,7 @@ Infisical supports the use of [Personal Access Tokens](https://supabase.com/dash "appConnection": { "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", "name": "my-supabase-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "description": null, "version": 1, "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", diff --git a/docs/integrations/app-connections/teamcity.mdx b/docs/integrations/app-connections/teamcity.mdx index 889355954..311326f07 100644 --- a/docs/integrations/app-connections/teamcity.mdx +++ b/docs/integrations/app-connections/teamcity.mdx @@ -51,7 +51,7 @@ Infisical supports connecting to TeamCity using Access Tokens. 1. Navigate to App Connections - In your Infisical dashboard, go to **Organization Settings** and select the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) 2. Add Connection @@ -68,7 +68,7 @@ Infisical supports connecting to TeamCity using Access Tokens. ![TeamCity Connection Modal](/images/app-connections/teamcity/teamcity-app-connection-modal.png) 4. Connection Created - After clicking Create, your **TeamCity Connection** is established and ready to use with your Infisical projects. + After clicking Create, your **TeamCity Connection** is established and ready to use with your Infisical project. ![TeamCity Connection Created](/images/app-connections/teamcity/teamcity-app-connection-created.png) @@ -84,6 +84,7 @@ Infisical supports connecting to TeamCity using Access Tokens. --data '{ "name": "my-teamcity-connection", "method": "access-token", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "accessToken": "...", "instanceUrl": "https://yourcompany.teamcity.com" @@ -98,6 +99,7 @@ Infisical supports connecting to TeamCity using Access Tokens. "appConnection": { "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", "name": "my-teamcity-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "description": null, "version": 1, "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", diff --git a/docs/integrations/app-connections/terraform-cloud.mdx b/docs/integrations/app-connections/terraform-cloud.mdx index 02deb22cc..bc3da7810 100644 --- a/docs/integrations/app-connections/terraform-cloud.mdx +++ b/docs/integrations/app-connections/terraform-cloud.mdx @@ -30,7 +30,7 @@ Infisical supports connecting to Terraform Cloud using a service user. - 1. Navigate to the **App Connections** tab on the **Organization Settings** page. + 1. Navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) 2. Select the **Terraform Cloud Connection** option from the connection options modal. ![Select Terraform Cloud Connection](/images/app-connections/terraform-cloud/terraform-cloud-app-connection-option.png) @@ -52,6 +52,7 @@ Infisical supports connecting to Terraform Cloud using a service user. --data '{ "name": "my-terraform-cloud-connection", "method": "api-token", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "apiToken": "...", } @@ -65,6 +66,7 @@ Infisical supports connecting to Terraform Cloud using a service user. "appConnection": { "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "name": "my-terraform-cloud-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "version": 123, "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "createdAt": "2023-11-07T05:31:56Z", diff --git a/docs/integrations/app-connections/vercel.mdx b/docs/integrations/app-connections/vercel.mdx index 7ab7bea1b..17973db9b 100644 --- a/docs/integrations/app-connections/vercel.mdx +++ b/docs/integrations/app-connections/vercel.mdx @@ -37,7 +37,7 @@ Infisical supports connecting to Vercel using API Tokens. 1. Navigate to App Connections - In your Infisical dashboard, go to **Organization Settings** and select the **App Connections** tab. + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) 2. Add Connection @@ -52,7 +52,7 @@ Infisical supports connecting to Vercel using API Tokens. ![Vercel Connection Modal](/images/app-connections/vercel/vercel-app-connection-modal.png) 4. Connection Created - After clicking Create, your **Vercel Connection** is established and ready to use with your Infisical projects. + After clicking Create, your **Vercel Connection** is established and ready to use with your Infisical project. ![Vercel Connection Created](/images/app-connections/vercel/vercel-app-connection-created.png) @@ -67,6 +67,7 @@ Infisical supports connecting to Vercel using API Tokens. --header 'Content-Type: application/json' \ --data '{ "name": "my-vercel-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "method": "api-token", "credentials": { "apiToken": "...", @@ -81,6 +82,7 @@ Infisical supports connecting to Vercel using API Tokens. "appConnection": { "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "name": "my-vercel-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "version": 123, "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "createdAt": "2025-04-01T05:31:56Z", diff --git a/docs/integrations/app-connections/windmill.mdx b/docs/integrations/app-connections/windmill.mdx index 5cab9fa38..d90c83a1b 100644 --- a/docs/integrations/app-connections/windmill.mdx +++ b/docs/integrations/app-connections/windmill.mdx @@ -47,7 +47,8 @@ Ensure the user generating the access token has the required role and permission - In your Infisical dashboard, go to **Organization Settings** and select the **App Connections** tab. + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. + ![App Connections Tab](/images/app-connections/general/add-connection.png) @@ -82,6 +83,7 @@ Ensure the user generating the access token has the required role and permission --data '{ "name": "my-windmill-connection", "method": "access-token", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "token": "...", "instanceUrl": "https://app.windmill.dev" @@ -96,6 +98,7 @@ Ensure the user generating the access token has the required role and permission "appConnection": { "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "name": "my-windmill-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "version": 123, "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "createdAt": "2025-04-01T05:31:56Z", diff --git a/docs/integrations/app-connections/zabbix.mdx b/docs/integrations/app-connections/zabbix.mdx index c4d47b22e..45141e827 100644 --- a/docs/integrations/app-connections/zabbix.mdx +++ b/docs/integrations/app-connections/zabbix.mdx @@ -31,7 +31,7 @@ Infisical supports the use of [API Tokens](https://www.zabbix.com/documentation/ - In your Infisical dashboard, go to **Organization Settings** and select the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. ![App Connections Tab](/images/app-connections/general/add-connection.png) @@ -50,7 +50,7 @@ Infisical supports the use of [API Tokens](https://www.zabbix.com/documentation/ ![Zabbix Connection Modal](/images/app-connections/zabbix/zabbix-app-connection-form.png) - After clicking Create, your **Zabbix Connection** is established and ready to use with your Infisical projects. + After clicking Create, your **Zabbix Connection** is established and ready to use with your Infisical project. ![Zabbix Connection Created](/images/app-connections/zabbix/zabbix-app-connection-generated.png) @@ -68,6 +68,7 @@ Infisical supports the use of [API Tokens](https://www.zabbix.com/documentation/ --data '{ "name": "my-zabbix-connection", "method": "api-token", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "credentials": { "apiToken": "[API TOKEN]", "instanceUrl": "https://zabbix.example.com" @@ -82,6 +83,7 @@ Infisical supports the use of [API Tokens](https://www.zabbix.com/documentation/ "appConnection": { "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", "name": "my-zabbix-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", "description": null, "version": 1, "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", diff --git a/docs/integrations/platforms/infisical-agent.mdx b/docs/integrations/platforms/infisical-agent.mdx index e0ff5b643..a93bfcdf0 100644 --- a/docs/integrations/platforms/infisical-agent.mdx +++ b/docs/integrations/platforms/infisical-agent.mdx @@ -366,3 +366,21 @@ password = "{{ .Value }}" **Returns**: A list of secret objects with the following keys `Key, WorkspaceId, Value, Type, ID, and Comment` + + + ```bash + dynamic_secret "" "" "" "" "" + ``` + + ```bash example-redis-dynamic-secret + {{ with dynamic_secret "aaa-o7en-s5qm" "dev" "/" "redis" "1m" }} + {{ .DB_USERNAME }}={{ .DB_PASSWORD }} + {{- end }} + + **Function Name**: dynamic_secret + + **Description**: This function can be used to render a dynamic secret lease credentials. The credentials are automatically renewed before they expire, ensuring that the rendered credentials are always up-to-date. + + **Returns**: An object with keys corresponding to the dynamic secret lease credentials. + ``` + \ No newline at end of file diff --git a/docs/integrations/platforms/kubernetes-injector.mdx b/docs/integrations/platforms/kubernetes-injector.mdx index 7fea3dc92..d5d48598b 100644 --- a/docs/integrations/platforms/kubernetes-injector.mdx +++ b/docs/integrations/platforms/kubernetes-injector.mdx @@ -59,9 +59,10 @@ The Infisical Agent Injector supports the following annotations: The inject annotation is used to enable the injector on a pod. Set the value to `true` and the pod will be patched with an Infisical Agent container on update or create. - The inject mode annotation is used to specify the mode to use to inject the secrets into the pod. Currently only `init` mode is supported. + The inject mode annotation is used to specify the mode to use to inject the secrets into the pod. - `init`: The init method will create an init container for the pod that will render the secrets into a shared volume mount within the pod. The agent init container will run before any other containers in the pod runs, including other init containers. + - `sidecar`: The sidecar method will create a sidecar container for the pod that will render the secrets into a shared volume mount within the pod. The agent sidecar container will run alongside the main container in the pod. This means that the secrets rendered will always be in sync with your Infisical secrets. The agent config map annotation is used to specify the name of the config map that contains the configuration for the injector. The config map must be in the same namespace as the pod. @@ -203,7 +204,7 @@ metadata: app: demo annotations: org.infisical.com/inject: "true" # Set to true for the injector to patch the pod on create/update events - org.infisical.com/inject-mode: "init" # The mode to use to inject the secrets into the pod. Currently only `init` mode is supported. + org.infisical.com/inject-mode: "init" # The mode to use to inject the secrets into the pod. init|sidecar org.infisical.com/agent-config-map: "name-of-config-map" # The name of the config map that you created above, which contains all the settings for injecting the secrets into the pod spec: # ... diff --git a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx index d72d58957..d7fb6249a 100644 --- a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx @@ -68,6 +68,22 @@ When `hostAPI` is not defined the operator fetches secrets from Infisical Cloud. available on paid plans. Default re-sync interval is every 1 minute. + + This property enables instant updates from Infisical. When set to true, + changes made to secrets in Infisical will be immediately pushed to the + operator, triggering a configuration update. This reduces the need for + periodic re-syncs. + + + Note that `Instant Updates` is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Pro**, + and **Enterprise Tier** with varying retention periods. If you're self-hosting Infisical, + then you should contact sales@infisical.com to purchase an enterprise license to use it. + + + + This block defines the TLS settings to use for connecting to the Infisical instance. @@ -146,6 +162,7 @@ spec: projectSlug: # <-- project slug projectId: # <-- project id + secretName: # OPTIONAL: If you want to fetch a single Infisical secret, you can specify the secret name here. If not specified, all secrets in the specified scope will be fetched. envSlug: # "dev", "staging", "prod", etc.. secretsPath: "" # Root is "/" credentialsRef: @@ -331,6 +348,7 @@ spec: projectSlug: your-project-slug envSlug: prod secretsPath: "/path" + secretName: # OPTIONAL: If you want to fetch a single Infisical secret, you can specify the secret name here. If not specified, all secrets in the specified scope will be fetched. recursive: true ... ``` @@ -526,6 +544,7 @@ spec: projectSlug: your-project-slug envSlug: prod secretsPath: "/path" + secretName: # OPTIONAL: If you want to fetch a single Infisical secret, you can specify the secret name here. If not specified, all secrets in the specified scope will be fetched. recursive: true ... ``` @@ -574,6 +593,7 @@ spec: projectSlug: your-project-slug envSlug: prod secretsPath: "/path" + secretName: # OPTIONAL: If you want to fetch a single Infisical secret, you can specify the secret name here. If not specified, all secrets in the specified scope will be fetched. recursive: true ... ``` @@ -619,6 +639,7 @@ spec: projectSlug: your-project-slug envSlug: prod secretsPath: "/path" + secretName: # OPTIONAL: If you want to fetch a single Infisical secret, you can specify the secret name here. If not specified, all secrets in the specified scope will be fetched. recursive: true ... ``` @@ -664,6 +685,7 @@ spec: projectSlug: your-project-slug envSlug: prod secretsPath: "/path" + secretName: # OPTIONAL: If you want to fetch a single Infisical secret, you can specify the secret name here. If not specified, all secrets in the specified scope will be fetched. recursive: true ... ``` @@ -711,6 +733,7 @@ spec: projectSlug: your-project-slug envSlug: prod secretsPath: "/path" + secretName: # OPTIONAL: If you want to fetch a single Infisical secret, you can specify the secret name here. If not specified, all secrets in the specified scope will be fetched. recursive: true ... ``` @@ -764,6 +787,7 @@ spec: projectSlug: # <-- project slug envSlug: # "dev", "staging", "prod", etc.. secretsPath: "" # Root is "/" + secretName: # OPTIONAL: If you want to fetch a single Infisical secret, you can specify the secret name here. If not specified, all secrets in the specified scope will be fetched. identityId: credentialsRef: secretName: ldap-auth-credentials # <-- name of the Kubernetes secret that stores our machine identity credentials diff --git a/docs/internals/permissions/organization-permissions.mdx b/docs/internals/permissions/organization-permissions.mdx index 5f5fc962f..993e270ed 100644 --- a/docs/internals/permissions/organization-permissions.mdx +++ b/docs/internals/permissions/organization-permissions.mdx @@ -9,7 +9,7 @@ Infisical's organization permissions system follows a role-based access control Each permission consists of: -- **Subject**: The resource the permission applies to (e.g., workspaces, members, billing) +- **Subject**: The resource the permission applies to (e.g., project, members, billing) - **Action**: The operation that can be performed (e.g., read, create, edit, delete) Some organization-level resources—specifically `app-connections`—support conditional permissions and permission inversion for more granular access control. @@ -18,13 +18,13 @@ Some organization-level resources—specifically `app-connections`—support con Below is a comprehensive list of all available organization-level subjects and their supported actions, organized by functional area. -### Workspace Management +### Project Management -#### Subject: `workspace` +#### Subject: `project` (formerly workspace) -| Action | Description | -| -------- | --------------------- | -| `create` | Create new workspaces | +| Action | Description | +| -------- | ------------------ | +| `create` | Create new project | ### Role Management @@ -218,6 +218,15 @@ Supports conditions and permission inversion | `delete-gateways` | Remove gateways from organization | | `attach-gateways` | Attach gateways to resources | +#### Subject: `relay` + +| Action | Description | +| --------------- | ------------------------------- | +| `list-relays` | View all organization relays | +| `create-relays` | Add new relays to organization | +| `edit-relays` | Modify existing relay settings | +| `delete-relays` | Remove relays from organization | + #### Subject: `machine-identity-auth-template` | Action | Description | diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index c823cf4a7..3a4dd11e6 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -86,7 +86,7 @@ Below is a comprehensive list of all available project-level subjects and their | `edit` | Modify existing tags | | `delete` | Remove tags from the project | -#### Subject: `workspace` +#### Subject: `project` | Action | Description | | -------- | ------------------------- | @@ -135,6 +135,18 @@ Below is a comprehensive list of all available project-level subjects and their | `edit` | Modify token properties | | `delete` | Revoke or remove service tokens | +#### Subject: `app-connections` + +Supports conditions and permission inversion + +| Action | Description | +| ------------------------- | ---------------------------------- | +| `read-app-connections` | View app connection configurations | +| `create-app-connections` | Create new app connections | +| `edit-app-connections` | Modify existing app connections | +| `delete-app-connections` | Remove app connections | +| `connect-app-connections` | Use app connections | + ### Secrets Management #### Subject: `secrets` diff --git a/docs/sdks/languages/node.mdx b/docs/sdks/languages/node.mdx index e28e9c9f2..b56bf7400 100644 --- a/docs/sdks/languages/node.mdx +++ b/docs/sdks/languages/node.mdx @@ -50,6 +50,7 @@ The SDK methods are organized into the following high-level categories: 4. `projects`: Creates and manages projects. 5. `environments`: Creates and manages environments. 6. `folders`: Creates and manages folders. +7. `kms`: Manages KMS keys and encryption/signing operations. ### `auth` @@ -456,7 +457,7 @@ const project = await client.projects().create({ projectDescription: "", // Optional slug: "", // Optional template: "", // Optional - kmsKeyId: "kms-key-id" // Optional + kmsKeyId: "" // Optional }); ``` @@ -556,4 +557,223 @@ const folders = await client.folders().listFolders({ - `recursive` (boolean): An optional flag to list folders recursively. Defaults to `false`. **Returns:** -- `Folder[]`: An array of folders. \ No newline at end of file +- `Folder[]`: An array of folders. + +### `kms` + +The KMS (Key Management Service) module allows you to create and manage cryptographic keys for encryption and digital signing operations. + +#### Create a new KMS key + +Creating a new KMS key can be done by using the `.kms().keys().create({})` function. Keys can be created for either encryption/decryption or signing/verification operations. + +##### Example for creating an encryption key + +```typescript +import { InfisicalSDK, KeyUsage, EncryptionAlgorithm } from "@infisical/sdk"; +const client = new InfisicalSDK(); + +await client.auth().universalAuth.login({ + clientId: "CLIENT_ID", + clientSecret: "CLIENT_SECRET" +}); + +const encryptionKey = await client.kms().keys().create({ + projectId: "your-project-id", + name: "my-encryption-key", + description: "Key for encrypting sensitive data", + keyUsage: KeyUsage.ENCRYPTION, + encryptionAlgorithm: EncryptionAlgorithm.AES_256_GCM +}); + +console.log(encryptionKey); +``` + +##### Example for creating a signing key + +```typescript +const signingKey = await client.kms().keys().create({ + projectId: "your-project-id", + name: "my-signing-key", + description: "Key for signing documents", + keyUsage: KeyUsage.SIGNING, + encryptionAlgorithm: EncryptionAlgorithm.RSA_4096 +}); + +console.log(signingKey); +``` + +**Parameters:** +- `projectId` (string): The ID of your project. +- `name` (string): The name of the KMS key. +- `description` (string, optional): A description of the key's purpose. +- `keyUsage` (KeyUsage): Either `KeyUsage.ENCRYPTION` for encrypt/decrypt operations or `KeyUsage.SIGNING` for sign/verify operations. +- `encryptionAlgorithm` (EncryptionAlgorithm): The algorithm to use. Options include: + - For encryption: `AES_256_GCM`, `AES_128_GCM`, `RSA_4096`, `ECC_NIST_P256` + - For signing: `RSA_4096`, `ECC_NIST_P256` + +**Returns:** +- `KmsKey`: The created KMS key object. + +#### Get a KMS key by name + +```typescript +const key = await client.kms().keys().getByName({ + projectId: "your-project-id", + name: "my-encryption-key" +}); + +console.log(key); +``` + +**Parameters:** +- `projectId` (string): The ID of your project. +- `name` (string): The name of the KMS key to retrieve. + +**Returns:** +- `KmsKey`: The KMS key object. + +#### Delete a KMS key + +```typescript +const deletedKey = await client.kms().keys().delete({ + keyId: "" +}); + +console.log(deletedKey); +``` + +**Parameters:** +- `keyId` (string): The ID of the KMS key to delete. + +**Returns:** +- `KmsKey`: The deleted KMS key object. + +### `kms.encryption` + +The encryption module provides operations for encrypting and decrypting data using KMS keys created with `KeyUsage.ENCRYPTION`. + +#### Encrypt data + +```typescript +const encrypted = await client.kms().encryption().encrypt({ + keyId: "", + plaintext: "" +}); + +console.log(encrypted); // Returns the ciphertext string +``` + +**Parameters:** +- `keyId` (string): The ID of the encryption key. +- `plaintext` (string): The data to encrypt. This must be base64 encoded. + +**Returns:** +- `string`: The encrypted ciphertext. + +#### Decrypt data + +```typescript +const decrypted = await client.kms().encryption().decrypt({ + keyId: "", + ciphertext: "" +}); + +console.log(decrypted); // Returns the original plaintext +``` + +**Parameters:** +- `keyId` (string): The ID of the encryption key used to encrypt the data. +- `ciphertext` (string): The encrypted data to decrypt. + +**Returns:** +- `string`: The decrypted plaintext. + +### `kms.signing` + +The signing module provides operations for digitally signing data and verifying signatures using KMS keys created with `KeyUsage.SIGNING`. + +#### Sign data + +```typescript +import { SigningAlgorithm } from "@infisical/sdk"; + +const signature = await client.kms().signing().sign({ + keyId: "", + data: "", + signingAlgorithm: SigningAlgorithm.RSASSA_PSS_SHA_256, + isDigest: false // Optional: set to true if data is already a hash digest +}); + +console.log(signature); +``` + +**Parameters:** +- `keyId` (string): The ID of the signing key. +- `data` (string): The data to sign. +- `signingAlgorithm` (SigningAlgorithm): The signing algorithm to use. Available algorithms: + - **RSA PSS** (non-deterministic): `RSASSA_PSS_SHA_256`, `RSASSA_PSS_SHA_384`, `RSASSA_PSS_SHA_512` + - **RSA PKCS#1 v1.5** (deterministic): `RSASSA_PKCS1_V1_5_SHA_256`, `RSASSA_PKCS1_V1_5_SHA_384`, `RSASSA_PKCS1_V1_5_SHA_512` + - **ECDSA** (non-deterministic): `ECDSA_SHA_256`, `ECDSA_SHA_384`, `ECDSA_SHA_512` +- `isDigest` (boolean, optional): Whether the data is already a hash digest. Defaults to `false`. + +**Returns:** +- `KmsSignDataResponse`: Object containing the signature, keyId, and signingAlgorithm. + +#### Verify a signature + +```typescript +const verification = await client.kms().signing().verify({ + keyId: "", + data: "", // Must be base64 encoded + signature: "", + signingAlgorithm: SigningAlgorithm.RSASSA_PSS_SHA_256, + isDigest: false // Optional: set to true if data is already a hash digest +}); + +console.log(verification.signatureValid); // true or false +``` + +**Parameters:** +- `keyId` (string): The ID of the signing key used to create the signature. +- `data` (string): The original data that was signed (must be base64 encoded). +- `signature` (string): The signature to verify. +- `signingAlgorithm` (SigningAlgorithm): The same signing algorithm used to create the signature. +- `isDigest` (boolean, optional): Whether the data is already a hash digest. Defaults to `false`. + +**Returns:** +- `KmsVerifyDataResponse`: Object containing `signatureValid` (boolean), `keyId`, and `signingAlgorithm`. + +#### Get supported signing algorithms for a key + +```typescript +const algorithms = await client.kms().signing().listSigningAlgorithms({ + keyId: "" +}); + +console.log(algorithms); // Array of supported SigningAlgorithm values +``` + +**Parameters:** +- `keyId` (string): The ID of the KMS signing key. + +**Returns:** +- `SigningAlgorithm[]`: Array of supported signing algorithms for the key. + +#### Get public key + +Retrieve the public key for signature verification operations. + +```typescript +const publicKey = await client.kms().signing().getPublicKey({ + keyId: "" +}); + +console.log(publicKey); // Returns the public key string +``` + +**Parameters:** +- `keyId` (string): The ID of the KMS signing key. + +**Returns:** +- `string`: The public key in PEM format. \ No newline at end of file diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 8d351d7f2..551c79184 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -672,6 +672,16 @@ You can configure third-party app connections for re-use across Infisical Projec + + + The Application ID of your Heroku OAuth application. + + + + The Secret of your Heroku OAuth application. + + + ## Native Secret Integrations To help you sync secrets from Infisical to services such as Github and Gitlab, Infisical provides native integrations out of the box. diff --git a/docs/self-hosting/guides/upgrading-infisical.mdx b/docs/self-hosting/guides/upgrading-infisical.mdx index cae8193bf..0ffe32346 100644 --- a/docs/self-hosting/guides/upgrading-infisical.mdx +++ b/docs/self-hosting/guides/upgrading-infisical.mdx @@ -41,9 +41,16 @@ Now, migrations run automatically during boot-up. This improvement streamlines t - Ensure you have a complete backup of your Postgres database. - Verify that your backup is current and accessible. -2. **Select the Upgrade Version:** - - Visit the [Infisical releases page](https://github.com/Infisical/infisical/releases) for a list of available versions. - - Look for releases with the prefix `infisical/` as there are other releases that are not related to the Infisical instance. +2. **Plan Your Upgrade Path:** + - Use our [Upgrade Path Tool](https://app.infisical.com/upgrade-path) to analyze your upgrade path between your current version and target version. + - The tool will show you: + - **Breaking changes** that require action before upgrading + - **Database migrations** that may require additional settings or precautions + - **Step-by-step upgrade path** with intermediate versions if needed + - Review any breaking changes and plan necessary configuration updates before proceeding. + - Visit the [Infisical releases page](https://github.com/Infisical/infisical/releases) for a complete list of available versions. + +![Upgrade Path Tool showing breaking changes and migration information](/images/self-hosting/helper/upgrade-path-tool.png) 3. **Start the Upgrade Process:** - Launch the new version of Infisical. During startup, the application will automatically compare the current database schema with the updated schema in the code. diff --git a/docs/snippets/AppConnectionsBrowser.jsx b/docs/snippets/AppConnectionsBrowser.jsx index 72ad40cff..951a643dd 100644 --- a/docs/snippets/AppConnectionsBrowser.jsx +++ b/docs/snippets/AppConnectionsBrowser.jsx @@ -42,6 +42,7 @@ export const AppConnectionsBrowser = () => { {"name": "PostgreSQL", "slug": "postgres", "path": "/integrations/app-connections/postgres", "description": "Learn how to connect your PostgreSQL database to pull secrets from Infisical.", "category": "Databases"}, {"name": "Microsoft SQL Server", "slug": "mssql", "path": "/integrations/app-connections/mssql", "description": "Learn how to connect your SQL Server database to pull secrets from Infisical.", "category": "Databases"}, {"name": "Oracle Database", "slug": "oracledb", "path": "/integrations/app-connections/oracledb", "description": "Learn how to connect your Oracle database to pull secrets from Infisical.", "category": "Databases"}, + {"name": "Redis", "slug": "redis", "path": "/integrations/app-connections/redis", "description": "Learn how to connect Redis to pull secrets from Infisical.", "category": "Databases"}, {"name": "LDAP", "slug": "ldap", "path": "/integrations/app-connections/ldap", "description": "Learn how to connect your LDAP to pull secrets from Infisical.", "category": "Directory Services"}, {"name": "Auth0", "slug": "auth0", "path": "/integrations/app-connections/auth0", "description": "Learn how to connect your Auth0 to pull secrets from Infisical.", "category": "Identity & Auth"}, {"name": "Okta", "slug": "okta", "path": "/integrations/app-connections/okta", "description": "Learn how to connect your Okta to pull secrets from Infisical.", "category": "Identity & Auth"} diff --git a/docs/snippets/DynamicSecretsBrowser.jsx b/docs/snippets/DynamicSecretsBrowser.jsx index 6438deea0..650e47c3c 100644 --- a/docs/snippets/DynamicSecretsBrowser.jsx +++ b/docs/snippets/DynamicSecretsBrowser.jsx @@ -10,6 +10,7 @@ export const DynamicSecretsBrowser = () => { {"name": "AWS IAM", "slug": "aws-iam", "path": "/documentation/platform/dynamic-secrets/aws-iam", "description": "Learn how to generate dynamic AWS IAM credentials on-demand.", "category": "Cloud Providers"}, {"name": "AWS ElastiCache", "slug": "aws-elasticache", "path": "/documentation/platform/dynamic-secrets/aws-elasticache", "description": "Learn how to generate dynamic AWS ElastiCache credentials on-demand.", "category": "Caches"}, {"name": "Azure Entra ID", "slug": "azure-entra-id", "path": "/documentation/platform/dynamic-secrets/azure-entra-id", "description": "Learn how to generate dynamic Azure Entra ID credentials on-demand.", "category": "Cloud Providers"}, + {"name": "Azure SQL Database", "slug": "azure-sql-database", "path": "/documentation/platform/dynamic-secrets/azure-sql-database", "description": "Learn how to generate dynamic Azure SQL Database credentials on-demand.", "category": "Databases"}, {"name": "GCP IAM", "slug": "gcp-iam", "path": "/documentation/platform/dynamic-secrets/gcp-iam", "description": "Learn how to generate dynamic GCP IAM credentials on-demand.", "category": "Cloud Providers"}, {"name": "Cassandra", "slug": "cassandra", "path": "/documentation/platform/dynamic-secrets/cassandra", "description": "Learn how to generate dynamic Cassandra database credentials on-demand.", "category": "Databases"}, {"name": "Couchbase", "slug": "couchbase", "path": "/documentation/platform/dynamic-secrets/couchbase", "description": "Learn how to generate dynamic Couchbase database credentials on-demand.", "category": "Databases"}, diff --git a/docs/snippets/RotationsBrowser.jsx b/docs/snippets/RotationsBrowser.jsx index 3d50656b6..3dbede698 100644 --- a/docs/snippets/RotationsBrowser.jsx +++ b/docs/snippets/RotationsBrowser.jsx @@ -14,6 +14,7 @@ export const RotationsBrowser = () => { {"name": "LDAP Password", "slug": "ldap-password", "path": "/documentation/platform/secret-rotation/ldap-password", "description": "Learn how to automatically rotate LDAP user passwords.", "category": "Identity & Auth"}, {"name": "MySQL", "slug": "mysql-credentials", "path": "/documentation/platform/secret-rotation/mysql-credentials", "description": "Learn how to automatically rotate MySQL database credentials.", "category": "Databases"}, {"name": "PostgreSQL", "slug": "postgres-credentials", "path": "/documentation/platform/secret-rotation/postgres-credentials", "description": "Learn how to automatically rotate PostgreSQL database credentials.", "category": "Databases"}, + {"name": "Redis", "slug": "redis-credentials", "path": "/documentation/platform/secret-rotation/redis-credentials", "description": "Learn how to automatically rotate Redis database credentials.", "category": "Databases"}, {"name": "Microsoft SQL Server", "slug": "mssql-credentials", "path": "/documentation/platform/secret-rotation/mssql-credentials", "description": "Learn how to automatically rotate Microsoft SQL Server credentials.", "category": "Databases"}, {"name": "Oracle Database", "slug": "oracledb-credentials", "path": "/documentation/platform/secret-rotation/oracledb-credentials", "description": "Learn how to automatically rotate Oracle Database credentials.", "category": "Databases"} ].sort(function(a, b) { diff --git a/frontend/public/images/integrations/Redis.png b/frontend/public/images/integrations/Redis.png new file mode 100644 index 000000000..3ef8adffd Binary files /dev/null and b/frontend/public/images/integrations/Redis.png differ diff --git a/frontend/src/components/app-connections/AppConnectionOption.tsx b/frontend/src/components/app-connections/AppConnectionOption.tsx new file mode 100644 index 000000000..2978dc9f0 --- /dev/null +++ b/frontend/src/components/app-connections/AppConnectionOption.tsx @@ -0,0 +1,45 @@ +import { components, OptionProps } from "react-select"; +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; +import { faBuilding, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Badge, Tooltip } from "@app/components/v2"; +import { TAvailableAppConnection } from "@app/hooks/api/appConnections"; + +export const AppConnectionOption = ({ + isSelected, + children, + ...props +}: OptionProps) => { + const isCreateOption = props.data.id === "_create"; + + return ( + +
+ {isCreateOption ? ( +
+ + Create New Connection +
+ ) : ( + <> +

{children}

+ {!props.data.projectId && ( + +
+ + + Organization + +
+
+ )} + {isSelected && ( + + )} + + )} +
+
+ ); +}; diff --git a/frontend/src/components/app-connections/index.ts b/frontend/src/components/app-connections/index.ts new file mode 100644 index 000000000..7ced7c907 --- /dev/null +++ b/frontend/src/components/app-connections/index.ts @@ -0,0 +1 @@ +export * from "./AppConnectionOption"; diff --git a/frontend/src/components/auth/Mfa.tsx b/frontend/src/components/auth/Mfa.tsx index 64c8a5ee6..efc33d52b 100644 --- a/frontend/src/components/auth/Mfa.tsx +++ b/frontend/src/components/auth/Mfa.tsx @@ -5,10 +5,12 @@ import { t } from "i18next"; import Error from "@app/components/basic/Error"; import TotpRegistration from "@app/components/mfa/TotpRegistration"; +import { createNotification } from "@app/components/notifications"; import SecurityClient from "@app/components/utilities/SecurityClient"; -import { Button, Input } from "@app/components/v2"; -import { useSendMfaToken } from "@app/hooks/api"; -import { checkUserTotpMfa, verifyMfaToken } from "@app/hooks/api/auth/queries"; +import { Button, Tooltip } from "@app/components/v2"; +import { isInfisicalCloud } from "@app/helpers/platform"; +import { useLogoutUser, useSendMfaToken } from "@app/hooks/api"; +import { checkUserTotpMfa, verifyMfaToken, verifyRecoveryCode } from "@app/hooks/api/auth/queries"; import { MfaMethod } from "@app/hooks/api/auth/types"; // The style for the verification code input @@ -17,10 +19,10 @@ const codeInputProps = { fontFamily: "monospace", margin: "4px", MozAppearance: "textfield", - width: "48px", + width: "55px", borderRadius: "5px", fontSize: "24px", - height: "48px", + height: "55px", paddingLeft: "7", backgroundColor: "#0d1117", color: "white", @@ -60,11 +62,13 @@ type Props = { export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Props) => { const [mfaCode, setMfaCode] = useState(""); + const [showRecoveryCodeInput, setShowRecoveryCodeInput] = useState(false); const navigate = useNavigate(); const [isLoading, setIsLoading] = useState(false); const [isLoadingResend, setIsLoadingResend] = useState(false); const [triesLeft, setTriesLeft] = useState(undefined); const [shouldShowTotpRegistration, setShouldShowTotpRegistration] = useState(false); + const logout = useLogoutUser(true); const sendMfaToken = useSendMfaToken(); @@ -79,35 +83,57 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop } }, []); + const getExpectedCodeLength = () => { + if (method === MfaMethod.EMAIL) return 6; + if (method === MfaMethod.TOTP) return showRecoveryCodeInput ? 8 : 6; + return 6; + }; + + const isCodeComplete = mfaCode.length === getExpectedCodeLength(); + const verifyMfa = async (event: React.FormEvent) => { event.preventDefault(); + if (!mfaCode.trim() || !isCodeComplete) return; + setIsLoading(true); try { - const { token } = await verifyMfaToken({ - email, - mfaCode, - mfaMethod: method - }); + let result; + + if (method === MfaMethod.TOTP && showRecoveryCodeInput) { + result = await verifyRecoveryCode(mfaCode.trim()); + } else { + result = await verifyMfaToken({ + email, + mfaCode: mfaCode.trim(), + mfaMethod: method + }); + } SecurityClient.setMfaToken(""); - SecurityClient.setToken(token); + SecurityClient.setToken(result.token); await successCallback(); if (closeMfa) { closeMfa(); } } catch { - if (triesLeft) { - setTriesLeft((left) => { - if (triesLeft === 1) { - navigate({ to: "/" }); - - SecurityClient.setMfaToken(""); - SecurityClient.setToken(""); - } - return (left as number) - 1; - }); + if (typeof triesLeft === "number") { + const newTriesLeft = triesLeft - 1; + setTriesLeft(newTriesLeft); + if (newTriesLeft <= 0) { + createNotification({ + text: "User is temporary locked due to multiple failed login attempts. Try again later. You can also reset your password now to proceed.", + type: "error" + }); + setIsLoading(false); + SecurityClient.setMfaToken(""); + SecurityClient.setToken(""); + SecurityClient.setSignupToken(""); + await logout.mutateAsync(); + navigate({ to: "/login" }); + return; + } } else { setTriesLeft(2); } @@ -147,7 +173,7 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop } return ( -
+
{!hideLogo && (
@@ -162,79 +188,134 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop )} {method === MfaMethod.TOTP && ( - <> -

- Authenticator MFA Required +

+

Two-Factor Authentication

+

+ {showRecoveryCodeInput + ? "Enter one of your backup recovery codes" + : "Enter the verification code from your authenticator app"}

-

- Open the authenticator app on your mobile device to get your verification code or enter - a recovery code. -

- +
)}
-
+
{method === MfaMethod.EMAIL && ( - +
+ +
)} {method === MfaMethod.TOTP && ( -
- setMfaCode(e.target.value)} /> +
+
)}
-
+
{method === MfaMethod.EMAIL && ( - +
+ +
)} {method === MfaMethod.TOTP && ( -
- setMfaCode(e.target.value)} /> +
+
)}
{typeof triesLeft === "number" && ( )} -
-
- -
+
+
{method === MfaMethod.TOTP && ( -
- - - Lost your recovery codes? Reset your account - - +
+ +
+ + {isInfisicalCloud() ? ( + <> +
Account Recovery Required
+
+ Contact support with valid proof of account ownership to initiate recovery +
+
support@infisical.com
+ + ) : ( + <> +
Account Recovery Required
+
+ Contact your instance administrator with valid proof of account ownership to + initiate recovery +
+ + )} +
+ } + > + + Lost your recovery codes? + + +
)} {method === MfaMethod.EMAIL && ( diff --git a/frontend/src/components/mfa/RecoveryCodesDownload.tsx b/frontend/src/components/mfa/RecoveryCodesDownload.tsx new file mode 100644 index 000000000..50219f7dc --- /dev/null +++ b/frontend/src/components/mfa/RecoveryCodesDownload.tsx @@ -0,0 +1,111 @@ +import { useState } from "react"; +import { faCopy, faDownload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button, Modal, ModalContent } from "../v2"; + +type Props = { + isOpen: boolean; + onClose: () => void; + recoveryCodes: string[]; + onDownloadComplete: () => void; +}; + +export const RecoveryCodesDownload = ({ + isOpen, + onClose, + recoveryCodes, + onDownloadComplete +}: Props) => { + const [hasDownloaded, setHasDownloaded] = useState(false); + const [copied, setCopied] = useState(false); + + const downloadRecoveryCodes = () => { + const content = [...recoveryCodes].join("\n"); + + const blob = new Blob([content], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `infisical-recovery-codes-${new Date().toISOString().split("T")[0]}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + setHasDownloaded(true); + }; + + const copyToClipboard = async () => { + const text = recoveryCodes.join("\n"); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy recovery codes:", err); + } + }; + + const handleClose = () => { + if (hasDownloaded) { + onDownloadComplete(); + onClose(); + } + }; + + return ( + {}}> + +
+
+ Save these codes securely. Each can only be used once. +
+ +
+
+ {recoveryCodes.map((code, index) => ( +
+ {index + 1}. + {code} +
+ ))} +
+
+ +
+ + + +
+ + {hasDownloaded ? ( +

+ Recovery codes downloaded. You can now close this modal. +

+ ) : ( +

+ Download the recovery codes to continue. +

+ )} +
+
+
+ ); +}; diff --git a/frontend/src/components/mfa/TotpRegistration.tsx b/frontend/src/components/mfa/TotpRegistration.tsx index b6e2ebe2a..0b79bfd98 100644 --- a/frontend/src/components/mfa/TotpRegistration.tsx +++ b/frontend/src/components/mfa/TotpRegistration.tsx @@ -7,6 +7,7 @@ import { useVerifyUserTotpRegistration } from "@app/hooks/api/users/mutation"; import { createNotification } from "../notifications"; import { Button, ContentLoader, Input } from "../v2"; +import { RecoveryCodesDownload } from "./RecoveryCodesDownload"; type Props = { onComplete?: () => Promise; @@ -19,20 +20,39 @@ const TotpRegistration = ({ onComplete, shouldCenterQr }: Props) => { useVerifyUserTotpRegistration(); const [qrCodeUrl, setQrCodeUrl] = useState(""); const [totp, setTotp] = useState(""); + const [showRecoveryModal, setShowRecoveryModal] = useState(false); + const [recoveryCodes, setRecoveryCodes] = useState([]); const handleTotpVerify = async (event: React.FormEvent) => { event.preventDefault(); - await verifyUserTotp({ - totp - }); + try { + const result = await verifyUserTotp({ + totp + }); - createNotification({ - text: "Successfully configured mobile authenticator", - type: "success" - }); + createNotification({ + text: "Successfully configured mobile authenticator", + type: "success" + }); + if (result.recoveryCodes && result.recoveryCodes.length > 0) { + setRecoveryCodes(result.recoveryCodes); + setShowRecoveryModal(true); + } else if (onComplete) { + onComplete(); + } + } catch { + createNotification({ + text: "Failed to verify TOTP code", + type: "error" + }); + } + }; + + const handleRecoveryDownloadComplete = async () => { + setShowRecoveryModal(false); if (onComplete) { - onComplete(); + await onComplete(); } }; @@ -52,28 +72,37 @@ const TotpRegistration = ({ onComplete, shouldCenterQr }: Props) => { } return ( -
-
- 1. Download a two-step verification app (Duo, Google Authenticator, etc.) and scan the QR - code. -
-
- registration-qr -
-
-
2. Enter the resulting verification code
-
- setTotp(e.target.value)} - value={totp} - placeholder="Verification code" - /> - + <> +
+
+ 1. Download a two-step verification app (Duo, Google Authenticator, etc.) and scan the QR + code.
- -
+
+ registration-qr +
+
+
2. Enter the resulting verification code
+
+ setTotp(e.target.value)} + value={totp} + placeholder="Verification code" + /> + +
+
+
+ + setShowRecoveryModal(false)} + recoveryCodes={recoveryCodes} + onDownloadComplete={handleRecoveryDownloadComplete} + /> + ); }; diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index 1283c02d1..b16015680 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -4,7 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Link, useParams } from "@tanstack/react-router"; import { twMerge } from "tailwind-merge"; -import { useOrganization, useWorkspace } from "@app/context"; +import { useOrganization, useProject } from "@app/context"; import { useToggle } from "@app/hooks"; import { createNotification } from "../notifications"; @@ -51,7 +51,7 @@ export default function NavHeader({ isProtectedBranch = false, protectionPolicyName }: Props): JSX.Element { - const { currentWorkspace } = useWorkspace(); + const { currentProject } = useProject(); const { currentOrg } = useOrganization(); const [isCopied, { timedToggle: toggleIsCopied }] = useToggle(false); @@ -79,7 +79,7 @@ export default function NavHeader({ <>
- {currentWorkspace?.name} + {currentProject?.name}
)} @@ -93,7 +93,7 @@ export default function NavHeader({ {pageName === "Secrets" ? ( {pageName} @@ -129,7 +129,7 @@ export default function NavHeader({ {userAvailableEnvs?.find(({ slug }) => slug === currentEnv)?.name} @@ -192,7 +192,7 @@ export default function NavHeader({ ({ ...query, secretPath: newSecretPath })} diff --git a/frontend/src/components/permissions/AccessTree/hooks/index.ts b/frontend/src/components/permissions/AccessTree/hooks/index.ts index 717bdaf3a..b02e6a9d6 100644 --- a/frontend/src/components/permissions/AccessTree/hooks/index.ts +++ b/frontend/src/components/permissions/AccessTree/hooks/index.ts @@ -3,7 +3,7 @@ import { useFormContext, useWatch } from "react-hook-form"; import { MongoAbility, MongoQuery } from "@casl/ability"; import { Edge, Node, useEdgesState, useNodesState } from "@xyflow/react"; -import { ProjectPermissionSub, useWorkspace } from "@app/context"; +import { ProjectPermissionSub, useProject } from "@app/context"; import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; import { useListProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/queries"; import { TSecretFolderWithPath } from "@app/hooks/api/secretFolders/types"; @@ -36,15 +36,15 @@ export const useAccessTree = ( searchPath: string, subject: ProjectPermissionSub ) => { - const { currentWorkspace } = useWorkspace(); + const { currentProject } = useProject(); const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext(); const { control } = useFormContext(); const metadata = useWatch({ control, name: "metadata" }); const [nodes, setNodes] = useNodesState([]); const [edges, setEdges] = useEdgesState([]); - const [environment, setEnvironment] = useState(currentWorkspace.environments[0]?.slug ?? ""); + const [environment, setEnvironment] = useState(currentProject.environments[0]?.slug ?? ""); const { data: environmentsFolders, isPending } = useListProjectEnvironmentsFolders( - currentWorkspace.id + currentProject.id ); const [levelFolderMap, setLevelFolderMap] = useState({}); @@ -279,7 +279,7 @@ export const useAccessTree = ( environment, setEnvironment, isLoading: isPending, - environments: currentWorkspace.environments, + environments: currentProject.environments, secretName, setSecretName, viewMode, diff --git a/frontend/src/components/permissions/OrgPermissionCan.tsx b/frontend/src/components/permissions/OrgPermissionCan.tsx index 8e0bf08ad..bdb39ba1b 100644 --- a/frontend/src/components/permissions/OrgPermissionCan.tsx +++ b/frontend/src/components/permissions/OrgPermissionCan.tsx @@ -1,8 +1,10 @@ import { FunctionComponent, ReactNode } from "react"; -import { BoundCanProps, Can } from "@casl/react"; +import { AbilityTuple, MongoAbility } from "@casl/ability"; +import { Can } from "@casl/react"; import { TooltipProps } from "@app/components/v2/Tooltip/Tooltip"; -import { TOrgPermission, useOrgPermission } from "@app/context/OrgPermissionContext"; +import { useOrgPermission } from "@app/context/OrgPermissionContext"; +import { OrgPermissionSet } from "@app/context/OrgPermissionContext/types"; import { AccessRestrictedBanner, Tooltip } from "../v2"; @@ -14,7 +16,7 @@ export const OrgPermissionGuardBanner = () => { ); }; -type Props = { +type Props = { label?: ReactNode; // this prop is used when there exist already a tooltip as helper text for users // so when permission is allowed same tooltip will be reused to show helpertext @@ -22,9 +24,18 @@ type Props = { allowedLabel?: string; renderGuardBanner?: boolean; tooltipProps?: Omit; -} & BoundCanProps; + I: T[0]; + ability?: MongoAbility; + children: ReactNode | ((isAllowed: boolean, ability: T) => ReactNode); + passThrough?: boolean; +} & ( + | { an: T[1] } + | { + a: T[1]; + } +); -export const OrgPermissionCan: FunctionComponent = ({ +export const OrgPermissionCan: FunctionComponent> = ({ label = "Access restricted", children, passThrough = true, @@ -41,9 +52,7 @@ export const OrgPermissionCan: FunctionComponent = ({ {(isAllowed, ability) => { // akhilmhdh: This is set as type due to error in casl react type. const finalChild = - typeof children === "function" - ? children(isAllowed, ability as TOrgPermission) - : children; + typeof children === "function" ? children(isAllowed, ability as any) : children; if (!isAllowed && passThrough) { return ( diff --git a/frontend/src/components/permissions/VariablePermissionCan.tsx b/frontend/src/components/permissions/VariablePermissionCan.tsx new file mode 100644 index 000000000..a9e24e3c7 --- /dev/null +++ b/frontend/src/components/permissions/VariablePermissionCan.tsx @@ -0,0 +1,17 @@ +import { OrgPermissionCan } from "./OrgPermissionCan"; +import { ProjectPermissionCan } from "./ProjectPermissionCan"; + +interface PermissionCanProps { + type: "project" | "org"; + I: any; + a: any; + children: (isAllowed: boolean, ability?: any) => React.ReactNode; +} + +export const VariablePermissionCan = ({ type, children, ...props }: PermissionCanProps) => { + if (type === "project") { + return {children}; + } + + return {children}; +}; diff --git a/frontend/src/components/permissions/index.tsx b/frontend/src/components/permissions/index.tsx index c40079a4f..db6ca3296 100644 --- a/frontend/src/components/permissions/index.tsx +++ b/frontend/src/components/permissions/index.tsx @@ -3,3 +3,4 @@ export { GlobPermissionInfo } from "./GlobPermissionInfo"; export { OrgPermissionCan } from "./OrgPermissionCan"; export { PermissionDeniedBanner } from "./PermissionDeniedBanner"; export { ProjectPermissionCan } from "./ProjectPermissionCan"; +export * from "./VariablePermissionCan"; diff --git a/frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx b/frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx new file mode 100644 index 000000000..5b5e81ba3 --- /dev/null +++ b/frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from "react"; + +import { Modal, ModalContent } from "@app/components/v2"; +import { PkiSync, TPkiSync } from "@app/hooks/api/pkiSyncs"; + +import { CreatePkiSyncForm } from "./forms"; +import { PkiSyncModalHeader } from "./PkiSyncModalHeader"; +import { PkiSyncSelect } from "./PkiSyncSelect"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + selectSync?: PkiSync | null; +}; + +type ContentProps = { + onComplete: (pkiSync: TPkiSync) => void; + selectedSync: PkiSync | null; + setSelectedSync: (selectedSync: PkiSync | null) => void; +}; + +const Content = ({ onComplete, setSelectedSync, selectedSync }: ContentProps) => { + if (selectedSync) { + return ( + setSelectedSync(null)} + destination={selectedSync} + /> + ); + } + + return ; +}; + +export const CreatePkiSyncModal = ({ onOpenChange, selectSync = null, ...props }: Props) => { + const [selectedSync, setSelectedSync] = useState(selectSync); + + useEffect(() => { + setSelectedSync(selectSync); + }, [selectSync]); + + return ( + { + if (!isOpen) setSelectedSync(null); + onOpenChange(isOpen); + }} + > + + ) : ( + "Add Sync" + ) + } + className="max-w-2xl" + bodyClassName="overflow-visible" + subTitle={ + selectedSync ? undefined : "Select a third-party service to sync certificates to." + } + > + { + setSelectedSync(null); + onOpenChange(false); + }} + selectedSync={selectedSync} + setSelectedSync={setSelectedSync} + /> + + + ); +}; diff --git a/frontend/src/components/pki-syncs/DeletePkiSyncModal.tsx b/frontend/src/components/pki-syncs/DeletePkiSyncModal.tsx new file mode 100644 index 000000000..e465d3f34 --- /dev/null +++ b/frontend/src/components/pki-syncs/DeletePkiSyncModal.tsx @@ -0,0 +1,56 @@ +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal } from "@app/components/v2"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { TPkiSync, useDeletePkiSync } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSync?: TPkiSync; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onComplete?: () => void; +}; + +export const DeletePkiSyncModal = ({ isOpen, onOpenChange, pkiSync, onComplete }: Props) => { + const deleteSync = useDeletePkiSync(); + + if (!pkiSync) return null; + + const { id: syncId, name, destination, projectId } = pkiSync; + + const handleDeletePkiSync = async () => { + const destinationName = PKI_SYNC_MAP[destination].name; + + try { + await deleteSync.mutateAsync({ + syncId, + projectId, + destination + }); + + createNotification({ + text: `Successfully deleted ${destinationName} PKI Sync`, + type: "success" + }); + + if (onComplete) onComplete(); + onOpenChange(false); + } catch (err) { + console.error(err); + + createNotification({ + text: `Failed to delete ${destinationName} PKI Sync`, + type: "error" + }); + } + }; + + return ( + + ); +}; diff --git a/frontend/src/components/pki-syncs/EditPkiSyncModal.tsx b/frontend/src/components/pki-syncs/EditPkiSyncModal.tsx new file mode 100644 index 000000000..db5745aad --- /dev/null +++ b/frontend/src/components/pki-syncs/EditPkiSyncModal.tsx @@ -0,0 +1,29 @@ +import { PkiSyncEditFields } from "@app/components/pki-syncs/types"; +import { Modal, ModalContent } from "@app/components/v2"; +import { TPkiSync } from "@app/hooks/api/pkiSyncs"; + +import { EditPkiSyncForm } from "./forms"; +import { PkiSyncModalHeader } from "./PkiSyncModalHeader"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + pkiSync?: TPkiSync; + fields: PkiSyncEditFields; +}; + +export const EditPkiSyncModal = ({ pkiSync, onOpenChange, fields, ...props }: Props) => { + if (!pkiSync) return null; + + return ( + + } + className="max-w-2xl" + bodyClassName="overflow-visible" + > + onOpenChange(false)} fields={fields} pkiSync={pkiSync} /> + + + ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncImportCertificatesModal.tsx b/frontend/src/components/pki-syncs/PkiSyncImportCertificatesModal.tsx new file mode 100644 index 000000000..33b06dbe4 --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncImportCertificatesModal.tsx @@ -0,0 +1,95 @@ +import { createNotification } from "@app/components/notifications"; +import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { TPkiSync, useTriggerPkiSyncImportCertificates } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSync?: TPkiSync; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + pkiSync: TPkiSync; + onComplete: () => void; +}; + +const Content = ({ pkiSync, onComplete }: ContentProps) => { + const { id: syncId, destination, projectId } = pkiSync; + const destinationName = PKI_SYNC_MAP[destination].name; + + const triggerImportCertificates = useTriggerPkiSyncImportCertificates(); + + const handleTriggerImportCertificates = async () => { + try { + await triggerImportCertificates.mutateAsync({ + syncId, + destination, + projectId + }); + + createNotification({ + text: `Successfully triggered certificate import for ${destinationName} Sync`, + type: "success" + }); + + onComplete(); + } catch (err) { + console.error(err); + + createNotification({ + text: `Failed to trigger certificate import for ${destinationName} Sync`, + type: "error" + }); + } + }; + + return ( +
{ + e.preventDefault(); + handleTriggerImportCertificates(); + }} + > +

+ Are you sure you want to import certificates from this {destinationName} destination into + Infisical? +

+

+ This operation will retrieve certificates from {destinationName} and make them available in + your PKI subscriber. Only certificates that are not already imported will be processed. +

+
+ + + + +
+
+ ); +}; + +export const PkiSyncImportCertificatesModal = ({ isOpen, onOpenChange, pkiSync }: Props) => { + if (!pkiSync) return null; + + const destinationName = PKI_SYNC_MAP[pkiSync.destination].name; + + return ( + + + onOpenChange(false)} /> + + + ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncImportStatusBadge.tsx b/frontend/src/components/pki-syncs/PkiSyncImportStatusBadge.tsx new file mode 100644 index 000000000..13c6bf031 --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncImportStatusBadge.tsx @@ -0,0 +1,112 @@ +import { ReactNode, useEffect, useMemo, useState } from "react"; +import { + faCheck, + faDownload, + faTriangleExclamation, + faXmark, + IconDefinition +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { differenceInSeconds } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { Badge, Tooltip } from "@app/components/v2"; +import { BadgeProps } from "@app/components/v2/Badge/Badge"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { PkiSyncStatus, TPkiSync } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSync: TPkiSync; + className?: string; + mini?: boolean; +}; + +export const PkiSyncImportStatusBadge = ({ pkiSync, className, mini }: Props) => { + const { importStatus, lastImportMessage, lastImportedAt, destination } = pkiSync; + const [hide, setHide] = useState(importStatus === PkiSyncStatus.Succeeded); + const destinationName = PKI_SYNC_MAP[destination].name; + + useEffect(() => { + if (importStatus === PkiSyncStatus.Succeeded) { + setTimeout(() => setHide(true), 3000); + } else { + setHide(false); + } + }, [importStatus]); + + const failureMessage = useMemo(() => { + if (importStatus === PkiSyncStatus.Failed) { + if (lastImportMessage) + try { + return JSON.stringify(JSON.parse(lastImportMessage), null, 2); + } catch { + return lastImportMessage; + } + + return "An Unknown Error Occurred."; + } + return null; + }, [importStatus, lastImportMessage]); + + if (!importStatus || hide) return null; + + let variant: BadgeProps["variant"]; + let label: string; + let icon: IconDefinition; + let tooltipContent: ReactNode; + + switch (importStatus) { + case PkiSyncStatus.Pending: + case PkiSyncStatus.Running: + variant = "primary"; + label = "Importing Certificates..."; + tooltipContent = `Importing certificates from ${destinationName}. This may take a moment.`; + icon = faDownload; + + break; + case PkiSyncStatus.Failed: + variant = "danger"; + label = "Failed to Import Certificates"; + icon = faTriangleExclamation; + tooltipContent = ( +
+ {failureMessage && ( +
+
+ +
+ {mini ? "Failed to Import Certificates" : "Failure Reason"} +
+
+
{failureMessage}
+
+ )} +
+ ); + + break; + case PkiSyncStatus.Succeeded: + default: + // only show success for a bit... + if (lastImportedAt && differenceInSeconds(new Date(), lastImportedAt) > 15) return null; + + tooltipContent = "Successfully imported certificates."; + variant = "success"; + label = "Certificates Imported"; + icon = faCheck; + } + + return ( + +
+ + + {!mini && {label}} + +
+
+ ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncModalHeader.tsx b/frontend/src/components/pki-syncs/PkiSyncModalHeader.tsx new file mode 100644 index 000000000..917d221f6 --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncModalHeader.tsx @@ -0,0 +1,49 @@ +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { PkiSync } from "@app/hooks/api/pkiSyncs"; + +type Props = { + destination: PkiSync; + isConfigured: boolean; +}; + +export const PkiSyncModalHeader = ({ destination, isConfigured }: Props) => { + const destinationDetails = PKI_SYNC_MAP[destination]; + + return ( +
+ {`${destinationDetails.name} +
+
+ {destinationDetails.name} Certificate Sync + +
+ + Docs + +
+
+
+

+ {isConfigured + ? `Edit ${destinationDetails.name} Certificate Sync` + : `Sync certificates to ${destinationDetails.name}`} +

+
+
+ ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncRemoveCertificatesModal.tsx b/frontend/src/components/pki-syncs/PkiSyncRemoveCertificatesModal.tsx new file mode 100644 index 000000000..f17855289 --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncRemoveCertificatesModal.tsx @@ -0,0 +1,87 @@ +import { createNotification } from "@app/components/notifications"; +import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { TPkiSync, useTriggerPkiSyncRemoveCertificates } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSync?: TPkiSync; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + pkiSync: TPkiSync; + onComplete: () => void; +}; + +const Content = ({ pkiSync, onComplete }: ContentProps) => { + const { id: syncId, destination, projectId } = pkiSync; + const destinationName = PKI_SYNC_MAP[destination].name; + + const triggerRemoveCertificates = useTriggerPkiSyncRemoveCertificates(); + + const handleTriggerRemoveCertificates = async () => { + try { + await triggerRemoveCertificates.mutateAsync({ + syncId, + destination, + projectId + }); + + createNotification({ + text: `Successfully triggered certificate removal for ${destinationName} Sync`, + type: "success" + }); + + onComplete(); + } catch (err) { + console.error(err); + + createNotification({ + text: `Failed to trigger certificate removal for ${destinationName} Sync`, + type: "error" + }); + } + }; + + return ( +
{ + e.preventDefault(); + handleTriggerRemoveCertificates(); + }} + > +

+ Are you sure you want to remove certificates synced by Infisical from this {destinationName}{" "} + destination? +

+
+ + + + +
+
+ ); +}; + +export const PkiSyncRemoveCertificatesModal = ({ isOpen, onOpenChange, pkiSync }: Props) => { + if (!pkiSync) return null; + + const destinationName = PKI_SYNC_MAP[pkiSync.destination].name; + + return ( + + + onOpenChange(false)} /> + + + ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncRemoveStatusBadge.tsx b/frontend/src/components/pki-syncs/PkiSyncRemoveStatusBadge.tsx new file mode 100644 index 000000000..f87a0ae4d --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncRemoveStatusBadge.tsx @@ -0,0 +1,112 @@ +import { ReactNode, useEffect, useMemo, useState } from "react"; +import { + faCheck, + faEraser, + faTriangleExclamation, + faXmark, + IconDefinition +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { differenceInSeconds } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { Badge, Tooltip } from "@app/components/v2"; +import { BadgeProps } from "@app/components/v2/Badge/Badge"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { PkiSyncStatus, TPkiSync } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSync: TPkiSync; + className?: string; + mini?: boolean; +}; + +export const PkiSyncRemoveStatusBadge = ({ pkiSync, className, mini }: Props) => { + const { removeStatus, lastRemoveMessage, lastRemovedAt, destination } = pkiSync; + const [hide, setHide] = useState(removeStatus === PkiSyncStatus.Succeeded); + const destinationName = PKI_SYNC_MAP[destination].name; + + useEffect(() => { + if (removeStatus === PkiSyncStatus.Succeeded) { + setTimeout(() => setHide(true), 3000); + } else { + setHide(false); + } + }, [removeStatus]); + + const failureMessage = useMemo(() => { + if (removeStatus === PkiSyncStatus.Failed) { + if (lastRemoveMessage) + try { + return JSON.stringify(JSON.parse(lastRemoveMessage), null, 2); + } catch { + return lastRemoveMessage; + } + + return "An Unknown Error Occurred."; + } + return null; + }, [removeStatus, lastRemoveMessage]); + + if (!removeStatus || hide) return null; + + let variant: BadgeProps["variant"]; + let label: string; + let icon: IconDefinition; + let tooltipContent: ReactNode; + + switch (removeStatus) { + case PkiSyncStatus.Pending: + case PkiSyncStatus.Running: + variant = "primary"; + label = "Removing Certificates..."; + tooltipContent = `Removing certificates from ${destinationName}. This may take a moment.`; + icon = faEraser; + + break; + case PkiSyncStatus.Failed: + variant = "danger"; + label = "Failed to Remove Certificates"; + icon = faTriangleExclamation; + tooltipContent = ( +
+ {failureMessage && ( +
+
+ +
+ {mini ? "Failed to Remove Certificates" : "Failure Reason"} +
+
+
{failureMessage}
+
+ )} +
+ ); + + break; + case PkiSyncStatus.Succeeded: + default: + // only show success for a bit... + if (lastRemovedAt && differenceInSeconds(new Date(), lastRemovedAt) > 15) return null; + + tooltipContent = "Successfully removed certificates."; + variant = "success"; + label = "Certificates Removed"; + icon = faCheck; + } + + return ( + +
+ + + {!mini && {label}} + +
+
+ ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncSelect.tsx b/frontend/src/components/pki-syncs/PkiSyncSelect.tsx new file mode 100644 index 000000000..cf1dd1764 --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncSelect.tsx @@ -0,0 +1,155 @@ +import { useMemo } from "react"; +import { faInfoCircle, faMagnifyingGlass, faSearch } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { EmptyState, Input, Pagination, Spinner, Tooltip } from "@app/components/v2"; +import { useSubscription } from "@app/context"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; +import { PkiSync, usePkiSyncOptions } from "@app/hooks/api/pkiSyncs"; + +import { UpgradePlanModal } from "../license/UpgradePlanModal"; + +type Props = { + onSelect: (destination: PkiSync) => void; +}; + +export const PkiSyncSelect = ({ onSelect }: Props) => { + const { subscription } = useSubscription(); + const { isPending, data: pkiSyncOptions } = usePkiSyncOptions(); + + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); + + const { search, setSearch, setPage, page, perPage, setPerPage, offset } = usePagination("", { + initPerPage: 16 + }); + + const filteredOptions = useMemo( + () => + pkiSyncOptions?.filter(({ destination }) => { + const { name } = PKI_SYNC_MAP[destination]; + return ( + name?.toLowerCase().includes(search.trim().toLowerCase()) || + destination.toLowerCase().includes(search.toLowerCase()) + ); + }) ?? [], + [pkiSyncOptions, search] + ); + + useResetPageHelper({ + totalCount: filteredOptions.length, + offset, + setPage + }); + + if (isPending) { + return ( +
+ +

Loading options...

+
+ ); + } + + return ( +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search options..." + className="bg-mineshaft-800 placeholder:text-mineshaft-400" + /> +
+ {filteredOptions.slice(offset, perPage * page)?.map(({ destination, enterprise }) => { + const { image, name } = PKI_SYNC_MAP[destination]; + return ( + + ); + })} + {!filteredOptions?.length && ( + + )} +
+ {Boolean(filteredOptions.length) && ( + +

Infisical is constantly adding support for more services.

+

+ {`If you don't see the third-party + service you're looking for,`}{" "} + + let us know on Slack + {" "} + or{" "} + + make a request on GitHub + + . +

+ + } + > +
+ + Don't see the third-party service you're looking for? + + +
+ + } + count={filteredOptions.length} + page={page} + perPage={perPage} + onChangePage={setPage} + onChangePerPage={setPerPage} + perPageList={[16]} + /> + )} + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use every Certificate Sync if you switch to Infisical's Enterprise plan." + /> +
+ ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncStatusBadge.tsx b/frontend/src/components/pki-syncs/PkiSyncStatusBadge.tsx new file mode 100644 index 000000000..1fa207202 --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncStatusBadge.tsx @@ -0,0 +1,55 @@ +import { + faCheck, + faExclamationTriangle, + faHourglass, + faRotate, + IconDefinition +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Badge, BadgeProps } from "@app/components/v2/Badge/Badge"; +import { PkiSyncStatus } from "@app/hooks/api/pkiSyncs"; + +type Props = { + status: PkiSyncStatus; +} & Omit; + +export const PkiSyncStatusBadge = ({ status }: Props) => { + let variant: BadgeProps["variant"]; + let text: string; + let icon: IconDefinition; + + switch (status) { + case PkiSyncStatus.Failed: + variant = "danger"; + text = "Failed to Sync"; + icon = faExclamationTriangle; + break; + case PkiSyncStatus.Succeeded: + variant = "success"; + text = "Synced"; + icon = faCheck; + break; + case PkiSyncStatus.Pending: + variant = "primary"; + text = "Queued"; + icon = faHourglass; + break; + case PkiSyncStatus.Running: + default: + variant = "primary"; + text = "Syncing"; + icon = faRotate; + break; + } + + return ( + + + {text} + + ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncTable.tsx b/frontend/src/components/pki-syncs/PkiSyncTable.tsx new file mode 100644 index 000000000..983e4687c --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncTable.tsx @@ -0,0 +1,121 @@ +import { faPlug, faRefresh, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + Badge, + EmptyState, + IconButton, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { TPkiSync } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSyncs: TPkiSync[]; + onEdit: (pkiSync: TPkiSync) => void; + onDelete: (pkiSync: TPkiSync) => void; + onTrigger: (pkiSync: TPkiSync) => void; +}; + +const getSyncStatusBadge = (status?: string) => { + switch (status) { + case "SUCCESS": + return Success; + case "FAILED": + return Failed; + case "RUNNING": + return Running; + case "PENDING": + default: + return Pending; + } +}; + +export const PkiSyncTable = ({ pkiSyncs, onEdit, onDelete, onTrigger }: Props) => { + if (!pkiSyncs.length) { + return ( +
+ + Start by creating a PKI sync to synchronize certificates with external services + +
+ ); + } + + return ( + + + + + + + + + + + + + + {pkiSyncs.map((pkiSync) => ( + onEdit(pkiSync)} + > + + + + + + + + + ))} + +
NameDestinationConnectionAuto SyncStatusLast Sync +
{pkiSync.name} +
+ {pkiSync.destination} +
+
{pkiSync.appConnectionName || "Unknown"} + + {pkiSync.isAutoSyncEnabled ? "Enabled" : "Disabled"} + + {getSyncStatusBadge(pkiSync.syncStatus ?? undefined)} + {pkiSync.lastSyncedAt + ? new Date(pkiSync.lastSyncedAt).toLocaleDateString() + : "Never"} + +
+ { + e.stopPropagation(); + onTrigger(pkiSync); + }} + > + + + { + e.stopPropagation(); + onDelete(pkiSync); + }} + > + + +
+
+
+ ); +}; diff --git a/frontend/src/components/pki-syncs/forms/AzureKeyVaultPkiSyncFields.tsx b/frontend/src/components/pki-syncs/forms/AzureKeyVaultPkiSyncFields.tsx new file mode 100644 index 000000000..87feebff7 --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/AzureKeyVaultPkiSyncFields.tsx @@ -0,0 +1,37 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Input } from "@app/components/v2"; +import { PkiSync } from "@app/hooks/api/pkiSyncs"; + +import { PkiSyncConnectionField } from "./PkiSyncConnectionField"; +import { TPkiSyncForm } from "./schemas"; + +export const AzureKeyVaultPkiSyncFields = () => { + const { control, setValue } = useFormContext< + TPkiSyncForm & { destination: PkiSync.AzureKeyVault } + >(); + + return ( + <> + { + setValue("destinationConfig.vaultBaseUrl", ""); + }} + /> + ( + + + + )} + /> + + ); +}; diff --git a/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx b/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx new file mode 100644 index 000000000..02066c1cc --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx @@ -0,0 +1,245 @@ +import { useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Tab } from "@headlessui/react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Switch } from "@app/components/v2"; +import { useProject } from "@app/context"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { PkiSync, TPkiSync, useCreatePkiSync, usePkiSyncOption } from "@app/hooks/api/pkiSyncs"; + +import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields"; +import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields"; +import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields"; +import { PkiSyncReviewFields } from "./PkiSyncReviewFields"; +import { PkiSyncSourceFields } from "./PkiSyncSourceFields"; +import { PkiSyncFormSchema, TPkiSyncForm } from "./schemas"; + +type Props = { + onComplete: (pkiSync: TPkiSync) => void; + destination: PkiSync; + onCancel: () => void; +}; + +const FORM_TABS: { name: string; key: string; fields: (keyof TPkiSyncForm)[] }[] = [ + { name: "Source", key: "source", fields: ["subscriberId"] }, + { name: "Destination", key: "destination", fields: ["connection", "destinationConfig"] }, + { name: "Sync Options", key: "options", fields: ["syncOptions"] }, + { name: "Details", key: "details", fields: ["name", "description"] }, + { name: "Review", key: "review", fields: [] } +]; + +export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props) => { + const createPkiSync = useCreatePkiSync(); + const { currentProject } = useProject(); + const { name: destinationName } = PKI_SYNC_MAP[destination]; + + const [showConfirmation, setShowConfirmation] = useState(false); + + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + + const { syncOption } = usePkiSyncOption(destination); + + const formMethods = useForm({ + resolver: zodResolver(PkiSyncFormSchema), + defaultValues: { + destination, + isAutoSyncEnabled: false, + syncOptions: { + canImportCertificates: false, + canRemoveCertificates: false, + certificateNameSchema: syncOption?.defaultCertificateNameSchema + } + } as Partial, + reValidateMode: "onChange" + }); + + const onSubmit = async ({ connection, destinationConfig, ...formData }: TPkiSyncForm) => { + try { + const pkiSync = await createPkiSync.mutateAsync({ + ...formData, + connectionId: connection.id, + projectId: currentProject.id, + destinationConfig + }); + + createNotification({ + text: `Successfully added ${destinationName} Certificate Sync`, + type: "success" + }); + onComplete(pkiSync); + } catch (err: Error | unknown) { + console.error(err); + setShowConfirmation(false); + createNotification({ + title: `Failed to add ${destinationName} Certificate Sync`, + text: err instanceof Error ? err.message : "An unknown error occurred", + type: "error" + }); + } + }; + + const handlePrev = () => { + if (selectedTabIndex === 0) { + onCancel(); + return; + } + + setSelectedTabIndex((prev) => prev - 1); + }; + + const { handleSubmit, trigger, control } = formMethods; + + const isStepValid = async (index: number) => trigger(FORM_TABS[index].fields); + + const isFinalStep = selectedTabIndex === FORM_TABS.length - 1; + + const handleNext = async () => { + if (isFinalStep) { + setShowConfirmation(true); + return; + } + + const isValid = await isStepValid(selectedTabIndex); + + if (!isValid) return; + + setSelectedTabIndex((prev) => prev + 1); + }; + + const isTabEnabled = async (index: number) => { + let isEnabled = true; + for (let i = index - 1; i >= 0; i -= 1) { + // eslint-disable-next-line no-await-in-loop + isEnabled = isEnabled && (await isStepValid(i)); + } + + return isEnabled; + }; + + if (showConfirmation) + return ( + <> +
+
+ + Certificate Sync Behavior +
+

+ Only certificates managed by Infisical will be affected during sync operations. + Certificates not created or managed by Infisical will remain untouched, and changes made + to Infisical-managed certificates directly in the destination service may be overwritten + by future syncs. +

+
+
+ + + +
+ + ); + + return ( +
+ + + + {FORM_TABS.map((tab, index) => ( + { + e.preventDefault(); + const isEnabled = await isTabEnabled(index); + setSelectedTabIndex((prev) => (isEnabled ? index : prev)); + }} + className={({ selected }) => + `w-30 -mb-[0.14rem] ${index > selectedTabIndex ? "opacity-30" : ""} px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${ + selected + ? "border-b-2 border-mineshaft-300 text-mineshaft-200" + : "text-bunker-300" + }` + } + key={tab.key} + > + {index + 1}. {tab.name} + + ))} + + + + + + + + + + + { + return ( + + +

Auto-Sync {value ? "Enabled" : "Disabled"}

+
+
+ ); + }} + /> +
+ + + + + + +
+
+
+ +
+ + {selectedTabIndex > 0 && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx b/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx new file mode 100644 index 000000000..c4407ceee --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx @@ -0,0 +1,108 @@ +import { ReactNode } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import { createNotification } from "@app/components/notifications"; +import { PkiSyncEditFields } from "@app/components/pki-syncs/types"; +import { Button, ModalClose } from "@app/components/v2"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { TPkiSync, useUpdatePkiSync } from "@app/hooks/api/pkiSyncs"; + +import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields"; +import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields"; +import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields"; +import { PkiSyncSourceFields } from "./PkiSyncSourceFields"; +import { TPkiSyncForm, UpdatePkiSyncFormSchema } from "./schemas"; + +type Props = { + onComplete: (pkiSync: TPkiSync) => void; + pkiSync: TPkiSync; + fields: PkiSyncEditFields; +}; + +export const EditPkiSyncForm = ({ pkiSync, fields, onComplete }: Props) => { + const updatePkiSync = useUpdatePkiSync(); + const { name: destinationName } = PKI_SYNC_MAP[pkiSync.destination]; + + const formMethods = useForm({ + resolver: zodResolver(UpdatePkiSyncFormSchema), + defaultValues: { + ...pkiSync, + description: pkiSync.description ?? "", + connection: { + id: pkiSync.connectionId, + name: pkiSync.appConnectionName + } + } as Partial, + reValidateMode: "onChange" + }); + + const onSubmit = async ({ connection, ...formData }: TPkiSyncForm) => { + try { + const updatedPkiSync = await updatePkiSync.mutateAsync({ + syncId: pkiSync.id, + ...formData, + connectionId: connection.id, + projectId: pkiSync.projectId, + destination: pkiSync.destination + }); + + createNotification({ + text: `Successfully updated ${destinationName} PKI Sync`, + type: "success" + }); + onComplete(updatedPkiSync); + } catch (err: any) { + console.error(err); + createNotification({ + title: `Failed to update ${destinationName} PKI Sync`, + text: err.message, + type: "error" + }); + } + }; + + let Component: ReactNode; + + switch (fields) { + case PkiSyncEditFields.Destination: + Component = ; + break; + case PkiSyncEditFields.Options: + Component = ; + break; + case PkiSyncEditFields.Source: + Component = ; + break; + case PkiSyncEditFields.Details: + default: + Component = ; + break; + } + + const { + handleSubmit, + formState: { isSubmitting, isDirty } + } = formMethods; + + return ( +
+ {Component} +
+ + + + +
+
+ ); +}; diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx new file mode 100644 index 000000000..95bbfc531 --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx @@ -0,0 +1,89 @@ +import { Controller, useFormContext } from "react-hook-form"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link } from "@tanstack/react-router"; + +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { ProjectPermissionSub, useProject, useProjectPermission } from "@app/context"; +import { ProjectPermissionAppConnectionActions } from "@app/context/ProjectPermissionContext/types"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { PKI_SYNC_CONNECTION_MAP } from "@app/helpers/pkiSyncs"; +import { useListAvailableAppConnections } from "@app/hooks/api/appConnections"; + +import { TPkiSyncForm } from "./schemas"; + +type Props = { + onChange?: VoidFunction; +}; + +export const PkiSyncConnectionField = ({ onChange: callback }: Props) => { + const { permission } = useProjectPermission(); + const { control, watch } = useFormContext(); + const { currentProject } = useProject(); + + const destination = watch("destination"); + const app = PKI_SYNC_CONNECTION_MAP[destination]; + + const { data: availableConnections, isPending } = useListAvailableAppConnections( + app, + currentProject.id + ); + + const connectionName = APP_CONNECTION_MAP[app].name; + + const canCreateConnection = permission.can( + ProjectPermissionAppConnectionActions.Create, + ProjectPermissionSub.AppConnections + ); + + const appName = APP_CONNECTION_MAP[PKI_SYNC_CONNECTION_MAP[destination]].name; + + return ( + <> +

+ Specify the {appName} Connection to use to connect to {connectionName} and configure + destination parameters. +

+ ( + + { + onChange(newValue); + if (callback) callback(); + }} + isLoading={isPending} + options={availableConnections} + placeholder="Select connection..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + control={control} + name="connection" + /> + {availableConnections?.length === 0 && ( +

+ + {canCreateConnection ? ( + <> + You do not have access to any {appName} Connections. Create one from the{" "} + + App Connections + {" "} + page. + + ) : ( + `You do not have access to any ${appName} Connections. Contact an admin to create one.` + )} +

+ )} + + ); +}; diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx new file mode 100644 index 000000000..4566b76e3 --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx @@ -0,0 +1,23 @@ +import { useFormContext } from "react-hook-form"; + +import { PkiSync } from "@app/hooks/api/pkiSyncs"; + +import { AzureKeyVaultPkiSyncFields } from "./AzureKeyVaultPkiSyncFields"; +import { TPkiSyncForm } from "./schemas"; + +export const PkiSyncDestinationFields = () => { + const { watch } = useFormContext(); + + const destination = watch("destination"); + + switch (destination) { + case PkiSync.AzureKeyVault: + return ; + default: + return ( +
+

Unsupported destination: {destination}

+
+ ); + } +}; diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncDetailsFields.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncDetailsFields.tsx new file mode 100644 index 000000000..d7fc282dd --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/PkiSyncDetailsFields.tsx @@ -0,0 +1,51 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Input, TextArea } from "@app/components/v2"; + +import { TPkiSyncForm } from "./schemas"; + +export const PkiSyncDetailsFields = () => { + const { control } = useFormContext(); + + return ( + <> +

+ Provide a name and description for this Certificate Sync. +

+ ( + + + + )} + control={control} + name="name" + /> + ( + +