diff --git a/.env.example b/.env.example index 8ee3d2001..23f845b71 100644 --- a/.env.example +++ b/.env.example @@ -107,6 +107,14 @@ INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY= INF_APP_CONNECTION_GITHUB_APP_SLUG= INF_APP_CONNECTION_GITHUB_APP_ID= +#github radar app connection +INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID= +INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET= +INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY= +INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG= +INF_APP_CONNECTION_GITHUB_RADAR_APP_ID= +INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET= + #gcp app connection INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL= diff --git a/.infisicalignore b/.infisicalignore index b00bf0995..7c203945f 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -28,3 +28,16 @@ frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow docs/cli/commands/user.mdx:generic-api-key:51 frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx:generic-api-key:76 docs/integrations/app-connections/hashicorp-vault.mdx:generic-api-key:188 +cli/detect/config/gitleaks.toml:gcp-api-key:567 +cli/detect/config/gitleaks.toml:gcp-api-key:569 +cli/detect/config/gitleaks.toml:gcp-api-key:570 +cli/detect/config/gitleaks.toml:gcp-api-key:572 +cli/detect/config/gitleaks.toml:gcp-api-key:574 +cli/detect/config/gitleaks.toml:gcp-api-key:575 +cli/detect/config/gitleaks.toml:gcp-api-key:576 +cli/detect/config/gitleaks.toml:gcp-api-key:577 +cli/detect/config/gitleaks.toml:gcp-api-key:578 +cli/detect/config/gitleaks.toml:gcp-api-key:579 +cli/detect/config/gitleaks.toml:gcp-api-key:581 +cli/detect/config/gitleaks.toml:gcp-api-key:582 +backend/src/services/smtp/smtp-service.ts:generic-api-key:79 diff --git a/Dockerfile.fips.standalone-infisical b/Dockerfile.fips.standalone-infisical index 33360bf45..c799aaf23 100644 --- a/Dockerfile.fips.standalone-infisical +++ b/Dockerfile.fips.standalone-infisical @@ -133,8 +133,8 @@ RUN apt-get update && apt-get install -y \ RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nSetup = /usr/lib/x86_64-linux-gnu/odbc/libtdsS.so\nFileUsage = 1\n" > /etc/odbcinst.ini # Install Infisical CLI -RUN curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | bash \ - && apt-get update && apt-get install -y infisical=0.31.1 \ +RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash \ + && apt-get update && apt-get install -y infisical=0.41.2 \ && rm -rf /var/lib/apt/lists/* RUN groupadd -r -g 1001 nodejs && useradd -r -u 1001 -g nodejs non-root-user @@ -171,6 +171,7 @@ ENV NODE_ENV production ENV STANDALONE_BUILD true ENV STANDALONE_MODE true ENV ChrystokiConfigurationPath=/usr/safenet/lunaclient/ +ENV NODE_OPTIONS="--max-old-space-size=1024" WORKDIR /backend diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index 6d582ce76..45295dec8 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -127,8 +127,8 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists/* # Install Infisical CLI -RUN curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | bash \ - && apt-get update && apt-get install -y infisical=0.31.1 \ +RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash \ + && apt-get update && apt-get install -y infisical=0.41.2 \ && rm -rf /var/lib/apt/lists/* WORKDIR / @@ -168,6 +168,7 @@ ENV HTTPS_ENABLED false ENV NODE_ENV production ENV STANDALONE_BUILD true ENV STANDALONE_MODE true +ENV NODE_OPTIONS="--max-old-space-size=1024" WORKDIR /backend diff --git a/backend/Dockerfile b/backend/Dockerfile index b9edf8b98..79333cb92 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -54,8 +54,8 @@ COPY --from=build /app . # Install Infisical CLI RUN apt-get install -y curl bash && \ - curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | bash && \ - apt-get update && apt-get install -y infisical=0.8.1 git + curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash && \ + apt-get update && apt-get install -y infisical=0.41.2 git HEALTHCHECK --interval=10s --timeout=3s --start-period=10s \ CMD node healthcheck.js diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index 3435672e7..75c561ac1 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -55,9 +55,9 @@ RUN mkdir -p /etc/softhsm2/tokens && \ # ? App setup # Install Infisical CLI -RUN curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | bash && \ +RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash && \ apt-get update && \ - apt-get install -y infisical=0.8.1 + apt-get install -y infisical=0.41.2 WORKDIR /app diff --git a/backend/Dockerfile.dev.fips b/backend/Dockerfile.dev.fips index 8c40404dc..0afb330e5 100644 --- a/backend/Dockerfile.dev.fips +++ b/backend/Dockerfile.dev.fips @@ -64,9 +64,9 @@ RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \ # ? App setup # Install Infisical CLI -RUN curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | bash && \ +RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash && \ apt-get update && \ - apt-get install -y infisical=0.8.1 + apt-get install -y infisical=0.41.2 WORKDIR /app diff --git a/backend/e2e-test/mocks/keystore.ts b/backend/e2e-test/mocks/keystore.ts index 48f52f9e7..f4f251616 100644 --- a/backend/e2e-test/mocks/keystore.ts +++ b/backend/e2e-test/mocks/keystore.ts @@ -1,4 +1,8 @@ +import RE2 from "re2"; + import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { applyJitter } from "@app/lib/dates"; +import { delay as delayMs } from "@app/lib/delay"; import { Lock } from "@app/lib/red-lock"; export const mockKeyStore = (): TKeyStoreFactory => { @@ -18,6 +22,27 @@ export const mockKeyStore = (): TKeyStoreFactory => { delete store[key]; return 1; }, + deleteItems: async ({ pattern, batchSize = 500, delay = 1500, jitter = 200 }) => { + const regex = new RE2(`^${pattern.replace(/[-[\]/{}()+?.\\^$|]/g, "\\$&").replace(/\*/g, ".*")}$`); + let totalDeleted = 0; + const keys = Object.keys(store); + + for (let i = 0; i < keys.length; i += batchSize) { + const batch = keys.slice(i, i + batchSize); + + for (const key of batch) { + if (regex.test(key)) { + delete store[key]; + totalDeleted += 1; + } + } + + // eslint-disable-next-line no-await-in-loop + await delayMs(Math.max(0, applyJitter(delay, jitter))); + } + + return totalDeleted; + }, getItem: async (key) => { const value = store[key]; if (typeof value === "string") { diff --git a/backend/e2e-test/vitest-environment-knex.ts b/backend/e2e-test/vitest-environment-knex.ts index 46b322349..92cf86e66 100644 --- a/backend/e2e-test/vitest-environment-knex.ts +++ b/backend/e2e-test/vitest-environment-knex.ts @@ -15,8 +15,8 @@ import { mockSmtpServer } from "./mocks/smtp"; import { initDbConnection } from "@app/db"; import { queueServiceFactory } from "@app/queue"; import { keyStoreFactory } from "@app/keystore/keystore"; -import { Redis } from "ioredis"; import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; +import { buildRedisFromConfig } from "@app/lib/config/redis"; dotenv.config({ path: path.join(__dirname, "../../.env.test"), debug: true }); export default { @@ -30,7 +30,7 @@ export default { dbRootCert: envConfig.DB_ROOT_CERT }); - const redis = new Redis(envConfig.REDIS_URL); + const redis = buildRedisFromConfig(envConfig); await redis.flushdb("SYNC"); try { @@ -55,8 +55,8 @@ export default { }); const smtp = mockSmtpServer(); - const queue = queueServiceFactory(envConfig.REDIS_URL, { dbConnectionUrl: envConfig.DB_CONNECTION_URI }); - const keyStore = keyStoreFactory(envConfig.REDIS_URL); + const queue = queueServiceFactory(envConfig, { dbConnectionUrl: envConfig.DB_CONNECTION_URI }); + const keyStore = keyStoreFactory(envConfig); const hsmModule = initializeHsmModule(envConfig); hsmModule.initialize(); diff --git a/backend/package-lock.json b/backend/package-lock.json index 93c28c9e2..49df5a596 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,7 @@ "@aws-sdk/client-elasticache": "^3.637.0", "@aws-sdk/client-iam": "^3.525.0", "@aws-sdk/client-kms": "^3.609.0", + "@aws-sdk/client-route-53": "^3.810.0", "@aws-sdk/client-secrets-manager": "^3.504.0", "@aws-sdk/client-sts": "^3.600.0", "@casl/ability": "^6.5.0", @@ -33,7 +34,8 @@ "@infisical/quic": "^1.0.8", "@node-saml/passport-saml": "^5.0.1", "@octokit/auth-app": "^7.1.1", - "@octokit/plugin-paginate-graphql": "^5.2.4", + "@octokit/core": "^5.2.1", + "@octokit/plugin-paginate-graphql": "^4.0.1", "@octokit/plugin-retry": "^5.0.5", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", @@ -54,6 +56,7 @@ "@slack/oauth": "^3.0.2", "@slack/web-api": "^7.8.0", "@ucast/mongo2js": "^1.3.4", + "acme-client": "^5.4.0", "ajv": "^8.12.0", "argon2": "^0.31.2", "aws-sdk": "^2.1553.0", @@ -89,6 +92,7 @@ "mysql2": "^3.9.8", "nanoid": "^3.3.8", "nodemailer": "^6.9.9", + "oci-sdk": "^2.108.0", "odbc": "^2.4.9", "openid-client": "^5.6.5", "ora": "^7.0.1", @@ -121,7 +125,7 @@ "tweetnacl-util": "^0.15.1", "uuid": "^9.0.1", "zod": "^3.22.4", - "zod-to-json-schema": "^3.22.4" + "zod-to-json-schema": "^3.24.5" }, "bin": { "backend": "dist/main.js" @@ -916,6 +920,1020 @@ "node": ">=16.0.0" } }, + "node_modules/@aws-sdk/client-route-53": { + "version": "3.810.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-route-53/-/client-route-53-3.810.0.tgz", + "integrity": "sha512-1LD2aGD+Zg/ctD+0WtGlm3HEsGtrBi/a8KOMrARerlELAXtdIrYBV714FJKji7nALFro+HMstqTYcdXiszA3qA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.810.0", + "@aws-sdk/credential-provider-node": "3.810.0", + "@aws-sdk/middleware-host-header": "3.804.0", + "@aws-sdk/middleware-logger": "3.804.0", + "@aws-sdk/middleware-recursion-detection": "3.804.0", + "@aws-sdk/middleware-sdk-route53": "3.804.0", + "@aws-sdk/middleware-user-agent": "3.810.0", + "@aws-sdk/region-config-resolver": "3.808.0", + "@aws-sdk/types": "3.804.0", + "@aws-sdk/util-endpoints": "3.808.0", + "@aws-sdk/util-user-agent-browser": "3.804.0", + "@aws-sdk/util-user-agent-node": "3.810.0", + "@aws-sdk/xml-builder": "3.804.0", + "@smithy/config-resolver": "^4.1.2", + "@smithy/core": "^3.3.3", + "@smithy/fetch-http-handler": "^5.0.2", + "@smithy/hash-node": "^4.0.2", + "@smithy/invalid-dependency": "^4.0.2", + "@smithy/middleware-content-length": "^4.0.2", + "@smithy/middleware-endpoint": "^4.1.6", + "@smithy/middleware-retry": "^4.1.7", + "@smithy/middleware-serde": "^4.0.5", + "@smithy/middleware-stack": "^4.0.2", + "@smithy/node-config-provider": "^4.1.1", + "@smithy/node-http-handler": "^4.0.4", + "@smithy/protocol-http": "^5.1.0", + "@smithy/smithy-client": "^4.2.6", + "@smithy/types": "^4.2.0", + "@smithy/url-parser": "^4.0.2", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.14", + "@smithy/util-defaults-mode-node": "^4.0.14", + "@smithy/util-endpoints": "^3.0.4", + "@smithy/util-middleware": "^4.0.2", + "@smithy/util-retry": "^4.0.3", + "@smithy/util-utf8": "^4.0.0", + "@smithy/util-waiter": "^4.0.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/client-sso": { + "version": "3.810.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.810.0.tgz", + "integrity": "sha512-Txp/3jHqkfA4BTklQEOGiZ1yTUxg+hITislfaWEzJ904vlDt4DvAljTlhfaz7pceCLA2+LhRlYZYSv7t5b0Ltw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.810.0", + "@aws-sdk/middleware-host-header": "3.804.0", + "@aws-sdk/middleware-logger": "3.804.0", + "@aws-sdk/middleware-recursion-detection": "3.804.0", + "@aws-sdk/middleware-user-agent": "3.810.0", + "@aws-sdk/region-config-resolver": "3.808.0", + "@aws-sdk/types": "3.804.0", + "@aws-sdk/util-endpoints": "3.808.0", + "@aws-sdk/util-user-agent-browser": "3.804.0", + "@aws-sdk/util-user-agent-node": "3.810.0", + "@smithy/config-resolver": "^4.1.2", + "@smithy/core": "^3.3.3", + "@smithy/fetch-http-handler": "^5.0.2", + "@smithy/hash-node": "^4.0.2", + "@smithy/invalid-dependency": "^4.0.2", + "@smithy/middleware-content-length": "^4.0.2", + "@smithy/middleware-endpoint": "^4.1.6", + "@smithy/middleware-retry": "^4.1.7", + "@smithy/middleware-serde": "^4.0.5", + "@smithy/middleware-stack": "^4.0.2", + "@smithy/node-config-provider": "^4.1.1", + "@smithy/node-http-handler": "^4.0.4", + "@smithy/protocol-http": "^5.1.0", + "@smithy/smithy-client": "^4.2.6", + "@smithy/types": "^4.2.0", + "@smithy/url-parser": "^4.0.2", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.14", + "@smithy/util-defaults-mode-node": "^4.0.14", + "@smithy/util-endpoints": "^3.0.4", + "@smithy/util-middleware": "^4.0.2", + "@smithy/util-retry": "^4.0.3", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/core": { + "version": "3.810.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.810.0.tgz", + "integrity": "sha512-s2IJk+qa/15YZcv3pbdQNATDR+YdYnHf94MrAeVAWubtRLnzD8JciC+gh4LSPp7JzrWSvVOg2Ut1S+0y89xqCg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.804.0", + "@smithy/core": "^3.3.3", + "@smithy/node-config-provider": "^4.1.1", + "@smithy/property-provider": "^4.0.2", + "@smithy/protocol-http": "^5.1.0", + "@smithy/signature-v4": "^5.1.0", + "@smithy/smithy-client": "^4.2.6", + "@smithy/types": "^4.2.0", + "@smithy/util-middleware": "^4.0.2", + "fast-xml-parser": "4.4.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.810.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.810.0.tgz", + "integrity": "sha512-iwHqF+KryKONfbdFk3iKhhPk4fHxh5QP5fXXR//jhYwmszaLOwc7CLCE9AxhgiMzAs+kV8nBFQZvdjFpPzVGOA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.810.0", + "@aws-sdk/types": "3.804.0", + "@smithy/property-provider": "^4.0.2", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.810.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.810.0.tgz", + "integrity": "sha512-SKzjLd+8ugif7yy9sOAAdnPE1vCBHQe6jKgs2AadMpCmWm34DiHz/KuulHdvURUGMIi7CvmaC8aH77twDPYbtg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.810.0", + "@aws-sdk/types": "3.804.0", + "@smithy/fetch-http-handler": "^5.0.2", + "@smithy/node-http-handler": "^4.0.4", + "@smithy/property-provider": "^4.0.2", + "@smithy/protocol-http": "^5.1.0", + "@smithy/smithy-client": "^4.2.6", + "@smithy/types": "^4.2.0", + "@smithy/util-stream": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.810.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.810.0.tgz", + "integrity": "sha512-H2QCSnxWJ/mj8HTcyHmCmyQ5bO/+imRi4mlBIpUyKjiYKro52WD3gXlGgPIDo2q3UFIHq37kmYvS00i+qIY9tw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.810.0", + "@aws-sdk/credential-provider-env": "3.810.0", + "@aws-sdk/credential-provider-http": "3.810.0", + "@aws-sdk/credential-provider-process": "3.810.0", + "@aws-sdk/credential-provider-sso": "3.810.0", + "@aws-sdk/credential-provider-web-identity": "3.810.0", + "@aws-sdk/nested-clients": "3.810.0", + "@aws-sdk/types": "3.804.0", + "@smithy/credential-provider-imds": "^4.0.4", + "@smithy/property-provider": "^4.0.2", + "@smithy/shared-ini-file-loader": "^4.0.2", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.810.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.810.0.tgz", + "integrity": "sha512-9E3Chv3x+RBM3N1bwLCyvXxoiPAckCI74wG7ePN4F3b/7ieIkbEl/3Hd67j1fnt62Xa1cjUHRu2tz5pdEv5G1Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.810.0", + "@aws-sdk/credential-provider-http": "3.810.0", + "@aws-sdk/credential-provider-ini": "3.810.0", + "@aws-sdk/credential-provider-process": "3.810.0", + "@aws-sdk/credential-provider-sso": "3.810.0", + "@aws-sdk/credential-provider-web-identity": "3.810.0", + "@aws-sdk/types": "3.804.0", + "@smithy/credential-provider-imds": "^4.0.4", + "@smithy/property-provider": "^4.0.2", + "@smithy/shared-ini-file-loader": "^4.0.2", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.810.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.810.0.tgz", + "integrity": "sha512-42kE6MLdsmMGp1id3Gisal4MbMiF7PIc0tAznTeIuE8r7cIF8yeQWw/PBOIvjyI57DxbyKzLUAMEJuigUpApCw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.810.0", + "@aws-sdk/types": "3.804.0", + "@smithy/property-provider": "^4.0.2", + "@smithy/shared-ini-file-loader": "^4.0.2", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.810.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.810.0.tgz", + "integrity": "sha512-8WjX6tz+FCvM93Y33gsr13p/HiiTJmVn5AK1O8PTkvHBclQDzmtAW5FdPqTpAJGswLW2FB0xRqdsSMN2dQEjNw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.810.0", + "@aws-sdk/core": "3.810.0", + "@aws-sdk/token-providers": "3.810.0", + "@aws-sdk/types": "3.804.0", + "@smithy/property-provider": "^4.0.2", + "@smithy/shared-ini-file-loader": "^4.0.2", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.810.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.810.0.tgz", + "integrity": "sha512-uKQJY0AcPyrvMmfGLo36semgjqJ4vmLTqOSW9u40qQDspRnG73/P09lAO2ntqKlhwvMBt3XfcNnOpyyhKRcOfA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.810.0", + "@aws-sdk/nested-clients": "3.810.0", + "@aws-sdk/types": "3.804.0", + "@smithy/property-provider": "^4.0.2", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.804.0.tgz", + "integrity": "sha512-bum1hLVBrn2lJCi423Z2fMUYtsbkGI2s4N+2RI2WSjvbaVyMSv/WcejIrjkqiiMR+2Y7m5exgoKeg4/TODLDPQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.804.0", + "@smithy/protocol-http": "^5.1.0", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/middleware-logger": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.804.0.tgz", + "integrity": "sha512-w/qLwL3iq0KOPQNat0Kb7sKndl9BtceigINwBU7SpkYWX9L/Lem6f8NPEKrC9Tl4wDBht3Yztub4oRTy/horJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.804.0", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.804.0.tgz", + "integrity": "sha512-zqHOrvLRdsUdN/ehYfZ9Tf8svhbiLLz5VaWUz22YndFv6m9qaAcijkpAOlKexsv3nLBMJdSdJ6GUTAeIy3BZzw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.804.0", + "@smithy/protocol-http": "^5.1.0", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.810.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.810.0.tgz", + "integrity": "sha512-gLMJcqgIq7k9skX8u0Yyi+jil4elbsmLf3TuDuqNdlqiZ44/AKdDFfU3mU5tRUtMfP42a3gvb2U3elP0BIeybQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.810.0", + "@aws-sdk/types": "3.804.0", + "@aws-sdk/util-endpoints": "3.808.0", + "@smithy/core": "^3.3.3", + "@smithy/protocol-http": "^5.1.0", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.808.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.808.0.tgz", + "integrity": "sha512-9x2QWfphkARZY5OGkl9dJxZlSlYM2l5inFeo2bKntGuwg4A4YUe5h7d5yJ6sZbam9h43eBrkOdumx03DAkQF9A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.804.0", + "@smithy/node-config-provider": "^4.1.1", + "@smithy/types": "^4.2.0", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/token-providers": { + "version": "3.810.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.810.0.tgz", + "integrity": "sha512-fdgHRCDpnzsD+0km7zuRbHRysJECfS8o9T9/pZ6XAr1z2FNV/UveHtnUYq0j6XpDMrIm0/suvXbshIjQU+a+sw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/nested-clients": "3.810.0", + "@aws-sdk/types": "3.804.0", + "@smithy/property-provider": "^4.0.2", + "@smithy/shared-ini-file-loader": "^4.0.2", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/types": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.804.0.tgz", + "integrity": "sha512-A9qnsy9zQ8G89vrPPlNG9d1d8QcKRGqJKqwyGgS0dclJpwy6d1EWgQLIolKPl6vcFpLoe6avLOLxr+h8ur5wpg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/util-endpoints": { + "version": "3.808.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.808.0.tgz", + "integrity": "sha512-N6Lic98uc4ADB7fLWlzx+1uVnq04VgVjngZvwHoujcRg9YDhIg9dUDiTzD5VZv13g1BrPYmvYP1HhsildpGV6w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.804.0", + "@smithy/types": "^4.2.0", + "@smithy/util-endpoints": "^3.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.804.0.tgz", + "integrity": "sha512-KfW6T6nQHHM/vZBBdGn6fMyG/MgX5lq82TDdX4HRQRRuHKLgBWGpKXqqvBwqIaCdXwWHgDrg2VQups6GqOWW2A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.804.0", + "@smithy/types": "^4.2.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.810.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.810.0.tgz", + "integrity": "sha512-T56/ANEGNuvhqVoWZdr+0ZY2hjV93cH2OfGHIlVTVSAMACWG54XehDPESEso1CJNhJGYZPsE+FE42HGCk/XDMg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.810.0", + "@aws-sdk/types": "3.804.0", + "@smithy/node-config-provider": "^4.1.1", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@aws-sdk/xml-builder": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.804.0.tgz", + "integrity": "sha512-JbGWp36IG9dgxtvC6+YXwt5WDZYfuamWFtVfK6fQpnmL96dx+GUPOXPKRWdw67WLKf2comHY28iX2d3z35I53Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/abort-controller": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.2.tgz", + "integrity": "sha512-Sl/78VDtgqKxN2+1qduaVE140XF+Xg+TafkncspwM4jFP/LHr76ZHmIY/y3V1M0mMLNk+Je6IGbzxy23RSToMw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/config-resolver": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.1.2.tgz", + "integrity": "sha512-7r6mZGwb5LmLJ+zPtkLoznf2EtwEuSWdtid10pjGl/7HefCE4mueOkrfki8JCUm99W6UfP47/r3tbxx9CfBN5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.1", + "@smithy/types": "^4.2.0", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/core": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.3.3.tgz", + "integrity": "sha512-CiJNc0b/WdnttAfQ6uMkxPQ3Z8hG/ba8wF89x9KtBBLDdZk6CX52K4F8hbe94uNbc8LDUuZFtbqfdhM3T21naw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.0.5", + "@smithy/protocol-http": "^5.1.0", + "@smithy/types": "^4.2.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.2", + "@smithy/util-stream": "^4.2.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/credential-provider-imds": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.4.tgz", + "integrity": "sha512-jN6M6zaGVyB8FmNGG+xOPQB4N89M1x97MMdMnm1ESjljLS3Qju/IegQizKujaNcy2vXAvrz0en8bobe6E55FEA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.1", + "@smithy/property-provider": "^4.0.2", + "@smithy/types": "^4.2.0", + "@smithy/url-parser": "^4.0.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/fetch-http-handler": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.0.2.tgz", + "integrity": "sha512-+9Dz8sakS9pe7f2cBocpJXdeVjMopUDLgZs1yWeu7h++WqSbjUYv/JAJwKwXw1HV6gq1jyWjxuyn24E2GhoEcQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.1.0", + "@smithy/querystring-builder": "^4.0.2", + "@smithy/types": "^4.2.0", + "@smithy/util-base64": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/hash-node": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.0.2.tgz", + "integrity": "sha512-VnTpYPnRUE7yVhWozFdlxcYknv9UN7CeOqSrMH+V877v4oqtVYuoqhIhtSjmGPvYrYnAkaM61sLMKHvxL138yg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/invalid-dependency": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.0.2.tgz", + "integrity": "sha512-GatB4+2DTpgWPday+mnUkoumP54u/MDM/5u44KF9hIu8jF0uafZtQLcdfIKkIcUNuF/fBojpLEHZS/56JqPeXQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/is-array-buffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.0.0.tgz", + "integrity": "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/middleware-content-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.0.2.tgz", + "integrity": "sha512-hAfEXm1zU+ELvucxqQ7I8SszwQ4znWMbNv6PLMndN83JJN41EPuS93AIyh2N+gJ6x8QFhzSO6b7q2e6oClDI8A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.1.0", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/middleware-endpoint": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.1.6.tgz", + "integrity": "sha512-Zdieg07c3ua3ap5ungdcyNnY1OsxmsXXtKDTk28+/YbwIPju0Z1ZX9X5AnkjmDE3+AbqgvhtC/ZuCMSr6VSfPw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.3.3", + "@smithy/middleware-serde": "^4.0.5", + "@smithy/node-config-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.0.2", + "@smithy/types": "^4.2.0", + "@smithy/url-parser": "^4.0.2", + "@smithy/util-middleware": "^4.0.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/middleware-retry": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.1.7.tgz", + "integrity": "sha512-lFIFUJ0E/4I0UaIDY5usNUzNKAghhxO0lDH4TZktXMmE+e4ActD9F154Si0Unc01aCPzcwd+NcOwQw6AfXXRRQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.1", + "@smithy/protocol-http": "^5.1.0", + "@smithy/service-error-classification": "^4.0.3", + "@smithy/smithy-client": "^4.2.6", + "@smithy/types": "^4.2.0", + "@smithy/util-middleware": "^4.0.2", + "@smithy/util-retry": "^4.0.3", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/middleware-serde": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.0.5.tgz", + "integrity": "sha512-yREC3q/HXqQigq29xX3hiy6tFi+kjPKXoYUQmwQdgPORLbQ0n6V2Z/Iw9Nnlu66da9fM/WhDtGvYvqwecrCljQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.1.0", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/middleware-stack": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.0.2.tgz", + "integrity": "sha512-eSPVcuJJGVYrFYu2hEq8g8WWdJav3sdrI4o2c6z/rjnYDd3xH9j9E7deZQCzFn4QvGPouLngH3dQ+QVTxv5bOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/node-config-provider": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.1.1.tgz", + "integrity": "sha512-1slS5jf5icHETwl5hxEVBj+mh6B+LbVW4yRINsGtUKH+nxM5Pw2H59+qf+JqYFCHp9jssG4vX81f5WKnjMN3Vw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.0.2", + "@smithy/shared-ini-file-loader": "^4.0.2", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/node-http-handler": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.0.4.tgz", + "integrity": "sha512-/mdqabuAT3o/ihBGjL94PUbTSPSRJ0eeVTdgADzow0wRJ0rN4A27EOrtlK56MYiO1fDvlO3jVTCxQtQmK9dZ1g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.0.2", + "@smithy/protocol-http": "^5.1.0", + "@smithy/querystring-builder": "^4.0.2", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/property-provider": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.0.2.tgz", + "integrity": "sha512-wNRoQC1uISOuNc2s4hkOYwYllmiyrvVXWMtq+TysNRVQaHm4yoafYQyjN/goYZS+QbYlPIbb/QRjaUZMuzwQ7A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/protocol-http": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.1.0.tgz", + "integrity": "sha512-KxAOL1nUNw2JTYrtviRRjEnykIDhxc84qMBzxvu1MUfQfHTuBlCG7PA6EdVwqpJjH7glw7FqQoFxUJSyBQgu7g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/querystring-builder": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.0.2.tgz", + "integrity": "sha512-NTOs0FwHw1vimmQM4ebh+wFQvOwkEf/kQL6bSM1Lock+Bv4I89B3hGYoUEPkmvYPkDKyp5UdXJYu+PoTQ3T31Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "@smithy/util-uri-escape": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/querystring-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.0.2.tgz", + "integrity": "sha512-v6w8wnmZcVXjfVLjxw8qF7OwESD9wnpjp0Dqry/Pod0/5vcEA3qxCr+BhbOHlxS8O+29eLpT3aagxXGwIoEk7Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/service-error-classification": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.0.3.tgz", + "integrity": "sha512-FTbcajmltovWMjj3tksDQdD23b2w6gH+A0DYA1Yz3iSpjDj8fmkwy62UnXcWMy4d5YoMoSyLFHMfkEVEzbiN8Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/shared-ini-file-loader": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.2.tgz", + "integrity": "sha512-J9/gTWBGVuFZ01oVA6vdb4DAjf1XbDhK6sLsu3OS9qmLrS6KB5ygpeHiM3miIbj1qgSJ96GYszXFWv6ErJ8QEw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/signature-v4": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.1.0.tgz", + "integrity": "sha512-4t5WX60sL3zGJF/CtZsUQTs3UrZEDO2P7pEaElrekbLqkWPYkgqNW1oeiNYC6xXifBnT9dVBOnNQRvOE9riU9w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.0.0", + "@smithy/protocol-http": "^5.1.0", + "@smithy/types": "^4.2.0", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-middleware": "^4.0.2", + "@smithy/util-uri-escape": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/smithy-client": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.2.6.tgz", + "integrity": "sha512-WEqP0wQ1N/lVS4pwNK1Vk+0i6QIr66cq/xbu1dVy1tM0A0qYwAYyz0JhbquzM5pMa8s89lyDBtoGKxo7iG74GA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.3.3", + "@smithy/middleware-endpoint": "^4.1.6", + "@smithy/middleware-stack": "^4.0.2", + "@smithy/protocol-http": "^5.1.0", + "@smithy/types": "^4.2.0", + "@smithy/util-stream": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/types": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.2.0.tgz", + "integrity": "sha512-7eMk09zQKCO+E/ivsjQv+fDlOupcFUCSC/L2YUPgwhvowVGWbPQHjEFcmjt7QQ4ra5lyowS92SV53Zc6XD4+fg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/url-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.0.2.tgz", + "integrity": "sha512-Bm8n3j2ScqnT+kJaClSVCMeiSenK6jVAzZCNewsYWuZtnBehEz4r2qP0riZySZVfzB+03XZHJeqfmJDkeeSLiQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.0.2", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/util-base64": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.0.0.tgz", + "integrity": "sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/util-body-length-browser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.0.0.tgz", + "integrity": "sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/util-body-length-node": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.0.0.tgz", + "integrity": "sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/util-buffer-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.0.0.tgz", + "integrity": "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/util-config-provider": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.0.0.tgz", + "integrity": "sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.14.tgz", + "integrity": "sha512-l7QnMX8VcDOH6n/fBRu4zqguSlOBZxFzWqp58dXFSARFBjNlmEDk5G/z4T7BMGr+rI0Pg8MkhmMUfEtHFgpy2g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.0.2", + "@smithy/smithy-client": "^4.2.6", + "@smithy/types": "^4.2.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/util-defaults-mode-node": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.14.tgz", + "integrity": "sha512-Ujs1gsWDo3m/T63VWBTBmHLTD2UlU6J6FEokLCEp7OZQv45jcjLHoxTwgWsi8ULpsYozvH4MTWkRP+bhwr0vDg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.1.2", + "@smithy/credential-provider-imds": "^4.0.4", + "@smithy/node-config-provider": "^4.1.1", + "@smithy/property-provider": "^4.0.2", + "@smithy/smithy-client": "^4.2.6", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/util-endpoints": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.0.4.tgz", + "integrity": "sha512-VfFATC1bmZLV2858B/O1NpMcL32wYo8DPPhHxYxDCodDl3f3mSZ5oJheW1IF91A0EeAADz2WsakM/hGGPGNKLg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.1", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/util-hex-encoding": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.0.0.tgz", + "integrity": "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/util-middleware": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.0.2.tgz", + "integrity": "sha512-6GDamTGLuBQVAEuQ4yDQ+ti/YINf/MEmIegrEeg7DdB/sld8BX1lqt9RRuIcABOhAGTA50bRbPzErez7SlDtDQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/util-retry": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.0.3.tgz", + "integrity": "sha512-DPuYjZQDXmKr/sNvy9Spu8R/ESa2e22wXZzSAY6NkjOLj6spbIje/Aq8rT97iUMdDj0qHMRIe+bTxvlU74d9Ng==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.0.3", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/util-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.2.0.tgz", + "integrity": "sha512-Vj1TtwWnuWqdgQI6YTUF5hQ/0jmFiOYsc51CSMgj7QfyO+RF4EnT2HNjoviNlOOmgzgvf3f5yno+EiC4vrnaWQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.0.2", + "@smithy/node-http-handler": "^4.0.4", + "@smithy/types": "^4.2.0", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/util-uri-escape": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.0.0.tgz", + "integrity": "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/util-utf8": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.0.0.tgz", + "integrity": "sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-route-53/node_modules/@smithy/util-waiter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.0.3.tgz", + "integrity": "sha512-JtaY3FxmD+te+KSI2FJuEcfNC9T/DGGVf551babM7fAaXhjJUt7oSYurH1Devxd2+BOSUACCgt3buinx4UnmEA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.0.2", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/client-s3": { "version": "3.682.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.682.0.tgz", @@ -1997,6 +3015,45 @@ "node": ">=16.0.0" } }, + "node_modules/@aws-sdk/middleware-sdk-route53": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-route53/-/middleware-sdk-route53-3.804.0.tgz", + "integrity": "sha512-mqZBsfyvp9nV3jC2djmSpw6bMXY0FrV1/OUyMlhwKU1fIWzpw0Ytax0/LPKQGhaXd5bpgOcrq5QanFXLGt6xsw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.804.0", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-route53/node_modules/@aws-sdk/types": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.804.0.tgz", + "integrity": "sha512-A9qnsy9zQ8G89vrPPlNG9d1d8QcKRGqJKqwyGgS0dclJpwy6d1EWgQLIolKPl6vcFpLoe6avLOLxr+h8ur5wpg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-route53/node_modules/@smithy/types": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.2.0.tgz", + "integrity": "sha512-7eMk09zQKCO+E/ivsjQv+fDlOupcFUCSC/L2YUPgwhvowVGWbPQHjEFcmjt7QQ4ra5lyowS92SV53Zc6XD4+fg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/middleware-sdk-s3": { "version": "3.682.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.682.0.tgz", @@ -2099,6 +3156,786 @@ "node": ">=16.0.0" } }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.810.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.810.0.tgz", + "integrity": "sha512-w+tGXFSQjzvJ3j2sQ4GJRdD+YXLTgwLd9eG/A+7pjrv2yLLV70M4HqRrFqH06JBjqT5rsOxonc/QSjROyxk+IA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.810.0", + "@aws-sdk/middleware-host-header": "3.804.0", + "@aws-sdk/middleware-logger": "3.804.0", + "@aws-sdk/middleware-recursion-detection": "3.804.0", + "@aws-sdk/middleware-user-agent": "3.810.0", + "@aws-sdk/region-config-resolver": "3.808.0", + "@aws-sdk/types": "3.804.0", + "@aws-sdk/util-endpoints": "3.808.0", + "@aws-sdk/util-user-agent-browser": "3.804.0", + "@aws-sdk/util-user-agent-node": "3.810.0", + "@smithy/config-resolver": "^4.1.2", + "@smithy/core": "^3.3.3", + "@smithy/fetch-http-handler": "^5.0.2", + "@smithy/hash-node": "^4.0.2", + "@smithy/invalid-dependency": "^4.0.2", + "@smithy/middleware-content-length": "^4.0.2", + "@smithy/middleware-endpoint": "^4.1.6", + "@smithy/middleware-retry": "^4.1.7", + "@smithy/middleware-serde": "^4.0.5", + "@smithy/middleware-stack": "^4.0.2", + "@smithy/node-config-provider": "^4.1.1", + "@smithy/node-http-handler": "^4.0.4", + "@smithy/protocol-http": "^5.1.0", + "@smithy/smithy-client": "^4.2.6", + "@smithy/types": "^4.2.0", + "@smithy/url-parser": "^4.0.2", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.14", + "@smithy/util-defaults-mode-node": "^4.0.14", + "@smithy/util-endpoints": "^3.0.4", + "@smithy/util-middleware": "^4.0.2", + "@smithy/util-retry": "^4.0.3", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/core": { + "version": "3.810.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.810.0.tgz", + "integrity": "sha512-s2IJk+qa/15YZcv3pbdQNATDR+YdYnHf94MrAeVAWubtRLnzD8JciC+gh4LSPp7JzrWSvVOg2Ut1S+0y89xqCg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.804.0", + "@smithy/core": "^3.3.3", + "@smithy/node-config-provider": "^4.1.1", + "@smithy/property-provider": "^4.0.2", + "@smithy/protocol-http": "^5.1.0", + "@smithy/signature-v4": "^5.1.0", + "@smithy/smithy-client": "^4.2.6", + "@smithy/types": "^4.2.0", + "@smithy/util-middleware": "^4.0.2", + "fast-xml-parser": "4.4.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.804.0.tgz", + "integrity": "sha512-bum1hLVBrn2lJCi423Z2fMUYtsbkGI2s4N+2RI2WSjvbaVyMSv/WcejIrjkqiiMR+2Y7m5exgoKeg4/TODLDPQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.804.0", + "@smithy/protocol-http": "^5.1.0", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/middleware-logger": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.804.0.tgz", + "integrity": "sha512-w/qLwL3iq0KOPQNat0Kb7sKndl9BtceigINwBU7SpkYWX9L/Lem6f8NPEKrC9Tl4wDBht3Yztub4oRTy/horJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.804.0", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.804.0.tgz", + "integrity": "sha512-zqHOrvLRdsUdN/ehYfZ9Tf8svhbiLLz5VaWUz22YndFv6m9qaAcijkpAOlKexsv3nLBMJdSdJ6GUTAeIy3BZzw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.804.0", + "@smithy/protocol-http": "^5.1.0", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.810.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.810.0.tgz", + "integrity": "sha512-gLMJcqgIq7k9skX8u0Yyi+jil4elbsmLf3TuDuqNdlqiZ44/AKdDFfU3mU5tRUtMfP42a3gvb2U3elP0BIeybQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.810.0", + "@aws-sdk/types": "3.804.0", + "@aws-sdk/util-endpoints": "3.808.0", + "@smithy/core": "^3.3.3", + "@smithy/protocol-http": "^5.1.0", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.808.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.808.0.tgz", + "integrity": "sha512-9x2QWfphkARZY5OGkl9dJxZlSlYM2l5inFeo2bKntGuwg4A4YUe5h7d5yJ6sZbam9h43eBrkOdumx03DAkQF9A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.804.0", + "@smithy/node-config-provider": "^4.1.1", + "@smithy/types": "^4.2.0", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/types": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.804.0.tgz", + "integrity": "sha512-A9qnsy9zQ8G89vrPPlNG9d1d8QcKRGqJKqwyGgS0dclJpwy6d1EWgQLIolKPl6vcFpLoe6avLOLxr+h8ur5wpg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/util-endpoints": { + "version": "3.808.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.808.0.tgz", + "integrity": "sha512-N6Lic98uc4ADB7fLWlzx+1uVnq04VgVjngZvwHoujcRg9YDhIg9dUDiTzD5VZv13g1BrPYmvYP1HhsildpGV6w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.804.0", + "@smithy/types": "^4.2.0", + "@smithy/util-endpoints": "^3.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.804.0.tgz", + "integrity": "sha512-KfW6T6nQHHM/vZBBdGn6fMyG/MgX5lq82TDdX4HRQRRuHKLgBWGpKXqqvBwqIaCdXwWHgDrg2VQups6GqOWW2A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.804.0", + "@smithy/types": "^4.2.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.810.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.810.0.tgz", + "integrity": "sha512-T56/ANEGNuvhqVoWZdr+0ZY2hjV93cH2OfGHIlVTVSAMACWG54XehDPESEso1CJNhJGYZPsE+FE42HGCk/XDMg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.810.0", + "@aws-sdk/types": "3.804.0", + "@smithy/node-config-provider": "^4.1.1", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/abort-controller": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.2.tgz", + "integrity": "sha512-Sl/78VDtgqKxN2+1qduaVE140XF+Xg+TafkncspwM4jFP/LHr76ZHmIY/y3V1M0mMLNk+Je6IGbzxy23RSToMw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/config-resolver": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.1.2.tgz", + "integrity": "sha512-7r6mZGwb5LmLJ+zPtkLoznf2EtwEuSWdtid10pjGl/7HefCE4mueOkrfki8JCUm99W6UfP47/r3tbxx9CfBN5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.1", + "@smithy/types": "^4.2.0", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/core": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.3.3.tgz", + "integrity": "sha512-CiJNc0b/WdnttAfQ6uMkxPQ3Z8hG/ba8wF89x9KtBBLDdZk6CX52K4F8hbe94uNbc8LDUuZFtbqfdhM3T21naw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.0.5", + "@smithy/protocol-http": "^5.1.0", + "@smithy/types": "^4.2.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.2", + "@smithy/util-stream": "^4.2.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/credential-provider-imds": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.4.tgz", + "integrity": "sha512-jN6M6zaGVyB8FmNGG+xOPQB4N89M1x97MMdMnm1ESjljLS3Qju/IegQizKujaNcy2vXAvrz0en8bobe6E55FEA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.1", + "@smithy/property-provider": "^4.0.2", + "@smithy/types": "^4.2.0", + "@smithy/url-parser": "^4.0.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/fetch-http-handler": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.0.2.tgz", + "integrity": "sha512-+9Dz8sakS9pe7f2cBocpJXdeVjMopUDLgZs1yWeu7h++WqSbjUYv/JAJwKwXw1HV6gq1jyWjxuyn24E2GhoEcQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.1.0", + "@smithy/querystring-builder": "^4.0.2", + "@smithy/types": "^4.2.0", + "@smithy/util-base64": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/hash-node": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.0.2.tgz", + "integrity": "sha512-VnTpYPnRUE7yVhWozFdlxcYknv9UN7CeOqSrMH+V877v4oqtVYuoqhIhtSjmGPvYrYnAkaM61sLMKHvxL138yg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/invalid-dependency": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.0.2.tgz", + "integrity": "sha512-GatB4+2DTpgWPday+mnUkoumP54u/MDM/5u44KF9hIu8jF0uafZtQLcdfIKkIcUNuF/fBojpLEHZS/56JqPeXQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/is-array-buffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.0.0.tgz", + "integrity": "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/middleware-content-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.0.2.tgz", + "integrity": "sha512-hAfEXm1zU+ELvucxqQ7I8SszwQ4znWMbNv6PLMndN83JJN41EPuS93AIyh2N+gJ6x8QFhzSO6b7q2e6oClDI8A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.1.0", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/middleware-endpoint": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.1.6.tgz", + "integrity": "sha512-Zdieg07c3ua3ap5ungdcyNnY1OsxmsXXtKDTk28+/YbwIPju0Z1ZX9X5AnkjmDE3+AbqgvhtC/ZuCMSr6VSfPw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.3.3", + "@smithy/middleware-serde": "^4.0.5", + "@smithy/node-config-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.0.2", + "@smithy/types": "^4.2.0", + "@smithy/url-parser": "^4.0.2", + "@smithy/util-middleware": "^4.0.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/middleware-retry": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.1.7.tgz", + "integrity": "sha512-lFIFUJ0E/4I0UaIDY5usNUzNKAghhxO0lDH4TZktXMmE+e4ActD9F154Si0Unc01aCPzcwd+NcOwQw6AfXXRRQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.1", + "@smithy/protocol-http": "^5.1.0", + "@smithy/service-error-classification": "^4.0.3", + "@smithy/smithy-client": "^4.2.6", + "@smithy/types": "^4.2.0", + "@smithy/util-middleware": "^4.0.2", + "@smithy/util-retry": "^4.0.3", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/middleware-serde": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.0.5.tgz", + "integrity": "sha512-yREC3q/HXqQigq29xX3hiy6tFi+kjPKXoYUQmwQdgPORLbQ0n6V2Z/Iw9Nnlu66da9fM/WhDtGvYvqwecrCljQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.1.0", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/middleware-stack": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.0.2.tgz", + "integrity": "sha512-eSPVcuJJGVYrFYu2hEq8g8WWdJav3sdrI4o2c6z/rjnYDd3xH9j9E7deZQCzFn4QvGPouLngH3dQ+QVTxv5bOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-config-provider": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.1.1.tgz", + "integrity": "sha512-1slS5jf5icHETwl5hxEVBj+mh6B+LbVW4yRINsGtUKH+nxM5Pw2H59+qf+JqYFCHp9jssG4vX81f5WKnjMN3Vw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.0.2", + "@smithy/shared-ini-file-loader": "^4.0.2", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.0.4.tgz", + "integrity": "sha512-/mdqabuAT3o/ihBGjL94PUbTSPSRJ0eeVTdgADzow0wRJ0rN4A27EOrtlK56MYiO1fDvlO3jVTCxQtQmK9dZ1g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.0.2", + "@smithy/protocol-http": "^5.1.0", + "@smithy/querystring-builder": "^4.0.2", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/property-provider": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.0.2.tgz", + "integrity": "sha512-wNRoQC1uISOuNc2s4hkOYwYllmiyrvVXWMtq+TysNRVQaHm4yoafYQyjN/goYZS+QbYlPIbb/QRjaUZMuzwQ7A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/protocol-http": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.1.0.tgz", + "integrity": "sha512-KxAOL1nUNw2JTYrtviRRjEnykIDhxc84qMBzxvu1MUfQfHTuBlCG7PA6EdVwqpJjH7glw7FqQoFxUJSyBQgu7g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/querystring-builder": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.0.2.tgz", + "integrity": "sha512-NTOs0FwHw1vimmQM4ebh+wFQvOwkEf/kQL6bSM1Lock+Bv4I89B3hGYoUEPkmvYPkDKyp5UdXJYu+PoTQ3T31Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "@smithy/util-uri-escape": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/querystring-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.0.2.tgz", + "integrity": "sha512-v6w8wnmZcVXjfVLjxw8qF7OwESD9wnpjp0Dqry/Pod0/5vcEA3qxCr+BhbOHlxS8O+29eLpT3aagxXGwIoEk7Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/service-error-classification": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.0.3.tgz", + "integrity": "sha512-FTbcajmltovWMjj3tksDQdD23b2w6gH+A0DYA1Yz3iSpjDj8fmkwy62UnXcWMy4d5YoMoSyLFHMfkEVEzbiN8Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/shared-ini-file-loader": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.2.tgz", + "integrity": "sha512-J9/gTWBGVuFZ01oVA6vdb4DAjf1XbDhK6sLsu3OS9qmLrS6KB5ygpeHiM3miIbj1qgSJ96GYszXFWv6ErJ8QEw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/signature-v4": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.1.0.tgz", + "integrity": "sha512-4t5WX60sL3zGJF/CtZsUQTs3UrZEDO2P7pEaElrekbLqkWPYkgqNW1oeiNYC6xXifBnT9dVBOnNQRvOE9riU9w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.0.0", + "@smithy/protocol-http": "^5.1.0", + "@smithy/types": "^4.2.0", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-middleware": "^4.0.2", + "@smithy/util-uri-escape": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/smithy-client": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.2.6.tgz", + "integrity": "sha512-WEqP0wQ1N/lVS4pwNK1Vk+0i6QIr66cq/xbu1dVy1tM0A0qYwAYyz0JhbquzM5pMa8s89lyDBtoGKxo7iG74GA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.3.3", + "@smithy/middleware-endpoint": "^4.1.6", + "@smithy/middleware-stack": "^4.0.2", + "@smithy/protocol-http": "^5.1.0", + "@smithy/types": "^4.2.0", + "@smithy/util-stream": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/types": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.2.0.tgz", + "integrity": "sha512-7eMk09zQKCO+E/ivsjQv+fDlOupcFUCSC/L2YUPgwhvowVGWbPQHjEFcmjt7QQ4ra5lyowS92SV53Zc6XD4+fg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/url-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.0.2.tgz", + "integrity": "sha512-Bm8n3j2ScqnT+kJaClSVCMeiSenK6jVAzZCNewsYWuZtnBehEz4r2qP0riZySZVfzB+03XZHJeqfmJDkeeSLiQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.0.2", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/util-base64": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.0.0.tgz", + "integrity": "sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/util-body-length-browser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.0.0.tgz", + "integrity": "sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/util-body-length-node": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.0.0.tgz", + "integrity": "sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/util-buffer-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.0.0.tgz", + "integrity": "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/util-config-provider": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.0.0.tgz", + "integrity": "sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.14.tgz", + "integrity": "sha512-l7QnMX8VcDOH6n/fBRu4zqguSlOBZxFzWqp58dXFSARFBjNlmEDk5G/z4T7BMGr+rI0Pg8MkhmMUfEtHFgpy2g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.0.2", + "@smithy/smithy-client": "^4.2.6", + "@smithy/types": "^4.2.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/util-defaults-mode-node": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.14.tgz", + "integrity": "sha512-Ujs1gsWDo3m/T63VWBTBmHLTD2UlU6J6FEokLCEp7OZQv45jcjLHoxTwgWsi8ULpsYozvH4MTWkRP+bhwr0vDg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.1.2", + "@smithy/credential-provider-imds": "^4.0.4", + "@smithy/node-config-provider": "^4.1.1", + "@smithy/property-provider": "^4.0.2", + "@smithy/smithy-client": "^4.2.6", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/util-endpoints": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.0.4.tgz", + "integrity": "sha512-VfFATC1bmZLV2858B/O1NpMcL32wYo8DPPhHxYxDCodDl3f3mSZ5oJheW1IF91A0EeAADz2WsakM/hGGPGNKLg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.1", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/util-hex-encoding": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.0.0.tgz", + "integrity": "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/util-middleware": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.0.2.tgz", + "integrity": "sha512-6GDamTGLuBQVAEuQ4yDQ+ti/YINf/MEmIegrEeg7DdB/sld8BX1lqt9RRuIcABOhAGTA50bRbPzErez7SlDtDQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/util-retry": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.0.3.tgz", + "integrity": "sha512-DPuYjZQDXmKr/sNvy9Spu8R/ESa2e22wXZzSAY6NkjOLj6spbIje/Aq8rT97iUMdDj0qHMRIe+bTxvlU74d9Ng==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.0.3", + "@smithy/types": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/util-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.2.0.tgz", + "integrity": "sha512-Vj1TtwWnuWqdgQI6YTUF5hQ/0jmFiOYsc51CSMgj7QfyO+RF4EnT2HNjoviNlOOmgzgvf3f5yno+EiC4vrnaWQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.0.2", + "@smithy/node-http-handler": "^4.0.4", + "@smithy/types": "^4.2.0", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/util-uri-escape": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.0.0.tgz", + "integrity": "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/util-utf8": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.0.0.tgz", + "integrity": "sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/node-http-handler": { "version": "3.374.0", "resolved": "https://registry.npmjs.org/@aws-sdk/node-http-handler/-/node-http-handler-3.374.0.tgz", @@ -7805,119 +9642,38 @@ } }, "node_modules/@octokit/core": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.5.tgz", - "integrity": "sha512-vvmsN0r7rguA+FySiCsbaTTobSftpIDIpPW81trAmsv9TGxg3YCujAxRYp/Uy8xmDgYCzzgulG62H7KYUFmeIg==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.1.tgz", + "integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==", "license": "MIT", - "peer": true, "dependencies": { - "@octokit/auth-token": "^5.0.0", - "@octokit/graphql": "^8.2.2", - "@octokit/request": "^9.2.3", - "@octokit/request-error": "^6.1.8", - "@octokit/types": "^14.0.0", - "before-after-hook": "^3.0.2", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/core/node_modules/@octokit/auth-token": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.2.tgz", - "integrity": "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/core/node_modules/@octokit/endpoint": { - "version": "10.1.4", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.4.tgz", - "integrity": "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/types": "^14.0.0", - "universal-user-agent": "^7.0.2" + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.4.1", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" }, "engines": { "node": ">= 18" } }, "node_modules/@octokit/core/node_modules/@octokit/openapi-types": { - "version": "25.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.0.0.tgz", - "integrity": "sha512-FZvktFu7HfOIJf2BScLKIEYjDsw6RKc7rBJCdvCTfKsVnx2GEB/Nbzjr29DUdb7vQhlzS/j8qDzdditP0OC6aw==", - "license": "MIT", - "peer": true - }, - "node_modules/@octokit/core/node_modules/@octokit/request": { - "version": "9.2.3", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.3.tgz", - "integrity": "sha512-Ma+pZU8PXLOEYzsWf0cn/gY+ME57Wq8f49WTXA8FMHp2Ps9djKw//xYJ1je8Hm0pR2lU9FUGeJRWOtxq6olt4w==", - "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/endpoint": "^10.1.4", - "@octokit/request-error": "^6.1.8", - "@octokit/types": "^14.0.0", - "fast-content-type-parse": "^2.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/core/node_modules/@octokit/request-error": { - "version": "6.1.8", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.8.tgz", - "integrity": "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/types": "^14.0.0" - }, - "engines": { - "node": ">= 18" - } + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" }, "node_modules/@octokit/core/node_modules/@octokit/types": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.0.0.tgz", - "integrity": "sha512-VVmZP0lEhbo2O1pdq63gZFiGCKkm8PPp8AUOijlwPO6hojEVjspA0MWKP7E4hbvGxzFKNqKr6p0IYtOH/Wf/zA==", + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", "license": "MIT", - "peer": true, "dependencies": { - "@octokit/openapi-types": "^25.0.0" + "@octokit/openapi-types": "^24.2.0" } }, - "node_modules/@octokit/core/node_modules/fast-content-type-parse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", - "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "peer": true - }, - "node_modules/@octokit/core/node_modules/universal-user-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", - "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==", - "license": "ISC", - "peer": true - }, "node_modules/@octokit/endpoint": { "version": "9.0.6", "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.6.tgz", @@ -7947,105 +9703,34 @@ } }, "node_modules/@octokit/graphql": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.2.2.tgz", - "integrity": "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.1.tgz", + "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", "license": "MIT", - "peer": true, "dependencies": { - "@octokit/request": "^9.2.3", - "@octokit/types": "^14.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/graphql/node_modules/@octokit/endpoint": { - "version": "10.1.4", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.4.tgz", - "integrity": "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/types": "^14.0.0", - "universal-user-agent": "^7.0.2" + "@octokit/request": "^8.4.1", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" }, "engines": { "node": ">= 18" } }, "node_modules/@octokit/graphql/node_modules/@octokit/openapi-types": { - "version": "25.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.0.0.tgz", - "integrity": "sha512-FZvktFu7HfOIJf2BScLKIEYjDsw6RKc7rBJCdvCTfKsVnx2GEB/Nbzjr29DUdb7vQhlzS/j8qDzdditP0OC6aw==", - "license": "MIT", - "peer": true - }, - "node_modules/@octokit/graphql/node_modules/@octokit/request": { - "version": "9.2.3", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.3.tgz", - "integrity": "sha512-Ma+pZU8PXLOEYzsWf0cn/gY+ME57Wq8f49WTXA8FMHp2Ps9djKw//xYJ1je8Hm0pR2lU9FUGeJRWOtxq6olt4w==", - "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/endpoint": "^10.1.4", - "@octokit/request-error": "^6.1.8", - "@octokit/types": "^14.0.0", - "fast-content-type-parse": "^2.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/graphql/node_modules/@octokit/request-error": { - "version": "6.1.8", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.8.tgz", - "integrity": "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/types": "^14.0.0" - }, - "engines": { - "node": ">= 18" - } + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" }, "node_modules/@octokit/graphql/node_modules/@octokit/types": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.0.0.tgz", - "integrity": "sha512-VVmZP0lEhbo2O1pdq63gZFiGCKkm8PPp8AUOijlwPO6hojEVjspA0MWKP7E4hbvGxzFKNqKr6p0IYtOH/Wf/zA==", + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", "license": "MIT", - "peer": true, "dependencies": { - "@octokit/openapi-types": "^25.0.0" + "@octokit/openapi-types": "^24.2.0" } }, - "node_modules/@octokit/graphql/node_modules/fast-content-type-parse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", - "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "peer": true - }, - "node_modules/@octokit/graphql/node_modules/universal-user-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", - "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==", - "license": "ISC", - "peer": true - }, "node_modules/@octokit/oauth-authorization-url": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-7.1.1.tgz", @@ -8141,15 +9826,15 @@ } }, "node_modules/@octokit/plugin-paginate-graphql": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-graphql/-/plugin-paginate-graphql-5.2.4.tgz", - "integrity": "sha512-pLZES1jWaOynXKHOqdnwZ5ULeVR6tVVCMm+AUbp0htdcyXDU95WbkYdU4R2ej1wKj5Tu94Mee2Ne0PjPO9cCyA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-graphql/-/plugin-paginate-graphql-4.0.1.tgz", + "integrity": "sha512-R8ZQNmrIKKpHWC6V2gum4x9LG2qF1RxRjo27gjQcG3j+vf2tLsEfE7I/wRWEPzYMaenr1M+qDAtNcwZve1ce1A==", "license": "MIT", "engines": { "node": ">= 18" }, "peerDependencies": { - "@octokit/core": ">=6" + "@octokit/core": ">=5" } }, "node_modules/@octokit/plugin-paginate-rest": { @@ -8302,59 +9987,6 @@ "node": ">= 18" } }, - "node_modules/@octokit/rest/node_modules/@octokit/core": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.1.tgz", - "integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==", - "license": "MIT", - "dependencies": { - "@octokit/auth-token": "^4.0.0", - "@octokit/graphql": "^7.1.0", - "@octokit/request": "^8.4.1", - "@octokit/request-error": "^5.1.1", - "@octokit/types": "^13.0.0", - "before-after-hook": "^2.2.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/rest/node_modules/@octokit/graphql": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.1.tgz", - "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", - "license": "MIT", - "dependencies": { - "@octokit/request": "^8.4.1", - "@octokit/types": "^13.0.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/rest/node_modules/@octokit/openapi-types": { - "version": "24.2.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", - "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", - "license": "MIT" - }, - "node_modules/@octokit/rest/node_modules/@octokit/types": { - "version": "13.10.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", - "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^24.2.0" - } - }, - "node_modules/@octokit/rest/node_modules/before-after-hook": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", - "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", - "license": "Apache-2.0" - }, "node_modules/@octokit/types": { "version": "12.4.0", "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.4.0.tgz", @@ -10860,6 +12492,12 @@ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" }, + "node_modules/@types/isomorphic-fetch": { + "version": "0.0.35", + "resolved": "https://registry.npmjs.org/@types/isomorphic-fetch/-/isomorphic-fetch-0.0.35.tgz", + "integrity": "sha512-DaZNUvLDCAnCTjgwxgiL1eQdxIKEpNLOlTNtAgnZc50bG2copGhRrFN9/PxPBuJe+tZVLCbQ7ls0xveXVRPkvw==", + "license": "MIT" + }, "node_modules/@types/jmespath": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/@types/jmespath/-/jmespath-0.15.2.tgz", @@ -10893,6 +12531,12 @@ "integrity": "sha512-2h3tFvkbHksiNcDiUdcJ08gXWG10fnahp30GJ2Tbt4vd4pfsbfkoKTaTbYykFoppaJ6DL3914nQ3PU1vVIlBRQ==", "dev": true }, + "node_modules/@types/jssha": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/jssha/-/jssha-2.0.0.tgz", + "integrity": "sha512-oBnY3csYnXfqZXDRBJwP1nDDJCW/+VMJ88UHT4DCy0deSXpJIQvMCwYlnmdW4M+u7PiSfQc44LmiFcUbJ8hLEw==", + "license": "MIT" + }, "node_modules/@types/ldapjs": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-2.2.5.tgz", @@ -10984,6 +12628,15 @@ "@types/node": "*" } }, + "node_modules/@types/opossum": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/opossum/-/opossum-4.1.1.tgz", + "integrity": "sha512-9TMnd8AWRVtnZMqBbbzceQoJdafErgUViogFaQ3eetsbeLtiFFZ695mepNaLtlfJi4uRP3GmHfe3CJ2DZKaxYA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/passport": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.16.tgz", @@ -11231,6 +12884,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/sshpk": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@types/sshpk/-/sshpk-1.10.3.tgz", + "integrity": "sha512-cru1waDhHZnZuB18E6Dgf2UXf8U93mdOEDcKYe5jTri+fpucidSs7DLmGICpLxN+95aYkwtgeyny9fBFzQVdmA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -12001,6 +13663,39 @@ "node": ">= 0.6" } }, + "node_modules/acme-client": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/acme-client/-/acme-client-5.4.0.tgz", + "integrity": "sha512-mORqg60S8iML6XSmVjqjGHJkINrCGLMj2QvDmFzI9vIlv1RGlyjmw3nrzaINJjkNsYXC41XhhD5pfy7CtuGcbA==", + "license": "MIT", + "dependencies": { + "@peculiar/x509": "^1.11.0", + "asn1js": "^3.0.5", + "axios": "^1.7.2", + "debug": "^4.3.5", + "node-forge": "^1.3.1" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/acme-client/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/acorn": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", @@ -12563,6 +14258,12 @@ "fastq": "^1.17.1" } }, + "node_modules/await-semaphore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/await-semaphore/-/await-semaphore-0.1.3.tgz", + "integrity": "sha512-d1W2aNSYcz/sxYO4pMGX9vq65qOTu0P800epMud+6cYYX0QcT7zyqcxec3VWzpgvdXo57UWmVbZpLMjX2m1I7Q==", + "license": "MIT" + }, "node_modules/aws-sdk": { "version": "2.1553.0", "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1553.0.tgz", @@ -12793,17 +14494,31 @@ "node": ">= 10.0.0" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bcrypt-pbkdf/node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" }, "node_modules/before-after-hook": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", - "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", - "license": "Apache-2.0", - "peer": true + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", + "license": "Apache-2.0" }, "node_modules/big-integer": { "version": "1.6.52", @@ -14038,6 +15753,18 @@ "dev": true, "license": "MIT" }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -14574,6 +16301,22 @@ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/ecc-jsbn/node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT" + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -14871,6 +16614,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es6-promise": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.6.tgz", + "integrity": "sha512-aRVgGdnmW2OiySVPUC9e6m+plolMAJKjZnQlCwNSuK5yQ0JN61DZSO1X1Ufd1foqWRAlig0rhduTCHe7sVtK5Q==", + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", @@ -16612,6 +18361,15 @@ "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz", "integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==" }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -17241,6 +18999,20 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/http-signature": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.1.tgz", + "integrity": "sha512-Y29YKEc8MQsjch/VzkUVJ+2MXd9WcR42fK5u36CZf4G8bXw2DXMTWuESiB0R6m59JAWxlPPw5/Fri/t/AyyueA==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.14.1" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -17946,6 +19718,16 @@ "node": ">=18" } }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", @@ -18172,6 +19954,12 @@ "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-ref-resolver": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", @@ -18278,6 +20066,44 @@ "npm": ">=6" } }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "license": "MIT", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/jsprim/node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/jsprim/node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, "node_modules/jsrp": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/jsrp/-/jsrp-0.2.4.tgz", @@ -18288,6 +20114,16 @@ "randombytes": "^2.0.0" } }, + "node_modules/jssha": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-2.4.1.tgz", + "integrity": "sha512-77DN1YurYgh+7FPCTJ2CQ6hVDHgIWiHxm4Y5/mAdnpETKYagX22pVWMz4xfKF5fcpNfMaztgVj+/B1bt2k23Eg==", + "deprecated": "jsSHA versions < 3.0.0 will no longer receive feature updates", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/jwa": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", @@ -19740,6 +21576,15 @@ } } }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-gyp": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.3.1.tgz", @@ -20160,6 +22005,1722 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "dev": true }, + "node_modules/oci-accessgovernancecp": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-accessgovernancecp/-/oci-accessgovernancecp-2.108.0.tgz", + "integrity": "sha512-lohjenh/9XOWSt34clBbCMIa460TC1Lxrj+myry0JrFR8P5zzehqjmLDEUpDjpXx0oACP5t+3bhwuDn/GDzj7w==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-adm": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-adm/-/oci-adm-2.108.0.tgz", + "integrity": "sha512-V8faYUwFeQFYFcl6bqnxlF9CzILH6VAb/kzXH9sHX8R2OYF8vXW7rTH72VlW5vxqEDzX0zYhHu46sJo/C5vkBw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-aianomalydetection": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-aianomalydetection/-/oci-aianomalydetection-2.108.0.tgz", + "integrity": "sha512-tJvJ/Mh0owQAIKVsTZyiPXymmUKP1b99yZDYg4rWy3mojrUZ6wHAT5OGgMOa9cSdfTvlkTnyZ194yt54ymG38g==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-aidocument": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-aidocument/-/oci-aidocument-2.108.0.tgz", + "integrity": "sha512-fLGR1rnbhPOgKZ2NReWiYR83XyNY3wW5jV91Q0twSnFuFbkWGE0b/P/89ire06DMkRvkb/nESuYActhDUFhcGg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-ailanguage": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-ailanguage/-/oci-ailanguage-2.108.0.tgz", + "integrity": "sha512-DhwnTXbSs3Z43B4+sK3l7NU+hbOcfk/ZBWfcy32jOA0DsOaH2WUiaUss801IvoE8iaWuAFbNX1odpphgFFHfwg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-aispeech": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-aispeech/-/oci-aispeech-2.108.0.tgz", + "integrity": "sha512-WZUUugibvl5qaX8IgiUj/1hIC3PAZIm9uPQnLMGXeFYmO/Zu5YunVJ85T/qTI3U7UY8O3kdGiXsUKFKCTnZc1A==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-aivision": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-aivision/-/oci-aivision-2.108.0.tgz", + "integrity": "sha512-cgoQ73OfY2+6AELGzXqv4nf9EIUXFx8ENYgRgg6P4DnyFY04NgeUufiZGM2nB4XByxJ862DzBw2YydKlTXPi7A==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-analytics": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-analytics/-/oci-analytics-2.108.0.tgz", + "integrity": "sha512-p09Hk1fFz85nhvkWaFDEEUNwUJFBQFXQpj4OZzGA8orERJhi1dzd6X1Px1dCHpXTaPG8S8NWk2tRV/uloVd7AQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-announcementsservice": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-announcementsservice/-/oci-announcementsservice-2.108.0.tgz", + "integrity": "sha512-rYBcCHP+jZ4CGkJ0mUd6jdFU149AQjxqagoXH/LMUYvSS3ATUf91LlbiWQW22w5k0Otl5SSdMmEiIV7h7NUYyg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-apigateway": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-apigateway/-/oci-apigateway-2.108.0.tgz", + "integrity": "sha512-h6fIWU0kDPTxeqOsNJL35nPrDL8yr4YEKyuhJ4SQsA3wY2BqGs//eX+we4tTXHkEFBtbVJdRWYrt7BU8adsG9w==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-apmconfig": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-apmconfig/-/oci-apmconfig-2.108.0.tgz", + "integrity": "sha512-a7YYSKFjdrH9nrngT1OwQ/40yTnPo1SzVas9ImeFLkUFm/5lC/D8t6Rmv7dlR/WO7U5o7e5dN5zLEDVf9LZqBg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-apmcontrolplane": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-apmcontrolplane/-/oci-apmcontrolplane-2.108.0.tgz", + "integrity": "sha512-jIeCJVr+Ci+3Ogifcwe5OYyeQg6otmoT+UiGoMHcUn+gNTgPcG5tSsqQ7C/2KL/qg1P+XEBoDtMen5wBXionPg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-apmsynthetics": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-apmsynthetics/-/oci-apmsynthetics-2.108.0.tgz", + "integrity": "sha512-h13UuPx0UUHV+IyoJtsov4KvNwB0l4QJABW8K49xsOkllUd4IO4VomJw1yhrau9OVXe8QE0P2GyCAHdH1rJPfw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-apmtraces": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-apmtraces/-/oci-apmtraces-2.108.0.tgz", + "integrity": "sha512-ufH4/WYXd2N6AveLCKDynaCx/T9UgzQ/LOatIbtZ1Q86ywd5aS4vdnzKxWUMdDNI//10z92nWeCZCqGov0HgkA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-appmgmtcontrol": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-appmgmtcontrol/-/oci-appmgmtcontrol-2.108.0.tgz", + "integrity": "sha512-JgFGFoJZW0gTtc010K19uIXGOevqpOT575ndRGxpQO04U+41GRDb+IOUe2NYe4ehw1rfYRA4/uUM0xdcbzPPRg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-artifacts": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-artifacts/-/oci-artifacts-2.108.0.tgz", + "integrity": "sha512-ZljcFpyjVuQZiu4V/gKTEjxu1pMiQVH9o5k+Ys++cMtQWBvGyMlnxrDwpFRi16vHoXuPxKw2ygvjqM2wNN8M/g==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-audit": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-audit/-/oci-audit-2.108.0.tgz", + "integrity": "sha512-i7iH6sMzqGi0zl3SwNatnnzqb5CkKXr1sIW8uiamBghhyjIE8sehrW/eQl+hrzKNk/piSvi/5f5ftZ0MIQjxww==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-autoscaling": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-autoscaling/-/oci-autoscaling-2.108.0.tgz", + "integrity": "sha512-HCIU06FXuDa3suv/t0q+dchZAPFr6tRswccRIytvVm4eBJHiB6vblEqrJ6jgPzBd6F+y+hPZ4ZlJ1L2MLE6WYA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-bastion": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-bastion/-/oci-bastion-2.108.0.tgz", + "integrity": "sha512-6Ys6CAO6K+ylKkjQcyBr7oglRQZWd4RZPJdhFqybIQmeysKzzaY44zoWMah7tcYryu3sKaypsmrypIOTXHIwdA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-bds": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-bds/-/oci-bds-2.108.0.tgz", + "integrity": "sha512-eaWmH312PSJd1WiS6eV9KoKUGDzq94UwlaaqslS6Yo8cOLBWhNhrd4yoIcL/rGWpURLAuNfFkqqaIPEUWSzw1A==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-blockchain": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-blockchain/-/oci-blockchain-2.108.0.tgz", + "integrity": "sha512-KTVP/Nlki8Z5ZekU39N/IMEr7LhXbRtz+8u7e8VnGmiHbrJGEA377KQv0cUDczQ8vyC+7IbWLIGvXUgXYVd93A==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-budget": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-budget/-/oci-budget-2.108.0.tgz", + "integrity": "sha512-fy6DKzWD+HgDXjx0HzjgKz5nIRrDeZhn8EIiAMaCqUsPFA982iNw8BRwgw5k0tU6BZf/kwDeLUDc3O1NvlhUDQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-capacitymanagement": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-capacitymanagement/-/oci-capacitymanagement-2.108.0.tgz", + "integrity": "sha512-CEIoKbD49h7naGRFgyqfDnyEtQZfAl4b9IJsx+jJXvJ2sTmhYJagbWoBA/MkoHoYvRfUE3o2VVM1SkBMKLjE/Q==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-certificates": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-certificates/-/oci-certificates-2.108.0.tgz", + "integrity": "sha512-OmeY3hj3VX5r0IkyZg/IMv14CmVnhIUA04aAhtA9F94TlxGKgg6muQ5OppPhnmKrrvuBZhV+8w/3p0BUB0gt1g==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-certificatesmanagement": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-certificatesmanagement/-/oci-certificatesmanagement-2.108.0.tgz", + "integrity": "sha512-yDkpv49vDkGun6Byju19Uxm5+aR38zA1vEexW33hDWyOghLvZP1Jasu41G6xljytoIIy24wykBTZ3IAnB6I00g==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-cims": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-cims/-/oci-cims-2.108.0.tgz", + "integrity": "sha512-3lny4DzRAwtBGGs35K7LsVf388V3AQAyuiIhWDGBB71HtgBw9VGrL3sBB3jyO5WCjwE/akKEXJLKPvpjBgZBGw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-cloudbridge": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-cloudbridge/-/oci-cloudbridge-2.108.0.tgz", + "integrity": "sha512-R6diQhWNusQ7jJU/z45IyrquJz5iZd1NHovNP9TwtkQw2yPrdIbMjLZYhoWSsVDNOnffn1q1VreYNYkid/4qoA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-cloudguard": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-cloudguard/-/oci-cloudguard-2.108.0.tgz", + "integrity": "sha512-0qrH8OM1f1pIHc8tqOpeZfh+DRPlP88FikVf8woCeM8ekD4ysV04+zATAf+w8VVeASkHbYQnrTQB+UgtXMOB7A==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-cloudmigrations": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-cloudmigrations/-/oci-cloudmigrations-2.108.0.tgz", + "integrity": "sha512-ZITVnShAItKIoB2ONp4+XONUVUjKyh5dMg0Mh2Ik1OL2JpKr6tu5KWWUie15azaDj4TqQ022mSbHfdsV51DEIw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-clusterplacementgroups": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-clusterplacementgroups/-/oci-clusterplacementgroups-2.108.0.tgz", + "integrity": "sha512-3TpH2710n4yJFI/oeMyEND719KbgiuP/OD9jjZMiGIDDi9XjGwZnsKNYP6K0kgRriwEDtUNZdFBDtJa69XyB7Q==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-common": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-common/-/oci-common-2.108.0.tgz", + "integrity": "sha512-H7kaU/A57ksvmXlLLFnTo91CeG6m3M5nbqYbWgniHl84vEmaM0vHhe5C9jQOpPUuhMdRRB2GareJYBjP79cqBg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "@types/isomorphic-fetch": "0.0.35", + "@types/jsonwebtoken": "9.0.0", + "@types/jssha": "2.0.0", + "@types/opossum": "4.1.1", + "@types/sshpk": "1.10.3", + "es6-promise": "4.2.6", + "http-signature": "1.3.1", + "isomorphic-fetch": "3.0.0", + "jsonwebtoken": "9.0.0", + "jssha": "2.4.1", + "opossum": "5.0.1", + "sshpk": "1.16.1", + "uuid": "3.3.3" + } + }, + "node_modules/oci-common/node_modules/@types/jsonwebtoken": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", + "integrity": "sha512-mM4TkDpA9oixqg1Fv2vVpOFyIVLJjm5x4k0V+K/rEsizfjD7Tk7LKk3GTtbB7KCfP0FEHQtsZqFxYA0+sijNVg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/oci-common/node_modules/jsonwebtoken": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", + "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash": "^4.17.21", + "ms": "^2.1.1", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/oci-common/node_modules/uuid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", + "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/oci-computecloudatcustomer": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-computecloudatcustomer/-/oci-computecloudatcustomer-2.108.0.tgz", + "integrity": "sha512-UU7GHrvMm6cJ1LeRbJfazq0/FrKEphePulLhvGn3IMiDxYRuAfON8RNydaJGZ+Q8s1Qv4BdID0//zPO7QkiD2Q==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-computeinstanceagent": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-computeinstanceagent/-/oci-computeinstanceagent-2.108.0.tgz", + "integrity": "sha512-1vn2zjyyCOOAtTKiyOG9pm9OxD0VPXZH7HPQP3CRcTalahfFY3WTy0Ti51/Ozk0rOLKUBpyJzcfsknaO87p4LQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-containerengine": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-containerengine/-/oci-containerengine-2.108.0.tgz", + "integrity": "sha512-OcQUtL/3rthwVx3rOTC1vJDTc6FL/kr0gQpqbjRtU5HBGnZE7Y7+R+CLWi+WejX6qP4IgU+Wv2T2Jig4tROZEQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-containerinstances": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-containerinstances/-/oci-containerinstances-2.108.0.tgz", + "integrity": "sha512-0JFULah06CupSJxrHZeOvdSYn6OkYw+/KY3eCb49K6Ht9/dtumHKTLVvGrr/b9JlAtl8ZPdFOd8hNb9WA/dAGQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-core": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-core/-/oci-core-2.108.0.tgz", + "integrity": "sha512-Nuowt0mFE+f1LDT+VFwQt9JRNzTsHkdRd8CPMBgS+czyyI89UsIkAYj9eVzgbyV98Btzv8aX/BF7EGh1BhYJKQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-dashboardservice": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-dashboardservice/-/oci-dashboardservice-2.108.0.tgz", + "integrity": "sha512-zmg7hgVjqXJ0zgf/53bxBnpiZ2nbb8InccjElbiApvUhICMw65BsDCh0JDxyZUbfFBORZSVJcWbr3J5PzudE1w==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-database": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-database/-/oci-database-2.108.0.tgz", + "integrity": "sha512-q4Jb9ZosdVbCFtqqDBy1RY0zqk4hSljtvGu+z5A3DyZ6DfL7ALUah8GweZeVWsX6vvfwrc7H1ca15ksxGwl9Lw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-databasemanagement": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-databasemanagement/-/oci-databasemanagement-2.108.0.tgz", + "integrity": "sha512-JQ0ysKWcG21jDGSCiOw1T/uqY+ChGG1SOKa/kMa4stLON86t/DAcjPE343uUtld4fts6GMBDXkGnx8hWW9BaQg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-databasemigration": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-databasemigration/-/oci-databasemigration-2.108.0.tgz", + "integrity": "sha512-OIEx0CNTi9m+ydeFCKCOslcWOWoX+xJkWMbiGGESQdjmSSczkMXoAK4Kn+XmUcRxap2DczBCICS+8dafl7Z76w==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-databasetools": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-databasetools/-/oci-databasetools-2.108.0.tgz", + "integrity": "sha512-CYTfqYOdL/INiTTAfRB+DJS06PUclNL6q1AWMYFgDi++R4jcIueyJDXqqnSzwkt/iBzA8IW72er5N8pvVxgYRw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-datacatalog": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-datacatalog/-/oci-datacatalog-2.108.0.tgz", + "integrity": "sha512-T4J175I1229EUpc68HaRMlIhIpQgRf2ajCCNriniwaaz7EWtej4LKRvzaw/eWe3DwHFI4kEb+9WXlpzHj0C7Hw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-dataflow": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-dataflow/-/oci-dataflow-2.108.0.tgz", + "integrity": "sha512-GHPiHHdEC0onqBA4GCHFQ8RdijYmro3hLYXztgrI+uMoIEb/ms9ayBptKliEHwlHmEF85or7/sn5JB2xqXGxPQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-dataintegration": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-dataintegration/-/oci-dataintegration-2.108.0.tgz", + "integrity": "sha512-/BoQhwoBsrK2wGaO9uV6idnIooPb9GTOuE30p+c02Bm7yuFmXTGgA/mQxIEk6TcBvcJGss+3pWiyDrq03gEyqw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-datalabelingservice": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-datalabelingservice/-/oci-datalabelingservice-2.108.0.tgz", + "integrity": "sha512-JE43+obBvanuiJepAGtCrz80giIMB2o8v5sP1Qe0xs7zuB1HtZ1k+tRuof2ipznbnLScCyUZGdROMReE5IiEmQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-datalabelingservicedataplane": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-datalabelingservicedataplane/-/oci-datalabelingservicedataplane-2.108.0.tgz", + "integrity": "sha512-m22njdO3pogqpbeOgJM+ArCwJJvWhHB2Nz13/Kj6YXPuQqFv0TUzGHEL2LuhqpNFdM4CvF5Dtb472q4Kav5+/A==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-datasafe": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-datasafe/-/oci-datasafe-2.108.0.tgz", + "integrity": "sha512-uZ18rhS9FmP//IrRunBQvdwbjdqQiLyKlIdaL0M8BiI+MDrE3ZXL5veZUS8GmD+xeBZyCHV8cqJAvQfcgsi2oA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-datascience": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-datascience/-/oci-datascience-2.108.0.tgz", + "integrity": "sha512-nh/LpXBVYvBrS3Rp8K7J/l8JurDm1geFEgu9oNXxJ+fheofpAZ6HHXeRIVadXHARXbgLuu7ta+pmB1IBft6OEg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-dblm": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-dblm/-/oci-dblm-2.108.0.tgz", + "integrity": "sha512-RJGrKUtzhWeXono6lUvNrRD/xrR4jVrmVNEgpSFTIkDM0rRlKP2lSqWdfZdB+qTt3QaNjKsRWm9TRxYyrroW+w==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-delegateaccesscontrol": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-delegateaccesscontrol/-/oci-delegateaccesscontrol-2.108.0.tgz", + "integrity": "sha512-Imri3k0tESbq4xNxlHwqfcRZgvVPxx2Lt29TMuC34mrj2zhNlcKIsg0xgvpmfB3NrEjKz9M/X4MSpIfjzVUJ+w==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-demandsignal": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-demandsignal/-/oci-demandsignal-2.108.0.tgz", + "integrity": "sha512-ZvpAJf5QnpeQ5rkMGuQIa04Dv0Q4gS+nmyWLXyIipMV5mrbkyWAdWo4crauZxiwBt6yyJe5r3zBkrNTtXWQubg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-desktops": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-desktops/-/oci-desktops-2.108.0.tgz", + "integrity": "sha512-sEBs1QzvOj1z6NQjZHln65bXyIqOla34lJLsN260GL2AjcV5V/j/8668FP7InxfRFvrb+2+jcefeI54tZbx+NQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-devops": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-devops/-/oci-devops-2.108.0.tgz", + "integrity": "sha512-HgLwTv4+TA/sXIRPGFflyiHQWL9OjQXvej2V+nwiujeiMYsJ66bvTTE3FIm3ku+6apHlD6zQQ8s8VBAJ/IR/bA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-disasterrecovery": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-disasterrecovery/-/oci-disasterrecovery-2.108.0.tgz", + "integrity": "sha512-GULI5fQg+8qWzw9Nk2U4p+COOiXBq5+6+XHRjfriEg1EkIwF+TUt71cwyOUaE+jKZQdI/gHz1ViQdy972S318w==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-dns": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-dns/-/oci-dns-2.108.0.tgz", + "integrity": "sha512-93hiGQU6tNwL++Qq6MNbw9RD6CFLl+6pUPaybnosR+/sjYnh0IinwFSaLChizzevSCWWILTr2h/BETafnDXXXA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-dts": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-dts/-/oci-dts-2.108.0.tgz", + "integrity": "sha512-62/xBcPGA6IlAgez0Vrakz7OLav4DTA8SEGpUJPDWdMAAMoJFeSSCVpPh/tVRY3jzjM+3rTTQzmXiI9yix0EaQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-email": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-email/-/oci-email-2.108.0.tgz", + "integrity": "sha512-xIZDTjxuOuK3gcMYUPXUf39NOVQB8frrSJvGTa8Lp4aQNDGgkbwpONCbmyJj7pb0tvOag1likbRuQi9M8Cmjwg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-emaildataplane": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-emaildataplane/-/oci-emaildataplane-2.108.0.tgz", + "integrity": "sha512-eSKh1yTNTwEF/YfuR2AaTmhyipG3jOBPA7kICx0Syrba4pPqvkNqgJBq1pQcJHH0wBxTZZTag7MlMPs4ibIz5g==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-emwarehouse": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-emwarehouse/-/oci-emwarehouse-2.108.0.tgz", + "integrity": "sha512-3vqgsNxz5jTXrrlZQT4Vl9zgGMABqD/OViv4oI/8zNS6eHC5zuUDVbGqe9sZNHlO1VtNOzDlRxbJHCzxj1mnwQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-events": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-events/-/oci-events-2.108.0.tgz", + "integrity": "sha512-4JoHrafbesO+cIXH9BADXZrZM2gCeUeNMTwZc38FP6rhOTK45CrYYP8Izbe+hzoKXCDsbp4rvLsxOdu9gJg1jQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-filestorage": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-filestorage/-/oci-filestorage-2.108.0.tgz", + "integrity": "sha512-XOce/0fDnnsgyRG3vKuHNXpbxAmQ/Erd2KwX9GGPlf6ig4uQHO4X6i2ylVD90OfaPxYbzwdV96j7cTwwlVHfXw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-fleetappsmanagement": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-fleetappsmanagement/-/oci-fleetappsmanagement-2.108.0.tgz", + "integrity": "sha512-OHq1Ctm3EJM6jasYvtHO5HaypR14tw8F6BJPOemCd3sRzoUiqIiZ1qxi1USYq5Salpng+FfvL18Qt4XEZhW+9Q==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-fleetsoftwareupdate": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-fleetsoftwareupdate/-/oci-fleetsoftwareupdate-2.108.0.tgz", + "integrity": "sha512-M5UEi4Kl1vNGUtnf4eoc74nFzZ8k0engo5L8ufJ/lszPtcMJ0pTMFuHB6A9lOSgaXnaIXRIOEj7aW4Wo9lj+6w==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-functions": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-functions/-/oci-functions-2.108.0.tgz", + "integrity": "sha512-nD5rVZ3Pve7oTq+Dvpj1uPymRArq45U8Lm7J/EZCwwI9sFTu5ZQyL0nbXNHJRda24W+QdnWeHGQSIB4hcA806A==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-fusionapps": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-fusionapps/-/oci-fusionapps-2.108.0.tgz", + "integrity": "sha512-B8sNAB6er9LBd0C/l3qYRPLqGzf0s0lgyYWDEZsPT5q+1wS9fSmBAvyAfU9bUrL8GgsCaNC4W96ROfEjQ/ZKwA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-generativeai": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-generativeai/-/oci-generativeai-2.108.0.tgz", + "integrity": "sha512-g2EGfBMDzVvo44IEPqLBBHf3pei0DvIOQUT23BgYO08nwfsNmHacuEx8yl6jZvb00/58qN+LjKBQ0807eoHGow==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-generativeaiagent": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-generativeaiagent/-/oci-generativeaiagent-2.108.0.tgz", + "integrity": "sha512-LbzrMlJsORYF67rcJl4gMmNXzzKk09HbnyoqxRT96tHYbSC72w7xMCMnc9xN2746K52mxT4ZsyOI+kPcD/XTww==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-generativeaiagentruntime": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-generativeaiagentruntime/-/oci-generativeaiagentruntime-2.108.0.tgz", + "integrity": "sha512-+TvJklyLWOlq70arKPnkfiCx8WTEDvb2sZgZiq7arxBJYUL3burg/bLO3Di+XlGaHMyoaBbKLW5tJM77rdFNKg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-generativeaiinference": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-generativeaiinference/-/oci-generativeaiinference-2.108.0.tgz", + "integrity": "sha512-OBKKowDh7duUiMRS2LWmLWDGpDz6th7Ef0NznAYGniXAzxf3x1VcGkGYHi/8NEB5GvTzCxbPXYBunA/C0oUL8w==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-genericartifactscontent": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-genericartifactscontent/-/oci-genericartifactscontent-2.108.0.tgz", + "integrity": "sha512-6hpnmK4TQG5rUtlib89BQjMte4ho6xhkCH+nyWqbUwUTDMn3L5/On8Nk/O3Rchm6/lgObHZ4v+1LNt1vxE96JQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-globallydistributeddatabase": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-globallydistributeddatabase/-/oci-globallydistributeddatabase-2.108.0.tgz", + "integrity": "sha512-db79VO9Z/dCyhVCIcvg5suqFoUwo7UhH9zT14T8rPTvSuLm3ISZsEYU6XEshXefsIIV5huzElus/SvCEI/JC8g==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-goldengate": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-goldengate/-/oci-goldengate-2.108.0.tgz", + "integrity": "sha512-6pn1HAIXsvfcFaiSdXQtTViRXqQtubyktQyY4hXQ0HyDU+Iv9ZaIXrJ8Z/aAWSRVvH3Rw0W7L5Le4Q1ar+FJ9g==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-governancerulescontrolplane": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-governancerulescontrolplane/-/oci-governancerulescontrolplane-2.108.0.tgz", + "integrity": "sha512-EdtoyGwHAoug/1hHkx3fa/7cOQ55TzAC9lvHJg23uUgM4zHbEjvRuMJjEoyMo6NBWoLk5jM67ESAeczkOCsGaQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-healthchecks": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-healthchecks/-/oci-healthchecks-2.108.0.tgz", + "integrity": "sha512-/98NvgW1uKMxyC+6pvLGaVapMkxsh1qhKjBTXJShIkmGKUycXIksCkknMRCeik98FWmvoSWfu9I0dnTewjTq/w==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-identity": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-identity/-/oci-identity-2.108.0.tgz", + "integrity": "sha512-yetR36jJYFEIthzBe7qBSiZQczKIcYT6SQAejxlAXTwAW5uSsRaR6tmv1H24hB/csjVfhZeHFjeDYP99oxsonQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-identitydataplane": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-identitydataplane/-/oci-identitydataplane-2.108.0.tgz", + "integrity": "sha512-WZdy59Lwy85swqYJb6U3pUBUfDNPVPn4mUd/LseezvoDTHwiNTw66DtEQK87XBNMa8O57t4GMdtRV798WqM1fg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-identitydomains": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-identitydomains/-/oci-identitydomains-2.108.0.tgz", + "integrity": "sha512-XQtHk2IA51gKvFAkXcKh/w7NhpoAUXMgy7/4ni8OKhR3Ru7bpGI0cea87iV/eNo0n/p3PxtAqy6AQF+/5VHdyA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-integration": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-integration/-/oci-integration-2.108.0.tgz", + "integrity": "sha512-wmNN4T536iyf5UJaibyHpaGPrA5jNG0r7CwdN0cxQXOw1BhWV8vLXQfEaE/TRDJEmFahbOvdHdEIBH+IExKMrA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-jms": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-jms/-/oci-jms-2.108.0.tgz", + "integrity": "sha512-R7NCobTxuMx8NWx6Vbov5cjBpLct9EW7cFmqurKTWdlWOC98spIujtMOkHnIcB4G5a6PaOWgViXhrjsVSarJaw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-jmsjavadownloads": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-jmsjavadownloads/-/oci-jmsjavadownloads-2.108.0.tgz", + "integrity": "sha512-JeVPC3nvB6MxvL9P3fYm8MkCKSWJDWUrn0i6S0iEAVMJwjfoXkamt1UL1P2r9jCxnkJK9nxJh0YnUUXsSUWrYw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-keymanagement": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-keymanagement/-/oci-keymanagement-2.108.0.tgz", + "integrity": "sha512-4hbzgIZI6C5TpUhPzt2jJASfke342aoOqH0oYKN1kb2cK+3BfyNFqh0loDgUtWkoKlk94joPAbHaf4ebmYmeHw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-licensemanager": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-licensemanager/-/oci-licensemanager-2.108.0.tgz", + "integrity": "sha512-OKjENCbpN6LOHSJLEbomhh9+cMxOMRmkKaJultzGvyMJ0GnULgFC5+6n/de+2/+rchQyai62JhZlU7fiXAUcFw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-limits": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-limits/-/oci-limits-2.108.0.tgz", + "integrity": "sha512-q8r56EfgjmFmUP6Jj7Bl668Jv3M+4AQM2kwWDMCWWJVIiyWLEmHURmiLkstnxlT9qfrjsHpyOp6Uy8fdm/idVg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-loadbalancer": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-loadbalancer/-/oci-loadbalancer-2.108.0.tgz", + "integrity": "sha512-N+PyjBLP2ng2HFNlL+iuSHvJHGJQmHYIYW8wuZ4sYvA9rz4um/PQMPD6OgRz+kO3dsxF/8dPkwnQ33RwHwxMfQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-lockbox": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-lockbox/-/oci-lockbox-2.108.0.tgz", + "integrity": "sha512-CrVmzyvjBpy7yVfOso2x0M16h+p0zNzn75/7QVx9ifwFxBhSJpiztjH0YTADHd1l0KMNXa1ZDsxKq0mOQlYp0Q==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-loganalytics": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-loganalytics/-/oci-loganalytics-2.108.0.tgz", + "integrity": "sha512-5uFYU/1uHJYg8evPECvXC6oTZhYwXUk3CKkEcklXQAHptDEMYzVu24S/nBkqgdjHAXWtIHElh8V0lrgboseodA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-logging": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-logging/-/oci-logging-2.108.0.tgz", + "integrity": "sha512-A7Gu+hoJGOI2tBrCkLdMHEabQmmUEAPZEUVEq9MrAsTqJYjZi89V7KNiaLx15ARCUaj9ivUYN9eamLfPSQQaJw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-loggingingestion": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-loggingingestion/-/oci-loggingingestion-2.108.0.tgz", + "integrity": "sha512-otlfcKBUpAvg81fbyIHMUMNuTuErmrNq/yK2SynwqJUTcZ9EjbaysqCwed67tRDyETqru1/qX2tmI2XCQkATCQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-loggingsearch": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-loggingsearch/-/oci-loggingsearch-2.108.0.tgz", + "integrity": "sha512-Pj629/S9LYPH+wkDdi1gazYzv8V7PvvW6OgKsg5zH6XKBhZrD4k3yHmW1nNoEVmR3JHFui4DcK8YBj3Jv2oLCA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-lustrefilestorage": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-lustrefilestorage/-/oci-lustrefilestorage-2.108.0.tgz", + "integrity": "sha512-WtQxJp2gQP/K411FHlh2bg8UW8Wx76b+ZA1ApySQR7IiXicDs0kSsfNluKat5DytFnAMt97Qh5pq2KnjwKg88w==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-managementagent": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-managementagent/-/oci-managementagent-2.108.0.tgz", + "integrity": "sha512-VCmMUoet6AsZXmKneykIxddRr6Wo01Aj1ByCnlg9PaqmWbNNv5PkmdzCDrpV8GNhpn2IyfAp61lr/lwI3imD+Q==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-managementdashboard": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-managementdashboard/-/oci-managementdashboard-2.108.0.tgz", + "integrity": "sha512-tVuAB2xRUiSYLjuUBiqtkqvszpmTQrVJLCZB92H+SzaNFeqa2OjoH/qK0jU6LOQuLfMr7SG3atATGYNqFLVMBw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-marketplace": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-marketplace/-/oci-marketplace-2.108.0.tgz", + "integrity": "sha512-zMsftpZM6VThpicbhciK/b1irkrJPbqX45aHvj2iS1q+I7h7dG1WD+LMK/K6oS53idzFclHOvBCN0DMg1OYEjw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-marketplaceprivateoffer": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-marketplaceprivateoffer/-/oci-marketplaceprivateoffer-2.108.0.tgz", + "integrity": "sha512-JMdMPLpRwiARCgjiEVbyVweYZjmIt2KntqG7o6SEDoIoE2j74poqHjP75M4794pNynFHT4AZJzb/Ryv1hwuWhQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-marketplacepublisher": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-marketplacepublisher/-/oci-marketplacepublisher-2.108.0.tgz", + "integrity": "sha512-EjjACVK6JDn3/m1kXadeJa+s7zUsPR9uVq+e6QFwyeB9ro1HnzG+qyYJikoDc/huF3ZYwQFlWcTFR3Q/x4acJg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-mediaservices": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-mediaservices/-/oci-mediaservices-2.108.0.tgz", + "integrity": "sha512-AB1dRo+g12Qq6ep/BrtEHjEG49NpePqgDt6/WZSre1BpEcfyONTCMQDaI7HpeUH2OuEsn/duXLEHVLE1w0zQzg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-mngdmac": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-mngdmac/-/oci-mngdmac-2.108.0.tgz", + "integrity": "sha512-kZg+mDSIeMQqBhQz0JmcSQKKdMizR975xxkLbLdUXdhtAoCcFMagYm4NqsdHGMbchMJ2JfKpB3ylPexoRyY89g==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-monitoring": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-monitoring/-/oci-monitoring-2.108.0.tgz", + "integrity": "sha512-nWRALVeyuIzFi7wRSb+hsYl8S7le7jlZKa47eCLlSHziHTJrDUT7PLAeSTdAVkPRzircHAFeD+H1SB8tVsyrzQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-mysql": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-mysql/-/oci-mysql-2.108.0.tgz", + "integrity": "sha512-Zr9B8hgwQy1Z+BTStUdrVnjj+2ZkeR3+NFlhxPGt2LvU+vm92XOUD33h6MbogswpJ/cb7RlazKxKQKJtPHfJPw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-networkfirewall": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-networkfirewall/-/oci-networkfirewall-2.108.0.tgz", + "integrity": "sha512-TvGwagr0Qyt/BFnxyrVmPnsFryRn9snLXwJPwQU2MMcGlnJHUDcsidhKxV/tJzSHYNINmYy9IXOy4D+E8a+wUA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-networkloadbalancer": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-networkloadbalancer/-/oci-networkloadbalancer-2.108.0.tgz", + "integrity": "sha512-XnuIvO4GRyKjRxigAMBl1zjKhxnGMyuo9fkx53nbPaWdmMM0mEbL6csKxTsozQxc29gUtUcZJI/tqvC/yBNQ+g==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-nosql": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-nosql/-/oci-nosql-2.108.0.tgz", + "integrity": "sha512-OJtPwgNmMPslXj/QIaT7NQQxPUrY1zCAPSZdCBfhlQH/D7x62w5A8BVP+HtkTA4LyN/GgGqnk/L01uni7lng8w==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-objectstorage": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-objectstorage/-/oci-objectstorage-2.108.0.tgz", + "integrity": "sha512-TjG6tf8RpnCz00loEy8Nhe/FD0P+TOkzyBaVoGODDr6rR1F7PmWL4k3u8rQA7tvTtJsIO//XaSaObcnW7ytM3g==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "await-semaphore": "^0.1.3", + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-oce": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-oce/-/oci-oce-2.108.0.tgz", + "integrity": "sha512-h+WUIkNpLTCjLLPM3xsMwR7r3K+NzId2Sxh4yIHbzOewRJIk1o/qpZzSyMevnUIL86S9IYPkVvjIVJnU2Dueyw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-ocicontrolcenter": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-ocicontrolcenter/-/oci-ocicontrolcenter-2.108.0.tgz", + "integrity": "sha512-I6MfZbsYHkNojoqvzKdkz8vlQu/ZcYm80mZf9XpK9HY00SyY/SfPtwjutyGi46L8dIBVoXHhUNP3hQcVD5T0rQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-ocvp": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-ocvp/-/oci-ocvp-2.108.0.tgz", + "integrity": "sha512-JCriglSsxC1YXfUd/3xVxMojZT+b/+Go3hRIxom4gkOJ3Ceo0x7VXfJKeYGnjqERP5YIA2KWztgHQwAgAG0xFA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-oda": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-oda/-/oci-oda-2.108.0.tgz", + "integrity": "sha512-QNkLczqrgaVVmnyYTRaiEF3cfohzgIYLyKpBT7Bg4Bu2kP0ljAQV0/BxD1XXpkUpC5atK5wjEq5urUWdybKqZg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-onesubscription": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-onesubscription/-/oci-onesubscription-2.108.0.tgz", + "integrity": "sha512-Er6TBhzziC5uHURHLl1AjHjPp2r8wvYH7p59aJ0uyuXucZ0rGVJ9IkmU8T+TNznSx59iEhcmsYvaZyYUPI3RkQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-ons": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-ons/-/oci-ons-2.108.0.tgz", + "integrity": "sha512-C1Z/OBjPFeL3wa1M8AHArCbeNqul3GoO9QbEwZQ5qsNyKlF9Ol06UcdXA9vhSl25q+sH905JetuGaa9knIKA1A==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-opa": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-opa/-/oci-opa-2.108.0.tgz", + "integrity": "sha512-8jB/IqlevkwXlq9DSzTJ+2re+Y6vLCKFyVhQX323Sc/BamrEP2YnCU5HInSy+9GzQWbkBJ872CPvuQxLpUgHCA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-opensearch": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-opensearch/-/oci-opensearch-2.108.0.tgz", + "integrity": "sha512-/Mofttk4YRa+/unJEFwis8gFgjQGCh55My5dzeKASBBlRGuSJTHWwJDBkZAsY/Boizen7nb2nvvgzQpB7Q42Zg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-operatoraccesscontrol": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-operatoraccesscontrol/-/oci-operatoraccesscontrol-2.108.0.tgz", + "integrity": "sha512-Ln9tyjySUvWA7kHx7EQ6z0+wWuHoYQ7e9AvsxIVCuL6WzVy+CEONknNhqXqfMmSQHexgpXPaVJtTd8HRoISGUA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-opsi": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-opsi/-/oci-opsi-2.108.0.tgz", + "integrity": "sha512-AhO4cME5h4dMsZSXlWKU4fAjf+G6KOSDC/q1/MKzsuFOVIw8YaNWEldOai1k+z5VRC1EWmuneFvgnK4EVnaeMA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-optimizer": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-optimizer/-/oci-optimizer-2.108.0.tgz", + "integrity": "sha512-FsmroTYeawQAiEraHHt3EG0WiMwjfjYCpSbhl0Rh4kBMEG27BbW6SBzE4J/Aam2ArJLM8NSdakDwAt3y8oJZdA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-osmanagement": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-osmanagement/-/oci-osmanagement-2.108.0.tgz", + "integrity": "sha512-FJsODD7muZCnpgGAL3t6rBvbg+cJRvcrjtVkPx147tED2wI1Hxrc/bXAhanC375rn4k8vFvrFjNSKCfF1VCG7A==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-osmanagementhub": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-osmanagementhub/-/oci-osmanagementhub-2.108.0.tgz", + "integrity": "sha512-u4yPdLxYGoSGMrI3jE5N0ruhH719Sm/Ga6uhONhufGew3g/fszaTQfjvDML9kSo+BtReiaua6gfAidi24dTfVQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-ospgateway": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-ospgateway/-/oci-ospgateway-2.108.0.tgz", + "integrity": "sha512-2DGQ903/wtQRvNEwScYTpr+pjcVmumAQw+ihWBV3WQ5bFCZNaFEfSyEu7wvFYxRMI4WvfwjivUmVUmSbTIVMzg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-osubbillingschedule": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-osubbillingschedule/-/oci-osubbillingschedule-2.108.0.tgz", + "integrity": "sha512-2iN2hoYUyR9yg/RUR7VnKVmW+N0HHBuO8cVXmgjdoybzS9rDLvAmy/JsoHS0tMt/a4ExAIi9iC228Fwk4x3Sgg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-osuborganizationsubscription": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-osuborganizationsubscription/-/oci-osuborganizationsubscription-2.108.0.tgz", + "integrity": "sha512-58WtIRE8+jK6V1Lzt9cToiv2wr4XIX41P3M6ab/vljusW5bJ+trNRrJXugcq6BE3+s9ysebdVY9mLe3O14qNLQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-osubsubscription": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-osubsubscription/-/oci-osubsubscription-2.108.0.tgz", + "integrity": "sha512-cCSjWrJVsOTkt2IhokdUkrx95VxLh4HuK51EAfo7ITLuOnMkScoDSFgTB48+Ktx3qjEMG9fNrM9lRxtktTpe3A==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-osubusage": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-osubusage/-/oci-osubusage-2.108.0.tgz", + "integrity": "sha512-bICbOu3MbKRnhV8iFLOHVhs31bQlmZS8cO8qWCFQgci7dt401mrxj+MFO0Yzq/Hk5unpbuFonLjlHsiD456ZDg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-psql": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-psql/-/oci-psql-2.108.0.tgz", + "integrity": "sha512-w3ruZcKn++JnnYiPu6gmnSCjD/NQ1SGCJUIGDcc+O2qI01tfVXa3BI/c2AY7Y5Z+DbRD/efO0hSZt/9mtkS5IQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-queue": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-queue/-/oci-queue-2.108.0.tgz", + "integrity": "sha512-G3VUM2a9X1Gu0KnhYsCQlbH/3kHvbMsOH1IbA/XAJPAmpWp4JcXhFzWzW03/aymkoqoA1Vt3p77u9ltmrhgG7g==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-recovery": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-recovery/-/oci-recovery-2.108.0.tgz", + "integrity": "sha512-6OSclD5wagdrJGZtRvJbE1FJq8wl9igGevDMrd1o5rPJoGd/pCmZBn9bvfLPL1Mw9h0YGSAKZhAeMd3pDCNaIQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-redis": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-redis/-/oci-redis-2.108.0.tgz", + "integrity": "sha512-MzCORmjESnRs9BkOc3gnrbNaNOwWgZI+tgK2xTVY65PIy4PyYq5qvwgO3wmnLc4sULI4nIoWpoMSDxYyly7Zgg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-resourcemanager": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-resourcemanager/-/oci-resourcemanager-2.108.0.tgz", + "integrity": "sha512-IBfQL1K7YaDyvO1UUM2277aH7gejRv6bcAD6G2kv7D/0PamavlDf1PJ1oGv8kQrPCjoALiBNNohelTEltywNOA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-resourcescheduler": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-resourcescheduler/-/oci-resourcescheduler-2.108.0.tgz", + "integrity": "sha512-DI2w49VFfzo1y3y4uaqa5sFqqlpZlyQm6qpuhIaCjjDyrvZpbLLAC8Q9dFKq15PpzA7vlKQPPnBFeoW6YAX4pQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-resourcesearch": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-resourcesearch/-/oci-resourcesearch-2.108.0.tgz", + "integrity": "sha512-9HCm5fVmZf9ANW02YL3UYn1xGy5b2WMkABmVjosAs2rsMbRPieoOuu0zKK+d2YG2cXXHc1cGfFALQV9rfRSI2w==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-rover": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-rover/-/oci-rover-2.108.0.tgz", + "integrity": "sha512-uy3oNTMQDaLLy7EUoOljcXsnSKshQof9ESajQOKq4+EXNiFQ9fa5PNTTVl6g10+GS5Mak4KVLTIU7UnMYqwX9A==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-sch": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-sch/-/oci-sch-2.108.0.tgz", + "integrity": "sha512-4W+LA2lXN/rKoj8ZRu5HMzFq0De/VLTtUTVY37srgYBs0mp7z+crX4VenA9sIiytYzrY3cijxsDZOe6EBxnMqQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-sdk": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-sdk/-/oci-sdk-2.108.0.tgz", + "integrity": "sha512-wc5FXeAGUxBzTbRohdn7zD9328akY6CZ9qZoMzdXNe7dn65flxn1iO/clsw0zc3StRgy+0NGia1ZvsHIgkWzcg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-accessgovernancecp": "2.108.0", + "oci-adm": "2.108.0", + "oci-aianomalydetection": "2.108.0", + "oci-aidocument": "2.108.0", + "oci-ailanguage": "2.108.0", + "oci-aispeech": "2.108.0", + "oci-aivision": "2.108.0", + "oci-analytics": "2.108.0", + "oci-announcementsservice": "2.108.0", + "oci-apigateway": "2.108.0", + "oci-apmconfig": "2.108.0", + "oci-apmcontrolplane": "2.108.0", + "oci-apmsynthetics": "2.108.0", + "oci-apmtraces": "2.108.0", + "oci-appmgmtcontrol": "2.108.0", + "oci-artifacts": "2.108.0", + "oci-audit": "2.108.0", + "oci-autoscaling": "2.108.0", + "oci-bastion": "2.108.0", + "oci-bds": "2.108.0", + "oci-blockchain": "2.108.0", + "oci-budget": "2.108.0", + "oci-capacitymanagement": "2.108.0", + "oci-certificates": "2.108.0", + "oci-certificatesmanagement": "2.108.0", + "oci-cims": "2.108.0", + "oci-cloudbridge": "2.108.0", + "oci-cloudguard": "2.108.0", + "oci-cloudmigrations": "2.108.0", + "oci-clusterplacementgroups": "2.108.0", + "oci-common": "2.108.0", + "oci-computecloudatcustomer": "2.108.0", + "oci-computeinstanceagent": "2.108.0", + "oci-containerengine": "2.108.0", + "oci-containerinstances": "2.108.0", + "oci-core": "2.108.0", + "oci-dashboardservice": "2.108.0", + "oci-database": "2.108.0", + "oci-databasemanagement": "2.108.0", + "oci-databasemigration": "2.108.0", + "oci-databasetools": "2.108.0", + "oci-datacatalog": "2.108.0", + "oci-dataflow": "2.108.0", + "oci-dataintegration": "2.108.0", + "oci-datalabelingservice": "2.108.0", + "oci-datalabelingservicedataplane": "2.108.0", + "oci-datasafe": "2.108.0", + "oci-datascience": "2.108.0", + "oci-dblm": "2.108.0", + "oci-delegateaccesscontrol": "2.108.0", + "oci-demandsignal": "2.108.0", + "oci-desktops": "2.108.0", + "oci-devops": "2.108.0", + "oci-disasterrecovery": "2.108.0", + "oci-dns": "2.108.0", + "oci-dts": "2.108.0", + "oci-email": "2.108.0", + "oci-emaildataplane": "2.108.0", + "oci-emwarehouse": "2.108.0", + "oci-events": "2.108.0", + "oci-filestorage": "2.108.0", + "oci-fleetappsmanagement": "2.108.0", + "oci-fleetsoftwareupdate": "2.108.0", + "oci-functions": "2.108.0", + "oci-fusionapps": "2.108.0", + "oci-generativeai": "2.108.0", + "oci-generativeaiagent": "2.108.0", + "oci-generativeaiagentruntime": "2.108.0", + "oci-generativeaiinference": "2.108.0", + "oci-genericartifactscontent": "2.108.0", + "oci-globallydistributeddatabase": "2.108.0", + "oci-goldengate": "2.108.0", + "oci-governancerulescontrolplane": "2.108.0", + "oci-healthchecks": "2.108.0", + "oci-identity": "2.108.0", + "oci-identitydataplane": "2.108.0", + "oci-identitydomains": "2.108.0", + "oci-integration": "2.108.0", + "oci-jms": "2.108.0", + "oci-jmsjavadownloads": "2.108.0", + "oci-keymanagement": "2.108.0", + "oci-licensemanager": "2.108.0", + "oci-limits": "2.108.0", + "oci-loadbalancer": "2.108.0", + "oci-lockbox": "2.108.0", + "oci-loganalytics": "2.108.0", + "oci-logging": "2.108.0", + "oci-loggingingestion": "2.108.0", + "oci-loggingsearch": "2.108.0", + "oci-lustrefilestorage": "2.108.0", + "oci-managementagent": "2.108.0", + "oci-managementdashboard": "2.108.0", + "oci-marketplace": "2.108.0", + "oci-marketplaceprivateoffer": "2.108.0", + "oci-marketplacepublisher": "2.108.0", + "oci-mediaservices": "2.108.0", + "oci-mngdmac": "2.108.0", + "oci-monitoring": "2.108.0", + "oci-mysql": "2.108.0", + "oci-networkfirewall": "2.108.0", + "oci-networkloadbalancer": "2.108.0", + "oci-nosql": "2.108.0", + "oci-objectstorage": "2.108.0", + "oci-oce": "2.108.0", + "oci-ocicontrolcenter": "2.108.0", + "oci-ocvp": "2.108.0", + "oci-oda": "2.108.0", + "oci-onesubscription": "2.108.0", + "oci-ons": "2.108.0", + "oci-opa": "2.108.0", + "oci-opensearch": "2.108.0", + "oci-operatoraccesscontrol": "2.108.0", + "oci-opsi": "2.108.0", + "oci-optimizer": "2.108.0", + "oci-osmanagement": "2.108.0", + "oci-osmanagementhub": "2.108.0", + "oci-ospgateway": "2.108.0", + "oci-osubbillingschedule": "2.108.0", + "oci-osuborganizationsubscription": "2.108.0", + "oci-osubsubscription": "2.108.0", + "oci-osubusage": "2.108.0", + "oci-psql": "2.108.0", + "oci-queue": "2.108.0", + "oci-recovery": "2.108.0", + "oci-redis": "2.108.0", + "oci-resourcemanager": "2.108.0", + "oci-resourcescheduler": "2.108.0", + "oci-resourcesearch": "2.108.0", + "oci-rover": "2.108.0", + "oci-sch": "2.108.0", + "oci-secrets": "2.108.0", + "oci-securityattribute": "2.108.0", + "oci-servicecatalog": "2.108.0", + "oci-servicemanagerproxy": "2.108.0", + "oci-servicemesh": "2.108.0", + "oci-stackmonitoring": "2.108.0", + "oci-streaming": "2.108.0", + "oci-tenantmanagercontrolplane": "2.108.0", + "oci-threatintelligence": "2.108.0", + "oci-usage": "2.108.0", + "oci-usageapi": "2.108.0", + "oci-vault": "2.108.0", + "oci-vbsinst": "2.108.0", + "oci-visualbuilder": "2.108.0", + "oci-vnmonitoring": "2.108.0", + "oci-vulnerabilityscanning": "2.108.0", + "oci-waa": "2.108.0", + "oci-waas": "2.108.0", + "oci-waf": "2.108.0", + "oci-workrequests": "2.108.0", + "oci-zpr": "2.108.0" + } + }, + "node_modules/oci-secrets": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-secrets/-/oci-secrets-2.108.0.tgz", + "integrity": "sha512-GFFCuaKnS8pX7mE4mvZn/3m+rlksbheRNBg0e3dADAE9/G8hcRDabfUcp8ee0I2IOGlfmPx1MqqVxGdOB5qePA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-securityattribute": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-securityattribute/-/oci-securityattribute-2.108.0.tgz", + "integrity": "sha512-5q7X2iTIFONcQZLMMyuSPEGwv+/H1zp6+A8pizNEKnt/Ky1Y6J7mVt8rIfjkmW2adjCMbJvjOd/FEx1qpPMSdA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-servicecatalog": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-servicecatalog/-/oci-servicecatalog-2.108.0.tgz", + "integrity": "sha512-wawMy6pyaaLGb//qDSRZY3RDlBAdcgiH5rT8HWIvjpty5/LUfAFEoc6GT+hXESJJnTgKPv3jVRsauKGaYY0ThQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-servicemanagerproxy": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-servicemanagerproxy/-/oci-servicemanagerproxy-2.108.0.tgz", + "integrity": "sha512-ze38V56A7Lj2bmu0zrJJP/p0zJXawdUZO4vzVNKTRCMuCHA/bpNgxhsqrZftrlh/hJHIvzTUorNqKG6db4rvpw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-servicemesh": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-servicemesh/-/oci-servicemesh-2.108.0.tgz", + "integrity": "sha512-PKCePlf3UBtmXXqkCLQb3ckhYcMPwUjigKQHJXLomqRsN/WWS4cjaXFfwPms1LHYiljFaUBBpYPEXXuNBIAxrw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-stackmonitoring": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-stackmonitoring/-/oci-stackmonitoring-2.108.0.tgz", + "integrity": "sha512-MnWwot6txJhUFjmToZLg/MqxOy9oUcuosOv1ndRt1KJzgHlVqNDKhlYSzsY3M1I/luLKl56MmGUXEmPByTVNsA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-streaming": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-streaming/-/oci-streaming-2.108.0.tgz", + "integrity": "sha512-EJflloCRvhKpmbMWLtDKgYQDiinZgIyyAArl0YChUuLXYs7ntXNdK7vkiz/xYtez1j06JSGVDlhoJGnWHnLLOA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-tenantmanagercontrolplane": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-tenantmanagercontrolplane/-/oci-tenantmanagercontrolplane-2.108.0.tgz", + "integrity": "sha512-qOrkZhRI54+dncswCWrzgYNUhC1v/RVrBm/3M34RrZP3XIUYk42FbWEGmAUIf8CsKqAnMihHGZhYmKLF+CnQEQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-threatintelligence": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-threatintelligence/-/oci-threatintelligence-2.108.0.tgz", + "integrity": "sha512-xU9XRZRfTrXN4+UzsPwQbZXmwyU9IY5CpXvsAO/PjdIZJGwl69CGlxSbbLk0ye9qC+0zdADN8fspHp7u++jJ3w==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-usage": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-usage/-/oci-usage-2.108.0.tgz", + "integrity": "sha512-qjP75B0BchoIHc2VCQF8Yehx69/2F/M0UT74GcGjiNewkjxlJwiDCegj+IeVHZ91OCmb1Dqva3u2zUfwxbaqIw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-usageapi": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-usageapi/-/oci-usageapi-2.108.0.tgz", + "integrity": "sha512-hKcssMA1aHia+EM1rD+tGt/njsUn3nYmHJKMzywLHKYnmsbtVVVli8bKlELBFkLVjkIyKPY7XJQfCRr5TPQBHw==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-vault": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-vault/-/oci-vault-2.108.0.tgz", + "integrity": "sha512-wDq/hibUkif9rYJOhkY6/D9RXhSCsMuaKQeR0WaO6MdYhe7zbo0uXlADn8nPHWJ257CUBoDcpPMWP+mlWyVq9g==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-vbsinst": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-vbsinst/-/oci-vbsinst-2.108.0.tgz", + "integrity": "sha512-ZH6igsrlPrkC6DS9g6c7F6nSAb6/s7NuT11ENc/i2zG2DtsZBOTkr5l8+/mG5AvzMocKRd7NrcHjR4IMxrnhMQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-visualbuilder": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-visualbuilder/-/oci-visualbuilder-2.108.0.tgz", + "integrity": "sha512-G2oISBuIwvzl6sJV4KwbZX5G5GwkDmKG4JX2jxp/WNTtawsUz/OfpbpPyx+y5raWCUh3Uji5vWMlDVXfhzzzhg==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-vnmonitoring": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-vnmonitoring/-/oci-vnmonitoring-2.108.0.tgz", + "integrity": "sha512-8oVv+nQddteOdUiqDZGxBJgwkib1NUt6WifsaP3Y+GJEzV42vNHxvjpmJbMuR7TTW4cjBV8mQS2TbTQmPhX/JQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-vulnerabilityscanning": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-vulnerabilityscanning/-/oci-vulnerabilityscanning-2.108.0.tgz", + "integrity": "sha512-duvDY4zrDXWdRWWyBGLpQSvfpjYHfNJHyxxMacHu5l0sZM3iKMjx56288PYSTanicknSJ7dKeh/2R5XlOGO35w==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-waa": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-waa/-/oci-waa-2.108.0.tgz", + "integrity": "sha512-k0yhzlWvM6ry7/eScX/nIB98q6s+yuMP/GUagja/U1AbVGUjLHBjbhi8hbjqtpfuRaUN3luxMS8nrs0t6ZQesQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-waas": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-waas/-/oci-waas-2.108.0.tgz", + "integrity": "sha512-KtjN2JZ7tN5rAZr5viJQs24xRGBWrjP7ZmeHPkdGW+96rHqExlDNfJAcyk0hhJNECSu/g400aPlQ9djfELXPMA==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-waf": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-waf/-/oci-waf-2.108.0.tgz", + "integrity": "sha512-delccqk+FkW2l9e0Bf9SzD6VYojctk8mJx6IasxGl/w3Dc3C9HoBi2l4tfz2sBgBExDDWAMwxUa3ygCX2N3r8Q==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-workrequests": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-workrequests/-/oci-workrequests-2.108.0.tgz", + "integrity": "sha512-vwIM+cEDZ2BhKX+bOH2POTMTnfFnfjs0QxprHb1F05wu5/a3ea35oCkcchyqa1uHh75tqnO/dGDlp6st6+IeIQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, + "node_modules/oci-zpr": { + "version": "2.108.0", + "resolved": "https://registry.npmjs.org/oci-zpr/-/oci-zpr-2.108.0.tgz", + "integrity": "sha512-Ktxh08Mozp4LJ5ADuDpfnaMZBSJQ04xSvjCod/kf2G3WTQ7OUctvppY1S7X2kCPnEfNORevkE/LPDDpCnZpGgQ==", + "license": "(UPL-1.0 OR Apache-2.0)", + "dependencies": { + "oci-common": "2.108.0", + "oci-workrequests": "2.108.0" + } + }, "node_modules/octokit-auth-probot": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/octokit-auth-probot/-/octokit-auth-probot-2.0.0.tgz", @@ -20480,6 +24041,15 @@ "node": ">=0.10" } }, + "node_modules/opossum": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/opossum/-/opossum-5.0.1.tgz", + "integrity": "sha512-iUDUQmFl3RanaBVLMDTZ6WtXj/Hk84pwJ5JWoJaQd1lXGifdApHhszI3biZvdBDdpTERCmB6x+7+uNvzhzVZIg==", + "license": "Apache-2.0", + "engines": { + "node": ">= 10" + } + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -21602,62 +25172,6 @@ "node": ">=18" } }, - "node_modules/probot/node_modules/@octokit/core": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.1.tgz", - "integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==", - "license": "MIT", - "dependencies": { - "@octokit/auth-token": "^4.0.0", - "@octokit/graphql": "^7.1.0", - "@octokit/request": "^8.4.1", - "@octokit/request-error": "^5.1.1", - "@octokit/types": "^13.0.0", - "before-after-hook": "^2.2.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/probot/node_modules/@octokit/core/node_modules/@octokit/types": { - "version": "13.10.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", - "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^24.2.0" - } - }, - "node_modules/probot/node_modules/@octokit/graphql": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.1.tgz", - "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", - "license": "MIT", - "dependencies": { - "@octokit/request": "^8.4.1", - "@octokit/types": "^13.0.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/probot/node_modules/@octokit/graphql/node_modules/@octokit/types": { - "version": "13.10.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", - "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^24.2.0" - } - }, - "node_modules/probot/node_modules/@octokit/openapi-types": { - "version": "24.2.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", - "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", - "license": "MIT" - }, "node_modules/probot/node_modules/@octokit/plugin-retry": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-6.0.1.tgz", @@ -21690,12 +25204,6 @@ "@octokit/core": "^5.0.0" } }, - "node_modules/probot/node_modules/before-after-hook": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", - "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", - "license": "Apache-2.0" - }, "node_modules/probot/node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -24375,6 +27883,43 @@ "node": ">= 0.6" } }, + "node_modules/sshpk": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", + "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", + "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sshpk/node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT" + }, + "node_modules/sshpk/node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/ssri": { "version": "10.0.6", "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz", @@ -27168,6 +30713,12 @@ "node": ">=18" } }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, "node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", @@ -27709,11 +31260,12 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.22.4", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.22.4.tgz", - "integrity": "sha512-2Ed5dJ+n/O3cU383xSY28cuVi0BCQhF8nYqWU5paEpl7fVdqdAmiLdqLyfblbNdfOFwFfi/mqU4O1pwc60iBhQ==", + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", + "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", + "license": "ISC", "peerDependencies": { - "zod": "^3.22.4" + "zod": "^3.24.1" } } } diff --git a/backend/package.json b/backend/package.json index c19d30441..c2bfc29d9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -38,8 +38,8 @@ "build:frontend": "npm run build --prefix ../frontend", "start": "node --enable-source-maps dist/main.mjs", "type:check": "tsc --noEmit", - "lint:fix": "eslint --fix --ext js,ts ./src", - "lint": "eslint 'src/**/*.ts'", + "lint:fix": "node --max-old-space-size=8192 ./node_modules/.bin/eslint --fix --ext js,ts ./src", + "lint": "node --max-old-space-size=8192 ./node_modules/.bin/eslint 'src/**/*.ts'", "test:unit": "vitest run -c vitest.unit.config.ts", "test:e2e": "vitest run -c vitest.e2e.config.ts --bail=1", "test:e2e-watch": "vitest -c vitest.e2e.config.ts --bail=1", @@ -131,6 +131,7 @@ "@aws-sdk/client-elasticache": "^3.637.0", "@aws-sdk/client-iam": "^3.525.0", "@aws-sdk/client-kms": "^3.609.0", + "@aws-sdk/client-route-53": "^3.810.0", "@aws-sdk/client-secrets-manager": "^3.504.0", "@aws-sdk/client-sts": "^3.600.0", "@casl/ability": "^6.5.0", @@ -152,7 +153,8 @@ "@infisical/quic": "^1.0.8", "@node-saml/passport-saml": "^5.0.1", "@octokit/auth-app": "^7.1.1", - "@octokit/plugin-paginate-graphql": "^5.2.4", + "@octokit/core": "^5.2.1", + "@octokit/plugin-paginate-graphql": "^4.0.1", "@octokit/plugin-retry": "^5.0.5", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", @@ -173,6 +175,7 @@ "@slack/oauth": "^3.0.2", "@slack/web-api": "^7.8.0", "@ucast/mongo2js": "^1.3.4", + "acme-client": "^5.4.0", "ajv": "^8.12.0", "argon2": "^0.31.2", "aws-sdk": "^2.1553.0", @@ -208,6 +211,7 @@ "mysql2": "^3.9.8", "nanoid": "^3.3.8", "nodemailer": "^6.9.9", + "oci-sdk": "^2.108.0", "odbc": "^2.4.9", "openid-client": "^5.6.5", "ora": "^7.0.1", @@ -240,6 +244,6 @@ "tweetnacl-util": "^0.15.1", "uuid": "^9.0.1", "zod": "^3.22.4", - "zod-to-json-schema": "^3.22.4" + "zod-to-json-schema": "^3.24.5" } } diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 6ec542c6b..344d1e02e 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -37,6 +37,7 @@ import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-ap import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service"; import { TSecretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service"; import { TSecretScanningServiceFactory } from "@app/ee/services/secret-scanning/secret-scanning-service"; +import { TSecretScanningV2ServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-service"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { TSshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service"; import { TSshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service"; @@ -53,6 +54,7 @@ import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; +import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; import { TCmekServiceFactory } from "@app/services/cmek/cmek-service"; import { TExternalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; @@ -66,6 +68,9 @@ import { TIdentityAzureAuthServiceFactory } from "@app/services/identity-azure-a import { TIdentityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; import { TIdentityJwtAuthServiceFactory } from "@app/services/identity-jwt-auth/identity-jwt-auth-service"; import { TIdentityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service"; +import { TIdentityLdapAuthServiceFactory } from "@app/services/identity-ldap-auth/identity-ldap-auth-service"; +import { TAllowedFields } from "@app/services/identity-ldap-auth/identity-ldap-auth-types"; +import { TIdentityOciAuthServiceFactory } from "@app/services/identity-oci-auth/identity-oci-auth-service"; import { TIdentityOidcAuthServiceFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-service"; import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; import { TIdentityTokenAuthServiceFactory } from "@app/services/identity-token-auth/identity-token-auth-service"; @@ -78,6 +83,8 @@ import { TOrgServiceFactory } from "@app/services/org/org-service"; import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; 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 { 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"; import { TProjectEnvServiceFactory } from "@app/services/project-env/project-env-service"; @@ -106,11 +113,16 @@ import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integ declare module "@fastify/request-context" { interface RequestContextData { reqId: string; + orgId?: string; identityAuthInfo?: { identityId: string; oidc?: { claims: Record; }; + kubernetes?: { + namespace: string; + name: string; + }; }; identityPermissionMetadata?: Record; // filled by permission service assumedPrivilegeDetails?: { requesterId: string; actorId: string; actorType: ActorType; projectId: string }; @@ -146,6 +158,13 @@ declare module "fastify" { providerAuthToken: string; externalProviderAccessToken?: string; }; + passportMachineIdentity: { + identityId: string; + user: { + uid: string; + mail?: string; + }; + }; kmipUser: { projectId: string; clientId: string; @@ -153,7 +172,9 @@ declare module "fastify" { }; auditLogInfo: Pick; ssoConfig: Awaited>; - ldapConfig: Awaited>; + ldapConfig: Awaited> & { + allowedFields?: TAllowedFields[]; + }; } interface FastifyInstance { @@ -197,8 +218,10 @@ declare module "fastify" { identityGcpAuth: TIdentityGcpAuthServiceFactory; identityAwsAuth: TIdentityAwsAuthServiceFactory; identityAzureAuth: TIdentityAzureAuthServiceFactory; + identityOciAuth: TIdentityOciAuthServiceFactory; identityOidcAuth: TIdentityOidcAuthServiceFactory; identityJwtAuth: TIdentityJwtAuthServiceFactory; + identityLdapAuth: TIdentityLdapAuthServiceFactory; accessApprovalPolicy: TAccessApprovalPolicyServiceFactory; accessApprovalRequest: TAccessApprovalRequestServiceFactory; secretApprovalPolicy: TSecretApprovalPolicyServiceFactory; @@ -220,6 +243,7 @@ declare module "fastify" { certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; certificateEst: TCertificateEstServiceFactory; pkiCollection: TPkiCollectionServiceFactory; + pkiSubscriber: TPkiSubscriberServiceFactory; secretScanning: TSecretScanningServiceFactory; license: TLicenseServiceFactory; trustedIp: TTrustedIpServiceFactory; @@ -252,6 +276,9 @@ declare module "fastify" { microsoftTeams: TMicrosoftTeamsServiceFactory; assumePrivileges: TAssumePrivilegeServiceFactory; githubOrgSync: TGithubOrgSyncServiceFactory; + secretScanningV2: TSecretScanningV2ServiceFactory; + internalCertificateAuthority: TInternalCertificateAuthorityServiceFactory; + pkiTemplate: TPkiTemplatesServiceFactory; }; // 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 13f3bc306..44cd6bc79 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -6,6 +6,9 @@ import { TAccessApprovalPoliciesApprovers, TAccessApprovalPoliciesApproversInsert, TAccessApprovalPoliciesApproversUpdate, + TAccessApprovalPoliciesBypassers, + TAccessApprovalPoliciesBypassersInsert, + TAccessApprovalPoliciesBypassersUpdate, TAccessApprovalPoliciesInsert, TAccessApprovalPoliciesUpdate, TAccessApprovalRequests, @@ -68,6 +71,9 @@ import { TDynamicSecrets, TDynamicSecretsInsert, TDynamicSecretsUpdate, + TExternalCertificateAuthorities, + TExternalCertificateAuthoritiesInsert, + TExternalCertificateAuthoritiesUpdate, TExternalGroupOrgRoleMappings, TExternalGroupOrgRoleMappingsInsert, TExternalGroupOrgRoleMappingsUpdate, @@ -119,6 +125,9 @@ import { TIdentityMetadata, TIdentityMetadataInsert, TIdentityMetadataUpdate, + TIdentityOciAuths, + TIdentityOciAuthsInsert, + TIdentityOciAuthsUpdate, TIdentityOidcAuths, TIdentityOidcAuthsInsert, TIdentityOidcAuthsUpdate, @@ -152,6 +161,9 @@ import { TIntegrations, TIntegrationsInsert, TIntegrationsUpdate, + TInternalCertificateAuthorities, + TInternalCertificateAuthoritiesInsert, + TInternalCertificateAuthoritiesUpdate, TInternalKms, TInternalKmsInsert, TInternalKmsUpdate, @@ -209,6 +221,9 @@ import { TPkiCollections, TPkiCollectionsInsert, TPkiCollectionsUpdate, + TPkiSubscribers, + TPkiSubscribersInsert, + TPkiSubscribersUpdate, TProjectBots, TProjectBotsInsert, TProjectBotsUpdate, @@ -264,6 +279,9 @@ import { TSecretApprovalPoliciesApprovers, TSecretApprovalPoliciesApproversInsert, TSecretApprovalPoliciesApproversUpdate, + TSecretApprovalPoliciesBypassers, + TSecretApprovalPoliciesBypassersInsert, + TSecretApprovalPoliciesBypassersUpdate, TSecretApprovalPoliciesInsert, TSecretApprovalPoliciesUpdate, TSecretApprovalRequests, @@ -318,9 +336,24 @@ import { TSecretRotationV2SecretMappingsInsert, TSecretRotationV2SecretMappingsUpdate, TSecrets, + TSecretScanningConfigs, + TSecretScanningConfigsInsert, + TSecretScanningConfigsUpdate, + TSecretScanningDataSources, + TSecretScanningDataSourcesInsert, + TSecretScanningDataSourcesUpdate, + TSecretScanningFindings, + TSecretScanningFindingsInsert, + TSecretScanningFindingsUpdate, TSecretScanningGitRisks, TSecretScanningGitRisksInsert, TSecretScanningGitRisksUpdate, + TSecretScanningResources, + TSecretScanningResourcesInsert, + TSecretScanningResourcesUpdate, + TSecretScanningScans, + TSecretScanningScansInsert, + TSecretScanningScansUpdate, TSecretSharing, TSecretSharingInsert, TSecretSharingUpdate, @@ -432,6 +465,11 @@ import { TWorkflowIntegrationsInsert, TWorkflowIntegrationsUpdate } from "@app/db/schemas"; +import { + TIdentityLdapAuths, + TIdentityLdapAuthsInsert, + TIdentityLdapAuthsUpdate +} from "@app/db/schemas/identity-ldap-auths"; import { TMicrosoftTeamsIntegrations, TMicrosoftTeamsIntegrationsInsert, @@ -527,6 +565,16 @@ declare module "knex/types/tables" { TCertificateAuthorityCrlInsert, TCertificateAuthorityCrlUpdate >; + [TableName.InternalCertificateAuthority]: KnexOriginal.CompositeTableType< + TInternalCertificateAuthorities, + TInternalCertificateAuthoritiesInsert, + TInternalCertificateAuthoritiesUpdate + >; + [TableName.ExternalCertificateAuthority]: KnexOriginal.CompositeTableType< + TExternalCertificateAuthorities, + TExternalCertificateAuthoritiesInsert, + TExternalCertificateAuthoritiesUpdate + >; [TableName.Certificate]: KnexOriginal.CompositeTableType; [TableName.CertificateTemplate]: KnexOriginal.CompositeTableType< TCertificateTemplates, @@ -559,6 +607,11 @@ declare module "knex/types/tables" { TPkiCollectionItemsInsert, TPkiCollectionItemsUpdate >; + [TableName.PkiSubscriber]: KnexOriginal.CompositeTableType< + TPkiSubscribers, + TPkiSubscribersInsert, + TPkiSubscribersUpdate + >; [TableName.UserGroupMembership]: KnexOriginal.CompositeTableType< TUserGroupMembership, TUserGroupMembershipInsert, @@ -725,6 +778,11 @@ declare module "knex/types/tables" { TIdentityAzureAuthsInsert, TIdentityAzureAuthsUpdate >; + [TableName.IdentityOciAuth]: KnexOriginal.CompositeTableType< + TIdentityOciAuths, + TIdentityOciAuthsInsert, + TIdentityOciAuthsUpdate + >; [TableName.IdentityOidcAuth]: KnexOriginal.CompositeTableType< TIdentityOidcAuths, TIdentityOidcAuthsInsert, @@ -735,6 +793,11 @@ declare module "knex/types/tables" { TIdentityJwtAuthsInsert, TIdentityJwtAuthsUpdate >; + [TableName.IdentityLdapAuth]: KnexOriginal.CompositeTableType< + TIdentityLdapAuths, + TIdentityLdapAuthsInsert, + TIdentityLdapAuthsUpdate + >; [TableName.IdentityUaClientSecret]: KnexOriginal.CompositeTableType< TIdentityUaClientSecrets, TIdentityUaClientSecretsInsert, @@ -778,6 +841,12 @@ declare module "knex/types/tables" { TAccessApprovalPoliciesApproversUpdate >; + [TableName.AccessApprovalPolicyBypasser]: KnexOriginal.CompositeTableType< + TAccessApprovalPoliciesBypassers, + TAccessApprovalPoliciesBypassersInsert, + TAccessApprovalPoliciesBypassersUpdate + >; + [TableName.AccessApprovalRequest]: KnexOriginal.CompositeTableType< TAccessApprovalRequests, TAccessApprovalRequestsInsert, @@ -801,6 +870,11 @@ declare module "knex/types/tables" { TSecretApprovalPoliciesApproversInsert, TSecretApprovalPoliciesApproversUpdate >; + [TableName.SecretApprovalPolicyBypasser]: KnexOriginal.CompositeTableType< + TSecretApprovalPoliciesBypassers, + TSecretApprovalPoliciesBypassersInsert, + TSecretApprovalPoliciesBypassersUpdate + >; [TableName.SecretApprovalRequest]: KnexOriginal.CompositeTableType< TSecretApprovalRequests, TSecretApprovalRequestsInsert, @@ -1048,5 +1122,30 @@ declare module "knex/types/tables" { TGithubOrgSyncConfigsInsert, TGithubOrgSyncConfigsUpdate >; + [TableName.SecretScanningDataSource]: KnexOriginal.CompositeTableType< + TSecretScanningDataSources, + TSecretScanningDataSourcesInsert, + TSecretScanningDataSourcesUpdate + >; + [TableName.SecretScanningResource]: KnexOriginal.CompositeTableType< + TSecretScanningResources, + TSecretScanningResourcesInsert, + TSecretScanningResourcesUpdate + >; + [TableName.SecretScanningScan]: KnexOriginal.CompositeTableType< + TSecretScanningScans, + TSecretScanningScansInsert, + TSecretScanningScansUpdate + >; + [TableName.SecretScanningFinding]: KnexOriginal.CompositeTableType< + TSecretScanningFindings, + TSecretScanningFindingsInsert, + TSecretScanningFindingsUpdate + >; + [TableName.SecretScanningConfig]: KnexOriginal.CompositeTableType< + TSecretScanningConfigs, + TSecretScanningConfigsInsert, + TSecretScanningConfigsUpdate + >; } } diff --git a/backend/src/db/migrations/20250429203304_certificates-ca-relation-removal.ts b/backend/src/db/migrations/20250429203304_certificates-ca-relation-removal.ts new file mode 100644 index 000000000..1137b9ab8 --- /dev/null +++ b/backend/src/db/migrations/20250429203304_certificates-ca-relation-removal.ts @@ -0,0 +1,44 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.Certificate)) { + const hasProjectIdColumn = await knex.schema.hasColumn(TableName.Certificate, "projectId"); + if (!hasProjectIdColumn) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.string("projectId", 36).nullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + }); + + await knex.raw(` + UPDATE "${TableName.Certificate}" cert + SET "projectId" = ca."projectId" + FROM "${TableName.CertificateAuthority}" ca + WHERE cert."caId" = ca.id + `); + + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.string("projectId").notNullable().alter(); + }); + } + + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.uuid("caId").nullable().alter(); + t.uuid("caCertId").nullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.Certificate)) { + if (await knex.schema.hasColumn(TableName.Certificate, "projectId")) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.dropForeign("projectId"); + t.dropColumn("projectId"); + }); + } + } + + // Altering back to notNullable for caId and caCertId will fail +} diff --git a/backend/src/db/migrations/20250429232917_store-cert-secret-key-and-chain.ts b/backend/src/db/migrations/20250429232917_store-cert-secret-key-and-chain.ts index cb5e44a03..f90b2d593 100644 --- a/backend/src/db/migrations/20250429232917_store-cert-secret-key-and-chain.ts +++ b/backend/src/db/migrations/20250429232917_store-cert-secret-key-and-chain.ts @@ -3,7 +3,7 @@ import { Knex } from "knex"; import { TableName } from "../schemas"; export async function up(knex: Knex): Promise { - if (await knex.schema.hasTable(TableName.CertificateBody)) { + if (!(await knex.schema.hasColumn(TableName.CertificateBody, "encryptedCertificateChain"))) { await knex.schema.alterTable(TableName.CertificateBody, (t) => { t.binary("encryptedCertificateChain").nullable(); }); @@ -25,7 +25,7 @@ export async function down(knex: Knex): Promise { await knex.schema.dropTable(TableName.CertificateSecret); } - if (await knex.schema.hasTable(TableName.CertificateBody)) { + if (await knex.schema.hasColumn(TableName.CertificateBody, "encryptedCertificateChain")) { await knex.schema.alterTable(TableName.CertificateBody, (t) => { t.dropColumn("encryptedCertificateChain"); }); diff --git a/backend/src/db/migrations/20250430174352_email-case-change.ts b/backend/src/db/migrations/20250430174352_email-case-change.ts new file mode 100644 index 000000000..d6b9b3980 --- /dev/null +++ b/backend/src/db/migrations/20250430174352_email-case-change.ts @@ -0,0 +1,47 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasEmail = await knex.schema.hasColumn(TableName.Users, "email"); + const hasUsername = await knex.schema.hasColumn(TableName.Users, "username"); + if (hasEmail) { + await knex(TableName.Users) + .where({ isGhost: false }) + .update({ + // @ts-expect-error email assume string this is expected + email: knex.raw("lower(email)") + }); + } + if (hasUsername) { + await knex.schema.raw(` + CREATE INDEX IF NOT EXISTS ${TableName.Users}_lower_username_idx + ON ${TableName.Users} (LOWER(username)) + `); + + const duplicatesSubquery = knex(TableName.Users) + .select(knex.raw("lower(username) as lowercase_username")) + .groupBy("lowercase_username") + .having(knex.raw("count(*)"), ">", 1); + + // Update usernames to lowercase where they won't create duplicates + await knex(TableName.Users) + .where({ isGhost: false }) + .whereRaw("username <> lower(username)") // Only update if not already lowercase + // @ts-expect-error username assume string this is expected + .whereNotIn(knex.raw("lower(username)"), duplicatesSubquery) + .update({ + // @ts-expect-error username assume string this is expected + username: knex.raw("lower(username)") + }); + } +} + +export async function down(knex: Knex): Promise { + const hasUsername = await knex.schema.hasColumn(TableName.Users, "username"); + if (hasUsername) { + await knex.schema.raw(` + DROP INDEX IF EXISTS ${TableName.Users}_lower_username_idx +`); + } +} diff --git a/backend/src/db/migrations/20250501164905_add-groups-to-ssh-host-login-user-mappings.ts b/backend/src/db/migrations/20250501164905_add-groups-to-ssh-host-login-user-mappings.ts new file mode 100644 index 000000000..4f08146f9 --- /dev/null +++ b/backend/src/db/migrations/20250501164905_add-groups-to-ssh-host-login-user-mappings.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.SshHostLoginUserMapping, "groupId"))) { + await knex.schema.alterTable(TableName.SshHostLoginUserMapping, (t) => { + t.uuid("groupId").nullable(); + t.foreign("groupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + t.unique(["sshHostLoginUserId", "groupId"]); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SshHostLoginUserMapping, "groupId")) { + await knex.schema.alterTable(TableName.SshHostLoginUserMapping, (t) => { + t.dropUnique(["sshHostLoginUserId", "groupId"]); + t.dropColumn("groupId"); + }); + } +} diff --git a/backend/src/db/migrations/20250505203703_project-templates-type-col.ts b/backend/src/db/migrations/20250505203703_project-templates-type-col.ts new file mode 100644 index 000000000..d1ef14d72 --- /dev/null +++ b/backend/src/db/migrations/20250505203703_project-templates-type-col.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { ProjectType, TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.ProjectTemplates, "type"))) { + await knex.schema.alterTable(TableName.ProjectTemplates, (t) => { + // defaulting to sm for migration to set existing, new ones will always be specified on creation + t.string("type").defaultTo(ProjectType.SecretManager).notNullable(); + t.jsonb("environments").nullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.ProjectTemplates, "type")) { + await knex.schema.alterTable(TableName.ProjectTemplates, (t) => { + t.dropColumn("type"); + // not reverting nullable environments + }); + } +} diff --git a/backend/src/db/migrations/20250507003056_identity-ldap-auth.ts b/backend/src/db/migrations/20250507003056_identity-ldap-auth.ts new file mode 100644 index 000000000..da9912022 --- /dev/null +++ b/backend/src/db/migrations/20250507003056_identity-ldap-auth.ts @@ -0,0 +1,39 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityLdapAuth))) { + await knex.schema.createTable(TableName.IdentityLdapAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + + t.binary("encryptedBindDN").notNullable(); + t.binary("encryptedBindPass").notNullable(); + t.binary("encryptedLdapCaCertificate").nullable(); + + t.string("url").notNullable(); + t.string("searchBase").notNullable(); + t.string("searchFilter").notNullable(); + + t.jsonb("allowedFields").nullable(); + + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityLdapAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityLdapAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityLdapAuth); +} diff --git a/backend/src/db/migrations/20250508160957_pki-subscriber.ts b/backend/src/db/migrations/20250508160957_pki-subscriber.ts new file mode 100644 index 000000000..0e1b50f03 --- /dev/null +++ b/backend/src/db/migrations/20250508160957_pki-subscriber.ts @@ -0,0 +1,46 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.PkiSubscriber))) { + await knex.schema.createTable(TableName.PkiSubscriber, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("caId").nullable(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("SET NULL"); + t.string("name").notNullable(); + t.string("commonName").notNullable(); + t.specificType("subjectAlternativeNames", "text[]").notNullable(); + t.string("ttl").notNullable(); + t.specificType("keyUsages", "text[]").notNullable(); + t.specificType("extendedKeyUsages", "text[]").notNullable(); + t.string("status").notNullable(); // active / disabled + t.unique(["projectId", "name"]); + }); + await createOnUpdateTrigger(knex, TableName.PkiSubscriber); + } + + const hasSubscriberCol = await knex.schema.hasColumn(TableName.Certificate, "pkiSubscriberId"); + if (!hasSubscriberCol) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.uuid("pkiSubscriberId").nullable(); + t.foreign("pkiSubscriberId").references("id").inTable(TableName.PkiSubscriber).onDelete("SET NULL"); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasSubscriberCol = await knex.schema.hasColumn(TableName.Certificate, "pkiSubscriberId"); + if (hasSubscriberCol) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.dropColumn("pkiSubscriberId"); + }); + } + + await knex.schema.dropTableIfExists(TableName.PkiSubscriber); + await dropOnUpdateTrigger(knex, TableName.PkiSubscriber); +} diff --git a/backend/src/db/migrations/20250508210717_identity-oci-auth.ts b/backend/src/db/migrations/20250508210717_identity-oci-auth.ts new file mode 100644 index 000000000..9512807d1 --- /dev/null +++ b/backend/src/db/migrations/20250508210717_identity-oci-auth.ts @@ -0,0 +1,30 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityOciAuth))) { + await knex.schema.createTable(TableName.IdentityOciAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + t.timestamps(true, true, true); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("type").notNullable(); + + t.string("tenancyOcid").notNullable(); + t.string("allowedUsernames").nullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityOciAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityOciAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityOciAuth); +} diff --git a/backend/src/db/migrations/20250512103022_identity-kubernetes-auth-gateway.ts b/backend/src/db/migrations/20250512103022_identity-kubernetes-auth-gateway.ts new file mode 100644 index 000000000..fcd9bfc3e --- /dev/null +++ b/backend/src/db/migrations/20250512103022_identity-kubernetes-auth-gateway.ts @@ -0,0 +1,25 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasGatewayIdColumn = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "gatewayId"); + + if (!hasGatewayIdColumn) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (table) => { + table.uuid("gatewayId").nullable(); + table.foreign("gatewayId").references("id").inTable(TableName.Gateway).onDelete("SET NULL"); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasGatewayIdColumn = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "gatewayId"); + + if (hasGatewayIdColumn) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (table) => { + table.dropForeign("gatewayId"); + table.dropColumn("gatewayId"); + }); + } +} diff --git a/backend/src/db/migrations/20250513081738_remove-gateway-project-link.ts b/backend/src/db/migrations/20250513081738_remove-gateway-project-link.ts new file mode 100644 index 000000000..3b3c5322e --- /dev/null +++ b/backend/src/db/migrations/20250513081738_remove-gateway-project-link.ts @@ -0,0 +1,110 @@ +import { Knex } from "knex"; + +import { inMemoryKeyStore } from "@app/keystore/memory"; +import { selectAllTableCols } from "@app/lib/knex"; +import { initLogger } from "@app/lib/logger"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { TableName } from "../schemas"; +import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEncryptionServices } from "./utils/services"; + +// Note(daniel): We aren't dropping tables or columns in this migrations so we can easily rollback if needed. +// In the future we need to drop the projectGatewayId on the dynamic secrets table, and drop the project_gateways table entirely. + +const BATCH_SIZE = 500; + +export async function up(knex: Knex): Promise { + // eslint-disable-next-line no-param-reassign + knex.replicaNode = () => { + return knex; + }; + + if (!(await knex.schema.hasColumn(TableName.DynamicSecret, "gatewayId"))) { + await knex.schema.alterTable(TableName.DynamicSecret, (table) => { + table.uuid("gatewayId").nullable(); + table.foreign("gatewayId").references("id").inTable(TableName.Gateway).onDelete("SET NULL"); + + table.index("gatewayId"); + }); + + const existingDynamicSecretsWithProjectGatewayId = await knex(TableName.DynamicSecret) + .select(selectAllTableCols(TableName.DynamicSecret)) + .whereNotNull(`${TableName.DynamicSecret}.projectGatewayId`) + .join(TableName.ProjectGateway, `${TableName.ProjectGateway}.id`, `${TableName.DynamicSecret}.projectGatewayId`) + .whereNotNull(`${TableName.ProjectGateway}.gatewayId`) + .select( + knex.ref("projectId").withSchema(TableName.ProjectGateway).as("projectId"), + knex.ref("gatewayId").withSchema(TableName.ProjectGateway).as("projectGatewayGatewayId") + ); + + initLogger(); + const envConfig = getMigrationEnvConfig(); + const keyStore = inMemoryKeyStore(); + const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); + + const updatedDynamicSecrets = await Promise.all( + existingDynamicSecretsWithProjectGatewayId.map(async (existingDynamicSecret) => { + if (!existingDynamicSecret.projectGatewayGatewayId) { + const result = { + ...existingDynamicSecret, + gatewayId: null + }; + + const { projectId, projectGatewayGatewayId, ...rest } = result; + return rest; + } + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: existingDynamicSecret.projectId + }); + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: existingDynamicSecret.projectId + }); + + let decryptedStoredInput = JSON.parse( + secretManagerDecryptor({ cipherTextBlob: Buffer.from(existingDynamicSecret.encryptedInput) }).toString() + ) as object; + + // We're not removing the existing projectGatewayId from the input so we can easily rollback without having to re-encrypt the input + decryptedStoredInput = { + ...decryptedStoredInput, + gatewayId: existingDynamicSecret.projectGatewayGatewayId + }; + + const encryptedInput = secretManagerEncryptor({ + plainText: Buffer.from(JSON.stringify(decryptedStoredInput)) + }).cipherTextBlob; + + const result = { + ...existingDynamicSecret, + encryptedInput, + gatewayId: existingDynamicSecret.projectGatewayGatewayId + }; + + const { projectId, projectGatewayGatewayId, ...rest } = result; + return rest; + }) + ); + + for (let i = 0; i < updatedDynamicSecrets.length; i += BATCH_SIZE) { + // eslint-disable-next-line no-await-in-loop + await knex(TableName.DynamicSecret) + .insert(updatedDynamicSecrets.slice(i, i + BATCH_SIZE)) + .onConflict("id") + .merge(); + } + } +} + +export async function down(knex: Knex): Promise { + // no re-encryption needed as we keep the old projectGatewayId in the input + if (await knex.schema.hasColumn(TableName.DynamicSecret, "gatewayId")) { + await knex.schema.alterTable(TableName.DynamicSecret, (table) => { + table.dropForeign("gatewayId"); + table.dropColumn("gatewayId"); + }); + } +} diff --git a/backend/src/db/migrations/20250515164622_select-org-products.ts b/backend/src/db/migrations/20250515164622_select-org-products.ts new file mode 100644 index 000000000..c290a4ee2 --- /dev/null +++ b/backend/src/db/migrations/20250515164622_select-org-products.ts @@ -0,0 +1,53 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const columns = await knex.table(TableName.Organization).columnInfo(); + + await knex.schema.alterTable(TableName.Organization, (t) => { + if (!columns.secretsProductEnabled) { + t.boolean("secretsProductEnabled").defaultTo(true); + } + if (!columns.pkiProductEnabled) { + t.boolean("pkiProductEnabled").defaultTo(true); + } + if (!columns.kmsProductEnabled) { + t.boolean("kmsProductEnabled").defaultTo(true); + } + if (!columns.sshProductEnabled) { + t.boolean("sshProductEnabled").defaultTo(true); + } + if (!columns.scannerProductEnabled) { + t.boolean("scannerProductEnabled").defaultTo(true); + } + if (!columns.shareSecretsProductEnabled) { + t.boolean("shareSecretsProductEnabled").defaultTo(true); + } + }); +} + +export async function down(knex: Knex): Promise { + const columns = await knex.table(TableName.Organization).columnInfo(); + + await knex.schema.alterTable(TableName.Organization, (t) => { + if (columns.secretsProductEnabled) { + t.dropColumn("secretsProductEnabled"); + } + if (columns.pkiProductEnabled) { + t.dropColumn("pkiProductEnabled"); + } + if (columns.kmsProductEnabled) { + t.dropColumn("kmsProductEnabled"); + } + if (columns.sshProductEnabled) { + t.dropColumn("sshProductEnabled"); + } + if (columns.scannerProductEnabled) { + t.dropColumn("scannerProductEnabled"); + } + if (columns.shareSecretsProductEnabled) { + t.dropColumn("shareSecretsProductEnabled"); + } + }); +} diff --git a/backend/src/db/migrations/20250516021501_toggle-secret-sharing-on-project.ts b/backend/src/db/migrations/20250516021501_toggle-secret-sharing-on-project.ts new file mode 100644 index 000000000..2600ae0f0 --- /dev/null +++ b/backend/src/db/migrations/20250516021501_toggle-secret-sharing-on-project.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasSecretSharingColumn = await knex.schema.hasColumn(TableName.Project, "secretSharing"); + if (!hasSecretSharingColumn) { + await knex.schema.table(TableName.Project, (table) => { + table.boolean("secretSharing").notNullable().defaultTo(true); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasSecretSharingColumn = await knex.schema.hasColumn(TableName.Project, "secretSharing"); + if (hasSecretSharingColumn) { + await knex.schema.table(TableName.Project, (table) => { + table.dropColumn("secretSharing"); + }); + } +} diff --git a/backend/src/db/migrations/20250516192508_secret-sharing-limits-for-org.ts b/backend/src/db/migrations/20250516192508_secret-sharing-limits-for-org.ts new file mode 100644 index 000000000..f68c1c29b --- /dev/null +++ b/backend/src/db/migrations/20250516192508_secret-sharing-limits-for-org.ts @@ -0,0 +1,35 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasLifetimeColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretLifetime"); + const hasViewLimitColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretViewLimit"); + + if (!hasLifetimeColumn || !hasViewLimitColumn) { + await knex.schema.alterTable(TableName.Organization, (t) => { + if (!hasLifetimeColumn) { + t.integer("maxSharedSecretLifetime").nullable().defaultTo(2592000); // 30 days in seconds + } + if (!hasViewLimitColumn) { + t.integer("maxSharedSecretViewLimit").nullable(); + } + }); + } +} + +export async function down(knex: Knex): Promise { + const hasLifetimeColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretLifetime"); + const hasViewLimitColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretViewLimit"); + + if (hasLifetimeColumn || hasViewLimitColumn) { + await knex.schema.alterTable(TableName.Organization, (t) => { + if (hasLifetimeColumn) { + t.dropColumn("maxSharedSecretLifetime"); + } + if (hasViewLimitColumn) { + t.dropColumn("maxSharedSecretViewLimit"); + } + }); + } +} diff --git a/backend/src/db/migrations/20250517002223_secret-share-to-specific-emails.ts b/backend/src/db/migrations/20250517002223_secret-share-to-specific-emails.ts new file mode 100644 index 000000000..6a02ae4eb --- /dev/null +++ b/backend/src/db/migrations/20250517002223_secret-share-to-specific-emails.ts @@ -0,0 +1,43 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretSharing)) { + const hasEncryptedSalt = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSalt"); + const hasAuthorizedEmails = await knex.schema.hasColumn(TableName.SecretSharing, "authorizedEmails"); + + if (!hasEncryptedSalt || !hasAuthorizedEmails) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + // These two columns are only needed when secrets are shared with a specific list of emails + + if (!hasEncryptedSalt) { + t.binary("encryptedSalt").nullable(); + } + + if (!hasAuthorizedEmails) { + t.json("authorizedEmails").nullable(); + } + }); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretSharing)) { + const hasEncryptedSalt = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSalt"); + const hasAuthorizedEmails = await knex.schema.hasColumn(TableName.SecretSharing, "authorizedEmails"); + + if (hasEncryptedSalt || hasAuthorizedEmails) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + if (hasEncryptedSalt) { + t.dropColumn("encryptedSalt"); + } + + if (hasAuthorizedEmails) { + t.dropColumn("authorizedEmails"); + } + }); + } + } +} diff --git a/backend/src/db/migrations/20250517002225_secret-scanning-v2.ts b/backend/src/db/migrations/20250517002225_secret-scanning-v2.ts new file mode 100644 index 000000000..86da451f3 --- /dev/null +++ b/backend/src/db/migrations/20250517002225_secret-scanning-v2.ts @@ -0,0 +1,107 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils"; +import { + SecretScanningFindingStatus, + SecretScanningScanStatus +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SecretScanningDataSource))) { + await knex.schema.createTable(TableName.SecretScanningDataSource, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("externalId").index(); // if we need a unique way of identifying this data source from an external resource + t.string("name", 48).notNullable(); + t.string("description"); + t.string("type").notNullable(); + t.jsonb("config").notNullable(); + t.binary("encryptedCredentials"); // webhook credentials, etc. + t.uuid("connectionId"); + t.boolean("isAutoScanEnabled").defaultTo(true); + t.foreign("connectionId").references("id").inTable(TableName.AppConnection); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.timestamps(true, true, true); + t.boolean("isDisconnected").notNullable().defaultTo(false); + t.unique(["projectId", "name"]); + }); + await createOnUpdateTrigger(knex, TableName.SecretScanningDataSource); + } + + if (!(await knex.schema.hasTable(TableName.SecretScanningResource))) { + await knex.schema.createTable(TableName.SecretScanningResource, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("externalId").notNullable(); + t.string("name").notNullable(); + t.string("type").notNullable(); + t.uuid("dataSourceId").notNullable(); + t.foreign("dataSourceId").references("id").inTable(TableName.SecretScanningDataSource).onDelete("CASCADE"); + t.timestamps(true, true, true); + t.unique(["dataSourceId", "externalId"]); + }); + await createOnUpdateTrigger(knex, TableName.SecretScanningResource); + } + + if (!(await knex.schema.hasTable(TableName.SecretScanningScan))) { + await knex.schema.createTable(TableName.SecretScanningScan, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("status").notNullable().defaultTo(SecretScanningScanStatus.Queued); + t.string("statusMessage", 1024); + t.string("type").notNullable(); + t.uuid("resourceId").notNullable(); + t.foreign("resourceId").references("id").inTable(TableName.SecretScanningResource).onDelete("CASCADE"); + t.timestamp("createdAt").defaultTo(knex.fn.now()); + }); + } + + if (!(await knex.schema.hasTable(TableName.SecretScanningFinding))) { + await knex.schema.createTable(TableName.SecretScanningFinding, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("dataSourceName").notNullable(); + t.string("dataSourceType").notNullable(); + t.string("resourceName").notNullable(); + t.string("resourceType").notNullable(); + t.string("rule").notNullable(); + t.string("severity").notNullable(); + t.string("status").notNullable().defaultTo(SecretScanningFindingStatus.Unresolved); + t.string("remarks"); + t.string("fingerprint").notNullable(); + t.jsonb("details").notNullable(); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("scanId"); + t.foreign("scanId").references("id").inTable(TableName.SecretScanningScan).onDelete("SET NULL"); + t.timestamps(true, true, true); + t.unique(["projectId", "fingerprint"]); + }); + await createOnUpdateTrigger(knex, TableName.SecretScanningFinding); + } + + if (!(await knex.schema.hasTable(TableName.SecretScanningConfig))) { + await knex.schema.createTable(TableName.SecretScanningConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("projectId").notNullable().unique(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.string("content", 5000); + t.timestamps(true, true, true); + }); + await createOnUpdateTrigger(knex, TableName.SecretScanningConfig); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretScanningFinding); + + await dropOnUpdateTrigger(knex, TableName.SecretScanningFinding); + await knex.schema.dropTableIfExists(TableName.SecretScanningScan); + + await knex.schema.dropTableIfExists(TableName.SecretScanningResource); + await dropOnUpdateTrigger(knex, TableName.SecretScanningResource); + + await knex.schema.dropTableIfExists(TableName.SecretScanningDataSource); + await dropOnUpdateTrigger(knex, TableName.SecretScanningDataSource); + + await knex.schema.dropTableIfExists(TableName.SecretScanningConfig); + await dropOnUpdateTrigger(knex, TableName.SecretScanningConfig); +} diff --git a/backend/src/db/migrations/20250521061831_increase-name-sizes.ts b/backend/src/db/migrations/20250521061831_increase-name-sizes.ts new file mode 100644 index 000000000..b87279339 --- /dev/null +++ b/backend/src/db/migrations/20250521061831_increase-name-sizes.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TableName.SecretSync, (t) => { + t.string("name", 64).notNullable().alter(); + }); + await knex.schema.alterTable(TableName.ProjectTemplates, (t) => { + t.string("name", 64).notNullable().alter(); + }); + await knex.schema.alterTable(TableName.AppConnection, (t) => { + t.string("name", 64).notNullable().alter(); + }); + await knex.schema.alterTable(TableName.SecretRotationV2, (t) => { + t.string("name", 64).notNullable().alter(); + }); +} + +export async function down(): Promise { + // No down migration or it will error +} diff --git a/backend/src/db/migrations/20250521110635_add-external-ca-pki.ts b/backend/src/db/migrations/20250521110635_add-external-ca-pki.ts new file mode 100644 index 000000000..8f84da5e0 --- /dev/null +++ b/backend/src/db/migrations/20250521110635_add-external-ca-pki.ts @@ -0,0 +1,205 @@ +import slugify from "@sindresorhus/slugify"; +import { Knex } from "knex"; + +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasCATable = await knex.schema.hasTable(TableName.CertificateAuthority); + const hasExternalCATable = await knex.schema.hasTable(TableName.ExternalCertificateAuthority); + const hasInternalCATable = await knex.schema.hasTable(TableName.InternalCertificateAuthority); + + if (hasCATable && !hasInternalCATable) { + await knex.schema.createTableLike(TableName.InternalCertificateAuthority, TableName.CertificateAuthority, (t) => { + t.uuid("caId").nullable(); + }); + + // @ts-expect-error intentional: migration + await knex(TableName.InternalCertificateAuthority).insert(knex(TableName.CertificateAuthority).select("*")); + await knex(TableName.InternalCertificateAuthority).update("caId", knex.ref("id")); + + await knex.schema.alterTable(TableName.InternalCertificateAuthority, (t) => { + t.dropColumn("projectId"); + t.dropColumn("requireTemplateForIssuance"); + t.dropColumn("createdAt"); + t.dropColumn("updatedAt"); + t.dropColumn("status"); + t.uuid("parentCaId") + .nullable() + .references("id") + .inTable(TableName.CertificateAuthority) + .onDelete("CASCADE") + .alter(); + t.uuid("activeCaCertId").nullable().references("id").inTable(TableName.CertificateAuthorityCert).alter(); + t.uuid("caId").notNullable().references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE").alter(); + }); + + await knex.schema.alterTable(TableName.CertificateAuthority, (t) => { + t.renameColumn("requireTemplateForIssuance", "enableDirectIssuance"); + t.string("name").nullable(); + }); + + // prefill name for existing internal CAs and flip enableDirectIssuance + const cas = await knex(TableName.CertificateAuthority).select("id", "friendlyName", "enableDirectIssuance"); + await Promise.all( + cas.map((ca) => { + const slugifiedName = ca.friendlyName + ? slugify(`${ca.friendlyName.slice(0, 16)}-${alphaNumericNanoId(8)}`) + : slugify(alphaNumericNanoId(12)); + + return knex(TableName.CertificateAuthority) + .where({ id: ca.id }) + .update({ name: slugifiedName, enableDirectIssuance: !ca.enableDirectIssuance }); + }) + ); + + await knex.schema.alterTable(TableName.CertificateAuthority, (t) => { + t.dropColumn("parentCaId"); + t.dropColumn("type"); + t.dropColumn("friendlyName"); + t.dropColumn("organization"); + t.dropColumn("ou"); + t.dropColumn("country"); + t.dropColumn("province"); + t.dropColumn("locality"); + t.dropColumn("commonName"); + t.dropColumn("dn"); + t.dropColumn("serialNumber"); + t.dropColumn("maxPathLength"); + t.dropColumn("keyAlgorithm"); + t.dropColumn("notBefore"); + t.dropColumn("notAfter"); + t.dropColumn("activeCaCertId"); + t.boolean("enableDirectIssuance").notNullable().defaultTo(true).alter(); + t.string("name").notNullable().alter(); + t.unique(["name", "projectId"]); + }); + } + + if (!hasExternalCATable) { + await knex.schema.createTable(TableName.ExternalCertificateAuthority, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("type").notNullable(); + t.uuid("appConnectionId").nullable(); + t.foreign("appConnectionId").references("id").inTable(TableName.AppConnection); + t.uuid("dnsAppConnectionId").nullable(); + t.foreign("dnsAppConnectionId").references("id").inTable(TableName.AppConnection); + t.uuid("caId").notNullable().references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.binary("credentials"); + t.json("configuration"); + }); + } + + if (await knex.schema.hasTable(TableName.PkiSubscriber)) { + await knex.schema.alterTable(TableName.PkiSubscriber, (t) => { + t.string("ttl").nullable().alter(); + + t.boolean("enableAutoRenewal").notNullable().defaultTo(false); + t.integer("autoRenewalPeriodInDays"); + t.datetime("lastAutoRenewAt"); + + t.string("lastOperationStatus"); + t.text("lastOperationMessage"); + t.dateTime("lastOperationAt"); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasCATable = await knex.schema.hasTable(TableName.CertificateAuthority); + const hasExternalCATable = await knex.schema.hasTable(TableName.ExternalCertificateAuthority); + const hasInternalCATable = await knex.schema.hasTable(TableName.InternalCertificateAuthority); + + if (hasCATable && hasInternalCATable) { + // First add all columns as nullable + await knex.schema.alterTable(TableName.CertificateAuthority, (t) => { + t.uuid("parentCaId").nullable().references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.string("type").nullable(); + t.string("friendlyName").nullable(); + t.string("organization").nullable(); + t.string("ou").nullable(); + t.string("country").nullable(); + t.string("province").nullable(); + t.string("locality").nullable(); + t.string("commonName").nullable(); + t.string("dn").nullable(); + t.string("serialNumber").nullable().unique(); + t.integer("maxPathLength").nullable(); + t.string("keyAlgorithm").nullable(); + t.timestamp("notBefore").nullable(); + t.timestamp("notAfter").nullable(); + t.uuid("activeCaCertId").nullable().references("id").inTable(TableName.CertificateAuthorityCert); + t.renameColumn("enableDirectIssuance", "requireTemplateForIssuance"); + t.dropColumn("name"); + }); + + // flip requireTemplateForIssuance for existing internal CAs + const cas = await knex(TableName.CertificateAuthority).select("id", "requireTemplateForIssuance"); + await Promise.all( + cas.map((ca) => { + return ( + knex(TableName.CertificateAuthority) + .where({ id: ca.id }) + // @ts-expect-error intentional: migration + .update({ requireTemplateForIssuance: !ca.requireTemplateForIssuance }) + ); + }) + ); + + await knex.raw(` + UPDATE ${TableName.CertificateAuthority} ca + SET + type = ica.type, + "friendlyName" = ica."friendlyName", + organization = ica.organization, + ou = ica.ou, + country = ica.country, + province = ica.province, + locality = ica.locality, + "commonName" = ica."commonName", + dn = ica.dn, + "parentCaId" = ica."parentCaId", + "serialNumber" = ica."serialNumber", + "maxPathLength" = ica."maxPathLength", + "keyAlgorithm" = ica."keyAlgorithm", + "notBefore" = ica."notBefore", + "notAfter" = ica."notAfter", + "activeCaCertId" = ica."activeCaCertId" + FROM ${TableName.InternalCertificateAuthority} ica + WHERE ca.id = ica."caId" + `); + + await knex.schema.alterTable(TableName.CertificateAuthority, (t) => { + t.string("type").notNullable().alter(); + t.string("friendlyName").notNullable().alter(); + t.string("organization").notNullable().alter(); + t.string("ou").notNullable().alter(); + t.string("country").notNullable().alter(); + t.string("province").notNullable().alter(); + t.string("locality").notNullable().alter(); + t.string("commonName").notNullable().alter(); + t.string("dn").notNullable().alter(); + t.string("keyAlgorithm").notNullable().alter(); + t.boolean("requireTemplateForIssuance").notNullable().defaultTo(false).alter(); + }); + + await knex.schema.dropTable(TableName.InternalCertificateAuthority); + } + + if (hasExternalCATable) { + await knex.schema.dropTable(TableName.ExternalCertificateAuthority); + } + + if (await knex.schema.hasTable(TableName.PkiSubscriber)) { + await knex.schema.alterTable(TableName.PkiSubscriber, (t) => { + t.dropColumn("enableAutoRenewal"); + t.dropColumn("autoRenewalPeriodInDays"); + t.dropColumn("lastAutoRenewAt"); + + t.dropColumn("lastOperationStatus"); + t.dropColumn("lastOperationMessage"); + t.dropColumn("lastOperationAt"); + }); + } +} diff --git a/backend/src/db/migrations/20250527030702_policy-bypassers.ts b/backend/src/db/migrations/20250527030702_policy-bypassers.ts new file mode 100644 index 000000000..98b1f4be1 --- /dev/null +++ b/backend/src/db/migrations/20250527030702_policy-bypassers.ts @@ -0,0 +1,48 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.AccessApprovalPolicyBypasser))) { + await knex.schema.createTable(TableName.AccessApprovalPolicyBypasser, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.uuid("bypasserGroupId").nullable(); + t.foreign("bypasserGroupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + + t.uuid("bypasserUserId").nullable(); + t.foreign("bypasserUserId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + + t.uuid("policyId").notNullable(); + t.foreign("policyId").references("id").inTable(TableName.AccessApprovalPolicy).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicyBypasser); + } + + if (!(await knex.schema.hasTable(TableName.SecretApprovalPolicyBypasser))) { + await knex.schema.createTable(TableName.SecretApprovalPolicyBypasser, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.uuid("bypasserGroupId").nullable(); + t.foreign("bypasserGroupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + + t.uuid("bypasserUserId").nullable(); + t.foreign("bypasserUserId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + + t.uuid("policyId").notNullable(); + t.foreign("policyId").references("id").inTable(TableName.SecretApprovalPolicy).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + await createOnUpdateTrigger(knex, TableName.SecretApprovalPolicyBypasser); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretApprovalPolicyBypasser); + await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyBypasser); + + await dropOnUpdateTrigger(knex, TableName.SecretApprovalPolicyBypasser); + await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyBypasser); +} diff --git a/backend/src/db/migrations/20250527140639_dynamic-secret-username-template.ts b/backend/src/db/migrations/20250527140639_dynamic-secret-username-template.ts new file mode 100644 index 000000000..2ff493c6f --- /dev/null +++ b/backend/src/db/migrations/20250527140639_dynamic-secret-username-template.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.DynamicSecret, "usernameTemplate"); + if (!hasColumn) { + await knex.schema.alterTable(TableName.DynamicSecret, (t) => { + t.string("usernameTemplate").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.DynamicSecret, "usernameTemplate"); + if (hasColumn) { + await knex.schema.alterTable(TableName.DynamicSecret, (t) => { + t.dropColumn("usernameTemplate"); + }); + } +} diff --git a/backend/src/db/migrations/20250527164523_add-mi-access-token-period.ts b/backend/src/db/migrations/20250527164523_add-mi-access-token-period.ts new file mode 100644 index 000000000..6c2442036 --- /dev/null +++ b/backend/src/db/migrations/20250527164523_add-mi-access-token-period.ts @@ -0,0 +1,139 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.IdentityAccessToken, "accessTokenPeriod"))) { + await knex.schema.alterTable(TableName.IdentityAccessToken, (t) => { + t.bigInteger("accessTokenPeriod").defaultTo(0).notNullable(); + }); + } + + if (!(await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "accessTokenPeriod"))) { + await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => { + t.bigInteger("accessTokenPeriod").defaultTo(0).notNullable(); + }); + } + + if (!(await knex.schema.hasColumn(TableName.IdentityAwsAuth, "accessTokenPeriod"))) { + await knex.schema.alterTable(TableName.IdentityAwsAuth, (t) => { + t.bigInteger("accessTokenPeriod").defaultTo(0).notNullable(); + }); + } + + if (!(await knex.schema.hasColumn(TableName.IdentityOidcAuth, "accessTokenPeriod"))) { + await knex.schema.alterTable(TableName.IdentityOidcAuth, (t) => { + t.bigInteger("accessTokenPeriod").defaultTo(0).notNullable(); + }); + } + + if (!(await knex.schema.hasColumn(TableName.IdentityAzureAuth, "accessTokenPeriod"))) { + await knex.schema.alterTable(TableName.IdentityAzureAuth, (t) => { + t.bigInteger("accessTokenPeriod").defaultTo(0).notNullable(); + }); + } + + if (!(await knex.schema.hasColumn(TableName.IdentityGcpAuth, "accessTokenPeriod"))) { + await knex.schema.alterTable(TableName.IdentityGcpAuth, (t) => { + t.bigInteger("accessTokenPeriod").defaultTo(0).notNullable(); + }); + } + + if (!(await knex.schema.hasColumn(TableName.IdentityJwtAuth, "accessTokenPeriod"))) { + await knex.schema.alterTable(TableName.IdentityJwtAuth, (t) => { + t.bigInteger("accessTokenPeriod").defaultTo(0).notNullable(); + }); + } + + if (!(await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "accessTokenPeriod"))) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (t) => { + t.bigInteger("accessTokenPeriod").defaultTo(0).notNullable(); + }); + } + + if (!(await knex.schema.hasColumn(TableName.IdentityLdapAuth, "accessTokenPeriod"))) { + await knex.schema.alterTable(TableName.IdentityLdapAuth, (t) => { + t.bigInteger("accessTokenPeriod").defaultTo(0).notNullable(); + }); + } + + if (!(await knex.schema.hasColumn(TableName.IdentityOciAuth, "accessTokenPeriod"))) { + await knex.schema.alterTable(TableName.IdentityOciAuth, (t) => { + t.bigInteger("accessTokenPeriod").defaultTo(0).notNullable(); + }); + } + + if (!(await knex.schema.hasColumn(TableName.IdentityTokenAuth, "accessTokenPeriod"))) { + await knex.schema.alterTable(TableName.IdentityTokenAuth, (t) => { + t.bigInteger("accessTokenPeriod").defaultTo(0).notNullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.IdentityAccessToken, "accessTokenPeriod")) { + await knex.schema.alterTable(TableName.IdentityAccessToken, (t) => { + t.dropColumn("accessTokenPeriod"); + }); + } + + if (await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "accessTokenPeriod")) { + await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => { + t.dropColumn("accessTokenPeriod"); + }); + } + + if (await knex.schema.hasColumn(TableName.IdentityAwsAuth, "accessTokenPeriod")) { + await knex.schema.alterTable(TableName.IdentityAwsAuth, (t) => { + t.dropColumn("accessTokenPeriod"); + }); + } + + if (await knex.schema.hasColumn(TableName.IdentityOidcAuth, "accessTokenPeriod")) { + await knex.schema.alterTable(TableName.IdentityOidcAuth, (t) => { + t.dropColumn("accessTokenPeriod"); + }); + } + + if (await knex.schema.hasColumn(TableName.IdentityAzureAuth, "accessTokenPeriod")) { + await knex.schema.alterTable(TableName.IdentityAzureAuth, (t) => { + t.dropColumn("accessTokenPeriod"); + }); + } + + if (await knex.schema.hasColumn(TableName.IdentityGcpAuth, "accessTokenPeriod")) { + await knex.schema.alterTable(TableName.IdentityGcpAuth, (t) => { + t.dropColumn("accessTokenPeriod"); + }); + } + + if (await knex.schema.hasColumn(TableName.IdentityJwtAuth, "accessTokenPeriod")) { + await knex.schema.alterTable(TableName.IdentityJwtAuth, (t) => { + t.dropColumn("accessTokenPeriod"); + }); + } + + if (await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "accessTokenPeriod")) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (t) => { + t.dropColumn("accessTokenPeriod"); + }); + } + + if (await knex.schema.hasColumn(TableName.IdentityLdapAuth, "accessTokenPeriod")) { + await knex.schema.alterTable(TableName.IdentityLdapAuth, (t) => { + t.dropColumn("accessTokenPeriod"); + }); + } + + if (await knex.schema.hasColumn(TableName.IdentityOciAuth, "accessTokenPeriod")) { + await knex.schema.alterTable(TableName.IdentityOciAuth, (t) => { + t.dropColumn("accessTokenPeriod"); + }); + } + + if (await knex.schema.hasColumn(TableName.IdentityTokenAuth, "accessTokenPeriod")) { + await knex.schema.alterTable(TableName.IdentityTokenAuth, (t) => { + t.dropColumn("accessTokenPeriod"); + }); + } +} diff --git a/backend/src/db/migrations/20250528145356_add-template-slug.ts b/backend/src/db/migrations/20250528145356_add-template-slug.ts new file mode 100644 index 000000000..34a7e38f8 --- /dev/null +++ b/backend/src/db/migrations/20250528145356_add-template-slug.ts @@ -0,0 +1,24 @@ +import slugify from "@sindresorhus/slugify"; +import { Knex } from "knex"; + +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasNameCol = await knex.schema.hasColumn(TableName.CertificateTemplate, "name"); + if (hasNameCol) { + const templates = await knex(TableName.CertificateTemplate).select("id", "name"); + await Promise.all( + templates.map((el) => { + const slugifiedName = el.name + ? slugify(`${el.name.slice(0, 16)}-${alphaNumericNanoId(8)}`) + : slugify(alphaNumericNanoId(12)); + + return knex(TableName.CertificateTemplate).where({ id: el.id }).update({ name: slugifiedName }); + }) + ); + } +} + +export async function down(): Promise {} diff --git a/backend/src/db/migrations/20250528183744_remove-encrypted-salt-from-shared-secret.ts b/backend/src/db/migrations/20250528183744_remove-encrypted-salt-from-shared-secret.ts new file mode 100644 index 000000000..5ccd0f631 --- /dev/null +++ b/backend/src/db/migrations/20250528183744_remove-encrypted-salt-from-shared-secret.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretSharing)) { + const hasEncryptedSalt = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSalt"); + + if (hasEncryptedSalt) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + t.dropColumn("encryptedSalt"); + }); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretSharing)) { + const hasEncryptedSalt = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSalt"); + + if (!hasEncryptedSalt) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + t.binary("encryptedSalt").nullable(); + }); + } + } +} diff --git a/backend/src/db/migrations/20250530152721_add-access-approval-request-deleted-at.ts b/backend/src/db/migrations/20250530152721_add-access-approval-request-deleted-at.ts new file mode 100644 index 000000000..547f1d1a7 --- /dev/null +++ b/backend/src/db/migrations/20250530152721_add-access-approval-request-deleted-at.ts @@ -0,0 +1,63 @@ +import { Knex } from "knex"; + +import { ApprovalStatus } from "@app/ee/services/secret-approval-request/secret-approval-request-types"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasPrivilegeDeletedAtColumn = await knex.schema.hasColumn( + TableName.AccessApprovalRequest, + "privilegeDeletedAt" + ); + const hasStatusColumn = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "status"); + + if (!hasPrivilegeDeletedAtColumn) { + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.timestamp("privilegeDeletedAt").nullable(); + }); + } + + if (!hasStatusColumn) { + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.string("status").defaultTo(ApprovalStatus.PENDING).notNullable(); + }); + + // Update existing rows based on business logic + // If privilegeId is not null, set status to "approved" + await knex(TableName.AccessApprovalRequest).whereNotNull("privilegeId").update({ status: ApprovalStatus.APPROVED }); + + // If privilegeId is null and there's a rejected reviewer, set to "rejected" + const rejectedRequestIds = await knex(TableName.AccessApprovalRequestReviewer) + .select("requestId") + .where("status", "rejected") + .distinct() + .pluck("requestId"); + + if (rejectedRequestIds.length > 0) { + await knex(TableName.AccessApprovalRequest) + .whereNull("privilegeId") + .whereIn("id", rejectedRequestIds) + .update({ status: ApprovalStatus.REJECTED }); + } + } +} + +export async function down(knex: Knex): Promise { + const hasPrivilegeDeletedAtColumn = await knex.schema.hasColumn( + TableName.AccessApprovalRequest, + "privilegeDeletedAt" + ); + const hasStatusColumn = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "status"); + + if (hasPrivilegeDeletedAtColumn) { + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.dropColumn("privilegeDeletedAt"); + }); + } + + if (hasStatusColumn) { + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.dropColumn("status"); + }); + } +} diff --git a/backend/src/db/migrations/20250604174128_identity-kubernetes-auth-gateway-reviewer.ts b/backend/src/db/migrations/20250604174128_identity-kubernetes-auth-gateway-reviewer.ts new file mode 100644 index 000000000..da5493153 --- /dev/null +++ b/backend/src/db/migrations/20250604174128_identity-kubernetes-auth-gateway-reviewer.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasTokenReviewModeColumn = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "tokenReviewMode"); + + if (!hasTokenReviewModeColumn) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (table) => { + table.string("tokenReviewMode").notNullable().defaultTo("api"); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasTokenReviewModeColumn = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "tokenReviewMode"); + + if (hasTokenReviewModeColumn) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (table) => { + table.dropColumn("tokenReviewMode"); + }); + } +} diff --git a/backend/src/db/schemas/access-approval-policies-bypassers.ts b/backend/src/db/schemas/access-approval-policies-bypassers.ts new file mode 100644 index 000000000..278e4b416 --- /dev/null +++ b/backend/src/db/schemas/access-approval-policies-bypassers.ts @@ -0,0 +1,26 @@ +// 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 AccessApprovalPoliciesBypassersSchema = z.object({ + id: z.string().uuid(), + bypasserGroupId: z.string().uuid().nullable().optional(), + bypasserUserId: z.string().uuid().nullable().optional(), + policyId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAccessApprovalPoliciesBypassers = z.infer; +export type TAccessApprovalPoliciesBypassersInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TAccessApprovalPoliciesBypassersUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/access-approval-requests.ts b/backend/src/db/schemas/access-approval-requests.ts index bfe990b3a..6a6f09148 100644 --- a/backend/src/db/schemas/access-approval-requests.ts +++ b/backend/src/db/schemas/access-approval-requests.ts @@ -18,7 +18,9 @@ export const AccessApprovalRequestsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), requestedByUserId: z.string().uuid(), - note: z.string().nullable().optional() + note: z.string().nullable().optional(), + privilegeDeletedAt: z.date().nullable().optional(), + status: z.string().default("pending") }); export type TAccessApprovalRequests = z.infer; diff --git a/backend/src/db/schemas/certificate-authorities.ts b/backend/src/db/schemas/certificate-authorities.ts index ffe0f7c44..62a2d6ceb 100644 --- a/backend/src/db/schemas/certificate-authorities.ts +++ b/backend/src/db/schemas/certificate-authorities.ts @@ -11,25 +11,10 @@ export const CertificateAuthoritiesSchema = z.object({ id: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - parentCaId: z.string().uuid().nullable().optional(), projectId: z.string(), - type: z.string(), + enableDirectIssuance: z.boolean().default(true), status: z.string(), - friendlyName: z.string(), - organization: z.string(), - ou: z.string(), - country: z.string(), - province: z.string(), - locality: z.string(), - commonName: z.string(), - dn: z.string(), - serialNumber: z.string().nullable().optional(), - maxPathLength: z.number().nullable().optional(), - keyAlgorithm: z.string(), - notBefore: z.date().nullable().optional(), - notAfter: z.date().nullable().optional(), - activeCaCertId: z.string().uuid().nullable().optional(), - requireTemplateForIssuance: z.boolean().default(false) + name: z.string() }); export type TCertificateAuthorities = z.infer; diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index 533f9b898..5b832bab4 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -11,7 +11,7 @@ export const CertificatesSchema = z.object({ id: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - caId: z.string().uuid(), + caId: z.string().uuid().nullable().optional(), status: z.string(), serialNumber: z.string(), friendlyName: z.string(), @@ -21,10 +21,12 @@ export const CertificatesSchema = z.object({ revokedAt: z.date().nullable().optional(), revocationReason: z.number().nullable().optional(), altNames: z.string().nullable().optional(), - caCertId: z.string().uuid(), + caCertId: z.string().uuid().nullable().optional(), certificateTemplateId: z.string().uuid().nullable().optional(), keyUsages: z.string().array().nullable().optional(), - extendedKeyUsages: z.string().array().nullable().optional() + extendedKeyUsages: z.string().array().nullable().optional(), + pkiSubscriberId: z.string().uuid().nullable().optional(), + projectId: z.string() }); export type TCertificates = z.infer; diff --git a/backend/src/db/schemas/dynamic-secrets.ts b/backend/src/db/schemas/dynamic-secrets.ts index 913a6d475..637d0c632 100644 --- a/backend/src/db/schemas/dynamic-secrets.ts +++ b/backend/src/db/schemas/dynamic-secrets.ts @@ -27,7 +27,9 @@ export const DynamicSecretsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), encryptedInput: zodBuffer, - projectGatewayId: z.string().uuid().nullable().optional() + projectGatewayId: z.string().uuid().nullable().optional(), + gatewayId: z.string().uuid().nullable().optional(), + usernameTemplate: z.string().nullable().optional() }); export type TDynamicSecrets = z.infer; diff --git a/backend/src/db/schemas/external-certificate-authorities.ts b/backend/src/db/schemas/external-certificate-authorities.ts new file mode 100644 index 000000000..4f20ce0da --- /dev/null +++ b/backend/src/db/schemas/external-certificate-authorities.ts @@ -0,0 +1,29 @@ +// 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 { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ExternalCertificateAuthoritiesSchema = z.object({ + id: z.string().uuid(), + type: z.string(), + appConnectionId: z.string().uuid().nullable().optional(), + dnsAppConnectionId: z.string().uuid().nullable().optional(), + caId: z.string().uuid(), + credentials: zodBuffer.nullable().optional(), + configuration: z.unknown().nullable().optional() +}); + +export type TExternalCertificateAuthorities = z.infer; +export type TExternalCertificateAuthoritiesInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TExternalCertificateAuthoritiesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/identity-access-tokens.ts b/backend/src/db/schemas/identity-access-tokens.ts index bbff1b88c..8f2b8b73b 100644 --- a/backend/src/db/schemas/identity-access-tokens.ts +++ b/backend/src/db/schemas/identity-access-tokens.ts @@ -21,7 +21,8 @@ export const IdentityAccessTokensSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), name: z.string().nullable().optional(), - authMethod: z.string() + authMethod: z.string(), + accessTokenPeriod: z.coerce.number().default(0) }); export type TIdentityAccessTokens = z.infer; diff --git a/backend/src/db/schemas/identity-aws-auths.ts b/backend/src/db/schemas/identity-aws-auths.ts index f4444b00f..83f5b43cf 100644 --- a/backend/src/db/schemas/identity-aws-auths.ts +++ b/backend/src/db/schemas/identity-aws-auths.ts @@ -19,7 +19,8 @@ export const IdentityAwsAuthsSchema = z.object({ type: z.string(), stsEndpoint: z.string(), allowedPrincipalArns: z.string(), - allowedAccountIds: z.string() + allowedAccountIds: z.string(), + accessTokenPeriod: z.coerce.number().default(0) }); export type TIdentityAwsAuths = z.infer; diff --git a/backend/src/db/schemas/identity-azure-auths.ts b/backend/src/db/schemas/identity-azure-auths.ts index 856f7b8f1..e8e1905e4 100644 --- a/backend/src/db/schemas/identity-azure-auths.ts +++ b/backend/src/db/schemas/identity-azure-auths.ts @@ -18,7 +18,8 @@ export const IdentityAzureAuthsSchema = z.object({ identityId: z.string().uuid(), tenantId: z.string(), resource: z.string(), - allowedServicePrincipalIds: z.string() + allowedServicePrincipalIds: z.string(), + accessTokenPeriod: z.coerce.number().default(0) }); export type TIdentityAzureAuths = z.infer; diff --git a/backend/src/db/schemas/identity-gcp-auths.ts b/backend/src/db/schemas/identity-gcp-auths.ts index 208058f60..536e7200a 100644 --- a/backend/src/db/schemas/identity-gcp-auths.ts +++ b/backend/src/db/schemas/identity-gcp-auths.ts @@ -19,7 +19,8 @@ export const IdentityGcpAuthsSchema = z.object({ type: z.string(), allowedServiceAccounts: z.string().nullable().optional(), allowedProjects: z.string().nullable().optional(), - allowedZones: z.string().nullable().optional() + allowedZones: z.string().nullable().optional(), + accessTokenPeriod: z.coerce.number().default(0) }); export type TIdentityGcpAuths = z.infer; diff --git a/backend/src/db/schemas/identity-jwt-auths.ts b/backend/src/db/schemas/identity-jwt-auths.ts index 1d3ea9c03..c11ba8adc 100644 --- a/backend/src/db/schemas/identity-jwt-auths.ts +++ b/backend/src/db/schemas/identity-jwt-auths.ts @@ -25,7 +25,8 @@ export const IdentityJwtAuthsSchema = z.object({ boundClaims: z.unknown(), boundSubject: z.string(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + accessTokenPeriod: z.coerce.number().default(0) }); export type TIdentityJwtAuths = z.infer; diff --git a/backend/src/db/schemas/identity-kubernetes-auths.ts b/backend/src/db/schemas/identity-kubernetes-auths.ts index 448cec386..8a351014a 100644 --- a/backend/src/db/schemas/identity-kubernetes-auths.ts +++ b/backend/src/db/schemas/identity-kubernetes-auths.ts @@ -29,7 +29,10 @@ export const IdentityKubernetesAuthsSchema = z.object({ allowedNames: z.string(), allowedAudience: z.string(), encryptedKubernetesTokenReviewerJwt: zodBuffer.nullable().optional(), - encryptedKubernetesCaCertificate: zodBuffer.nullable().optional() + encryptedKubernetesCaCertificate: zodBuffer.nullable().optional(), + gatewayId: z.string().uuid().nullable().optional(), + accessTokenPeriod: z.coerce.number().default(0), + tokenReviewMode: z.string().default("api") }); export type TIdentityKubernetesAuths = z.infer; diff --git a/backend/src/db/schemas/identity-ldap-auths.ts b/backend/src/db/schemas/identity-ldap-auths.ts new file mode 100644 index 000000000..e8d0658d5 --- /dev/null +++ b/backend/src/db/schemas/identity-ldap-auths.ts @@ -0,0 +1,33 @@ +// 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 { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityLdapAuthsSchema = z.object({ + id: z.string().uuid(), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + identityId: z.string().uuid(), + encryptedBindDN: zodBuffer, + encryptedBindPass: zodBuffer, + encryptedLdapCaCertificate: zodBuffer.nullable().optional(), + url: z.string(), + searchBase: z.string(), + searchFilter: z.string(), + allowedFields: z.unknown().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date(), + accessTokenPeriod: z.coerce.number().default(0) +}); + +export type TIdentityLdapAuths = z.infer; +export type TIdentityLdapAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityLdapAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/identity-oci-auths.ts b/backend/src/db/schemas/identity-oci-auths.ts new file mode 100644 index 000000000..438837691 --- /dev/null +++ b/backend/src/db/schemas/identity-oci-auths.ts @@ -0,0 +1,27 @@ +// 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 IdentityOciAuthsSchema = z.object({ + id: z.string().uuid(), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + createdAt: z.date(), + updatedAt: z.date(), + identityId: z.string().uuid(), + type: z.string(), + tenancyOcid: z.string(), + allowedUsernames: z.string().nullable().optional(), + accessTokenPeriod: z.coerce.number().default(0) +}); + +export type TIdentityOciAuths = z.infer; +export type TIdentityOciAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityOciAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/identity-oidc-auths.ts b/backend/src/db/schemas/identity-oidc-auths.ts index 03bfcf40a..5c652b0f8 100644 --- a/backend/src/db/schemas/identity-oidc-auths.ts +++ b/backend/src/db/schemas/identity-oidc-auths.ts @@ -27,7 +27,8 @@ export const IdentityOidcAuthsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), encryptedCaCertificate: zodBuffer.nullable().optional(), - claimMetadataMapping: z.unknown().nullable().optional() + claimMetadataMapping: z.unknown().nullable().optional(), + accessTokenPeriod: z.coerce.number().default(0) }); export type TIdentityOidcAuths = z.infer; diff --git a/backend/src/db/schemas/identity-token-auths.ts b/backend/src/db/schemas/identity-token-auths.ts index 0f3c8c9ff..e90e67533 100644 --- a/backend/src/db/schemas/identity-token-auths.ts +++ b/backend/src/db/schemas/identity-token-auths.ts @@ -15,7 +15,8 @@ export const IdentityTokenAuthsSchema = z.object({ accessTokenTrustedIps: z.unknown(), createdAt: z.date(), updatedAt: z.date(), - identityId: z.string().uuid() + identityId: z.string().uuid(), + accessTokenPeriod: z.coerce.number().default(0) }); export type TIdentityTokenAuths = z.infer; diff --git a/backend/src/db/schemas/identity-universal-auths.ts b/backend/src/db/schemas/identity-universal-auths.ts index eeec2f666..da27b4a55 100644 --- a/backend/src/db/schemas/identity-universal-auths.ts +++ b/backend/src/db/schemas/identity-universal-auths.ts @@ -17,7 +17,8 @@ export const IdentityUniversalAuthsSchema = z.object({ accessTokenTrustedIps: z.unknown(), createdAt: z.date(), updatedAt: z.date(), - identityId: z.string().uuid() + identityId: z.string().uuid(), + accessTokenPeriod: z.coerce.number().default(0) }); export type TIdentityUniversalAuths = z.infer; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index b71d51908..6743a23cc 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -1,5 +1,6 @@ export * from "./access-approval-policies"; export * from "./access-approval-policies-approvers"; +export * from "./access-approval-policies-bypassers"; export * from "./access-approval-requests"; export * from "./access-approval-requests-reviewers"; export * from "./api-keys"; @@ -20,6 +21,7 @@ export * from "./certificate-templates"; export * from "./certificates"; export * from "./dynamic-secret-leases"; export * from "./dynamic-secrets"; +export * from "./external-certificate-authorities"; export * from "./external-group-org-role-mappings"; export * from "./external-kms"; export * from "./gateways"; @@ -37,6 +39,7 @@ export * from "./identity-gcp-auths"; export * from "./identity-jwt-auths"; export * from "./identity-kubernetes-auths"; export * from "./identity-metadata"; +export * from "./identity-oci-auths"; export * from "./identity-oidc-auths"; export * from "./identity-org-memberships"; export * from "./identity-project-additional-privilege"; @@ -48,6 +51,7 @@ export * from "./identity-universal-auths"; export * from "./incident-contacts"; export * from "./integration-auths"; export * from "./integrations"; +export * from "./internal-certificate-authorities"; export * from "./internal-kms"; export * from "./kmip-client-certificates"; export * from "./kmip-clients"; @@ -69,6 +73,7 @@ export * from "./organizations"; export * from "./pki-alerts"; export * from "./pki-collection-items"; export * from "./pki-collections"; +export * from "./pki-subscribers"; export * from "./project-bots"; export * from "./project-environments"; export * from "./project-gateways"; @@ -88,6 +93,7 @@ export * from "./saml-configs"; export * from "./scim-tokens"; export * from "./secret-approval-policies"; export * from "./secret-approval-policies-approvers"; +export * from "./secret-approval-policies-bypassers"; export * from "./secret-approval-request-secret-tags"; export * from "./secret-approval-request-secret-tags-v2"; export * from "./secret-approval-requests"; @@ -105,7 +111,12 @@ export * from "./secret-rotation-outputs"; export * from "./secret-rotation-v2-secret-mappings"; export * from "./secret-rotations"; export * from "./secret-rotations-v2"; +export * from "./secret-scanning-configs"; +export * from "./secret-scanning-data-sources"; +export * from "./secret-scanning-findings"; export * from "./secret-scanning-git-risks"; +export * from "./secret-scanning-resources"; +export * from "./secret-scanning-scans"; export * from "./secret-sharing"; export * from "./secret-snapshot-folders"; export * from "./secret-snapshot-secrets"; diff --git a/backend/src/db/schemas/internal-certificate-authorities.ts b/backend/src/db/schemas/internal-certificate-authorities.ts new file mode 100644 index 000000000..70f31c155 --- /dev/null +++ b/backend/src/db/schemas/internal-certificate-authorities.ts @@ -0,0 +1,38 @@ +// 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 InternalCertificateAuthoritiesSchema = z.object({ + id: z.string().uuid(), + parentCaId: z.string().uuid().nullable().optional(), + type: z.string(), + friendlyName: z.string(), + organization: z.string(), + ou: z.string(), + country: z.string(), + province: z.string(), + locality: z.string(), + commonName: z.string(), + dn: z.string(), + serialNumber: z.string().nullable().optional(), + maxPathLength: z.number().nullable().optional(), + keyAlgorithm: z.string(), + notBefore: z.date().nullable().optional(), + notAfter: z.date().nullable().optional(), + activeCaCertId: z.string().uuid().nullable().optional(), + caId: z.string().uuid() +}); + +export type TInternalCertificateAuthorities = z.infer; +export type TInternalCertificateAuthoritiesInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TInternalCertificateAuthoritiesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 7fd77da6c..6722ce235 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -13,6 +13,8 @@ export enum TableName { SshCertificate = "ssh_certificates", SshCertificateBody = "ssh_certificate_bodies", CertificateAuthority = "certificate_authorities", + ExternalCertificateAuthority = "external_certificate_authorities", + InternalCertificateAuthority = "internal_certificate_authorities", CertificateTemplateEstConfig = "certificate_template_est_configs", CertificateAuthorityCert = "certificate_authority_certs", CertificateAuthoritySecret = "certificate_authority_secret", @@ -21,6 +23,7 @@ export enum TableName { CertificateBody = "certificate_bodies", CertificateSecret = "certificate_secrets", CertificateTemplate = "certificate_templates", + PkiSubscriber = "pki_subscribers", PkiAlert = "pki_alerts", PkiCollection = "pki_collections", PkiCollectionItem = "pki_collection_items", @@ -78,8 +81,10 @@ export enum TableName { IdentityAzureAuth = "identity_azure_auths", IdentityUaClientSecret = "identity_ua_client_secrets", IdentityAwsAuth = "identity_aws_auths", + IdentityOciAuth = "identity_oci_auths", IdentityOidcAuth = "identity_oidc_auths", IdentityJwtAuth = "identity_jwt_auths", + IdentityLdapAuth = "identity_ldap_auths", IdentityOrgMembership = "identity_org_memberships", IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembershipRole = "identity_project_membership_role", @@ -90,10 +95,12 @@ export enum TableName { ScimToken = "scim_tokens", AccessApprovalPolicy = "access_approval_policies", AccessApprovalPolicyApprover = "access_approval_policies_approvers", + AccessApprovalPolicyBypasser = "access_approval_policies_bypassers", AccessApprovalRequest = "access_approval_requests", AccessApprovalRequestReviewer = "access_approval_requests_reviewers", SecretApprovalPolicy = "secret_approval_policies", SecretApprovalPolicyApprover = "secret_approval_policies_approvers", + SecretApprovalPolicyBypasser = "secret_approval_policies_bypassers", SecretApprovalRequest = "secret_approval_requests", SecretApprovalRequestReviewer = "secret_approval_requests_reviewers", SecretApprovalRequestSecret = "secret_approval_requests_secrets", @@ -152,7 +159,12 @@ export enum TableName { MicrosoftTeamsIntegrations = "microsoft_teams_integrations", ProjectMicrosoftTeamsConfigs = "project_microsoft_teams_configs", SecretReminderRecipients = "secret_reminder_recipients", - GithubOrgSyncConfig = "github_org_sync_configs" + GithubOrgSyncConfig = "github_org_sync_configs", + SecretScanningDataSource = "secret_scanning_data_sources", + SecretScanningResource = "secret_scanning_resources", + SecretScanningScan = "secret_scanning_scans", + SecretScanningFinding = "secret_scanning_findings", + SecretScanningConfig = "secret_scanning_configs" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; @@ -185,11 +197,16 @@ export enum OrgMembershipStatus { } export enum ProjectMembershipRole { + // general Admin = "admin", Member = "member", Custom = "custom", Viewer = "viewer", - NoAccess = "no-access" + NoAccess = "no-access", + // ssh + SshHostBootstrapper = "ssh-host-bootstrapper", + // kms + KmsCryptographicOperator = "cryptographic-operator" } export enum SecretEncryptionAlgo { @@ -226,15 +243,18 @@ export enum IdentityAuthMethod { GCP_AUTH = "gcp-auth", AWS_AUTH = "aws-auth", AZURE_AUTH = "azure-auth", + OCI_AUTH = "oci-auth", OIDC_AUTH = "oidc-auth", - JWT_AUTH = "jwt-auth" + JWT_AUTH = "jwt-auth", + LDAP_AUTH = "ldap-auth" } export enum ProjectType { SecretManager = "secret-manager", CertificateManager = "cert-manager", KMS = "kms", - SSH = "ssh" + SSH = "ssh", + SecretScanning = "secret-scanning" } export enum ActionProjectType { @@ -242,6 +262,7 @@ export enum ActionProjectType { CertificateManager = ProjectType.CertificateManager, KMS = ProjectType.KMS, SSH = ProjectType.SSH, + SecretScanning = ProjectType.SecretScanning, // project operations that happen on all types Any = "any" } diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index 8d8279802..fb0728707 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -28,7 +28,15 @@ export const OrganizationsSchema = z.object({ privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), privilegeUpgradeInitiatedAt: z.date().nullable().optional(), bypassOrgAuthEnabled: z.boolean().default(false), - userTokenExpiration: z.string().nullable().optional() + userTokenExpiration: z.string().nullable().optional(), + secretsProductEnabled: z.boolean().default(true).nullable().optional(), + pkiProductEnabled: z.boolean().default(true).nullable().optional(), + kmsProductEnabled: z.boolean().default(true).nullable().optional(), + sshProductEnabled: z.boolean().default(true).nullable().optional(), + scannerProductEnabled: z.boolean().default(true).nullable().optional(), + shareSecretsProductEnabled: z.boolean().default(true).nullable().optional(), + maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(), + maxSharedSecretViewLimit: z.number().nullable().optional() }); export type TOrganizations = z.infer; diff --git a/backend/src/db/schemas/pki-subscribers.ts b/backend/src/db/schemas/pki-subscribers.ts new file mode 100644 index 000000000..0cdff4250 --- /dev/null +++ b/backend/src/db/schemas/pki-subscribers.ts @@ -0,0 +1,33 @@ +// 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 PkiSubscribersSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + projectId: z.string(), + caId: z.string().uuid().nullable().optional(), + name: z.string(), + commonName: z.string(), + subjectAlternativeNames: z.string().array(), + ttl: z.string().nullable().optional(), + keyUsages: z.string().array(), + extendedKeyUsages: z.string().array(), + status: z.string(), + enableAutoRenewal: z.boolean().default(false), + autoRenewalPeriodInDays: z.number().nullable().optional(), + lastAutoRenewAt: z.date().nullable().optional(), + lastOperationStatus: z.string().nullable().optional(), + lastOperationMessage: z.string().nullable().optional(), + lastOperationAt: z.date().nullable().optional() +}); + +export type TPkiSubscribers = z.infer; +export type TPkiSubscribersInsert = Omit, TImmutableDBKeys>; +export type TPkiSubscribersUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/project-templates.ts b/backend/src/db/schemas/project-templates.ts index 68f37d256..f12386165 100644 --- a/backend/src/db/schemas/project-templates.ts +++ b/backend/src/db/schemas/project-templates.ts @@ -12,10 +12,11 @@ export const ProjectTemplatesSchema = z.object({ name: z.string(), description: z.string().nullable().optional(), roles: z.unknown(), - environments: z.unknown(), + environments: z.unknown().nullable().optional(), orgId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + type: z.string().default("secret-manager") }); export type TProjectTemplates = z.infer; diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index 297601fd0..c1e96e8ce 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -27,7 +27,8 @@ export const ProjectsSchema = z.object({ description: z.string().nullable().optional(), type: z.string(), enforceCapitalization: z.boolean().default(false), - hasDeleteProtection: z.boolean().default(false).nullable().optional() + hasDeleteProtection: z.boolean().default(false).nullable().optional(), + secretSharing: z.boolean().default(true) }); export type TProjects = z.infer; diff --git a/backend/src/db/schemas/secret-approval-policies-bypassers.ts b/backend/src/db/schemas/secret-approval-policies-bypassers.ts new file mode 100644 index 000000000..86eea45d3 --- /dev/null +++ b/backend/src/db/schemas/secret-approval-policies-bypassers.ts @@ -0,0 +1,26 @@ +// 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 SecretApprovalPoliciesBypassersSchema = z.object({ + id: z.string().uuid(), + bypasserGroupId: z.string().uuid().nullable().optional(), + bypasserUserId: z.string().uuid().nullable().optional(), + policyId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TSecretApprovalPoliciesBypassers = z.infer; +export type TSecretApprovalPoliciesBypassersInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TSecretApprovalPoliciesBypassersUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-scanning-configs.ts b/backend/src/db/schemas/secret-scanning-configs.ts new file mode 100644 index 000000000..c3719d352 --- /dev/null +++ b/backend/src/db/schemas/secret-scanning-configs.ts @@ -0,0 +1,20 @@ +// 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 SecretScanningConfigsSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + content: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TSecretScanningConfigs = z.infer; +export type TSecretScanningConfigsInsert = Omit, TImmutableDBKeys>; +export type TSecretScanningConfigsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secret-scanning-data-sources.ts b/backend/src/db/schemas/secret-scanning-data-sources.ts new file mode 100644 index 000000000..d79b45e79 --- /dev/null +++ b/backend/src/db/schemas/secret-scanning-data-sources.ts @@ -0,0 +1,32 @@ +// 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 { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretScanningDataSourcesSchema = z.object({ + id: z.string().uuid(), + externalId: z.string().nullable().optional(), + name: z.string(), + description: z.string().nullable().optional(), + type: z.string(), + config: z.unknown(), + encryptedCredentials: zodBuffer.nullable().optional(), + connectionId: z.string().uuid().nullable().optional(), + isAutoScanEnabled: z.boolean().default(true).nullable().optional(), + projectId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + isDisconnected: z.boolean().default(false) +}); + +export type TSecretScanningDataSources = z.infer; +export type TSecretScanningDataSourcesInsert = Omit, TImmutableDBKeys>; +export type TSecretScanningDataSourcesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-scanning-findings.ts b/backend/src/db/schemas/secret-scanning-findings.ts new file mode 100644 index 000000000..c36f229f8 --- /dev/null +++ b/backend/src/db/schemas/secret-scanning-findings.ts @@ -0,0 +1,32 @@ +// 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 SecretScanningFindingsSchema = z.object({ + id: z.string().uuid(), + dataSourceName: z.string(), + dataSourceType: z.string(), + resourceName: z.string(), + resourceType: z.string(), + rule: z.string(), + severity: z.string(), + status: z.string().default("unresolved"), + remarks: z.string().nullable().optional(), + fingerprint: z.string(), + details: z.unknown(), + projectId: z.string(), + scanId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TSecretScanningFindings = z.infer; +export type TSecretScanningFindingsInsert = Omit, TImmutableDBKeys>; +export type TSecretScanningFindingsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-scanning-resources.ts b/backend/src/db/schemas/secret-scanning-resources.ts new file mode 100644 index 000000000..e791e1cd6 --- /dev/null +++ b/backend/src/db/schemas/secret-scanning-resources.ts @@ -0,0 +1,24 @@ +// 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 SecretScanningResourcesSchema = z.object({ + id: z.string().uuid(), + externalId: z.string(), + name: z.string(), + type: z.string(), + dataSourceId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TSecretScanningResources = z.infer; +export type TSecretScanningResourcesInsert = Omit, TImmutableDBKeys>; +export type TSecretScanningResourcesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-scanning-scans.ts b/backend/src/db/schemas/secret-scanning-scans.ts new file mode 100644 index 000000000..88e676b5b --- /dev/null +++ b/backend/src/db/schemas/secret-scanning-scans.ts @@ -0,0 +1,21 @@ +// 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 SecretScanningScansSchema = z.object({ + id: z.string().uuid(), + status: z.string().default("queued"), + statusMessage: z.string().nullable().optional(), + type: z.string(), + resourceId: z.string().uuid(), + createdAt: z.date().nullable().optional() +}); + +export type TSecretScanningScans = z.infer; +export type TSecretScanningScansInsert = Omit, TImmutableDBKeys>; +export type TSecretScanningScansUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secret-sharing.ts b/backend/src/db/schemas/secret-sharing.ts index 24ea26677..7a7bf17bb 100644 --- a/backend/src/db/schemas/secret-sharing.ts +++ b/backend/src/db/schemas/secret-sharing.ts @@ -27,7 +27,8 @@ export const SecretSharingSchema = z.object({ password: z.string().nullable().optional(), encryptedSecret: zodBuffer.nullable().optional(), identifier: z.string().nullable().optional(), - type: z.string().default("share") + type: z.string().default("share"), + authorizedEmails: z.unknown().nullable().optional() }); export type TSecretSharing = z.infer; diff --git a/backend/src/db/schemas/ssh-host-login-user-mappings.ts b/backend/src/db/schemas/ssh-host-login-user-mappings.ts index 6edb0d5a3..fd5fa460c 100644 --- a/backend/src/db/schemas/ssh-host-login-user-mappings.ts +++ b/backend/src/db/schemas/ssh-host-login-user-mappings.ts @@ -12,7 +12,8 @@ export const SshHostLoginUserMappingsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), sshHostLoginUserId: z.string().uuid(), - userId: z.string().uuid().nullable().optional() + userId: z.string().uuid().nullable().optional(), + groupId: z.string().uuid().nullable().optional() }); export type TSshHostLoginUserMappings = z.infer; diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts index 97a819234..2553a0efc 100644 --- a/backend/src/ee/routes/v1/access-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -1,7 +1,7 @@ import { nanoid } from "nanoid"; import { z } from "zod"; -import { ApproverType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; +import { ApproverType, BypasserType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; import { EnforcementLevel } from "@app/lib/types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -24,10 +24,19 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi 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(), name: z.string().optional() }) + z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), username: z.string().optional() }) ]) .array() + .max(100, "Cannot have more than 100 approvers") .min(1, { message: "At least one approver should be provided" }), + 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) @@ -72,7 +81,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi .object({ type: z.nativeEnum(ApproverType), id: z.string().nullable().optional() }) .array() .nullable() - .optional() + .optional(), + bypassers: z.object({ type: z.nativeEnum(BypasserType), id: z.string().nullable().optional() }).array() }) .array() .nullable() @@ -143,10 +153,19 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi 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(), name: z.string().optional() }) + 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" }), + .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).optional(), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), allowedSelfApprovals: z.boolean().default(true) @@ -220,6 +239,15 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi }) .array() .nullable() + .optional(), + bypassers: z + .object({ + type: z.nativeEnum(BypasserType), + id: z.string().nullable().optional(), + name: z.string().nullable().optional() + }) + .array() + .nullable() .optional() }) }) diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 8a7ccfdef..5b3a08b4b 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema, UsersSchema } from "@app/db/schemas"; import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types"; +import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -18,6 +19,9 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv server.route({ url: "/", method: "POST", + config: { + rateLimit: writeLimit + }, schema: { body: z.object({ permissions: z.any().array(), @@ -109,6 +113,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv name: z.string(), approvals: z.number(), approvers: z.string().array(), + bypassers: z.string().array(), secretPath: z.string().nullish(), envId: z.string(), enforcementLevel: z.string(), @@ -150,7 +155,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv requestId: z.string().trim() }), body: z.object({ - status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED]) + status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED]), + bypassReason: z.string().min(10).max(1000).optional() }), response: { 200: z.object({ @@ -166,7 +172,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, requestId: req.params.requestId, - status: req.body.status + status: req.body.status, + bypassReason: req.body.bypassReason }); return { review }; diff --git a/backend/src/ee/routes/v1/app-connection-routers/oci-connection-router.ts b/backend/src/ee/routes/v1/app-connection-routers/oci-connection-router.ts new file mode 100644 index 000000000..e87e5b69e --- /dev/null +++ b/backend/src/ee/routes/v1/app-connection-routers/oci-connection-router.ts @@ -0,0 +1,123 @@ +import z from "zod"; + +import { + CreateOCIConnectionSchema, + SanitizedOCIConnectionSchema, + UpdateOCIConnectionSchema +} from "@app/ee/services/app-connections/oci"; +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 { registerAppConnectionEndpoints } from "../../../../server/routes/v1/app-connection-routers/app-connection-endpoints"; + +export const registerOCIConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.OCI, + server, + sanitizedResponseSchema: SanitizedOCIConnectionSchema, + createSchema: CreateOCIConnectionSchema, + updateSchema: UpdateOCIConnectionSchema + }); + + // The following endpoints are for internal Infisical App use only and not part of the public API + server.route({ + method: "GET", + url: `/:connectionId/compartments`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const compartments = await server.services.appConnection.oci.listCompartments(connectionId, req.permission); + return compartments; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/vaults`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + querystring: z.object({ + compartmentOcid: z.string().min(1, "Compartment OCID required") + }), + response: { + 200: z + .object({ + id: z.string(), + displayName: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const { compartmentOcid } = req.query; + + const vaults = await server.services.appConnection.oci.listVaults( + { connectionId, compartmentOcid }, + req.permission + ); + return vaults; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/vault-keys`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + querystring: z.object({ + compartmentOcid: z.string().min(1, "Compartment OCID required"), + vaultOcid: z.string().min(1, "Vault OCID required") + }), + response: { + 200: z + .object({ + id: z.string(), + displayName: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const { compartmentOcid, vaultOcid } = req.query; + + const keys = await server.services.appConnection.oci.listVaultKeys( + { connectionId, compartmentOcid, vaultOcid }, + req.permission + ); + return keys; + } + }); +}; diff --git a/backend/src/ee/routes/v1/dynamic-secret-router.ts b/backend/src/ee/routes/v1/dynamic-secret-router.ts index 6e70effe4..b916bab67 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-router.ts @@ -6,6 +6,8 @@ import { ApiDocsTags, DYNAMIC_SECRETS } from "@app/lib/api-docs"; import { daysToMillisecond } from "@app/lib/dates"; import { removeTrailingSlash } from "@app/lib/fn"; import { ms } from "@app/lib/ms"; +import { isValidHandleBarTemplate } from "@app/lib/template/validate-handlebars"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -13,6 +15,31 @@ import { SanitizedDynamicSecretSchema } from "@app/server/routes/sanitizedSchema import { AuthMode } from "@app/services/auth/auth-type"; import { ResourceMetadataSchema } from "@app/services/resource-metadata/resource-metadata-schema"; +const validateUsernameTemplateCharacters = characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Underscore, + CharacterType.Hyphen, + CharacterType.OpenBrace, + CharacterType.CloseBrace, + CharacterType.CloseBracket, + CharacterType.OpenBracket, + CharacterType.Fullstop, + CharacterType.SingleQuote, + CharacterType.Spaces, + CharacterType.Pipe +]); + +const userTemplateSchema = z + .string() + .trim() + .max(255) + .refine((el) => validateUsernameTemplateCharacters(el)) + .refine((el) => + isValidHandleBarTemplate(el, { + allowedExpressions: (val) => ["randomUsername", "unixTimestamp", "identity.name"].includes(val) + }) + ); + export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", @@ -52,7 +79,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => path: z.string().describe(DYNAMIC_SECRETS.CREATE.path).trim().default("/").transform(removeTrailingSlash), environmentSlug: z.string().describe(DYNAMIC_SECRETS.CREATE.environmentSlug).min(1), name: slugSchema({ min: 1, max: 64, field: "Name" }).describe(DYNAMIC_SECRETS.CREATE.name), - metadata: ResourceMetadataSchema.optional() + metadata: ResourceMetadataSchema.optional(), + usernameTemplate: userTemplateSchema.optional() }), response: { 200: z.object({ @@ -73,39 +101,6 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => } }); - server.route({ - method: "POST", - url: "/entra-id/users", - config: { - rateLimit: readLimit - }, - schema: { - body: z.object({ - tenantId: z.string().min(1).describe("The tenant ID of the Azure Entra ID"), - applicationId: z.string().min(1).describe("The application ID of the Azure Entra ID App Registration"), - clientSecret: z.string().min(1).describe("The client secret of the Azure Entra ID App Registration") - }), - response: { - 200: z - .object({ - name: z.string().min(1).describe("The name of the user"), - id: z.string().min(1).describe("The ID of the user"), - email: z.string().min(1).describe("The email of the user") - }) - .array() - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const data = await server.services.dynamicSecret.fetchAzureEntraIdUsers({ - tenantId: req.body.tenantId, - applicationId: req.body.applicationId, - clientSecret: req.body.clientSecret - }); - return data; - } - }); - server.route({ method: "PATCH", url: "/:name", @@ -150,7 +145,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => }) .nullable(), newName: z.string().describe(DYNAMIC_SECRETS.UPDATE.newName).optional(), - metadata: ResourceMetadataSchema.optional() + metadata: ResourceMetadataSchema.optional(), + usernameTemplate: userTemplateSchema.nullable().optional() }) }), response: { @@ -328,4 +324,37 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => return { leases }; } }); + + server.route({ + method: "POST", + url: "/entra-id/users", + config: { + rateLimit: readLimit + }, + schema: { + body: z.object({ + tenantId: z.string().min(1).describe("The tenant ID of the Azure Entra ID"), + applicationId: z.string().min(1).describe("The application ID of the Azure Entra ID App Registration"), + clientSecret: z.string().min(1).describe("The client secret of the Azure Entra ID App Registration") + }), + response: { + 200: z + .object({ + name: z.string().min(1).describe("The name of the user"), + id: z.string().min(1).describe("The ID of the user"), + email: z.string().min(1).describe("The email of the user") + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.dynamicSecret.fetchAzureEntraIdUsers({ + tenantId: req.body.tenantId, + applicationId: req.body.applicationId, + clientSecret: req.body.clientSecret + }); + return data; + } + }); }; diff --git a/backend/src/ee/routes/v1/gateway-router.ts b/backend/src/ee/routes/v1/gateway-router.ts index c916e229e..40e9c1580 100644 --- a/backend/src/ee/routes/v1/gateway-router.ts +++ b/backend/src/ee/routes/v1/gateway-router.ts @@ -121,14 +121,7 @@ export const registerGatewayRouter = async (server: FastifyZodProvider) => { identity: z.object({ name: z.string(), id: z.string() - }), - projects: z - .object({ - name: z.string(), - id: z.string(), - slug: z.string() - }) - .array() + }) }).array() }) } @@ -158,17 +151,15 @@ export const registerGatewayRouter = async (server: FastifyZodProvider) => { identity: z.object({ name: z.string(), id: z.string() - }), - projectGatewayId: z.string() + }) }).array() }) } }, onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN, AuthMode.JWT]), handler: async (req) => { - const gateways = await server.services.gateway.getProjectGateways({ - projectId: req.params.projectId, - projectPermission: req.permission + const gateways = await server.services.gateway.listGateways({ + orgPermission: req.permission }); return { gateways }; } @@ -216,8 +207,7 @@ export const registerGatewayRouter = async (server: FastifyZodProvider) => { id: z.string() }), body: z.object({ - name: slugSchema({ field: "name" }).optional(), - projectIds: z.string().array().optional() + name: slugSchema({ field: "name" }).optional() }), response: { 200: z.object({ @@ -230,8 +220,7 @@ export const registerGatewayRouter = async (server: FastifyZodProvider) => { const gateway = await server.services.gateway.updateGatewayById({ orgPermission: req.permission, id: req.params.id, - name: req.body.name, - projectIds: req.body.projectIds + name: req.body.name }); return { gateway }; } diff --git a/backend/src/ee/routes/v1/ldap-router.ts b/backend/src/ee/routes/v1/ldap-router.ts index 5f80ad02b..57c5736df 100644 --- a/backend/src/ee/routes/v1/ldap-router.ts +++ b/backend/src/ee/routes/v1/ldap-router.ts @@ -98,6 +98,9 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { server.route({ url: "/login", method: "POST", + config: { + rateLimit: writeLimit + }, schema: { body: z.object({ organizationSlug: z.string().trim() diff --git a/backend/src/ee/routes/v1/license-router.ts b/backend/src/ee/routes/v1/license-router.ts index b19faaf70..0a59fa7b5 100644 --- a/backend/src/ee/routes/v1/license-router.ts +++ b/backend/src/ee/routes/v1/license-router.ts @@ -47,7 +47,7 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { 200: z.object({ plan: z.any() }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const plan = await server.services.license.getOrgPlan({ actorId: req.permission.id, diff --git a/backend/src/ee/routes/v1/project-template-router.ts b/backend/src/ee/routes/v1/project-template-router.ts index 08d16414b..5d33b4d58 100644 --- a/backend/src/ee/routes/v1/project-template-router.ts +++ b/backend/src/ee/routes/v1/project-template-router.ts @@ -1,9 +1,8 @@ import { z } from "zod"; -import { ProjectMembershipRole, ProjectTemplatesSchema } from "@app/db/schemas"; +import { ProjectMembershipRole, ProjectTemplatesSchema, ProjectType } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission"; -import { ProjectTemplateDefaultEnvironments } from "@app/ee/services/project-template/project-template-constants"; import { isInfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-fns"; import { ApiDocsTags, ProjectTemplates } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -35,6 +34,7 @@ const SanitizedProjectTemplateSchema = ProjectTemplatesSchema.extend({ position: z.number().min(1) }) .array() + .nullable() }); const ProjectTemplateRolesSchema = z @@ -104,6 +104,9 @@ export const registerProjectTemplateRouter = async (server: FastifyZodProvider) hide: false, tags: [ApiDocsTags.ProjectTemplates], description: "List project templates for the current organization.", + querystring: z.object({ + type: z.nativeEnum(ProjectType).optional().describe(ProjectTemplates.LIST.type) + }), response: { 200: z.object({ projectTemplates: SanitizedProjectTemplateSchema.array() @@ -112,7 +115,8 @@ export const registerProjectTemplateRouter = async (server: FastifyZodProvider) }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const projectTemplates = await server.services.projectTemplate.listProjectTemplatesByOrg(req.permission); + const { type } = req.query; + const projectTemplates = await server.services.projectTemplate.listProjectTemplatesByOrg(req.permission, type); const auditTemplates = projectTemplates.filter((template) => !isInfisicalProjectTemplate(template.name)); @@ -184,6 +188,7 @@ export const registerProjectTemplateRouter = async (server: FastifyZodProvider) tags: [ApiDocsTags.ProjectTemplates], description: "Create a project template.", body: z.object({ + type: z.nativeEnum(ProjectType).describe(ProjectTemplates.CREATE.type), name: slugSchema({ field: "name" }) .refine((val) => !isInfisicalProjectTemplate(val), { message: `The requested project template name is reserved.` @@ -191,9 +196,7 @@ export const registerProjectTemplateRouter = async (server: FastifyZodProvider) .describe(ProjectTemplates.CREATE.name), description: z.string().max(256).trim().optional().describe(ProjectTemplates.CREATE.description), roles: ProjectTemplateRolesSchema.default([]).describe(ProjectTemplates.CREATE.roles), - environments: ProjectTemplateEnvironmentsSchema.default(ProjectTemplateDefaultEnvironments).describe( - ProjectTemplates.CREATE.environments - ) + environments: ProjectTemplateEnvironmentsSchema.describe(ProjectTemplates.CREATE.environments).optional() }), response: { 200: z.object({ diff --git a/backend/src/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index f2df2fb89..c8395d608 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -145,7 +145,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({ externalId: profile.nameID, - email, + email: email.toLowerCase(), firstName, lastName: lastName as string, relayState: (req.body as { RelayState?: string }).RelayState, @@ -166,6 +166,9 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { server.route({ url: "/redirect/saml2/organizations/:orgSlug", method: "GET", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ orgSlug: z.string().trim() @@ -192,6 +195,9 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { server.route({ url: "/redirect/saml2/:samlConfigId", method: "GET", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ samlConfigId: z.string().trim() @@ -218,6 +224,9 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { server.route({ url: "/saml2/:samlConfigId", method: "POST", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ samlConfigId: z.string().trim() diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts index cd5f2f9f3..5fa0d19e8 100644 --- a/backend/src/ee/routes/v1/scim-router.ts +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -196,6 +196,9 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { server.route({ url: "/Users", method: "POST", + config: { + rateLimit: writeLimit + }, schema: { body: z.object({ schemas: z.array(z.string()), diff --git a/backend/src/ee/routes/v1/secret-approval-policy-router.ts b/backend/src/ee/routes/v1/secret-approval-policy-router.ts index 846b60923..ebe1345b3 100644 --- a/backend/src/ee/routes/v1/secret-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-policy-router.ts @@ -1,7 +1,7 @@ import { nanoid } from "nanoid"; import { z } from "zod"; -import { ApproverType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; +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"; @@ -30,10 +30,19 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi 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(), name: z.string().optional() }) + 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" }), + .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) @@ -75,10 +84,19 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi 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(), name: z.string().optional() }) + 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" }), + .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() @@ -157,6 +175,12 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi id: z.string().nullable().optional(), type: z.nativeEnum(ApproverType) }) + .array(), + bypassers: z + .object({ + id: z.string().nullable().optional(), + type: z.nativeEnum(BypasserType) + }) .array() }) .array() @@ -193,7 +217,14 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi .object({ id: z.string().nullable().optional(), type: z.nativeEnum(ApproverType), - name: z.string().nullable().optional() + username: z.string().nullable().optional() + }) + .array(), + bypassers: z + .object({ + id: z.string().nullable().optional(), + type: z.nativeEnum(BypasserType), + username: z.string().nullable().optional() }) .array() }) 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 7d2cdcc0c..eed5cd34a 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -47,6 +47,11 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv userId: z.string().nullable().optional() }) .array(), + bypassers: z + .object({ + userId: z.string().nullable().optional() + }) + .array(), secretPath: z.string().optional().nullable(), enforcementLevel: z.string(), deletedAt: z.date().nullish(), @@ -266,6 +271,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv name: z.string(), approvals: z.number(), approvers: approvalRequestUser.array(), + bypassers: approvalRequestUser.array(), secretPath: z.string().optional().nullable(), enforcementLevel: z.string(), deletedAt: z.date().nullish(), diff --git a/backend/src/ee/routes/v1/secret-sync-routers/oci-vault-sync-router.ts b/backend/src/ee/routes/v1/secret-sync-routers/oci-vault-sync-router.ts new file mode 100644 index 000000000..2efe3e3f5 --- /dev/null +++ b/backend/src/ee/routes/v1/secret-sync-routers/oci-vault-sync-router.ts @@ -0,0 +1,16 @@ +import { + CreateOCIVaultSyncSchema, + OCIVaultSyncSchema, + UpdateOCIVaultSyncSchema +} from "@app/ee/services/secret-sync/oci-vault"; +import { registerSyncSecretsEndpoints } from "@app/server/routes/v1/secret-sync-routers/secret-sync-endpoints"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; + +export const registerOCIVaultSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.OCIVault, + server, + responseSchema: OCIVaultSyncSchema, + createSchema: CreateOCIVaultSyncSchema, + updateSchema: UpdateOCIVaultSyncSchema + }); diff --git a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts index e44693643..26e8cad3b 100644 --- a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts +++ b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts @@ -97,7 +97,7 @@ export const registerSshCertificateTemplateRouter = async (server: FastifyZodPro allowCustomKeyIds: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowCustomKeyIds) }) .refine((data) => ms(data.maxTTL) >= ms(data.ttl), { - message: "Max TLL must be greater than or equal to TTL", + message: "Max TTL must be greater than or equal to TTL", path: ["maxTTL"] }), response: { diff --git a/backend/src/ee/routes/v1/ssh-host-router.ts b/backend/src/ee/routes/v1/ssh-host-router.ts index 93748c27f..4c749f6f5 100644 --- a/backend/src/ee/routes/v1/ssh-host-router.ts +++ b/backend/src/ee/routes/v1/ssh-host-router.ts @@ -73,7 +73,7 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const host = await server.services.sshHost.getSshHost({ + const host = await server.services.sshHost.getSshHostById({ sshHostId: req.params.sshHostId, actor: req.permission.type, actorId: req.permission.id, diff --git a/backend/src/ee/routes/v2/index.ts b/backend/src/ee/routes/v2/index.ts index 70e5005a4..e364f4949 100644 --- a/backend/src/ee/routes/v2/index.ts +++ b/backend/src/ee/routes/v2/index.ts @@ -2,6 +2,10 @@ import { registerSecretRotationV2Router, SECRET_ROTATION_REGISTER_ROUTER_MAP } from "@app/ee/routes/v2/secret-rotation-v2-routers"; +import { + registerSecretScanningV2Router, + SECRET_SCANNING_REGISTER_ROUTER_MAP +} from "@app/ee/routes/v2/secret-scanning-v2-routers"; import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router"; import { registerProjectRoleRouter } from "./project-role-router"; @@ -31,4 +35,17 @@ export const registerV2EERoutes = async (server: FastifyZodProvider) => { }, { prefix: "/secret-rotations" } ); + + await server.register( + async (secretScanningV2Router) => { + // register generic secret scanning endpoints + await secretScanningV2Router.register(registerSecretScanningV2Router); + + // register service-specific secret scanning endpoints (gitlab/github, etc.) + for await (const [type, router] of Object.entries(SECRET_SCANNING_REGISTER_ROUTER_MAP)) { + await secretScanningV2Router.register(router, { prefix: `data-sources/${type}` }); + } + }, + { prefix: "/secret-scanning" } + ); }; 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 90edc1306..c33609621 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 @@ -5,6 +5,7 @@ import { registerAwsIamUserSecretRotationRouter } from "./aws-iam-user-secret-ro import { registerAzureClientSecretRotationRouter } from "./azure-client-secret-rotation-router"; import { registerLdapPasswordRotationRouter } from "./ldap-password-rotation-router"; import { registerMsSqlCredentialsRotationRouter } from "./mssql-credentials-rotation-router"; +import { registerMySqlCredentialsRotationRouter } from "./mysql-credentials-rotation-router"; import { registerPostgresCredentialsRotationRouter } from "./postgres-credentials-rotation-router"; export * from "./secret-rotation-v2-router"; @@ -15,6 +16,7 @@ export const SECRET_ROTATION_REGISTER_ROUTER_MAP: Record< > = { [SecretRotation.PostgresCredentials]: registerPostgresCredentialsRotationRouter, [SecretRotation.MsSqlCredentials]: registerMsSqlCredentialsRotationRouter, + [SecretRotation.MySqlCredentials]: registerMySqlCredentialsRotationRouter, [SecretRotation.Auth0ClientSecret]: registerAuth0ClientSecretRotationRouter, [SecretRotation.AzureClientSecret]: registerAzureClientSecretRotationRouter, [SecretRotation.AwsIamUserSecret]: registerAwsIamUserSecretRotationRouter, diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/mysql-credentials-rotation-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/mysql-credentials-rotation-router.ts new file mode 100644 index 000000000..99f02731c --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/mysql-credentials-rotation-router.ts @@ -0,0 +1,19 @@ +import { + CreateMySqlCredentialsRotationSchema, + MySqlCredentialsRotationSchema, + UpdateMySqlCredentialsRotationSchema +} from "@app/ee/services/secret-rotation-v2/mysql-credentials"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { SqlCredentialsRotationGeneratedCredentialsSchema } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials"; + +import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints"; + +export const registerMySqlCredentialsRotationRouter = async (server: FastifyZodProvider) => + registerSecretRotationEndpoints({ + type: SecretRotation.MySqlCredentials, + server, + responseSchema: MySqlCredentialsRotationSchema, + createSchema: CreateMySqlCredentialsRotationSchema, + updateSchema: UpdateMySqlCredentialsRotationSchema, + generatedCredentialsSchema: SqlCredentialsRotationGeneratedCredentialsSchema + }); 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 298f2c412..5e3e09846 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 @@ -6,6 +6,7 @@ import { AwsIamUserSecretRotationListItemSchema } from "@app/ee/services/secret- import { AzureClientSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/azure-client-secret"; import { LdapPasswordRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/ldap-password"; import { MsSqlCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; +import { MySqlCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/mysql-credentials"; import { PostgresCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema"; import { ApiDocsTags, SecretRotations } from "@app/lib/api-docs"; @@ -16,6 +17,7 @@ import { AuthMode } from "@app/services/auth/auth-type"; const SecretRotationV2OptionsSchema = z.discriminatedUnion("type", [ PostgresCredentialsRotationListItemSchema, MsSqlCredentialsRotationListItemSchema, + MySqlCredentialsRotationListItemSchema, Auth0ClientSecretRotationListItemSchema, AzureClientSecretRotationListItemSchema, AwsIamUserSecretRotationListItemSchema, diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/github-secret-scanning-router.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/github-secret-scanning-router.ts new file mode 100644 index 000000000..3961e7cbd --- /dev/null +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/github-secret-scanning-router.ts @@ -0,0 +1,16 @@ +import { registerSecretScanningEndpoints } from "@app/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-endpoints"; +import { + CreateGitHubDataSourceSchema, + GitHubDataSourceSchema, + UpdateGitHubDataSourceSchema +} from "@app/ee/services/secret-scanning-v2/github"; +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; + +export const registerGitHubSecretScanningRouter = async (server: FastifyZodProvider) => + registerSecretScanningEndpoints({ + type: SecretScanningDataSource.GitHub, + server, + responseSchema: GitHubDataSourceSchema, + createSchema: CreateGitHubDataSourceSchema, + updateSchema: UpdateGitHubDataSourceSchema + }); diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts new file mode 100644 index 000000000..703529947 --- /dev/null +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts @@ -0,0 +1,12 @@ +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; + +import { registerGitHubSecretScanningRouter } from "./github-secret-scanning-router"; + +export * from "./secret-scanning-v2-router"; + +export const SECRET_SCANNING_REGISTER_ROUTER_MAP: Record< + SecretScanningDataSource, + (server: FastifyZodProvider) => Promise +> = { + [SecretScanningDataSource.GitHub]: registerGitHubSecretScanningRouter +}; diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-endpoints.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-endpoints.ts new file mode 100644 index 000000000..3a5c6b4d7 --- /dev/null +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-endpoints.ts @@ -0,0 +1,593 @@ +import { z } from "zod"; + +import { SecretScanningResourcesSchema, SecretScanningScansSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { + SecretScanningDataSource, + SecretScanningScanStatus +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { SECRET_SCANNING_DATA_SOURCE_NAME_MAP } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-maps"; +import { + TSecretScanningDataSource, + TSecretScanningDataSourceInput +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; +import { ApiDocsTags, SecretScanningDataSources } from "@app/lib/api-docs"; +import { startsWithVowel } 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"; + +export const registerSecretScanningEndpoints = < + T extends TSecretScanningDataSource, + I extends TSecretScanningDataSourceInput +>({ + server, + type, + createSchema, + updateSchema, + responseSchema +}: { + type: SecretScanningDataSource; + server: FastifyZodProvider; + createSchema: z.ZodType<{ + name: string; + projectId: string; + connectionId?: string; + config: Partial; + description?: string | null; + isAutoScanEnabled?: boolean; + }>; + updateSchema: z.ZodType<{ + name?: string; + config?: Partial; + description?: string | null; + isAutoScanEnabled?: boolean; + }>; + responseSchema: z.ZodTypeAny; +}) => { + const sourceType = SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]; + + server.route({ + method: "GET", + url: `/`, + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `List the ${sourceType} Data Sources for the specified project.`, + querystring: z.object({ + projectId: z + .string() + .trim() + .min(1, "Project ID required") + .describe(SecretScanningDataSources.LIST(type).projectId) + }), + response: { + 200: z.object({ dataSources: responseSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId } + } = req; + + const dataSources = (await server.services.secretScanningV2.listSecretScanningDataSourcesByProjectId( + { projectId, type }, + req.permission + )) as T[]; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_LIST, + metadata: { + type, + count: dataSources.length, + dataSourceIds: dataSources.map((source) => source.id) + } + } + }); + + return { dataSources }; + } + }); + + server.route({ + method: "GET", + url: "/:dataSourceId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Get the specified ${sourceType} Data Source by ID.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.GET_BY_ID(type).dataSourceId) + }), + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const dataSource = (await server.services.secretScanningV2.findSecretScanningDataSourceById( + { dataSourceId, type }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: dataSource.projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_GET, + metadata: { + dataSourceId, + type + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "GET", + url: `/data-source-name/:dataSourceName`, + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Get the specified ${sourceType} Data Source by name and project ID.`, + params: z.object({ + sourceName: z + .string() + .trim() + .min(1, "Data Source name required") + .describe(SecretScanningDataSources.GET_BY_NAME(type).sourceName) + }), + querystring: z.object({ + projectId: z + .string() + .trim() + .min(1, "Project ID required") + .describe(SecretScanningDataSources.GET_BY_NAME(type).projectId) + }), + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { sourceName } = req.params; + const { projectId } = req.query; + + const dataSource = (await server.services.secretScanningV2.findSecretScanningDataSourceByName( + { sourceName, projectId, type }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_GET, + metadata: { + dataSourceId: dataSource.id, + type + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Create ${ + startsWithVowel(sourceType) ? "an" : "a" + } ${sourceType} Data Source for the specified project.`, + body: createSchema, + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const dataSource = (await server.services.secretScanningV2.createSecretScanningDataSource( + { ...req.body, type }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: dataSource.projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_CREATE, + metadata: { + dataSourceId: dataSource.id, + type, + ...req.body + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "PATCH", + url: "/:dataSourceId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Update the specified ${sourceType} Data Source.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.UPDATE(type).dataSourceId) + }), + body: updateSchema, + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const dataSource = (await server.services.secretScanningV2.updateSecretScanningDataSource( + { ...req.body, dataSourceId, type }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: dataSource.projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_UPDATE, + metadata: { + dataSourceId, + type, + ...req.body + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "DELETE", + url: `/:dataSourceId`, + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Delete the specified ${sourceType} Data Source.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.DELETE(type).dataSourceId) + }), + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const dataSource = (await server.services.secretScanningV2.deleteSecretScanningDataSource( + { type, dataSourceId }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: dataSource.projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_DELETE, + metadata: { + type, + dataSourceId + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "POST", + url: `/:dataSourceId/scan`, + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Trigger a scan for the specified ${sourceType} Data Source.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.SCAN(type).dataSourceId) + }), + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const dataSource = (await server.services.secretScanningV2.triggerSecretScanningDataSourceScan( + { type, dataSourceId }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: dataSource.projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_TRIGGER_SCAN, + metadata: { + type, + dataSourceId + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "POST", + url: `/:dataSourceId/resources/:resourceId/scan`, + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Trigger a scan for the specified ${sourceType} Data Source resource.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.SCAN(type).dataSourceId), + resourceId: z.string().uuid().describe(SecretScanningDataSources.SCAN(type).resourceId) + }), + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId, resourceId } = req.params; + + const dataSource = (await server.services.secretScanningV2.triggerSecretScanningDataSourceScan( + { type, dataSourceId, resourceId }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: dataSource.projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_TRIGGER_SCAN, + metadata: { + type, + dataSourceId, + resourceId + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "GET", + url: "/:dataSourceId/resources", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Get the resources associated with the specified ${sourceType} Data Source by ID.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.LIST_RESOURCES(type).dataSourceId) + }), + response: { + 200: z.object({ resources: SecretScanningResourcesSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const { resources, projectId } = await server.services.secretScanningV2.listSecretScanningResourcesByDataSourceId( + { dataSourceId, type }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_RESOURCE_LIST, + metadata: { + dataSourceId, + type, + resourceIds: resources.map((resource) => resource.id), + count: resources.length + } + } + }); + + return { resources }; + } + }); + + server.route({ + method: "GET", + url: "/:dataSourceId/scans", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Get the scans associated with the specified ${sourceType} Data Source by ID.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.LIST_SCANS(type).dataSourceId) + }), + response: { + 200: z.object({ scans: SecretScanningScansSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const { scans, projectId } = await server.services.secretScanningV2.listSecretScanningScansByDataSourceId( + { dataSourceId, type }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_SCAN_LIST, + metadata: { + dataSourceId, + type, + count: scans.length + } + } + }); + + return { scans }; + } + }); + + // not exposed, for UI only + server.route({ + method: "GET", + url: "/:dataSourceId/resources-dashboard", + config: { + rateLimit: readLimit + }, + schema: { + tags: [ApiDocsTags.SecretScanning], + params: z.object({ + dataSourceId: z.string().uuid() + }), + response: { + 200: z.object({ + resources: SecretScanningResourcesSchema.extend({ + lastScannedAt: z.date().nullish(), + lastScanStatus: z.nativeEnum(SecretScanningScanStatus).nullish(), + lastScanStatusMessage: z.string().nullish(), + unresolvedFindings: z.number() + }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const { resources, projectId } = + await server.services.secretScanningV2.listSecretScanningResourcesWithDetailsByDataSourceId( + { dataSourceId, type }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_RESOURCE_LIST, + metadata: { + dataSourceId, + type, + resourceIds: resources.map((resource) => resource.id), + count: resources.length + } + } + }); + + return { resources }; + } + }); + + server.route({ + method: "GET", + url: "/:dataSourceId/scans-dashboard", + config: { + rateLimit: readLimit + }, + schema: { + tags: [ApiDocsTags.SecretScanning], + params: z.object({ + dataSourceId: z.string().uuid() + }), + response: { + 200: z.object({ + scans: SecretScanningScansSchema.extend({ + unresolvedFindings: z.number(), + resolvedFindings: z.number(), + resourceName: z.string() + }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const { scans, projectId } = + await server.services.secretScanningV2.listSecretScanningScansWithDetailsByDataSourceId( + { dataSourceId, type }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_SCAN_LIST, + metadata: { + dataSourceId, + type, + count: scans.length + } + } + }); + + return { scans }; + } + }); +}; diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts new file mode 100644 index 000000000..70cfd08dc --- /dev/null +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts @@ -0,0 +1,366 @@ +import { z } from "zod"; + +import { SecretScanningConfigsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { GitHubDataSourceListItemSchema } from "@app/ee/services/secret-scanning-v2/github"; +import { + SecretScanningFindingStatus, + SecretScanningScanStatus +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + SecretScanningDataSourceSchema, + SecretScanningFindingSchema +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas"; +import { + ApiDocsTags, + SecretScanningConfigs, + SecretScanningDataSources, + SecretScanningFindings +} 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"; + +const SecretScanningDataSourceOptionsSchema = z.discriminatedUnion("type", [GitHubDataSourceListItemSchema]); + +export const registerSecretScanningV2Router = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/data-sources/options", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "List the available Secret Scanning Data Source Options.", + response: { + 200: z.object({ + dataSourceOptions: SecretScanningDataSourceOptionsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: () => { + const dataSourceOptions = server.services.secretScanningV2.listSecretScanningDataSourceOptions(); + return { dataSourceOptions }; + } + }); + + server.route({ + method: "GET", + url: "/data-sources", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "List all the Secret Scanning Data Sources for the specified project.", + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required").describe(SecretScanningDataSources.LIST().projectId) + }), + response: { + 200: z.object({ dataSources: SecretScanningDataSourceSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const dataSources = await server.services.secretScanningV2.listSecretScanningDataSourcesByProjectId( + { projectId }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_LIST, + metadata: { + dataSourceIds: dataSources.map((dataSource) => dataSource.id), + count: dataSources.length + } + } + }); + + return { dataSources }; + } + }); + + server.route({ + method: "GET", + url: "/findings", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "List all the Secret Scanning Findings for the specified project.", + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required").describe(SecretScanningFindings.LIST.projectId) + }), + response: { + 200: z.object({ findings: SecretScanningFindingSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const findings = await server.services.secretScanningV2.listSecretScanningFindingsByProjectId( + projectId, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_FINDING_LIST, + metadata: { + findingIds: findings.map((finding) => finding.id), + count: findings.length + } + } + }); + + return { findings }; + } + }); + + server.route({ + method: "PATCH", + url: "/findings/:findingId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "Update the specified Secret Scanning Finding.", + params: z.object({ + findingId: z.string().trim().min(1, "Finding ID required").describe(SecretScanningFindings.UPDATE.findingId) + }), + body: z.object({ + status: z.nativeEnum(SecretScanningFindingStatus).optional().describe(SecretScanningFindings.UPDATE.status), + remarks: z.string().nullish().describe(SecretScanningFindings.UPDATE.remarks) + }), + response: { + 200: z.object({ finding: SecretScanningFindingSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + params: { findingId }, + body, + permission + } = req; + + const { finding, projectId } = await server.services.secretScanningV2.updateSecretScanningFindingById( + { findingId, ...body }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_FINDING_UPDATE, + metadata: { + findingId, + ...body + } + } + }); + + return { finding }; + } + }); + + server.route({ + method: "GET", + url: "/configs", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "Get the Secret Scanning Config for the specified project.", + querystring: z.object({ + projectId: z + .string() + .trim() + .min(1, "Project ID required") + .describe(SecretScanningConfigs.GET_BY_PROJECT_ID.projectId) + }), + response: { + 200: z.object({ + config: z.object({ content: z.string().nullish(), projectId: z.string(), updatedAt: z.date().nullish() }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const config = await server.services.secretScanningV2.findSecretScanningConfigByProjectId(projectId, permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_CONFIG_GET + } + }); + + return { config }; + } + }); + + server.route({ + method: "PATCH", + url: "/configs", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "Update the specified Secret Scanning Configuration.", + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required").describe(SecretScanningConfigs.UPDATE.projectId) + }), + body: z.object({ + content: z.string().nullable().describe(SecretScanningConfigs.UPDATE.content) + }), + response: { + 200: z.object({ config: SecretScanningConfigsSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId }, + body, + permission + } = req; + + const config = await server.services.secretScanningV2.upsertSecretScanningConfig( + { projectId, ...body }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_CONFIG_UPDATE, + metadata: body + } + }); + + return { config }; + } + }); + + // not exposed, for UI only + server.route({ + method: "GET", + url: "/data-sources-dashboard", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required") + }), + response: { + 200: z.object({ + dataSources: z + .intersection( + SecretScanningDataSourceSchema, + z.object({ + lastScannedAt: z.date().nullish(), + lastScanStatus: z.nativeEnum(SecretScanningScanStatus).nullish(), + lastScanStatusMessage: z.string().nullish(), + unresolvedFindings: z.number().nullish() + }) + ) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const dataSources = await server.services.secretScanningV2.listSecretScanningDataSourcesWithDetailsByProjectId( + { projectId }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_LIST, + metadata: { + dataSourceIds: dataSources.map((dataSource) => dataSource.id), + count: dataSources.length + } + } + }); + + return { dataSources }; + } + }); + + server.route({ + method: "GET", + url: "/unresolved-findings-count", + config: { + rateLimit: readLimit + }, + schema: { + tags: [ApiDocsTags.SecretScanning], + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required").describe(SecretScanningFindings.LIST.projectId) + }), + response: { + 200: z.object({ unresolvedFindings: z.number() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const unresolvedFindings = + await server.services.secretScanningV2.getSecretScanningUnresolvedFindingsCountByProjectId( + projectId, + permission + ); + + return { unresolvedFindings }; + } + }); +}; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts index e14854d8f..c141c762b 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts @@ -8,3 +8,10 @@ export const accessApprovalPolicyApproverDALFactory = (db: TDbClient) => { const accessApprovalPolicyApproverOrm = ormify(db, TableName.AccessApprovalPolicyApprover); return { ...accessApprovalPolicyApproverOrm }; }; + +export type TAccessApprovalPolicyBypasserDALFactory = ReturnType; + +export const accessApprovalPolicyBypasserDALFactory = (db: TDbClient) => { + const accessApprovalPolicyBypasserOrm = ormify(db, TableName.AccessApprovalPolicyBypasser); + return { ...accessApprovalPolicyBypasserOrm }; +}; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts index e14451498..c61d209c3 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts @@ -1,11 +1,11 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { AccessApprovalPoliciesSchema, TableName, TAccessApprovalPolicies } from "@app/db/schemas"; +import { AccessApprovalPoliciesSchema, TableName, TAccessApprovalPolicies, TUsers } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, sqlNestRelationships, TFindFilter } from "@app/lib/knex"; -import { ApproverType } from "./access-approval-policy-types"; +import { ApproverType, BypasserType } from "./access-approval-policy-types"; export type TAccessApprovalPolicyDALFactory = ReturnType; @@ -34,9 +34,22 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { `${TableName.AccessApprovalPolicyApprover}.policyId` ) .leftJoin(TableName.Users, `${TableName.AccessApprovalPolicyApprover}.approverUserId`, `${TableName.Users}.id`) + .leftJoin( + TableName.AccessApprovalPolicyBypasser, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.Users).as("bypasserUsers"), + `${TableName.AccessApprovalPolicyBypasser}.bypasserUserId`, + `bypasserUsers.id` + ) .select(tx.ref("username").withSchema(TableName.Users).as("approverUsername")) + .select(tx.ref("username").withSchema("bypasserUsers").as("bypasserUsername")) .select(tx.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) .select(tx.ref("approverGroupId").withSchema(TableName.AccessApprovalPolicyApprover)) + .select(tx.ref("bypasserUserId").withSchema(TableName.AccessApprovalPolicyBypasser)) + .select(tx.ref("bypasserGroupId").withSchema(TableName.AccessApprovalPolicyBypasser)) .select(tx.ref("name").withSchema(TableName.Environment).as("envName")) .select(tx.ref("slug").withSchema(TableName.Environment).as("envSlug")) .select(tx.ref("id").withSchema(TableName.Environment).as("envId")) @@ -129,6 +142,23 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { id, type: ApproverType.Group }) + }, + { + key: "bypasserUserId", + label: "bypassers" as const, + mapper: ({ bypasserUserId: id, bypasserUsername }) => ({ + id, + type: BypasserType.User, + name: bypasserUsername + }) + }, + { + key: "bypasserGroupId", + label: "bypassers" as const, + mapper: ({ bypasserGroupId: id }) => ({ + id, + type: BypasserType.Group + }) } ] }); @@ -144,5 +174,28 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { return softDeletedPolicy; }; - return { ...accessApprovalPolicyOrm, find, findById, softDeleteById }; + const findLastValidPolicy = async ({ envId, secretPath }: { envId: string; secretPath: string }, tx?: Knex) => { + try { + const result = await (tx || db.replicaNode())(TableName.AccessApprovalPolicy) + .where( + // eslint-disable-next-line @typescript-eslint/no-misused-promises + buildFindFilter( + { + envId, + secretPath + }, + TableName.AccessApprovalPolicy + ) + ) + .orderBy("deletedAt", "desc") + .orderByRaw(`"deletedAt" IS NULL`) + .first(); + + return result; + } catch (error) { + throw new DatabaseError({ error, name: "FindLastValidPolicy" }); + } + }; + + return { ...accessApprovalPolicyOrm, find, findById, softDeleteById, findLastValidPolicy }; }; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index 6b5014acc..71d15ce1c 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -4,6 +4,7 @@ import { ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; @@ -14,10 +15,14 @@ import { TAccessApprovalRequestReviewerDALFactory } from "../access-approval-req import { ApprovalStatus } from "../access-approval-request/access-approval-request-types"; import { TGroupDALFactory } from "../group/group-dal"; import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; -import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal"; +import { + TAccessApprovalPolicyApproverDALFactory, + TAccessApprovalPolicyBypasserDALFactory +} from "./access-approval-policy-approver-dal"; import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal"; import { ApproverType, + BypasserType, TCreateAccessApprovalPolicy, TDeleteAccessApprovalPolicy, TGetAccessApprovalPolicyByIdDTO, @@ -32,12 +37,14 @@ type TAccessApprovalPolicyServiceFactoryDep = { accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory; projectEnvDAL: Pick; accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory; + accessApprovalPolicyBypasserDAL: TAccessApprovalPolicyBypasserDALFactory; projectMembershipDAL: Pick; groupDAL: TGroupDALFactory; userDAL: Pick; accessApprovalRequestDAL: Pick; additionalPrivilegeDAL: Pick; accessApprovalRequestReviewerDAL: Pick; + orgMembershipDAL: Pick; }; export type TAccessApprovalPolicyServiceFactory = ReturnType; @@ -45,6 +52,7 @@ export type TAccessApprovalPolicyServiceFactory = ReturnType { const createAccessApprovalPolicy = async ({ name, @@ -63,6 +72,7 @@ export const accessApprovalPolicyServiceFactory = ({ actorAuthMethod, approvals, approvers, + bypassers, projectSlug, environment, enforcementLevel, @@ -82,7 +92,7 @@ export const accessApprovalPolicyServiceFactory = ({ .filter(Boolean) as string[]; const userApproverNames = approvers - .map((approver) => (approver.type === ApproverType.User ? approver.name : undefined)) + .map((approver) => (approver.type === ApproverType.User ? approver.username : undefined)) .filter(Boolean) as string[]; if (!groupApprovers && approvals > userApprovers.length + userApproverNames.length) @@ -147,6 +157,44 @@ export const accessApprovalPolicyServiceFactory = ({ .map((user) => user.id); verifyAllApprovers.push(...verifyGroupApprovers); + let groupBypassers: string[] = []; + let bypasserUserIds: string[] = []; + + if (bypassers && bypassers.length) { + groupBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.Group) + .map((bypasser) => bypasser.id) as string[]; + + const userBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.User) + .map((bypasser) => bypasser.id) + .filter(Boolean) as string[]; + + const userBypasserNames = bypassers + .map((bypasser) => (bypasser.type === BypasserType.User ? bypasser.username : undefined)) + .filter(Boolean) as string[]; + + bypasserUserIds = userBypassers; + if (userBypasserNames.length) { + const bypasserUsers = await userDAL.find({ + $in: { + username: userBypasserNames + } + }); + + const bypasserNamesFromDb = bypasserUsers.map((user) => user.username); + const invalidUsernames = userBypasserNames.filter((username) => !bypasserNamesFromDb.includes(username)); + + if (invalidUsernames.length) { + throw new BadRequestError({ + message: `Invalid bypasser user: ${invalidUsernames.join(", ")}` + }); + } + + bypasserUserIds = bypasserUserIds.concat(bypasserUsers.map((user) => user.id)); + } + } + const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => { const doc = await accessApprovalPolicyDAL.create( { @@ -159,6 +207,7 @@ export const accessApprovalPolicyServiceFactory = ({ }, tx ); + if (approverUserIds.length) { await accessApprovalPolicyApproverDAL.insertMany( approverUserIds.map((userId) => ({ @@ -179,8 +228,29 @@ export const accessApprovalPolicyServiceFactory = ({ ); } + if (bypasserUserIds.length) { + await accessApprovalPolicyBypasserDAL.insertMany( + bypasserUserIds.map((userId) => ({ + bypasserUserId: userId, + policyId: doc.id + })), + tx + ); + } + + if (groupBypassers.length) { + await accessApprovalPolicyBypasserDAL.insertMany( + groupBypassers.map((groupId) => ({ + bypasserGroupId: groupId, + policyId: doc.id + })), + tx + ); + } + return doc; }); + return { ...accessApproval, environment: env, projectId: project.id }; }; @@ -211,6 +281,7 @@ export const accessApprovalPolicyServiceFactory = ({ const updateAccessApprovalPolicy = async ({ policyId, approvers, + bypassers, secretPath, name, actorId, @@ -231,15 +302,15 @@ export const accessApprovalPolicyServiceFactory = ({ .filter(Boolean) as string[]; const userApproverNames = approvers - .map((approver) => (approver.type === ApproverType.User ? approver.name : undefined)) + .map((approver) => (approver.type === ApproverType.User ? approver.username : undefined)) .filter(Boolean) as string[]; const accessApprovalPolicy = await accessApprovalPolicyDAL.findById(policyId); - const currentAppovals = approvals || accessApprovalPolicy.approvals; + const currentApprovals = approvals || accessApprovalPolicy.approvals; if ( groupApprovers?.length === 0 && userApprovers && - currentAppovals > userApprovers.length + userApproverNames.length + currentApprovals > userApprovers.length + userApproverNames.length ) { throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); } @@ -258,6 +329,78 @@ export const accessApprovalPolicyServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); + let groupBypassers: string[] = []; + let bypasserUserIds: string[] = []; + + if (bypassers && bypassers.length) { + groupBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.Group) + .map((bypasser) => bypasser.id) as string[]; + + groupBypassers = [...new Set(groupBypassers)]; + + const userBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.User) + .map((bypasser) => bypasser.id) + .filter(Boolean) as string[]; + + const userBypasserNames = bypassers + .map((bypasser) => (bypasser.type === BypasserType.User ? bypasser.username : undefined)) + .filter(Boolean) as string[]; + + bypasserUserIds = userBypassers; + if (userBypasserNames.length) { + const bypasserUsers = await userDAL.find({ + $in: { + username: userBypasserNames + } + }); + + const bypasserNamesFromDb = bypasserUsers.map((user) => user.username); + const invalidUsernames = userBypasserNames.filter((username) => !bypasserNamesFromDb.includes(username)); + + if (invalidUsernames.length) { + throw new BadRequestError({ + message: `Invalid bypasser user: ${invalidUsernames.join(", ")}` + }); + } + + bypasserUserIds = [...new Set(bypasserUserIds.concat(bypasserUsers.map((user) => user.id)))]; + } + + // Validate user bypassers + if (bypasserUserIds.length > 0) { + const orgMemberships = await orgMembershipDAL.find({ + $in: { userId: bypasserUserIds }, + orgId: actorOrgId + }); + + if (orgMemberships.length !== bypasserUserIds.length) { + const foundUserIdsInOrg = new Set(orgMemberships.map((mem) => mem.userId)); + const missingUserIds = bypasserUserIds.filter((id) => !foundUserIdsInOrg.has(id)); + throw new BadRequestError({ + message: `One or more specified bypasser users are not part of the organization or do not exist. Invalid or non-member user IDs: ${missingUserIds.join(", ")}` + }); + } + } + + // Validate group bypassers + if (groupBypassers.length > 0) { + const orgGroups = await groupDAL.find({ + $in: { id: groupBypassers }, + orgId: actorOrgId + }); + + if (orgGroups.length !== groupBypassers.length) { + const foundGroupIdsInOrg = new Set(orgGroups.map((group) => group.id)); + const missingGroupIds = groupBypassers.filter((id) => !foundGroupIdsInOrg.has(id)); + throw new BadRequestError({ + message: `One or more specified bypasser groups are not part of the organization or do not exist. Invalid or non-member group IDs: ${missingGroupIds.join(", ")}` + }); + } + } + } + const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => { const doc = await accessApprovalPolicyDAL.updateById( accessApprovalPolicy.id, @@ -313,6 +456,28 @@ export const accessApprovalPolicyServiceFactory = ({ ); } + await accessApprovalPolicyBypasserDAL.delete({ policyId: doc.id }, tx); + + if (bypasserUserIds.length) { + await accessApprovalPolicyBypasserDAL.insertMany( + bypasserUserIds.map((userId) => ({ + bypasserUserId: userId, + policyId: doc.id + })), + tx + ); + } + + if (groupBypassers.length) { + await accessApprovalPolicyBypasserDAL.insertMany( + groupBypassers.map((groupId) => ({ + bypasserGroupId: groupId, + policyId: doc.id + })), + tx + ); + } + return doc; }); return { diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts index dde8ffbea..cef7f68f4 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts @@ -18,11 +18,20 @@ export enum ApproverType { User = "user" } +export enum BypasserType { + Group = "group", + User = "user" +} + export type TCreateAccessApprovalPolicy = { approvals: number; secretPath: string; environment: string; - approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; + approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; username?: string })[]; + bypassers?: ( + | { type: BypasserType.Group; id: string } + | { type: BypasserType.User; id?: string; username?: string } + )[]; projectSlug: string; name: string; enforcementLevel: EnforcementLevel; @@ -32,7 +41,11 @@ export type TCreateAccessApprovalPolicy = { export type TUpdateAccessApprovalPolicy = { policyId: string; approvals?: number; - approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; + approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; username?: string })[]; + bypassers?: ( + | { type: BypasserType.Group; id: string } + | { type: BypasserType.User; id?: string; username?: string } + )[]; secretPath?: string; name?: string; enforcementLevel?: EnforcementLevel; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts index e2075af0a..bfd07bdcf 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -1,7 +1,13 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { AccessApprovalRequestsSchema, TableName, TAccessApprovalRequests, TUsers } from "@app/db/schemas"; +import { + AccessApprovalRequestsSchema, + TableName, + TAccessApprovalRequests, + TUserGroupMembership, + TUsers +} from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships, TFindFilter } from "@app/lib/knex"; @@ -28,12 +34,12 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.AccessApprovalRequest}.policyId`, `${TableName.AccessApprovalPolicy}.id` ) - .leftJoin( TableName.AccessApprovalRequestReviewer, `${TableName.AccessApprovalRequest}.id`, `${TableName.AccessApprovalRequestReviewer}.requestId` ) + .leftJoin( TableName.AccessApprovalPolicyApprover, `${TableName.AccessApprovalPolicy}.id`, @@ -46,6 +52,17 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { ) .leftJoin(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) + .leftJoin( + TableName.AccessApprovalPolicyBypasser, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.UserGroupMembership).as("bypasserUserGroupMembership"), + `${TableName.AccessApprovalPolicyBypasser}.bypasserGroupId`, + `bypasserUserGroupMembership.groupId` + ) + .join( db(TableName.Users).as("requestedByUser"), `${TableName.AccessApprovalRequest}.requestedByUserId`, @@ -69,6 +86,9 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { .select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) .select(db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId")) + .select(db.ref("bypasserUserId").withSchema(TableName.AccessApprovalPolicyBypasser)) + .select(db.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId")) + .select( db.ref("projectId").withSchema(TableName.Environment), db.ref("slug").withSchema(TableName.Environment).as("envSlug"), @@ -145,7 +165,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { } : null, - isApproved: !!doc.policyDeletedAt || !!doc.privilegeId + isApproved: !!doc.policyDeletedAt || !!doc.privilegeId || doc.status !== ApprovalStatus.PENDING }), childrenMapper: [ { @@ -158,6 +178,12 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { key: "approverGroupUserId", label: "approvers" as const, mapper: ({ approverGroupUserId }) => approverGroupUserId + }, + { key: "bypasserUserId", label: "bypassers" as const, mapper: ({ bypasserUserId }) => bypasserUserId }, + { + key: "bypasserGroupUserId", + label: "bypassers" as const, + mapper: ({ bypasserGroupUserId }) => bypasserGroupUserId } ] }); @@ -166,7 +192,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { return formattedDocs.map((doc) => ({ ...doc, - policy: { ...doc.policy, approvers: doc.approvers } + policy: { ...doc.policy, approvers: doc.approvers, bypassers: doc.bypassers } })); } catch (error) { throw new DatabaseError({ error, name: "FindRequestsWithPrivilege" }); @@ -193,7 +219,6 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.AccessApprovalPolicy}.id`, `${TableName.AccessApprovalPolicyApprover}.policyId` ) - .leftJoin( db(TableName.Users).as("accessApprovalPolicyApproverUser"), `${TableName.AccessApprovalPolicyApprover}.approverUserId`, @@ -204,13 +229,33 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.AccessApprovalPolicyApprover}.approverGroupId`, `${TableName.UserGroupMembership}.groupId` ) - .leftJoin( db(TableName.Users).as("accessApprovalPolicyGroupApproverUser"), `${TableName.UserGroupMembership}.userId`, "accessApprovalPolicyGroupApproverUser.id" ) + .leftJoin( + TableName.AccessApprovalPolicyBypasser, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.Users).as("accessApprovalPolicyBypasserUser"), + `${TableName.AccessApprovalPolicyBypasser}.bypasserUserId`, + "accessApprovalPolicyBypasserUser.id" + ) + .leftJoin( + db(TableName.UserGroupMembership).as("bypasserUserGroupMembership"), + `${TableName.AccessApprovalPolicyBypasser}.bypasserGroupId`, + `bypasserUserGroupMembership.groupId` + ) + .leftJoin( + db(TableName.Users).as("accessApprovalPolicyGroupBypasserUser"), + `bypasserUserGroupMembership.userId`, + "accessApprovalPolicyGroupBypasserUser.id" + ) + .leftJoin( TableName.AccessApprovalRequestReviewer, `${TableName.AccessApprovalRequest}.id`, @@ -241,6 +286,18 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("firstName").withSchema("requestedByUser").as("requestedByUserFirstName"), tx.ref("lastName").withSchema("requestedByUser").as("requestedByUserLastName"), + // Bypassers + tx.ref("bypasserUserId").withSchema(TableName.AccessApprovalPolicyBypasser), + tx.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId"), + tx.ref("email").withSchema("accessApprovalPolicyBypasserUser").as("bypasserEmail"), + tx.ref("email").withSchema("accessApprovalPolicyGroupBypasserUser").as("bypasserGroupEmail"), + tx.ref("username").withSchema("accessApprovalPolicyBypasserUser").as("bypasserUsername"), + tx.ref("username").withSchema("accessApprovalPolicyGroupBypasserUser").as("bypasserGroupUsername"), + tx.ref("firstName").withSchema("accessApprovalPolicyBypasserUser").as("bypasserFirstName"), + tx.ref("firstName").withSchema("accessApprovalPolicyGroupBypasserUser").as("bypasserGroupFirstName"), + tx.ref("lastName").withSchema("accessApprovalPolicyBypasserUser").as("bypasserLastName"), + tx.ref("lastName").withSchema("accessApprovalPolicyGroupBypasserUser").as("bypasserGroupLastName"), + tx.ref("reviewerUserId").withSchema(TableName.AccessApprovalRequestReviewer), tx.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus"), @@ -265,7 +322,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { try { const sql = findQuery({ [`${TableName.AccessApprovalRequest}.id` as "id"]: id }, tx || db.replicaNode()); const docs = await sql; - const formatedDoc = sqlNestRelationships({ + const formattedDoc = sqlNestRelationships({ data: docs, key: "id", parentMapper: (el) => ({ @@ -335,13 +392,51 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { lastName, username }) + }, + { + key: "bypasserUserId", + label: "bypassers" as const, + mapper: ({ + bypasserUserId, + bypasserEmail: email, + bypasserUsername: username, + bypasserLastName: lastName, + bypasserFirstName: firstName + }) => ({ + userId: bypasserUserId, + email, + firstName, + lastName, + username + }) + }, + { + key: "bypasserGroupUserId", + label: "bypassers" as const, + mapper: ({ + userId, + bypasserGroupEmail: email, + bypasserGroupUsername: username, + bypasserGroupLastName: lastName, + bypasserFirstName: firstName + }) => ({ + userId, + email, + firstName, + lastName, + username + }) } ] }); - if (!formatedDoc?.[0]) return; + if (!formattedDoc?.[0]) return; return { - ...formatedDoc[0], - policy: { ...formatedDoc[0].policy, approvers: formatedDoc[0].approvers } + ...formattedDoc[0], + policy: { + ...formattedDoc[0].policy, + approvers: formattedDoc[0].approvers, + bypassers: formattedDoc[0].bypassers + } }; } catch (error) { throw new DatabaseError({ error, name: "FindByIdAccessApprovalRequest" }); @@ -392,14 +487,20 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { ] }); - // an approval is pending if there is no reviewer rejections and no privilege ID is set + // an approval is pending if there is no reviewer rejections, no privilege ID is set and the status is pending const pendingApprovals = formattedRequests.filter( - (req) => !req.privilegeId && !req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) + (req) => + !req.privilegeId && + !req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) && + req.status === ApprovalStatus.PENDING ); - // an approval is finalized if there are any rejections or a privilege ID is set + // an approval is finalized if there are any rejections, a privilege ID is set or the number of approvals is equal to the number of approvals required const finalizedApprovals = formattedRequests.filter( - (req) => req.privilegeId || req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) + (req) => + req.privilegeId || + req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) || + req.status !== ApprovalStatus.PENDING ); return { pendingCount: pendingApprovals.length, finalizedCount: finalizedApprovals.length }; 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 2b2758b2e..d03cc64c0 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 @@ -6,6 +6,7 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { EnforcementLevel } from "@app/lib/types"; import { triggerWorkflowIntegrationNotification } from "@app/lib/workflow-integrations/trigger-notification"; import { TriggerFeature } from "@app/lib/workflow-integrations/types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; @@ -55,7 +56,7 @@ type TSecretApprovalRequestServiceFactoryDep = { | "findOne" | "getCount" >; - accessApprovalPolicyDAL: Pick; + accessApprovalPolicyDAL: Pick; accessApprovalRequestReviewerDAL: Pick< TAccessApprovalRequestReviewerDALFactory, "create" | "find" | "findOne" | "transaction" @@ -130,7 +131,7 @@ export const accessApprovalRequestServiceFactory = ({ if (!environment) throw new NotFoundError({ message: `Environment with slug '${envSlug}' not found` }); - const policy = await accessApprovalPolicyDAL.findOne({ + const policy = await accessApprovalPolicyDAL.findLastValidPolicy({ envId: environment.id, secretPath }); @@ -202,7 +203,7 @@ export const accessApprovalRequestServiceFactory = ({ const isRejected = reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED); - if (!isRejected) { + if (!isRejected && duplicateRequest.status === ApprovalStatus.PENDING) { throw new BadRequestError({ message: "You already have a pending access request with the same criteria" }); } } @@ -323,24 +324,20 @@ export const accessApprovalRequestServiceFactory = ({ status, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + bypassReason }: TReviewAccessRequestDTO) => { const accessApprovalRequest = await accessApprovalRequestDAL.findById(requestId); if (!accessApprovalRequest) { throw new NotFoundError({ message: `Secret approval request with ID '${requestId}' not found` }); } - const { policy } = accessApprovalRequest; + const { policy, environment } = accessApprovalRequest; if (policy.deletedAt) { throw new BadRequestError({ message: "The policy associated with this access request has been deleted." }); } - if (!policy.allowedSelfApprovals && actorId === accessApprovalRequest.requestedByUserId) { - throw new BadRequestError({ - message: "Failed to review access approval request. Users are not authorized to review their own request." - }); - } const { membership, hasRole } = await permissionService.getProjectPermission({ actor, @@ -355,29 +352,71 @@ export const accessApprovalRequestServiceFactory = ({ throw new ForbiddenRequestError({ message: "You are not a member of this project" }); } + const isSelfApproval = actorId === accessApprovalRequest.requestedByUserId; + const isSoftEnforcement = policy.enforcementLevel === EnforcementLevel.Soft; + const canBypass = !policy.bypassers.length || policy.bypassers.some((bypasser) => bypasser.userId === actorId); + const cannotBypassUnderSoftEnforcement = !(isSoftEnforcement && canBypass); + + const isApprover = policy.approvers.find((approver) => approver.userId === actorId); + + // If user is (not an approver OR cant self approve) AND can't bypass policy + if ((!isApprover || (!policy.allowedSelfApprovals && isSelfApproval)) && cannotBypassUnderSoftEnforcement) { + throw new BadRequestError({ + message: "Failed to review access approval request. Users are not authorized to review their own request." + }); + } + if ( !hasRole(ProjectMembershipRole.Admin) && accessApprovalRequest.requestedByUserId !== actorId && // The request wasn't made by the current user - !policy.approvers.find((approver) => approver.userId === actorId) // The request isn't performed by an assigned approver + !isApprover // The request isn't performed by an assigned approver ) { throw new ForbiddenRequestError({ message: "You are not authorized to approve this request" }); } + const project = await projectDAL.findById(accessApprovalRequest.projectId); + if (!project) { + throw new NotFoundError({ message: "The project associated with this access request was not found." }); + } + const existingReviews = await accessApprovalRequestReviewerDAL.find({ requestId: accessApprovalRequest.id }); if (existingReviews.some((review) => review.status === ApprovalStatus.REJECTED)) { throw new BadRequestError({ message: "The request has already been rejected by another reviewer" }); } const reviewStatus = await accessApprovalRequestReviewerDAL.transaction(async (tx) => { - const review = await accessApprovalRequestReviewerDAL.findOne( + const isBreakGlassApprovalAttempt = + policy.enforcementLevel === EnforcementLevel.Soft && + actorId === accessApprovalRequest.requestedByUserId && + status === ApprovalStatus.APPROVED; + + let reviewForThisActorProcessing: { + id: string; + requestId: string; + reviewerUserId: string; + status: string; + createdAt: Date; + updatedAt: Date; + }; + + const existingReviewByActorInTx = await accessApprovalRequestReviewerDAL.findOne( { requestId: accessApprovalRequest.id, reviewerUserId: actorId }, tx ); - if (!review) { - const newReview = await accessApprovalRequestReviewerDAL.create( + + // Check if review exists for actor + if (existingReviewByActorInTx) { + // Check if breakglass re-approval + if (isBreakGlassApprovalAttempt && existingReviewByActorInTx.status === ApprovalStatus.APPROVED) { + reviewForThisActorProcessing = existingReviewByActorInTx; + } else { + throw new BadRequestError({ message: "You have already reviewed this request" }); + } + } else { + reviewForThisActorProcessing = await accessApprovalRequestReviewerDAL.create( { status, requestId: accessApprovalRequest.id, @@ -385,19 +424,26 @@ export const accessApprovalRequestServiceFactory = ({ }, tx ); + } - const allReviews = [...existingReviews, newReview]; + const otherReviews = existingReviews.filter((er) => er.reviewerUserId !== actorId); + const allUniqueReviews = [...otherReviews, reviewForThisActorProcessing]; - const approvedReviews = allReviews.filter((r) => r.status === ApprovalStatus.APPROVED); + const approvedReviews = allUniqueReviews.filter((r) => r.status === ApprovalStatus.APPROVED); + const meetsStandardApprovalThreshold = approvedReviews.length >= policy.approvals; - // approvals is the required number of approvals. If the number of approved reviews is equal to the number of required approvals, then the request is approved. - if (approvedReviews.length === policy.approvals) { + if ( + reviewForThisActorProcessing.status === ApprovalStatus.APPROVED && + (meetsStandardApprovalThreshold || isBreakGlassApprovalAttempt) + ) { + const currentRequestState = await accessApprovalRequestDAL.findById(accessApprovalRequest.id, tx); + let privilegeIdToSet = currentRequestState?.privilegeId || null; + + if (!privilegeIdToSet) { if (accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) { throw new BadRequestError({ message: "Temporary range is required for temporary access" }); } - let privilegeId: string | null = null; - if (!accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) { // Permanent access const privilege = await additionalPrivilegeDAL.create( @@ -409,7 +455,7 @@ export const accessApprovalRequestServiceFactory = ({ }, tx ); - privilegeId = privilege.id; + privilegeIdToSet = privilege.id; } else { // Temporary access const relativeTempAllocatedTimeInMs = ms(accessApprovalRequest.temporaryRange!); @@ -421,23 +467,61 @@ export const accessApprovalRequestServiceFactory = ({ projectId: accessApprovalRequest.projectId, slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`, permissions: JSON.stringify(accessApprovalRequest.permissions), - isTemporary: true, + isTemporary: true, // Explicitly set to true for the privilege temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative, temporaryRange: accessApprovalRequest.temporaryRange!, temporaryAccessStartTime: startTime, - temporaryAccessEndTime: new Date(new Date(startTime).getTime() + relativeTempAllocatedTimeInMs) + temporaryAccessEndTime: new Date(startTime.getTime() + relativeTempAllocatedTimeInMs) }, tx ); - privilegeId = privilege.id; + privilegeIdToSet = privilege.id; } - - await accessApprovalRequestDAL.updateById(accessApprovalRequest.id, { privilegeId }, tx); + await accessApprovalRequestDAL.updateById( + accessApprovalRequest.id, + { privilegeId: privilegeIdToSet, status: ApprovalStatus.APPROVED }, + tx + ); } - - return newReview; } - throw new BadRequestError({ message: "You have already reviewed this request" }); + + // Send notification if this was a breakglass approval + if (isBreakGlassApprovalAttempt) { + const cfg = getConfig(); + const actingUser = await userDAL.findById(actorId, tx); + + if (actingUser) { + const policyApproverUserIds = policy.approvers + .map((ap) => ap.userId) + .filter((id): id is string => typeof id === "string"); + + if (policyApproverUserIds.length > 0) { + const approverUsersForEmail = await userDAL.find({ $in: { id: policyApproverUserIds } }, { tx }); + const recipientEmails = approverUsersForEmail + .map((appUser) => appUser.email) + .filter((email): email is string => !!email); + + if (recipientEmails.length > 0) { + await smtpService.sendMail({ + recipients: recipientEmails, + subjectLine: "Infisical Secret Access Policy Bypassed", + substitutions: { + projectName: project.name, + requesterFullName: `${actingUser.firstName} ${actingUser.lastName}`, + requesterEmail: actingUser.email, + bypassReason: bypassReason || "No reason provided", + secretPath: policy.secretPath || "/", + environment, + approvalUrl: `${cfg.SITE_URL}/secret-manager/${project.id}/approval`, + requestType: "access" + }, + template: SmtpTemplates.AccessSecretRequestBypassed + }); + } + } + } + } + return reviewForThisActorProcessing; }); return reviewStatus; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts index 51a5e0ca2..162f8b3c6 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts @@ -17,6 +17,8 @@ export type TGetAccessRequestCountDTO = { export type TReviewAccessRequestDTO = { requestId: string; status: ApprovalStatus; + envName?: string; + bypassReason?: string; } & Omit; export type TCreateAccessApprovalRequestDTO = { diff --git a/backend/src/ee/services/app-connections/oci/index.ts b/backend/src/ee/services/app-connections/oci/index.ts new file mode 100644 index 000000000..eb2850d34 --- /dev/null +++ b/backend/src/ee/services/app-connections/oci/index.ts @@ -0,0 +1,4 @@ +export * from "./oci-connection-enums"; +export * from "./oci-connection-fns"; +export * from "./oci-connection-schemas"; +export * from "./oci-connection-types"; diff --git a/backend/src/ee/services/app-connections/oci/oci-connection-enums.ts b/backend/src/ee/services/app-connections/oci/oci-connection-enums.ts new file mode 100644 index 000000000..1b4319651 --- /dev/null +++ b/backend/src/ee/services/app-connections/oci/oci-connection-enums.ts @@ -0,0 +1,3 @@ +export enum OCIConnectionMethod { + AccessKey = "access-key" +} diff --git a/backend/src/ee/services/app-connections/oci/oci-connection-fns.ts b/backend/src/ee/services/app-connections/oci/oci-connection-fns.ts new file mode 100644 index 000000000..5dcf6ee7a --- /dev/null +++ b/backend/src/ee/services/app-connections/oci/oci-connection-fns.ts @@ -0,0 +1,139 @@ +import { common, identity, keymanagement } from "oci-sdk"; + +import { BadRequestError } from "@app/lib/errors"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { OCIConnectionMethod } from "./oci-connection-enums"; +import { TOCIConnection, TOCIConnectionConfig } from "./oci-connection-types"; + +export const getOCIProvider = async (config: TOCIConnectionConfig) => { + const { + credentials: { fingerprint, privateKey, region, tenancyOcid, userOcid } + } = config; + + const provider = new common.SimpleAuthenticationDetailsProvider( + tenancyOcid, + userOcid, + fingerprint, + privateKey, + null, + common.Region.fromRegionId(region) + ); + + return provider; +}; + +export const getOCIConnectionListItem = () => { + return { + name: "OCI" as const, + app: AppConnection.OCI as const, + methods: Object.values(OCIConnectionMethod) as [OCIConnectionMethod.AccessKey] + }; +}; + +export const validateOCIConnectionCredentials = async (config: TOCIConnectionConfig) => { + const provider = await getOCIProvider(config); + + try { + const identityClient = new identity.IdentityClient({ + authenticationDetailsProvider: provider + }); + + // Get user details - a lightweight call that validates all credentials + await identityClient.getUser({ userId: config.credentials.userOcid }); + } catch (error: unknown) { + if (error instanceof Error) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + + return config.credentials; +}; + +export const listOCICompartments = async (appConnection: TOCIConnection) => { + const provider = await getOCIProvider(appConnection); + + const identityClient = new identity.IdentityClient({ authenticationDetailsProvider: provider }); + const keyManagementClient = new keymanagement.KmsVaultClient({ + authenticationDetailsProvider: provider + }); + + const rootCompartment = await identityClient + .getTenancy({ + tenancyId: appConnection.credentials.tenancyOcid + }) + .then((response) => ({ + ...response.tenancy, + id: appConnection.credentials.tenancyOcid, + name: response.tenancy.name ? `${response.tenancy.name} (root)` : "root" + })); + + const compartments = await identityClient.listCompartments({ + compartmentId: appConnection.credentials.tenancyOcid, + compartmentIdInSubtree: true, + accessLevel: identity.requests.ListCompartmentsRequest.AccessLevel.Any, + lifecycleState: identity.models.Compartment.LifecycleState.Active + }); + + const allCompartments = [rootCompartment, ...compartments.items]; + const filteredCompartments = []; + + for await (const compartment of allCompartments) { + try { + // Check if user can list vaults in this compartment + await keyManagementClient.listVaults({ + compartmentId: compartment.id, + limit: 1 + }); + + filteredCompartments.push(compartment); + } catch (error) { + // Do nothing + } + } + + return filteredCompartments; +}; + +export const listOCIVaults = async (appConnection: TOCIConnection, compartmentOcid: string) => { + const provider = await getOCIProvider(appConnection); + + const keyManagementClient = new keymanagement.KmsVaultClient({ + authenticationDetailsProvider: provider + }); + + const vaults = await keyManagementClient.listVaults({ + compartmentId: compartmentOcid + }); + + return vaults.items.filter((v) => v.lifecycleState === keymanagement.models.Vault.LifecycleState.Active); +}; + +export const listOCIVaultKeys = async (appConnection: TOCIConnection, compartmentOcid: string, vaultOcid: string) => { + const provider = await getOCIProvider(appConnection); + + const kmsVaultClient = new keymanagement.KmsVaultClient({ + authenticationDetailsProvider: provider + }); + + const vault = await kmsVaultClient.getVault({ + vaultId: vaultOcid + }); + + const keyManagementClient = new keymanagement.KmsManagementClient({ + authenticationDetailsProvider: provider + }); + + keyManagementClient.endpoint = vault.vault.managementEndpoint; + + const keys = await keyManagementClient.listKeys({ + compartmentId: compartmentOcid + }); + + return keys.items.filter((v) => v.lifecycleState === keymanagement.models.KeySummary.LifecycleState.Enabled); +}; diff --git a/backend/src/ee/services/app-connections/oci/oci-connection-schemas.ts b/backend/src/ee/services/app-connections/oci/oci-connection-schemas.ts new file mode 100644 index 000000000..f09564455 --- /dev/null +++ b/backend/src/ee/services/app-connections/oci/oci-connection-schemas.ts @@ -0,0 +1,65 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { OCIConnectionMethod } from "./oci-connection-enums"; + +export const OCIConnectionAccessTokenCredentialsSchema = z.object({ + userOcid: z.string().trim().min(1, "User OCID required").describe(AppConnections.CREDENTIALS.OCI.userOcid), + tenancyOcid: z.string().trim().min(1, "Tenancy OCID required").describe(AppConnections.CREDENTIALS.OCI.tenancyOcid), + region: z.string().trim().min(1, "Region required").describe(AppConnections.CREDENTIALS.OCI.region), + fingerprint: z.string().trim().min(1, "Fingerprint required").describe(AppConnections.CREDENTIALS.OCI.fingerprint), + privateKey: z.string().trim().min(1, "Private Key required").describe(AppConnections.CREDENTIALS.OCI.privateKey) +}); + +const BaseOCIConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.OCI) }); + +export const OCIConnectionSchema = BaseOCIConnectionSchema.extend({ + method: z.literal(OCIConnectionMethod.AccessKey), + credentials: OCIConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedOCIConnectionSchema = z.discriminatedUnion("method", [ + BaseOCIConnectionSchema.extend({ + method: z.literal(OCIConnectionMethod.AccessKey), + credentials: OCIConnectionAccessTokenCredentialsSchema.pick({ + userOcid: true, + tenancyOcid: true, + region: true, + fingerprint: true + }) + }) +]); + +export const ValidateOCIConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(OCIConnectionMethod.AccessKey).describe(AppConnections.CREATE(AppConnection.OCI).method), + credentials: OCIConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.OCI).credentials + ) + }) +]); + +export const CreateOCIConnectionSchema = ValidateOCIConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.OCI) +); + +export const UpdateOCIConnectionSchema = z + .object({ + credentials: OCIConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.OCI).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.OCI)); + +export const OCIConnectionListItemSchema = z.object({ + name: z.literal("OCI"), + app: z.literal(AppConnection.OCI), + methods: z.nativeEnum(OCIConnectionMethod).array() +}); diff --git a/backend/src/ee/services/app-connections/oci/oci-connection-service.ts b/backend/src/ee/services/app-connections/oci/oci-connection-service.ts new file mode 100644 index 000000000..c2e60399c --- /dev/null +++ b/backend/src/ee/services/app-connections/oci/oci-connection-service.ts @@ -0,0 +1,91 @@ +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../../../../services/app-connection/app-connection-enums"; +import { TLicenseServiceFactory } from "../../license/license-service"; +import { listOCICompartments, listOCIVaultKeys, listOCIVaults } from "./oci-connection-fns"; +import { TOCIConnection } from "./oci-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +type TListOCIVaultsDTO = { + connectionId: string; + compartmentOcid: string; +}; + +type TListOCIVaultKeysDTO = { + connectionId: string; + compartmentOcid: string; + vaultOcid: string; +}; + +// Enterprise check +export const checkPlan = async (licenseService: Pick, orgId: string) => { + const plan = await licenseService.getPlan(orgId); + if (!plan.enterpriseAppConnections) + throw new BadRequestError({ + message: + "Failed to use app connection due to plan restriction. Upgrade plan to access enterprise app connections." + }); +}; + +export const ociConnectionService = ( + getAppConnection: TGetAppConnectionFunc, + licenseService: Pick +) => { + const listCompartments = async (connectionId: string, actor: OrgServiceActor) => { + await checkPlan(licenseService, actor.orgId); + + const appConnection = await getAppConnection(AppConnection.OCI, connectionId, actor); + + try { + const compartments = await listOCICompartments(appConnection); + return compartments; + } catch (error) { + logger.error(error, "Failed to establish connection with OCI"); + return []; + } + }; + + const listVaults = async ({ connectionId, compartmentOcid }: TListOCIVaultsDTO, actor: OrgServiceActor) => { + await checkPlan(licenseService, actor.orgId); + + const appConnection = await getAppConnection(AppConnection.OCI, connectionId, actor); + + try { + const vaults = await listOCIVaults(appConnection, compartmentOcid); + return vaults; + } catch (error) { + logger.error(error, "Failed to establish connection with OCI"); + return []; + } + }; + + const listVaultKeys = async ( + { connectionId, compartmentOcid, vaultOcid }: TListOCIVaultKeysDTO, + actor: OrgServiceActor + ) => { + await checkPlan(licenseService, actor.orgId); + + const appConnection = await getAppConnection(AppConnection.OCI, connectionId, actor); + + try { + const keys = await listOCIVaultKeys(appConnection, compartmentOcid, vaultOcid); + return keys; + } catch (error) { + logger.error(error, "Failed to establish connection with OCI"); + return []; + } + }; + + return { + listCompartments, + listVaults, + listVaultKeys + }; +}; diff --git a/backend/src/ee/services/app-connections/oci/oci-connection-types.ts b/backend/src/ee/services/app-connections/oci/oci-connection-types.ts new file mode 100644 index 000000000..e07554f29 --- /dev/null +++ b/backend/src/ee/services/app-connections/oci/oci-connection-types.ts @@ -0,0 +1,22 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../../../../services/app-connection/app-connection-enums"; +import { + CreateOCIConnectionSchema, + OCIConnectionSchema, + ValidateOCIConnectionCredentialsSchema +} from "./oci-connection-schemas"; + +export type TOCIConnection = z.infer; + +export type TOCIConnectionInput = z.infer & { + app: AppConnection.OCI; +}; + +export type TValidateOCIConnectionCredentialsSchema = typeof ValidateOCIConnectionCredentialsSchema; + +export type TOCIConnectionConfig = DiscriminativePick & { + orgId: string; +}; 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 d7cad74be..cfc01741f 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -1,3 +1,4 @@ +import { ProjectType } from "@app/db/schemas"; import { TCreateProjectTemplateDTO, TUpdateProjectTemplateDTO @@ -9,6 +10,18 @@ import { TSecretRotationV2Raw, TUpdateSecretRotationV2DTO } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { + SecretScanningDataSource, + SecretScanningScanStatus, + SecretScanningScanType +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + TCreateSecretScanningDataSourceDTO, + TDeleteSecretScanningDataSourceDTO, + TTriggerSecretScanningDataSourceDTO, + TUpdateSecretScanningDataSourceDTO, + TUpdateSecretScanningFindingDTO +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; @@ -19,9 +32,10 @@ import { TProjectPermission } from "@app/lib/types"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { TCreateAppConnectionDTO, TUpdateAppConnectionDTO } from "@app/services/app-connection/app-connection-types"; import { ActorType } from "@app/services/auth/auth-type"; -import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; -import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types"; +import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums"; import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { TAllowedFields } from "@app/services/identity-ldap-auth/identity-ldap-auth-types"; import { PkiItemType } from "@app/services/pki-collection/pki-collection-types"; import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums"; import { @@ -119,44 +133,66 @@ export enum EventType { CREATE_TOKEN_IDENTITY_TOKEN_AUTH = "create-token-identity-token-auth", UPDATE_TOKEN_IDENTITY_TOKEN_AUTH = "update-token-identity-token-auth", GET_TOKENS_IDENTITY_TOKEN_AUTH = "get-tokens-identity-token-auth", + ADD_IDENTITY_TOKEN_AUTH = "add-identity-token-auth", UPDATE_IDENTITY_TOKEN_AUTH = "update-identity-token-auth", GET_IDENTITY_TOKEN_AUTH = "get-identity-token-auth", REVOKE_IDENTITY_TOKEN_AUTH = "revoke-identity-token-auth", + LOGIN_IDENTITY_KUBERNETES_AUTH = "login-identity-kubernetes-auth", ADD_IDENTITY_KUBERNETES_AUTH = "add-identity-kubernetes-auth", UPDATE_IDENTITY_KUBENETES_AUTH = "update-identity-kubernetes-auth", GET_IDENTITY_KUBERNETES_AUTH = "get-identity-kubernetes-auth", REVOKE_IDENTITY_KUBERNETES_AUTH = "revoke-identity-kubernetes-auth", + LOGIN_IDENTITY_OIDC_AUTH = "login-identity-oidc-auth", ADD_IDENTITY_OIDC_AUTH = "add-identity-oidc-auth", UPDATE_IDENTITY_OIDC_AUTH = "update-identity-oidc-auth", GET_IDENTITY_OIDC_AUTH = "get-identity-oidc-auth", REVOKE_IDENTITY_OIDC_AUTH = "revoke-identity-oidc-auth", + LOGIN_IDENTITY_JWT_AUTH = "login-identity-jwt-auth", ADD_IDENTITY_JWT_AUTH = "add-identity-jwt-auth", UPDATE_IDENTITY_JWT_AUTH = "update-identity-jwt-auth", GET_IDENTITY_JWT_AUTH = "get-identity-jwt-auth", REVOKE_IDENTITY_JWT_AUTH = "revoke-identity-jwt-auth", + CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", + 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", + LOGIN_IDENTITY_GCP_AUTH = "login-identity-gcp-auth", ADD_IDENTITY_GCP_AUTH = "add-identity-gcp-auth", UPDATE_IDENTITY_GCP_AUTH = "update-identity-gcp-auth", REVOKE_IDENTITY_GCP_AUTH = "revoke-identity-gcp-auth", GET_IDENTITY_GCP_AUTH = "get-identity-gcp-auth", + LOGIN_IDENTITY_AWS_AUTH = "login-identity-aws-auth", ADD_IDENTITY_AWS_AUTH = "add-identity-aws-auth", UPDATE_IDENTITY_AWS_AUTH = "update-identity-aws-auth", REVOKE_IDENTITY_AWS_AUTH = "revoke-identity-aws-auth", GET_IDENTITY_AWS_AUTH = "get-identity-aws-auth", + + LOGIN_IDENTITY_OCI_AUTH = "login-identity-oci-auth", + ADD_IDENTITY_OCI_AUTH = "add-identity-oci-auth", + UPDATE_IDENTITY_OCI_AUTH = "update-identity-oci-auth", + REVOKE_IDENTITY_OCI_AUTH = "revoke-identity-oci-auth", + GET_IDENTITY_OCI_AUTH = "get-identity-oci-auth", + LOGIN_IDENTITY_AZURE_AUTH = "login-identity-azure-auth", ADD_IDENTITY_AZURE_AUTH = "add-identity-azure-auth", UPDATE_IDENTITY_AZURE_AUTH = "update-identity-azure-auth", GET_IDENTITY_AZURE_AUTH = "get-identity-azure-auth", REVOKE_IDENTITY_AZURE_AUTH = "revoke-identity-azure-auth", + + LOGIN_IDENTITY_LDAP_AUTH = "login-identity-ldap-auth", + ADD_IDENTITY_LDAP_AUTH = "add-identity-ldap-auth", + UPDATE_IDENTITY_LDAP_AUTH = "update-identity-ldap-auth", + GET_IDENTITY_LDAP_AUTH = "get-identity-ldap-auth", + REVOKE_IDENTITY_LDAP_AUTH = "revoke-identity-ldap-auth", + CREATE_ENVIRONMENT = "create-environment", UPDATE_ENVIRONMENT = "update-environment", DELETE_ENVIRONMENT = "delete-environment", @@ -208,6 +244,7 @@ export enum EventType { REMOVE_HOST_FROM_SSH_HOST_GROUP = "remove-host-from-ssh-host-group", CREATE_CA = "create-certificate-authority", GET_CA = "get-certificate-authority", + GET_CAS = "get-certificate-authorities", UPDATE_CA = "update-certificate-authority", DELETE_CA = "delete-certificate-authority", RENEW_CA = "renew-certificate-authority", @@ -218,6 +255,7 @@ export enum EventType { IMPORT_CA_CERT = "import-certificate-authority-cert", GET_CA_CRLS = "get-certificate-authority-crls", ISSUE_CERT = "issue-cert", + IMPORT_CERT = "import-cert", SIGN_CERT = "sign-cert", GET_CA_CERTIFICATE_TEMPLATES = "get-ca-certificate-templates", GET_CERT = "get-cert", @@ -237,6 +275,15 @@ export enum EventType { GET_PKI_COLLECTION_ITEMS = "get-pki-collection-items", ADD_PKI_COLLECTION_ITEM = "add-pki-collection-item", DELETE_PKI_COLLECTION_ITEM = "delete-pki-collection-item", + CREATE_PKI_SUBSCRIBER = "create-pki-subscriber", + UPDATE_PKI_SUBSCRIBER = "update-pki-subscriber", + DELETE_PKI_SUBSCRIBER = "delete-pki-subscriber", + GET_PKI_SUBSCRIBER = "get-pki-subscriber", + ISSUE_PKI_SUBSCRIBER_CERT = "issue-pki-subscriber-cert", + SIGN_PKI_SUBSCRIBER_CERT = "sign-pki-subscriber-cert", + AUTOMATED_RENEW_SUBSCRIBER_CERT = "automated-renew-subscriber-cert", + LIST_PKI_SUBSCRIBER_CERTS = "list-pki-subscriber-certs", + GET_SUBSCRIBER_ACTIVE_CERT_BUNDLE = "get-subscriber-active-cert-bundle", CREATE_KMS = "create-kms", UPDATE_KMS = "update-kms", DELETE_KMS = "delete-kms", @@ -285,7 +332,6 @@ export enum EventType { CREATE_PROJECT_TEMPLATE = "create-project-template", UPDATE_PROJECT_TEMPLATE = "update-project-template", DELETE_PROJECT_TEMPLATE = "delete-project-template", - APPLY_PROJECT_TEMPLATE = "apply-project-template", GET_APP_CONNECTIONS = "get-app-connections", GET_AVAILABLE_APP_CONNECTIONS_DETAILS = "get-available-app-connections-details", GET_APP_CONNECTION = "get-app-connection", @@ -345,7 +391,27 @@ export enum EventType { MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_LIST = "microsoft-teams-workflow-integration-list", PROJECT_ASSUME_PRIVILEGE_SESSION_START = "project-assume-privileges-session-start", - PROJECT_ASSUME_PRIVILEGE_SESSION_END = "project-assume-privileges-session-end" + PROJECT_ASSUME_PRIVILEGE_SESSION_END = "project-assume-privileges-session-end", + + SECRET_SCANNING_DATA_SOURCE_LIST = "secret-scanning-data-source-list", + SECRET_SCANNING_DATA_SOURCE_CREATE = "secret-scanning-data-source-create", + SECRET_SCANNING_DATA_SOURCE_UPDATE = "secret-scanning-data-source-update", + SECRET_SCANNING_DATA_SOURCE_DELETE = "secret-scanning-data-source-delete", + SECRET_SCANNING_DATA_SOURCE_GET = "secret-scanning-data-source-get", + SECRET_SCANNING_DATA_SOURCE_TRIGGER_SCAN = "secret-scanning-data-source-trigger-scan", + SECRET_SCANNING_DATA_SOURCE_SCAN = "secret-scanning-data-source-scan", + SECRET_SCANNING_RESOURCE_LIST = "secret-scanning-resource-list", + SECRET_SCANNING_SCAN_LIST = "secret-scanning-scan-list", + SECRET_SCANNING_FINDING_LIST = "secret-scanning-finding-list", + SECRET_SCANNING_FINDING_UPDATE = "secret-scanning-finding-update", + SECRET_SCANNING_CONFIG_GET = "secret-scanning-config-get", + SECRET_SCANNING_CONFIG_UPDATE = "secret-scanning-config-update", + + UPDATE_ORG = "update-org", + + CREATE_PROJECT = "create-project", + UPDATE_PROJECT = "update-project", + DELETE_PROJECT = "delete-project" } export const filterableSecretEvents: EventType[] = [ @@ -985,6 +1051,55 @@ interface GetIdentityAwsAuthEvent { }; } +interface LoginIdentityOciAuthEvent { + type: EventType.LOGIN_IDENTITY_OCI_AUTH; + metadata: { + identityId: string; + identityOciAuthId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityOciAuthEvent { + type: EventType.ADD_IDENTITY_OCI_AUTH; + metadata: { + identityId: string; + tenancyOcid: string; + allowedUsernames: string | null; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface DeleteIdentityOciAuthEvent { + type: EventType.REVOKE_IDENTITY_OCI_AUTH; + metadata: { + identityId: string; + }; +} + +interface UpdateIdentityOciAuthEvent { + type: EventType.UPDATE_IDENTITY_OCI_AUTH; + metadata: { + identityId: string; + tenancyOcid?: string; + allowedUsernames: string | null; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface GetIdentityOciAuthEvent { + type: EventType.GET_IDENTITY_OCI_AUTH; + metadata: { + identityId: string; + }; +} + interface LoginIdentityAzureAuthEvent { type: EventType.LOGIN_IDENTITY_AZURE_AUTH; metadata: { @@ -1034,6 +1149,55 @@ interface GetIdentityAzureAuthEvent { }; } +interface LoginIdentityLdapAuthEvent { + type: EventType.LOGIN_IDENTITY_LDAP_AUTH; + metadata: { + identityId: string; + ldapUsername: string; + ldapEmail?: string; + }; +} + +interface AddIdentityLdapAuthEvent { + type: EventType.ADD_IDENTITY_LDAP_AUTH; + metadata: { + identityId: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + allowedFields?: TAllowedFields[]; + url: string; + }; +} + +interface UpdateIdentityLdapAuthEvent { + type: EventType.UPDATE_IDENTITY_LDAP_AUTH; + metadata: { + identityId: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + allowedFields?: TAllowedFields[]; + url?: string; + }; +} + +interface GetIdentityLdapAuthEvent { + type: EventType.GET_IDENTITY_LDAP_AUTH; + metadata: { + identityId: string; + }; +} + +interface RevokeIdentityLdapAuthEvent { + type: EventType.REVOKE_IDENTITY_LDAP_AUTH; + metadata: { + identityId: string; + }; +} + interface LoginIdentityOidcAuthEvent { type: EventType.LOGIN_IDENTITY_OIDC_AUTH; metadata: { @@ -1644,7 +1808,8 @@ interface CreateCa { type: EventType.CREATE_CA; metadata: { caId: string; - dn: string; + name: string; + dn?: string; }; } @@ -1652,7 +1817,15 @@ interface GetCa { type: EventType.GET_CA; metadata: { caId: string; - dn: string; + name: string; + dn?: string; + }; +} + +interface GetCAs { + type: EventType.GET_CAS; + metadata: { + caIds: string[]; }; } @@ -1660,7 +1833,8 @@ interface UpdateCa { type: EventType.UPDATE_CA; metadata: { caId: string; - dn: string; + name: string; + dn?: string; status: CaStatus; }; } @@ -1669,7 +1843,8 @@ interface DeleteCa { type: EventType.DELETE_CA; metadata: { caId: string; - dn: string; + name: string; + dn?: string; }; } @@ -1739,6 +1914,15 @@ interface IssueCert { }; } +interface ImportCert { + type: EventType.IMPORT_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + interface SignCert { type: EventType.SIGN_CERT; metadata: { @@ -1899,6 +2083,95 @@ interface DeletePkiCollectionItem { }; } +interface CreatePkiSubscriber { + type: EventType.CREATE_PKI_SUBSCRIBER; + metadata: { + pkiSubscriberId: string; + caId?: string; + name: string; + commonName: string; + ttl?: string; + subjectAlternativeNames: string[]; + keyUsages: CertKeyUsage[]; + extendedKeyUsages: CertExtendedKeyUsage[]; + }; +} + +interface UpdatePkiSubscriber { + type: EventType.UPDATE_PKI_SUBSCRIBER; + metadata: { + pkiSubscriberId: string; + caId?: string; + name?: string; + commonName?: string; + ttl?: string; + subjectAlternativeNames?: string[]; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; + }; +} + +interface DeletePkiSubscriber { + type: EventType.DELETE_PKI_SUBSCRIBER; + metadata: { + pkiSubscriberId: string; + name: string; + }; +} + +interface GetPkiSubscriber { + type: EventType.GET_PKI_SUBSCRIBER; + metadata: { + pkiSubscriberId: string; + name: string; + }; +} + +interface IssuePkiSubscriberCert { + type: EventType.ISSUE_PKI_SUBSCRIBER_CERT; + metadata: { + subscriberId: string; + name: string; + serialNumber?: string; + }; +} + +interface AutomatedRenewPkiSubscriberCert { + type: EventType.AUTOMATED_RENEW_SUBSCRIBER_CERT; + metadata: { + subscriberId: string; + name: string; + }; +} + +interface SignPkiSubscriberCert { + type: EventType.SIGN_PKI_SUBSCRIBER_CERT; + metadata: { + subscriberId: string; + name: string; + serialNumber: string; + }; +} + +interface ListPkiSubscriberCerts { + type: EventType.LIST_PKI_SUBSCRIBER_CERTS; + metadata: { + subscriberId: string; + name: string; + projectId: string; + }; +} + +interface GetSubscriberActiveCertBundle { + type: EventType.GET_SUBSCRIBER_ACTIVE_CERT_BUNDLE; + metadata: { + subscriberId: string; + name: string; + certId: string; + serialNumber: string; + }; +} + interface CreateKmsEvent { type: EventType.CREATE_KMS; metadata: { @@ -2252,14 +2525,6 @@ interface DeleteProjectTemplateEvent { }; } -interface ApplyProjectTemplateEvent { - type: EventType.APPLY_PROJECT_TEMPLATE; - metadata: { - template: string; - projectId: string; - }; -} - interface GetAppConnectionsEvent { type: EventType.GET_APP_CONNECTIONS; metadata: { @@ -2714,6 +2979,154 @@ interface MicrosoftTeamsWorkflowIntegrationUpdateEvent { }; } +interface SecretScanningDataSourceListEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_LIST; + metadata: { + type?: SecretScanningDataSource; + count: number; + dataSourceIds: string[]; + }; +} + +interface SecretScanningDataSourceGetEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_GET; + metadata: { + type: SecretScanningDataSource; + dataSourceId: string; + }; +} + +interface SecretScanningDataSourceCreateEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_CREATE; + metadata: Omit & { dataSourceId: string }; +} + +interface SecretScanningDataSourceUpdateEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_UPDATE; + metadata: TUpdateSecretScanningDataSourceDTO; +} + +interface SecretScanningDataSourceDeleteEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_DELETE; + metadata: TDeleteSecretScanningDataSourceDTO; +} + +interface SecretScanningDataSourceTriggerScanEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_TRIGGER_SCAN; + metadata: TTriggerSecretScanningDataSourceDTO; +} + +interface SecretScanningDataSourceScanEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_SCAN; + metadata: { + scanId: string; + resourceId: string; + resourceType: string; + dataSourceId: string; + dataSourceType: string; + scanStatus: SecretScanningScanStatus; + scanType: SecretScanningScanType; + numberOfSecretsDetected?: number; + }; +} + +interface SecretScanningResourceListEvent { + type: EventType.SECRET_SCANNING_RESOURCE_LIST; + metadata: { + type: SecretScanningDataSource; + dataSourceId: string; + resourceIds: string[]; + count: number; + }; +} + +interface SecretScanningScanListEvent { + type: EventType.SECRET_SCANNING_SCAN_LIST; + metadata: { + type: SecretScanningDataSource; + dataSourceId: string; + count: number; + }; +} + +interface SecretScanningFindingListEvent { + type: EventType.SECRET_SCANNING_FINDING_LIST; + metadata: { + findingIds: string[]; + count: number; + }; +} + +interface SecretScanningFindingUpdateEvent { + type: EventType.SECRET_SCANNING_FINDING_UPDATE; + metadata: TUpdateSecretScanningFindingDTO; +} + +interface SecretScanningConfigUpdateEvent { + type: EventType.SECRET_SCANNING_CONFIG_UPDATE; + metadata: { + content: string | null; + }; +} + +interface SecretScanningConfigReadEvent { + type: EventType.SECRET_SCANNING_CONFIG_GET; + metadata?: Record; // not needed, based off projectId +} + +interface OrgUpdateEvent { + type: EventType.UPDATE_ORG; + metadata: { + name?: string; + slug?: string; + authEnforced?: boolean; + scimEnabled?: boolean; + defaultMembershipRoleSlug?: string; + enforceMfa?: boolean; + selectedMfaMethod?: string; + allowSecretSharingOutsideOrganization?: boolean; + bypassOrgAuthEnabled?: boolean; + userTokenExpiration?: string; + secretsProductEnabled?: boolean; + pkiProductEnabled?: boolean; + kmsProductEnabled?: boolean; + sshProductEnabled?: boolean; + scannerProductEnabled?: boolean; + shareSecretsProductEnabled?: boolean; + }; +} + +interface ProjectCreateEvent { + type: EventType.CREATE_PROJECT; + metadata: { + name: string; + slug?: string; + type: ProjectType; + }; +} + +interface ProjectUpdateEvent { + type: EventType.UPDATE_PROJECT; + metadata: { + name?: string; + description?: string; + autoCapitalization?: boolean; + hasDeleteProtection?: boolean; + slug?: string; + secretSharing?: boolean; + pitVersionLimit?: number; + auditLogsRetentionDays?: number; + }; +} + +interface ProjectDeleteEvent { + type: EventType.DELETE_PROJECT; + metadata: { + id: string; + name: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -2770,6 +3183,11 @@ export type Event = | UpdateIdentityAwsAuthEvent | GetIdentityAwsAuthEvent | DeleteIdentityAwsAuthEvent + | LoginIdentityOciAuthEvent + | AddIdentityOciAuthEvent + | UpdateIdentityOciAuthEvent + | GetIdentityOciAuthEvent + | DeleteIdentityOciAuthEvent | LoginIdentityAzureAuthEvent | AddIdentityAzureAuthEvent | DeleteIdentityAzureAuthEvent @@ -2785,6 +3203,11 @@ export type Event = | UpdateIdentityJwtAuthEvent | GetIdentityJwtAuthEvent | DeleteIdentityJwtAuthEvent + | LoginIdentityLdapAuthEvent + | AddIdentityLdapAuthEvent + | UpdateIdentityLdapAuthEvent + | GetIdentityLdapAuthEvent + | RevokeIdentityLdapAuthEvent | CreateEnvironmentEvent | GetEnvironmentEvent | UpdateEnvironmentEvent @@ -2828,6 +3251,7 @@ export type Event = | IssueSshHostHostCert | CreateCa | GetCa + | GetCAs | UpdateCa | DeleteCa | RenewCa @@ -2838,6 +3262,7 @@ export type Event = | ImportCaCert | GetCaCrls | IssueCert + | ImportCert | SignCert | GetCaCertificateTemplates | GetCert @@ -2857,6 +3282,15 @@ export type Event = | GetPkiCollectionItems | AddPkiCollectionItem | DeletePkiCollectionItem + | CreatePkiSubscriber + | UpdatePkiSubscriber + | DeletePkiSubscriber + | GetPkiSubscriber + | IssuePkiSubscriberCert + | SignPkiSubscriberCert + | AutomatedRenewPkiSubscriberCert + | ListPkiSubscriberCerts + | GetSubscriberActiveCertBundle | CreateKmsEvent | UpdateKmsEvent | DeleteKmsEvent @@ -2901,7 +3335,6 @@ export type Event = | CreateProjectTemplateEvent | UpdateProjectTemplateEvent | DeleteProjectTemplateEvent - | ApplyProjectTemplateEvent | GetAppConnectionsEvent | GetAvailableAppConnectionsDetailsEvent | GetAppConnectionEvent @@ -2963,4 +3396,21 @@ export type Event = | MicrosoftTeamsWorkflowIntegrationGetTeamsEvent | MicrosoftTeamsWorkflowIntegrationGetEvent | MicrosoftTeamsWorkflowIntegrationListEvent - | MicrosoftTeamsWorkflowIntegrationUpdateEvent; + | MicrosoftTeamsWorkflowIntegrationUpdateEvent + | SecretScanningDataSourceListEvent + | SecretScanningDataSourceGetEvent + | SecretScanningDataSourceCreateEvent + | SecretScanningDataSourceUpdateEvent + | SecretScanningDataSourceDeleteEvent + | SecretScanningDataSourceTriggerScanEvent + | SecretScanningDataSourceScanEvent + | SecretScanningResourceListEvent + | SecretScanningScanListEvent + | SecretScanningFindingListEvent + | SecretScanningFindingUpdateEvent + | SecretScanningConfigUpdateEvent + | SecretScanningConfigReadEvent + | OrgUpdateEvent + | ProjectCreateEvent + | ProjectUpdateEvent + | ProjectDeleteEvent; diff --git a/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts index b8f4ce663..844bda8ba 100644 --- a/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts +++ b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts @@ -7,6 +7,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { NotFoundError } from "@app/lib/errors"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { expandInternalCa } from "@app/services/certificate-authority/certificate-authority-fns"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; @@ -14,7 +15,7 @@ import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns import { TGetCaCrlsDTO, TGetCrlById } from "./certificate-authority-crl-types"; type TCertificateAuthorityCrlServiceFactoryDep = { - certificateAuthorityDAL: Pick; + certificateAuthorityDAL: Pick; certificateAuthorityCrlDAL: Pick; projectDAL: Pick; kmsService: Pick; @@ -37,7 +38,8 @@ export const certificateAuthorityCrlServiceFactory = ({ const caCrl = await certificateAuthorityCrlDAL.findById(crlId); if (!caCrl) throw new NotFoundError({ message: `CRL with ID '${crlId}' not found` }); - const ca = await certificateAuthorityDAL.findById(caCrl.caId); + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caCrl.caId); + if (!ca?.internalCa?.id) throw new NotFoundError({ message: `Internal CA with ID '${caCrl.caId}' not found` }); const keyId = await getProjectKmsCertificateKeyId({ projectId: ca.projectId, @@ -54,7 +56,7 @@ export const certificateAuthorityCrlServiceFactory = ({ const crl = new x509.X509Crl(decryptedCrl); return { - ca, + ca: expandInternalCa(ca), caCrl, crl: crl.rawData }; @@ -64,8 +66,8 @@ export const certificateAuthorityCrlServiceFactory = ({ * Returns a list of CRL ids for CA with id [caId] */ const getCaCrls = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCrlsDTO) => { - const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca?.internalCa?.id) throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); const { permission } = await permissionService.getProjectPermission({ actor, @@ -108,7 +110,7 @@ export const certificateAuthorityCrlServiceFactory = ({ ); return { - ca, + ca: expandInternalCa(ca), crls: decryptedCrls }; }; diff --git a/backend/src/ee/services/certificate-est/certificate-est-service.ts b/backend/src/ee/services/certificate-est/certificate-est-service.ts index 627cc58c6..5dcd2b5e2 100644 --- a/backend/src/ee/services/certificate-est/certificate-est-service.ts +++ b/backend/src/ee/services/certificate-est/certificate-est-service.ts @@ -6,7 +6,7 @@ import { isCertChainValid } from "@app/services/certificate/certificate-fns"; import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { getCaCertChain, getCaCertChains } from "@app/services/certificate-authority/certificate-authority-fns"; -import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; +import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { TCertificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; import { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; @@ -16,10 +16,10 @@ import { TLicenseServiceFactory } from "../license/license-service"; import { convertRawCertsToPkcs7 } from "./certificate-est-fns"; type TCertificateEstServiceFactoryDep = { - certificateAuthorityService: Pick; + internalCertificateAuthorityService: Pick; certificateTemplateService: Pick; certificateTemplateDAL: Pick; - certificateAuthorityDAL: Pick; + certificateAuthorityDAL: Pick; certificateAuthorityCertDAL: Pick; projectDAL: Pick; kmsService: Pick; @@ -29,7 +29,7 @@ type TCertificateEstServiceFactoryDep = { export type TCertificateEstServiceFactory = ReturnType; export const certificateEstServiceFactory = ({ - certificateAuthorityService, + internalCertificateAuthorityService, certificateTemplateService, certificateTemplateDAL, certificateAuthorityCertDAL, @@ -127,7 +127,7 @@ export const certificateEstServiceFactory = ({ }); } - const { certificate } = await certificateAuthorityService.signCertFromCa({ + const { certificate } = await internalCertificateAuthorityService.signCertFromCa({ isInternal: true, certificateTemplateId, csr @@ -188,7 +188,7 @@ export const certificateEstServiceFactory = ({ } } - const { certificate } = await certificateAuthorityService.signCertFromCa({ + const { certificate } = await internalCertificateAuthorityService.signCertFromCa({ isInternal: true, certificateTemplateId, csr @@ -227,15 +227,15 @@ export const certificateEstServiceFactory = ({ }); } - const ca = await certificateAuthorityDAL.findById(certTemplate.caId); - if (!ca) { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certTemplate.caId); + if (!ca?.internalCa?.id) { throw new NotFoundError({ - message: `Certificate Authority with ID '${certTemplate.caId}' not found` + message: `Internal Certificate Authority with ID '${certTemplate.caId}' not found` }); } const { caCert, caCertChain } = await getCaCertChain({ - caCertId: ca.activeCaCertId as string, + caCertId: ca.internalCa.activeCaCertId as string, certificateAuthorityDAL, certificateAuthorityCertDAL, projectDAL, diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts index fa1a80ac3..c38a8f146 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts @@ -99,7 +99,9 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ secretManagerDecryptor({ cipherTextBlob: dynamicSecretCfg.encryptedInput }).toString() ) as object; - await selectedProvider.revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId); + await selectedProvider.revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId, { + projectId: folder.projectId + }); await dynamicSecretLeaseDAL.deleteById(dynamicSecretLease.id); return; } @@ -133,7 +135,9 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ await Promise.all(dynamicSecretLeases.map(({ id }) => unsetLeaseRevocation(id))); await Promise.all( dynamicSecretLeases.map(({ externalEntityId }) => - selectedProvider.revoke(decryptedStoredInput, externalEntityId) + selectedProvider.revoke(decryptedStoredInput, externalEntityId, { + projectId: folder.projectId + }) ) ); } diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts index 49c55de66..519d95281 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts @@ -1,4 +1,5 @@ import { ForbiddenError, subject } from "@casl/ability"; +import RE2 from "re2"; import { ActionProjectType } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -11,10 +12,13 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { ms } from "@app/lib/ms"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { TUserDALFactory } from "@app/services/user/user-dal"; import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal"; import { DynamicSecretProviders, TDynamicProviderFns } from "../dynamic-secret/providers/models"; @@ -39,6 +43,8 @@ type TDynamicSecretLeaseServiceFactoryDep = { permissionService: Pick; projectDAL: Pick; kmsService: Pick; + userDAL: Pick; + identityDAL: TIdentityDALFactory; }; export type TDynamicSecretLeaseServiceFactory = ReturnType; @@ -52,8 +58,16 @@ export const dynamicSecretLeaseServiceFactory = ({ dynamicSecretQueueService, projectDAL, licenseService, - kmsService + kmsService, + userDAL, + identityDAL }: TDynamicSecretLeaseServiceFactoryDep) => { + const extractEmailUsername = (email: string) => { + const regex = new RE2(/^([^@]+)/); + const match = email.match(regex); + return match ? match[1] : email; + }; + const create = async ({ environmentSlug, path, @@ -132,7 +146,25 @@ export const dynamicSecretLeaseServiceFactory = ({ let result; try { - result = await selectedProvider.create(decryptedStoredInput, expireAt.getTime()); + const identity: { name: string } = { name: "" }; + if (actor === ActorType.USER) { + const user = await userDAL.findById(actorId); + if (user) { + identity.name = extractEmailUsername(user.username); + } + } else if (actor === ActorType.Machine) { + const machineIdentity = await identityDAL.findById(actorId); + if (machineIdentity) { + identity.name = machineIdentity.name; + } + } + result = await selectedProvider.create({ + inputs: decryptedStoredInput, + expireAt: expireAt.getTime(), + usernameTemplate: dynamicSecretCfg.usernameTemplate, + identity, + metadata: { projectId } + }); } catch (error: unknown) { if (error && typeof error === "object" && error !== null && "sqlMessage" in error) { throw new BadRequestError({ message: error.sqlMessage as string }); @@ -236,7 +268,8 @@ export const dynamicSecretLeaseServiceFactory = ({ const { entityId } = await selectedProvider.renew( decryptedStoredInput, dynamicSecretLease.externalEntityId, - expireAt.getTime() + expireAt.getTime(), + { projectId } ); await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id); @@ -312,7 +345,7 @@ export const dynamicSecretLeaseServiceFactory = ({ ) as object; const revokeResponse = await selectedProvider - .revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId) + .revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId, { projectId }) .catch(async (err) => { // only propogate this error if forced is false if (!isForced) return { error: err as Error }; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts index f653d0c0c..3b405a418 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts @@ -11,6 +11,8 @@ export const verifyHostInputValidity = async (host: string, isGateway = false) = if (appCfg.isDevelopmentMode) return [host]; + if (isGateway) return [host]; + const reservedHosts = [appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI)].concat( (appCfg.DB_READ_REPLICAS || []).map((el) => getDbConnectionHost(el.DB_CONNECTION_URI)), getDbConnectionHost(appCfg.REDIS_URL), @@ -58,7 +60,7 @@ export const verifyHostInputValidity = async (host: string, isGateway = false) = } } - if (!isGateway && !(appCfg.DYNAMIC_SECRET_ALLOW_INTERNAL_IP || appCfg.ALLOW_INTERNAL_IP_CONNECTIONS)) { + if (!(appCfg.DYNAMIC_SECRET_ALLOW_INTERNAL_IP || appCfg.ALLOW_INTERNAL_IP_CONNECTIONS)) { const isInternalIp = inputHostIps.some((el) => isPrivateIp(el)); if (isInternalIp) throw new BadRequestError({ message: "Invalid db host" }); } diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index 44c18b001..b502bf9f3 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -17,7 +17,8 @@ import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-fold import { TDynamicSecretLeaseDALFactory } from "../dynamic-secret-lease/dynamic-secret-lease-dal"; import { TDynamicSecretLeaseQueueServiceFactory } from "../dynamic-secret-lease/dynamic-secret-lease-queue"; -import { TProjectGatewayDALFactory } from "../gateway/project-gateway-dal"; +import { TGatewayDALFactory } from "../gateway/gateway-dal"; +import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TDynamicSecretDALFactory } from "./dynamic-secret-dal"; import { DynamicSecretStatus, @@ -44,9 +45,9 @@ type TDynamicSecretServiceFactoryDep = { licenseService: Pick; folderDAL: Pick; projectDAL: Pick; - permissionService: Pick; + permissionService: Pick; kmsService: Pick; - projectGatewayDAL: Pick; + gatewayDAL: Pick; resourceMetadataDAL: Pick; }; @@ -62,7 +63,7 @@ export const dynamicSecretServiceFactory = ({ dynamicSecretQueueService, projectDAL, kmsService, - projectGatewayDAL, + gatewayDAL, resourceMetadataDAL }: TDynamicSecretServiceFactoryDep) => { const create = async ({ @@ -77,7 +78,8 @@ export const dynamicSecretServiceFactory = ({ actorOrgId, defaultTTL, actorAuthMethod, - metadata + metadata, + usernameTemplate }: TCreateDynamicSecretDTO) => { const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); @@ -114,21 +116,37 @@ export const dynamicSecretServiceFactory = ({ throw new BadRequestError({ message: "Provided dynamic secret already exist under the folder" }); const selectedProvider = dynamicSecretProviders[provider.type]; - const inputs = await selectedProvider.validateProviderInputs(provider.inputs); + const inputs = await selectedProvider.validateProviderInputs(provider.inputs, { projectId }); let selectedGatewayId: string | null = null; - if (inputs && typeof inputs === "object" && "projectGatewayId" in inputs && inputs.projectGatewayId) { - const projectGatewayId = inputs.projectGatewayId as string; + if (inputs && typeof inputs === "object" && "gatewayId" in inputs && inputs.gatewayId) { + const gatewayId = inputs.gatewayId as string; - const projectGateway = await projectGatewayDAL.findOne({ id: projectGatewayId, projectId }); - if (!projectGateway) + const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: actorOrgId }); + + if (!gateway) { throw new NotFoundError({ - message: `Project gateway with ${projectGatewayId} not found` + message: `Gateway with ID ${gatewayId} not found` }); - selectedGatewayId = projectGateway.id; + } + + const { permission: orgPermission } = await permissionService.getOrgPermission( + actor, + actorId, + gateway.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(orgPermission).throwUnlessCan( + OrgPermissionGatewayActions.AttachGateways, + OrgPermissionSubjects.Gateway + ); + + selectedGatewayId = gateway.id; } - const isConnected = await selectedProvider.validateConnection(provider.inputs); + const isConnected = await selectedProvider.validateConnection(provider.inputs, { projectId }); if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" }); const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ @@ -146,7 +164,8 @@ export const dynamicSecretServiceFactory = ({ defaultTTL, folderId: folder.id, name, - projectGatewayId: selectedGatewayId + gatewayId: selectedGatewayId, + usernameTemplate }, tx ); @@ -182,7 +201,8 @@ export const dynamicSecretServiceFactory = ({ newName, actorOrgId, actorAuthMethod, - metadata + metadata, + usernameTemplate }: TUpdateDynamicSecretDTO) => { const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); @@ -252,26 +272,36 @@ export const dynamicSecretServiceFactory = ({ secretManagerDecryptor({ cipherTextBlob: dynamicSecretCfg.encryptedInput }).toString() ) as object; const newInput = { ...decryptedStoredInput, ...(inputs || {}) }; - const updatedInput = await selectedProvider.validateProviderInputs(newInput); + const updatedInput = await selectedProvider.validateProviderInputs(newInput, { projectId }); let selectedGatewayId: string | null = null; - if ( - updatedInput && - typeof updatedInput === "object" && - "projectGatewayId" in updatedInput && - updatedInput?.projectGatewayId - ) { - const projectGatewayId = updatedInput.projectGatewayId as string; + if (updatedInput && typeof updatedInput === "object" && "gatewayId" in updatedInput && updatedInput?.gatewayId) { + const gatewayId = updatedInput.gatewayId as string; - const projectGateway = await projectGatewayDAL.findOne({ id: projectGatewayId, projectId }); - if (!projectGateway) + const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: actorOrgId }); + if (!gateway) { throw new NotFoundError({ - message: `Project gateway with ${projectGatewayId} not found` + message: `Gateway with ID ${gatewayId} not found` }); - selectedGatewayId = projectGateway.id; + } + + const { permission: orgPermission } = await permissionService.getOrgPermission( + actor, + actorId, + gateway.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(orgPermission).throwUnlessCan( + OrgPermissionGatewayActions.AttachGateways, + OrgPermissionSubjects.Gateway + ); + + selectedGatewayId = gateway.id; } - const isConnected = await selectedProvider.validateConnection(newInput); + const isConnected = await selectedProvider.validateConnection(newInput, { projectId }); if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" }); const updatedDynamicCfg = await dynamicSecretDAL.transaction(async (tx) => { @@ -284,7 +314,8 @@ export const dynamicSecretServiceFactory = ({ defaultTTL, name: newName ?? name, status: null, - projectGatewayId: selectedGatewayId + gatewayId: selectedGatewayId, + usernameTemplate }, tx ); @@ -441,7 +472,9 @@ export const dynamicSecretServiceFactory = ({ secretManagerDecryptor({ cipherTextBlob: dynamicSecretCfg.encryptedInput }).toString() ) as object; const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; - const providerInputs = (await selectedProvider.validateProviderInputs(decryptedStoredInput)) as object; + const providerInputs = (await selectedProvider.validateProviderInputs(decryptedStoredInput, { + projectId + })) as object; return { ...dynamicSecretCfg, inputs: providerInputs }; }; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts index 58fdc2143..6720cf2c8 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts @@ -22,6 +22,7 @@ export type TCreateDynamicSecretDTO = { name: string; projectSlug: string; metadata?: ResourceMetadataDTO; + usernameTemplate?: string | null; } & Omit; export type TUpdateDynamicSecretDTO = { @@ -34,6 +35,7 @@ export type TUpdateDynamicSecretDTO = { inputs?: TProvider["inputs"]; projectSlug: string; metadata?: ResourceMetadataDTO; + usernameTemplate?: string | null; } & Omit; export type TDeleteDynamicSecretDTO = { diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts index f2907f7dc..89371f1bd 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts @@ -16,6 +16,7 @@ import { BadRequestError } from "@app/lib/errors"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { DynamicSecretAwsElastiCacheSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const CreateElastiCacheUserSchema = z.object({ UserId: z.string().trim().min(1), @@ -132,9 +133,15 @@ const generatePassword = () => { return customAlphabet(charset, 64)(); }; -const generateUsername = () => { +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"; - return `inf-${customAlphabet(charset, 32)()}`; // Username must start with an ascii letter, so we prepend the username with "inf-" + const randomUsername = `inf-${customAlphabet(charset, 32)()}`; + if (!usernameTemplate) return randomUsername; + return compileUsernameTemplate({ + usernameTemplate, + randomUsername, + identity + }); }; export const AwsElastiCacheDatabaseProvider = (): TDynamicProviderFns => { @@ -168,13 +175,21 @@ export const AwsElastiCacheDatabaseProvider = (): TDynamicProviderFns => { return true; }; - const create = async (inputs: unknown, expireAt: number) => { + 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); if (!(await validateConnection(providerInputs))) { throw new BadRequestError({ message: "Failed to establish connection" }); } - const leaseUsername = generateUsername(); + const leaseUsername = generateUsername(usernameTemplate, identity); const leasePassword = generatePassword(); const leaseExpiration = new Date(expireAt).toISOString(); 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 64ea6a02e..f7383d4ac 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts @@ -16,15 +16,26 @@ import { PutUserPolicyCommand, RemoveUserFromGroupCommand } from "@aws-sdk/client-iam"; +import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; +import { randomUUID } from "crypto"; import { z } from "zod"; +import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; -import { DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models"; +import { AwsIamAuthType, DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { + const randomUsername = alphaNumericNanoId(32); + if (!usernameTemplate) return randomUsername; + + return compileUsernameTemplate({ + usernameTemplate, + randomUsername, + identity + }); }; export const AwsIamProvider = (): TDynamicProviderFns => { @@ -33,7 +44,43 @@ export const AwsIamProvider = (): TDynamicProviderFns => { return providerInputs; }; - const $getClient = async (providerInputs: z.infer) => { + const $getClient = async (providerInputs: z.infer, projectId: string) => { + const appCfg = getConfig(); + if (providerInputs.method === AwsIamAuthType.AssumeRole) { + const stsClient = new STSClient({ + region: providerInputs.region, + 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 // if hosting on AWS + }); + + const command = new AssumeRoleCommand({ + RoleArn: providerInputs.roleArn, + RoleSessionName: `infisical-dynamic-secret-${randomUUID()}`, + DurationSeconds: 900, // 15 mins + ExternalId: projectId + }); + + const assumeRes = await stsClient.send(command); + + if (!assumeRes.Credentials?.AccessKeyId || !assumeRes.Credentials?.SecretAccessKey) { + throw new BadRequestError({ message: "Failed to assume role - verify credentials and role configuration" }); + } + const client = new IAMClient({ + region: providerInputs.region, + credentials: { + accessKeyId: assumeRes.Credentials?.AccessKeyId, + secretAccessKey: assumeRes.Credentials?.SecretAccessKey, + sessionToken: assumeRes.Credentials?.SessionToken + } + }); + return client; + } + const client = new IAMClient({ region: providerInputs.region, credentials: { @@ -45,19 +92,41 @@ export const AwsIamProvider = (): TDynamicProviderFns => { return client; }; - const validateConnection = async (inputs: unknown) => { + const validateConnection = async (inputs: unknown, { projectId }: { projectId: string }) => { const providerInputs = await validateProviderInputs(inputs); - const client = await $getClient(providerInputs); - - const isConnected = await client.send(new GetUserCommand({})).then(() => true); + const client = await $getClient(providerInputs, projectId); + const isConnected = await client + .send(new GetUserCommand({})) + .then(() => true) + .catch((err) => { + const message = (err as Error)?.message; + if ( + providerInputs.method === AwsIamAuthType.AssumeRole && + // assume role will throw an error asking to provider username, but if so this has access in aws correctly + message.includes("Must specify userName when calling with non-User credentials") + ) { + return true; + } + throw err; + }); return isConnected; }; - const create = async (inputs: unknown) => { - const providerInputs = await validateProviderInputs(inputs); - const client = await $getClient(providerInputs); + const create = async (data: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + identity?: { + name: string; + }; + metadata: { projectId: string }; + }) => { + const { inputs, usernameTemplate, metadata, identity } = data; - const username = generateUsername(); + 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 createUserRes = await client.send( new CreateUserCommand({ @@ -67,6 +136,7 @@ export const AwsIamProvider = (): TDynamicProviderFns => { UserName: username }) ); + if (!createUserRes.User) throw new BadRequestError({ message: "Failed to create AWS IAM User" }); if (userGroups) { await Promise.all( @@ -116,9 +186,9 @@ export const AwsIamProvider = (): TDynamicProviderFns => { }; }; - const revoke = async (inputs: unknown, entityId: string) => { + const revoke = async (inputs: unknown, entityId: string, metadata: { projectId: string }) => { const providerInputs = await validateProviderInputs(inputs); - const client = await $getClient(providerInputs); + const client = await $getClient(providerInputs, metadata.projectId); const username = entityId; diff --git a/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts b/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts index 17f644601..4b2232bc8 100644 --- a/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts +++ b/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts @@ -55,7 +55,7 @@ export const AzureEntraIDProvider = (): TDynamicProviderFns & { return data.success; }; - const create = async (inputs: unknown) => { + const create = async ({ inputs }: { inputs: unknown }) => { const providerInputs = await validateProviderInputs(inputs); const data = await $getToken(providerInputs.tenantId, providerInputs.applicationId, providerInputs.clientSecret); if (!data.success) { @@ -88,7 +88,7 @@ export const AzureEntraIDProvider = (): TDynamicProviderFns & { const revoke = async (inputs: unknown, entityId: string) => { // Creates a new password - await create(inputs); + await create({ inputs }); return { entityId }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts index 0b6d50146..b939dcad6 100644 --- a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts +++ b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts @@ -8,14 +8,21 @@ import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretCassandraSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const generatePassword = (size = 48) => { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + return compileUsernameTemplate({ + usernameTemplate, + randomUsername, + identity + }); }; export const CassandraProvider = (): TDynamicProviderFns => { @@ -69,11 +76,17 @@ export const CassandraProvider = (): TDynamicProviderFns => { return isConnected; }; - const create = async (inputs: unknown, expireAt: number) => { + 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 client = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate, identity); const password = generatePassword(); const { keyspace } = providerInputs; const expiration = new Date(expireAt).toISOString(); diff --git a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts index 6c1affa39..32d21ee76 100644 --- a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts +++ b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts @@ -6,14 +6,21 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretElasticSearchSchema, ElasticSearchAuthTypes, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const generatePassword = () => { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 64)(); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + return compileUsernameTemplate({ + usernameTemplate, + randomUsername, + identity + }); }; export const ElasticSearchProvider = (): TDynamicProviderFns => { @@ -64,11 +71,12 @@ export const ElasticSearchProvider = (): TDynamicProviderFns => { return infoResponse; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; usernameTemplate?: string | null; identity?: { name: string } }) => { + const { inputs, usernameTemplate, identity } = data; const providerInputs = await validateProviderInputs(inputs); const connection = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate, identity); const password = generatePassword(); await connection.security.putUser({ diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index cf6a33690..7e14cf1ab 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -7,6 +7,7 @@ import { AzureEntraIDProvider } from "./azure-entra-id"; import { CassandraProvider } from "./cassandra"; import { ElasticSearchProvider } from "./elastic-search"; import { GcpIamProvider } from "./gcp-iam"; +import { KubernetesProvider } from "./kubernetes"; import { LdapProvider } from "./ldap"; import { DynamicSecretProviders, TDynamicProviderFns } from "./models"; import { MongoAtlasProvider } from "./mongo-atlas"; @@ -17,9 +18,10 @@ import { SapAseProvider } from "./sap-ase"; import { SapHanaProvider } from "./sap-hana"; import { SqlDatabaseProvider } from "./sql-database"; import { TotpProvider } from "./totp"; +import { VerticaProvider } from "./vertica"; type TBuildDynamicSecretProviderDTO = { - gatewayService: Pick; + gatewayService: Pick; }; export const buildDynamicSecretProviders = ({ @@ -40,5 +42,7 @@ export const buildDynamicSecretProviders = ({ [DynamicSecretProviders.Snowflake]: SnowflakeProvider(), [DynamicSecretProviders.Totp]: TotpProvider(), [DynamicSecretProviders.SapAse]: SapAseProvider(), + [DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }), + [DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }), [DynamicSecretProviders.GcpIam]: GcpIamProvider() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts new file mode 100644 index 000000000..cf8f2b3e0 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts @@ -0,0 +1,620 @@ +import axios from "axios"; +import handlebars from "handlebars"; +import https from "https"; + +import { InternalServerError } from "@app/lib/errors"; +import { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; +import { TKubernetesTokenRequest } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-types"; + +import { TGatewayServiceFactory } from "../../gateway/gateway-service"; +import { + DynamicSecretKubernetesSchema, + KubernetesAuthMethod, + KubernetesCredentialType, + KubernetesRoleType, + TDynamicProviderFns +} from "./models"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +type TKubernetesProviderDTO = { + gatewayService: Pick; +}; + +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = `dynamic-secret-sa-${alphaNumericNanoId(10).toLowerCase()}`; + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); +}; + +export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretKubernetesSchema.parseAsync(inputs); + if (!providerInputs.gatewayId) { + await blockLocalAndPrivateIpAddresses(providerInputs.url); + } + + return providerInputs; + }; + + const $gatewayProxyWrapper = async ( + inputs: { + gatewayId: string; + targetHost: string; + targetPort: number; + caCert?: string; + reviewTokenThroughGateway: boolean; + enableSsl: boolean; + }, + gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise + ): Promise => { + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + + const callbackResult = await withGatewayProxy( + async (port, httpsAgent) => { + // Needs to be https protocol or the kubernetes API server will fail with "Client sent an HTTP request to an HTTPS server" + const res = await gatewayCallback( + inputs.reviewTokenThroughGateway ? "http://localhost" : "https://localhost", + port, + httpsAgent + ); + return res; + }, + { + protocol: inputs.reviewTokenThroughGateway ? GatewayProxyProtocol.Http : GatewayProxyProtocol.Tcp, + targetHost: inputs.targetHost, + targetPort: inputs.targetPort, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + }, + // we always pass this, because its needed for both tcp and http protocol + httpsAgent: new https.Agent({ + ca: inputs.caCert, + rejectUnauthorized: inputs.enableSsl + }) + } + ); + + return callbackResult; + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + + const serviceAccountDynamicCallback = async (host: string, port: number, httpsAgent?: https.Agent) => { + if (providerInputs.credentialType !== KubernetesCredentialType.Dynamic) { + throw new Error("invalid callback"); + } + + const baseUrl = port ? `${host}:${port}` : host; + const serviceAccountName = generateUsername(); + const roleBindingName = `${serviceAccountName}-role-binding`; + + // 1. Create a test service account + await axios.post( + `${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts`, + { + metadata: { + name: serviceAccountName, + namespace: providerInputs.namespace + } + }, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent + } + ); + + // 2. Create a test role binding + const roleBindingUrl = + providerInputs.roleType === KubernetesRoleType.ClusterRole + ? `${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings` + : `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.namespace}/rolebindings`; + + const roleBindingMetadata = { + name: roleBindingName, + ...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { namespace: providerInputs.namespace }) + }; + + await axios.post( + roleBindingUrl, + { + metadata: roleBindingMetadata, + roleRef: { + kind: providerInputs.roleType === KubernetesRoleType.ClusterRole ? "ClusterRole" : "Role", + name: providerInputs.role, + apiGroup: "rbac.authorization.k8s.io" + }, + subjects: [ + { + kind: "ServiceAccount", + name: serviceAccountName, + namespace: providerInputs.namespace + } + ] + }, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent + } + ); + + // 3. Request a token for the test service account + await axios.post( + `${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${serviceAccountName}/token`, + { + spec: { + expirationSeconds: 600, // 10 minutes + ...(providerInputs.audiences?.length ? { audiences: providerInputs.audiences } : {}) + } + }, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent + } + ); + + // 4. Cleanup: delete role binding and service account + if (providerInputs.roleType === KubernetesRoleType.Role) { + await axios.delete( + `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.namespace}/rolebindings/${roleBindingName}`, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent + } + ); + } else { + await axios.delete(`${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/${roleBindingName}`, { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent + }); + } + + await axios.delete( + `${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${serviceAccountName}`, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent + } + ); + }; + + const serviceAccountStaticCallback = async (host: string, port: number, httpsAgent?: https.Agent) => { + if (providerInputs.credentialType !== KubernetesCredentialType.Static) { + throw new Error("invalid callback"); + } + + const baseUrl = port ? `${host}:${port}` : host; + + await axios.get( + `${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${providerInputs.serviceAccountName}`, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent + } + ); + }; + + const url = new URL(providerInputs.url); + const k8sGatewayHost = url.hostname; + const k8sPort = url.port ? Number(url.port) : 443; + const k8sHost = `${url.protocol}//${url.hostname}`; + + try { + if (providerInputs.gatewayId) { + if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) { + await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sHost, + targetPort: k8sPort, + enableSsl: providerInputs.sslEnabled, + caCert: providerInputs.ca, + reviewTokenThroughGateway: true + }, + providerInputs.credentialType === KubernetesCredentialType.Static + ? serviceAccountStaticCallback + : serviceAccountDynamicCallback + ); + } else { + await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sGatewayHost, + targetPort: k8sPort, + enableSsl: providerInputs.sslEnabled, + caCert: providerInputs.ca, + reviewTokenThroughGateway: false + }, + providerInputs.credentialType === KubernetesCredentialType.Static + ? serviceAccountStaticCallback + : serviceAccountDynamicCallback + ); + } + } else if (providerInputs.credentialType === KubernetesCredentialType.Static) { + await serviceAccountStaticCallback(k8sHost, k8sPort); + } else { + await serviceAccountDynamicCallback(k8sHost, k8sPort); + } + + return true; + } catch (error) { + let errorMessage = error instanceof Error ? error.message : "Unknown error"; + if (axios.isAxiosError(error) && (error.response?.data as { message: string })?.message) { + errorMessage = (error.response?.data as { message: string }).message; + } + + throw new InternalServerError({ + message: `Failed to validate connection: ${errorMessage}` + }); + } + }; + + const create = async ({ + inputs, + expireAt, + usernameTemplate + }: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + }) => { + const providerInputs = await validateProviderInputs(inputs); + + const serviceAccountDynamicCallback = async (host: string, port: number, httpsAgent?: https.Agent) => { + if (providerInputs.credentialType !== KubernetesCredentialType.Dynamic) { + throw new Error("invalid callback"); + } + + const baseUrl = port ? `${host}:${port}` : host; + const serviceAccountName = generateUsername(usernameTemplate); + const roleBindingName = `${serviceAccountName}-role-binding`; + + // 1. Create the service account + await axios.post( + `${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts`, + { + metadata: { + name: serviceAccountName, + namespace: providerInputs.namespace + } + }, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent + } + ); + + // 2. Create the role binding + const roleBindingUrl = + providerInputs.roleType === KubernetesRoleType.ClusterRole + ? `${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings` + : `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.namespace}/rolebindings`; + + const roleBindingMetadata = { + name: roleBindingName, + ...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { namespace: providerInputs.namespace }) + }; + + await axios.post( + roleBindingUrl, + { + metadata: roleBindingMetadata, + roleRef: { + kind: providerInputs.roleType === KubernetesRoleType.ClusterRole ? "ClusterRole" : "Role", + name: providerInputs.role, + apiGroup: "rbac.authorization.k8s.io" + }, + subjects: [ + { + kind: "ServiceAccount", + name: serviceAccountName, + namespace: providerInputs.namespace + } + ] + }, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent + } + ); + + // 3. Request a token for the service account + const res = await axios.post( + `${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${serviceAccountName}/token`, + { + spec: { + expirationSeconds: Math.floor((expireAt - Date.now()) / 1000), + ...(providerInputs.audiences?.length ? { audiences: providerInputs.audiences } : {}) + } + }, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent + } + ); + + return { ...res.data, serviceAccountName }; + }; + + const tokenRequestStaticCallback = async (host: string, port: number, httpsAgent?: https.Agent) => { + if (providerInputs.credentialType !== KubernetesCredentialType.Static) { + throw new Error("invalid callback"); + } + + const baseUrl = port ? `${host}:${port}` : host; + + const res = await axios.post( + `${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${providerInputs.serviceAccountName}/token`, + { + spec: { + expirationSeconds: Math.floor((expireAt - Date.now()) / 1000), + ...(providerInputs.audiences?.length ? { audiences: providerInputs.audiences } : {}) + } + }, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent + } + ); + + return { ...res.data, serviceAccountName: providerInputs.serviceAccountName }; + }; + + const url = new URL(providerInputs.url); + const k8sHost = `${url.protocol}//${url.hostname}`; + const k8sGatewayHost = url.hostname; + const k8sPort = url.port ? Number(url.port) : 443; + + try { + let tokenData; + if (providerInputs.gatewayId) { + if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) { + tokenData = await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sHost, + targetPort: k8sPort, + enableSsl: providerInputs.sslEnabled, + caCert: providerInputs.ca, + reviewTokenThroughGateway: true + }, + providerInputs.credentialType === KubernetesCredentialType.Static + ? tokenRequestStaticCallback + : serviceAccountDynamicCallback + ); + } else { + tokenData = await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sGatewayHost, + targetPort: k8sPort, + enableSsl: providerInputs.sslEnabled, + caCert: providerInputs.ca, + reviewTokenThroughGateway: false + }, + providerInputs.credentialType === KubernetesCredentialType.Static + ? tokenRequestStaticCallback + : serviceAccountDynamicCallback + ); + } + } else { + tokenData = + providerInputs.credentialType === KubernetesCredentialType.Static + ? await tokenRequestStaticCallback(k8sHost, k8sPort) + : await serviceAccountDynamicCallback(k8sHost, k8sPort); + } + + return { + entityId: tokenData.serviceAccountName, + data: { TOKEN: tokenData.status.token } + }; + } catch (error) { + let errorMessage = error instanceof Error ? error.message : "Unknown error"; + if (axios.isAxiosError(error) && (error.response?.data as { message: string })?.message) { + errorMessage = (error.response?.data as { message: string }).message; + } + + throw new InternalServerError({ + message: `Failed to create dynamic secret: ${errorMessage}` + }); + } + }; + + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs); + + const serviceAccountDynamicCallback = async (host: string, port: number, httpsAgent?: https.Agent) => { + if (providerInputs.credentialType !== KubernetesCredentialType.Dynamic) { + throw new Error("invalid callback"); + } + + const baseUrl = port ? `${host}:${port}` : host; + const roleBindingName = `${entityId}-role-binding`; + + if (providerInputs.roleType === KubernetesRoleType.Role) { + await axios.delete( + `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.namespace}/rolebindings/${roleBindingName}`, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent + } + ); + } else { + await axios.delete(`${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/${roleBindingName}`, { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent + }); + } + + // Delete the service account + await axios.delete(`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${entityId}`, { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent + }); + }; + + if (providerInputs.credentialType === KubernetesCredentialType.Dynamic) { + const url = new URL(providerInputs.url); + const k8sGatewayHost = url.hostname; + const k8sPort = url.port ? Number(url.port) : 443; + const k8sHost = `${url.protocol}//${url.hostname}`; + + if (providerInputs.gatewayId) { + if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) { + await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sHost, + targetPort: k8sPort, + enableSsl: providerInputs.sslEnabled, + caCert: providerInputs.ca, + reviewTokenThroughGateway: true + }, + serviceAccountDynamicCallback + ); + } else { + await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sGatewayHost, + targetPort: k8sPort, + enableSsl: providerInputs.sslEnabled, + caCert: providerInputs.ca, + reviewTokenThroughGateway: false + }, + serviceAccountDynamicCallback + ); + } + } else { + await serviceAccountDynamicCallback(k8sHost, k8sPort); + } + } + + return { entityId }; + }; + + const renew = async (_inputs: unknown, entityId: string) => { + // No renewal necessary + return { entityId }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/ldap.ts b/backend/src/ee/services/dynamic-secret/providers/ldap.ts index cc68304e0..1de8aa1e6 100644 --- a/backend/src/ee/services/dynamic-secret/providers/ldap.ts +++ b/backend/src/ee/services/dynamic-secret/providers/ldap.ts @@ -9,6 +9,7 @@ import { BadRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { LdapCredentialType, LdapSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const generatePassword = () => { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; @@ -22,8 +23,14 @@ const encodePassword = (password?: string) => { return base64Password; }; -const generateUsername = () => { - return alphaNumericNanoId(20); +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + return compileUsernameTemplate({ + usernameTemplate, + randomUsername, + identity + }); }; const generateLDIF = ({ @@ -190,7 +197,8 @@ export const LdapProvider = (): TDynamicProviderFns => { return dnArray; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; usernameTemplate?: string | null; identity?: { name: string } }) => { + const { inputs, usernameTemplate, identity } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); @@ -217,7 +225,7 @@ export const LdapProvider = (): TDynamicProviderFns => { }); } } else { - const username = generateUsername(); + const username = generateUsername(usernameTemplate, identity); const password = generatePassword(); const generatedLdif = generateLDIF({ username, password, ldifTemplate: providerInputs.creationLdif }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index e999a65be..1594ec1c7 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -16,7 +16,13 @@ export enum SqlProviders { MySQL = "mysql2", Oracle = "oracledb", MsSQL = "mssql", - SapAse = "sap-ase" + SapAse = "sap-ase", + Vertica = "vertica" +} + +export enum AwsIamAuthType { + AssumeRole = "assume-role", + AccessKey = "access-key" } export enum ElasticSearchAuthTypes { @@ -29,6 +35,21 @@ export enum LdapCredentialType { Static = "static" } +export enum KubernetesCredentialType { + Static = "static", + Dynamic = "dynamic" +} + +export enum KubernetesRoleType { + ClusterRole = "cluster-role", + Role = "role" +} + +export enum KubernetesAuthMethod { + Gateway = "gateway", + Api = "api" +} + export enum TotpConfigType { URL = "url", MANUAL = "manual" @@ -137,7 +158,7 @@ export const DynamicSecretSqlDBSchema = z.object({ revocationStatement: z.string().trim(), renewStatement: z.string().trim().optional(), ca: z.string().optional(), - projectGatewayId: z.string().nullable().optional() + gatewayId: z.string().nullable().optional() }); export const DynamicSecretCassandraSchema = z.object({ @@ -163,16 +184,38 @@ export const DynamicSecretSapAseSchema = z.object({ revocationStatement: z.string().trim() }); -export const DynamicSecretAwsIamSchema = z.object({ - accessKey: z.string().trim().min(1), - secretAccessKey: z.string().trim().min(1), - region: z.string().trim().min(1), - awsPath: z.string().trim().optional(), - permissionBoundaryPolicyArn: z.string().trim().optional(), - policyDocument: z.string().trim().optional(), - userGroups: z.string().trim().optional(), - policyArns: z.string().trim().optional() -}); +export const DynamicSecretAwsIamSchema = z.preprocess( + (val) => { + if (typeof val === "object" && val !== null && !Object.hasOwn(val, "method")) { + // eslint-disable-next-line no-param-reassign + (val as { method: string }).method = AwsIamAuthType.AccessKey; + } + return val; + }, + z.discriminatedUnion("method", [ + z.object({ + method: z.literal(AwsIamAuthType.AccessKey), + accessKey: z.string().trim().min(1), + secretAccessKey: z.string().trim().min(1), + region: z.string().trim().min(1), + awsPath: z.string().trim().optional(), + permissionBoundaryPolicyArn: z.string().trim().optional(), + policyDocument: z.string().trim().optional(), + userGroups: z.string().trim().optional(), + policyArns: z.string().trim().optional() + }), + z.object({ + method: z.literal(AwsIamAuthType.AssumeRole), + roleArn: z.string().trim().min(1, "Role ARN required"), + region: z.string().trim().min(1), + awsPath: z.string().trim().optional(), + permissionBoundaryPolicyArn: z.string().trim().optional(), + policyDocument: z.string().trim().optional(), + userGroups: z.string().trim().optional(), + policyArns: z.string().trim().optional() + }) + ]) +); export const DynamicSecretMongoAtlasSchema = z.object({ adminPublicKey: z.string().trim().min(1).describe("Admin user public api key"), @@ -277,6 +320,84 @@ export const LdapSchema = z.union([ }) ]); +export const DynamicSecretKubernetesSchema = z + .discriminatedUnion("credentialType", [ + z.object({ + url: z.string().url().trim().min(1), + clusterToken: z.string().trim().optional(), + ca: z.string().optional(), + sslEnabled: z.boolean().default(false), + credentialType: z.literal(KubernetesCredentialType.Static), + serviceAccountName: z.string().trim().min(1), + namespace: z.string().trim().min(1), + gatewayId: z.string().optional(), + audiences: z.array(z.string().trim().min(1)), + authMethod: z.nativeEnum(KubernetesAuthMethod).default(KubernetesAuthMethod.Api) + }), + z.object({ + url: z.string().url().trim().min(1), + clusterToken: z.string().trim().optional(), + ca: z.string().optional(), + sslEnabled: z.boolean().default(false), + credentialType: z.literal(KubernetesCredentialType.Dynamic), + namespace: z.string().trim().min(1), + gatewayId: z.string().optional(), + audiences: z.array(z.string().trim().min(1)), + roleType: z.nativeEnum(KubernetesRoleType), + role: z.string().trim().min(1), + authMethod: z.nativeEnum(KubernetesAuthMethod).default(KubernetesAuthMethod.Api) + }) + ]) + .superRefine((data, ctx) => { + if (data.authMethod === KubernetesAuthMethod.Gateway && !data.gatewayId) { + ctx.addIssue({ + path: ["gatewayId"], + code: z.ZodIssueCode.custom, + message: "When auth method is set to Gateway, a gateway must be selected" + }); + } + if ((data.authMethod === KubernetesAuthMethod.Api || !data.authMethod) && !data.clusterToken) { + ctx.addIssue({ + path: ["clusterToken"], + code: z.ZodIssueCode.custom, + message: "When auth method is set to Manual Token, a cluster token must be provided" + }); + } + }); + +export const DynamicSecretVerticaSchema = z.object({ + host: z.string().trim().toLowerCase(), + port: z.number(), + username: z.string().trim(), + password: z.string().trim(), + database: z.string().trim(), + gatewayId: z.string().nullable().optional(), + creationStatement: z.string().trim(), + revocationStatement: 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") +}); + export const DynamicSecretTotpSchema = z.discriminatedUnion("configType", [ z.object({ configType: z.literal(TotpConfigType.URL), @@ -325,6 +446,8 @@ export enum DynamicSecretProviders { Snowflake = "snowflake", Totp = "totp", SapAse = "sap-ase", + Kubernetes = "kubernetes", + Vertica = "vertica", GcpIam = "gcp-iam" } @@ -344,13 +467,28 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ 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 }), + z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }), + z.object({ type: z.literal(DynamicSecretProviders.Vertica), inputs: DynamicSecretVerticaSchema }), z.object({ type: z.literal(DynamicSecretProviders.GcpIam), inputs: DynamicSecretGcpIamSchema }) ]); export type TDynamicProviderFns = { - create: (inputs: unknown, expireAt: number) => Promise<{ entityId: string; data: unknown }>; - validateConnection: (inputs: unknown) => Promise; - validateProviderInputs: (inputs: object) => Promise; - revoke: (inputs: unknown, entityId: string) => Promise<{ entityId: string }>; - renew: (inputs: unknown, entityId: string, expireAt: number) => Promise<{ entityId: string }>; + create: (arg: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + identity?: { + name: string; + }; + metadata: { projectId: string }; + }) => Promise<{ entityId: string; data: unknown }>; + validateConnection: (inputs: unknown, metadata: { projectId: string }) => Promise; + validateProviderInputs: (inputs: object, metadata: { projectId: string }) => Promise; + revoke: (inputs: unknown, entityId: string, metadata: { projectId: string }) => Promise<{ entityId: string }>; + renew: ( + inputs: unknown, + entityId: string, + expireAt: number, + metadata: { projectId: string } + ) => Promise<{ entityId: string }>; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts index 6cb414d10..6da8b4b4e 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts @@ -6,14 +6,21 @@ import { createDigestAuthRequestInterceptor } from "@app/lib/axios/digest-auth"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { DynamicSecretMongoAtlasSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const generatePassword = (size = 48) => { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { + const randomUsername = alphaNumericNanoId(32); + if (!usernameTemplate) return randomUsername; + return compileUsernameTemplate({ + usernameTemplate, + randomUsername, + identity + }); }; export const MongoAtlasProvider = (): TDynamicProviderFns => { @@ -57,11 +64,17 @@ export const MongoAtlasProvider = (): TDynamicProviderFns => { return isConnected; }; - const create = async (inputs: unknown, expireAt: number) => { + 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 client = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate, identity); const password = generatePassword(); const expiration = new Date(expireAt).toISOString(); await client({ diff --git a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts index bee29bfc4..331a355a7 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts @@ -6,14 +6,21 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretMongoDBSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const generatePassword = (size = 48) => { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { + const randomUsername = alphaNumericNanoId(32); + if (!usernameTemplate) return randomUsername; + return compileUsernameTemplate({ + usernameTemplate, + randomUsername, + identity + }); }; export const MongoDBProvider = (): TDynamicProviderFns => { @@ -53,11 +60,12 @@ export const MongoDBProvider = (): TDynamicProviderFns => { return isConnected; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; usernameTemplate?: string | null; identity?: { name: string } }) => { + const { inputs, usernameTemplate, identity } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate, identity); const password = generatePassword(); const db = client.db(providerInputs.database); diff --git a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts index f6c73ba54..76081c86c 100644 --- a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts +++ b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts @@ -8,14 +8,21 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretRabbitMqSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const generatePassword = () => { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 64)(); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + return compileUsernameTemplate({ + usernameTemplate, + randomUsername, + identity + }); }; type TCreateRabbitMQUser = { @@ -110,11 +117,12 @@ export const RabbitMqProvider = (): TDynamicProviderFns => { return infoResponse; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; usernameTemplate?: string | null; identity?: { name: string } }) => { + const { inputs, usernameTemplate, identity } = data; const providerInputs = await validateProviderInputs(inputs); const connection = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate, identity); const password = generatePassword(); await createRabbitMqUser({ diff --git a/backend/src/ee/services/dynamic-secret/providers/redis.ts b/backend/src/ee/services/dynamic-secret/providers/redis.ts index f180dd607..989ed96dc 100644 --- a/backend/src/ee/services/dynamic-secret/providers/redis.ts +++ b/backend/src/ee/services/dynamic-secret/providers/redis.ts @@ -9,14 +9,21 @@ import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretRedisDBSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const generatePassword = () => { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 64)(); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + return compileUsernameTemplate({ + usernameTemplate, + randomUsername, + identity + }); }; const executeTransactions = async (connection: Redis, commands: string[]): Promise<(string | null)[] | null> => { @@ -115,11 +122,17 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => { return pingResponse; }; - const create = async (inputs: unknown, expireAt: number) => { + 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 connection = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate, identity); const password = generatePassword(); const expiration = new Date(expireAt).toISOString(); diff --git a/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts index c832e9867..9c13d3efc 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts @@ -9,14 +9,21 @@ import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretSapAseSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const generatePassword = (size = 48) => { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(25); +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { + const randomUsername = `inf_${alphaNumericNanoId(25)}`; // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + return compileUsernameTemplate({ + usernameTemplate, + randomUsername, + identity + }); }; enum SapCommands { @@ -81,11 +88,12 @@ export const SapAseProvider = (): TDynamicProviderFns => { return true; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; usernameTemplate?: string | null; identity?: { name: string } }) => { + const { inputs, usernameTemplate, identity } = data; const providerInputs = await validateProviderInputs(inputs); - const username = `inf_${generateUsername()}`; - const password = `${generatePassword()}`; + const username = generateUsername(usernameTemplate, identity); + const password = generatePassword(); const client = await $getClient(providerInputs); const masterClient = await $getClient(providerInputs, true); diff --git a/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts b/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts index 1ad24473c..5c8a75555 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts @@ -15,14 +15,21 @@ import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretSapHanaSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const generatePassword = (size = 48) => { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + return compileUsernameTemplate({ + usernameTemplate, + randomUsername, + identity + }); }; export const SapHanaProvider = (): TDynamicProviderFns => { @@ -91,10 +98,16 @@ export const SapHanaProvider = (): TDynamicProviderFns => { return testResult; }; - const create = async (inputs: unknown, expireAt: number) => { + 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 username = generateUsername(); + const username = generateUsername(usernameTemplate, identity); const password = generatePassword(); const expiration = new Date(expireAt).toISOString(); diff --git a/backend/src/ee/services/dynamic-secret/providers/snowflake.ts b/backend/src/ee/services/dynamic-secret/providers/snowflake.ts index bea7eca89..9e97ecd30 100644 --- a/backend/src/ee/services/dynamic-secret/providers/snowflake.ts +++ b/backend/src/ee/services/dynamic-secret/providers/snowflake.ts @@ -8,6 +8,7 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { DynamicSecretSnowflakeSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; // destroy client requires callback... const noop = () => {}; @@ -17,8 +18,14 @@ const generatePassword = (size = 48) => { return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return `infisical_${alphaNumericNanoId(32)}`; // username must start with alpha character, hence prefix +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { + const randomUsername = `infisical_${alphaNumericNanoId(32)}`; // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + return compileUsernameTemplate({ + usernameTemplate, + randomUsername, + identity + }); }; const getDaysToExpiry = (expiryDate: Date) => { @@ -82,12 +89,18 @@ export const SnowflakeProvider = (): TDynamicProviderFns => { return isValidConnection; }; - const create = async (inputs: unknown, expireAt: number) => { + 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 client = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate, identity); const password = generatePassword(); try { diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index 178ca4ef9..d3217be37 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -3,13 +3,14 @@ import handlebars from "handlebars"; import knex from "knex"; import { z } from "zod"; -import { withGatewayProxy } from "@app/lib/gateway"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { TGatewayServiceFactory } from "../../gateway/gateway-service"; import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretSqlDBSchema, PasswordRequirements, SqlProviders, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; @@ -104,22 +105,34 @@ const generatePassword = (provider: SqlProviders, requirements?: PasswordRequire } }; -const generateUsername = (provider: SqlProviders) => { +const generateUsername = (provider: SqlProviders, usernameTemplate?: string | null, identity?: { name: string }) => { + let randomUsername = ""; // For oracle, the client assumes everything is upper case when not using quotes around the password - if (provider === SqlProviders.Oracle) return alphaNumericNanoId(32).toUpperCase(); - - return alphaNumericNanoId(32); + if (provider === SqlProviders.Oracle) { + randomUsername = alphaNumericNanoId(32).toUpperCase(); + } else { + randomUsername = alphaNumericNanoId(32); + } + if (!usernameTemplate) return randomUsername; + return compileUsernameTemplate({ + usernameTemplate, + randomUsername, + identity, + options: { + toUpperCase: provider === SqlProviders.Oracle + } + }); }; type TSqlDatabaseProviderDTO = { - gatewayService: Pick; + gatewayService: Pick; }; export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretSqlDBSchema.parseAsync(inputs); - const [hostIp] = await verifyHostInputValidity(providerInputs.host, Boolean(providerInputs.projectGatewayId)); + const [hostIp] = await verifyHostInputValidity(providerInputs.host, Boolean(providerInputs.gatewayId)); validateHandlebarTemplate("SQL creation", providerInputs.creationStatement, { allowedExpressions: (val) => ["username", "password", "expiration", "database"].includes(val) }); @@ -168,13 +181,14 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) providerInputs: z.infer, gatewayCallback: (host: string, port: number) => Promise ) => { - const relayDetails = await gatewayService.fnGetGatewayClientTls(providerInputs.projectGatewayId as string); + 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, @@ -202,7 +216,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) await db.destroy(); }; - if (providerInputs.projectGatewayId) { + if (providerInputs.gatewayId) { await gatewayProxyWrapper(providerInputs, gatewayCallback); } else { await gatewayCallback(); @@ -210,9 +224,17 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) return isConnected; }; - const create = async (inputs: unknown, expireAt: number) => { + 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 username = generateUsername(providerInputs.client); + const username = generateUsername(providerInputs.client, usernameTemplate, identity); + const password = generatePassword(providerInputs.client, providerInputs.passwordRequirements); const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { const db = await $getClient({ ...providerInputs, port, host }); @@ -238,7 +260,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) await db.destroy(); } }; - if (providerInputs.projectGatewayId) { + if (providerInputs.gatewayId) { await gatewayProxyWrapper(providerInputs, gatewayCallback); } else { await gatewayCallback(); @@ -265,7 +287,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) await db.destroy(); } }; - if (providerInputs.projectGatewayId) { + if (providerInputs.gatewayId) { await gatewayProxyWrapper(providerInputs, gatewayCallback); } else { await gatewayCallback(); @@ -301,7 +323,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) await db.destroy(); } }; - if (providerInputs.projectGatewayId) { + if (providerInputs.gatewayId) { await gatewayProxyWrapper(providerInputs, gatewayCallback); } else { await gatewayCallback(); diff --git a/backend/src/ee/services/dynamic-secret/providers/templateUtils.ts b/backend/src/ee/services/dynamic-secret/providers/templateUtils.ts new file mode 100644 index 000000000..70a083dbf --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/templateUtils.ts @@ -0,0 +1,80 @@ +/* eslint-disable func-names */ +import handlebars from "handlebars"; +import RE2 from "re2"; + +import { logger } from "@app/lib/logger"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +export const compileUsernameTemplate = ({ + usernameTemplate, + randomUsername, + identity, + unixTimestamp, + options +}: { + usernameTemplate: string; + randomUsername: string; + identity?: { name: string }; + unixTimestamp?: number; + options?: { + toUpperCase?: boolean; + }; +}): string => { + // Create isolated handlebars instance + const hbs = handlebars.create(); + + // Register random helper on local instance + hbs.registerHelper("random", function (length: number) { + if (typeof length !== "number" || length <= 0 || length > 100) { + return ""; + } + return alphaNumericNanoId(length); + }); + + // Register replace helper on local instance + hbs.registerHelper("replace", function (text: string, searchValue: string, replaceValue: string) { + // Convert to string if it's not already + const textStr = String(text || ""); + if (!textStr) { + return textStr; + } + + try { + const re2Pattern = new RE2(searchValue, "g"); + // Replace all occurrences + return re2Pattern.replace(textStr, replaceValue); + } catch (error) { + logger.error(error, "RE2 pattern failed, using original template"); + return textStr; + } + }); + + // Register truncate helper on local instance + hbs.registerHelper("truncate", function (text: string, length: number) { + // Convert to string if it's not already + const textStr = String(text || ""); + if (!textStr) { + return textStr; + } + + if (typeof length !== "number" || length <= 0) return textStr; + return textStr.substring(0, length); + }); + + // Compile template with context using local instance + const context = { + randomUsername, + unixTimestamp: unixTimestamp || Math.floor(Date.now() / 100), + identity: { + name: identity?.name + } + }; + + const result = hbs.compile(usernameTemplate)(context); + + if (options?.toUpperCase) { + return result.toUpperCase(); + } + + return result; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/vertica.ts b/backend/src/ee/services/dynamic-secret/providers/vertica.ts new file mode 100644 index 000000000..e361ab329 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/vertica.ts @@ -0,0 +1,368 @@ +import { randomInt } from "crypto"; +import handlebars from "handlebars"; +import knex, { Knex } from "knex"; +import { z } from "zod"; + +import { BadRequestError } from "@app/lib/errors"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { logger } from "@app/lib/logger"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; + +import { TGatewayServiceFactory } from "../../gateway/gateway-service"; +import { verifyHostInputValidity } from "../dynamic-secret-fns"; +import { DynamicSecretVerticaSchema, PasswordRequirements, TDynamicProviderFns } from "./models"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +interface VersionResult { + version: string; +} + +interface SessionResult { + session_id?: string; +} + +interface DatabaseQueryResult { + rows?: Array>; +} + +// Extended Knex client interface to handle Vertica-specific overrides +interface VerticaKnexClient extends Knex { + client: { + parseVersion?: () => string; + }; +} + +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[randomInt(chars.lowercase.length)]) + ); + } + + if (required.uppercase > 0) { + parts.push( + ...Array(required.uppercase) + .fill(0) + .map(() => chars.uppercase[randomInt(chars.uppercase.length)]) + ); + } + + if (required.digits > 0) { + parts.push( + ...Array(required.digits) + .fill(0) + .map(() => chars.digits[randomInt(chars.digits.length)]) + ); + } + + if (required.symbols > 0) { + parts.push( + ...Array(required.symbols) + .fill(0) + .map(() => chars.symbols[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[randomInt(allowedChars.length)]) + ); + + // shuffle the array to mix up the characters + for (let i = parts.length - 1; i > 0; i -= 1) { + const j = 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) => { + const randomUsername = `inf_${alphaNumericNanoId(25)}`; // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); +}; + +type TVerticaProviderDTO = { + gatewayService: Pick; +}; + +export const VerticaProvider = ({ gatewayService }: TVerticaProviderDTO): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretVerticaSchema.parseAsync(inputs); + + const [hostIp] = await verifyHostInputValidity(providerInputs.host, Boolean(providerInputs.gatewayId)); + validateHandlebarTemplate("Vertica creation", providerInputs.creationStatement, { + allowedExpressions: (val) => ["username", "password"].includes(val) + }); + if (providerInputs.revocationStatement) { + validateHandlebarTemplate("Vertica revoke", providerInputs.revocationStatement, { + allowedExpressions: (val) => ["username"].includes(val) + }); + } + return { ...providerInputs, hostIp }; + }; + + const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { + const config = { + client: "pg", + connection: { + host: providerInputs.hostIp, + port: providerInputs.port, + database: providerInputs.database, + user: providerInputs.username, + password: providerInputs.password, + ssl: false + }, + acquireConnectionTimeout: EXTERNAL_REQUEST_TIMEOUT, + pool: { + min: 0, + max: 1, + acquireTimeoutMillis: 30000, + createTimeoutMillis: 30000, + destroyTimeoutMillis: 5000, + idleTimeoutMillis: 30000, + reapIntervalMillis: 1000, + createRetryIntervalMillis: 100 + }, + // Disable version checking for Vertica compatibility + version: "9.6.0" // Fake a compatible PostgreSQL version + }; + + const client = knex(config) as VerticaKnexClient; + + // Override the version parsing to prevent errors with Vertica + if (client.client && typeof client.client.parseVersion !== "undefined") { + client.client.parseVersion = () => "9.6.0"; + } + + return client; + }; + + const gatewayProxyWrapper = async ( + providerInputs: z.infer, + gatewayCallback: (host: string, port: number) => Promise + ) => { + 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.hostIp, port = providerInputs.port) => { + let client: VerticaKnexClient | null = null; + + try { + client = await $getClient({ ...providerInputs, hostIp: host, port }); + + const clientResult: DatabaseQueryResult = await client.raw("SELECT version() AS version"); + + const resultFromSelectedDatabase = clientResult.rows?.[0] as VersionResult | undefined; + + if (!resultFromSelectedDatabase?.version) { + throw new BadRequestError({ + message: "Failed to validate Vertica connection, version query failed" + }); + } + + isConnected = true; + } finally { + if (client) await client.destroy(); + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + + return isConnected; + }; + + const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { + const { inputs, usernameTemplate } = data; + const providerInputs = await validateProviderInputs(inputs); + + const username = generateUsername(usernameTemplate); + const password = generatePassword(providerInputs.passwordRequirements); + + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + let client: VerticaKnexClient | null = null; + + try { + client = await $getClient({ ...providerInputs, hostIp: host, port }); + + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password + }); + + const queries = creationStatement.trim().replaceAll("\n", "").split(";").filter(Boolean); + + // Execute queries sequentially to maintain transaction integrity + for (const query of queries) { + const trimmedQuery = query.trim(); + if (trimmedQuery) { + // eslint-disable-next-line no-await-in-loop + await client.raw(trimmedQuery); + } + } + } finally { + if (client) await client.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, username: string) => { + const providerInputs = await validateProviderInputs(inputs); + + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + let client: VerticaKnexClient | null = null; + + try { + client = await $getClient({ ...providerInputs, hostIp: host, port }); + + const revokeStatement = handlebars.compile(providerInputs.revocationStatement, { noEscape: true })({ + username + }); + + const queries = revokeStatement.trim().replaceAll("\n", "").split(";").filter(Boolean); + + // Check for active sessions and close them + try { + const sessionResult: DatabaseQueryResult = await client.raw( + "SELECT session_id FROM sessions WHERE user_name = ?", + [username] + ); + + const activeSessions = (sessionResult.rows || []) as SessionResult[]; + + // Close all sessions in parallel since they're independent operations + if (activeSessions.length > 0) { + const sessionClosePromises = activeSessions.map(async (session) => { + try { + await client!.raw("SELECT close_session(?)", [session.session_id]); + } catch (error) { + // Continue if session is already closed + logger.error(error, `Failed to close session ${session.session_id}`); + } + }); + + await Promise.allSettled(sessionClosePromises); + } + } catch (error) { + // Continue if we can't query sessions (permissions, etc.) + logger.error(error, "Could not query/close active sessions"); + } + + // Execute revocation queries sequentially to maintain transaction integrity + for (const query of queries) { + const trimmedQuery = query.trim(); + if (trimmedQuery) { + // eslint-disable-next-line no-await-in-loop + await client.raw(trimmedQuery); + } + } + } finally { + if (client) await client.destroy(); + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + + return { entityId: username }; + }; + + const renew = async (_: unknown, username: string) => { + // No need for renewal + return { entityId: username }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/gateway/gateway-dal.ts b/backend/src/ee/services/gateway/gateway-dal.ts index fbf5558e4..31b4b727b 100644 --- a/backend/src/ee/services/gateway/gateway-dal.ts +++ b/backend/src/ee/services/gateway/gateway-dal.ts @@ -1,37 +1,34 @@ -import { Knex } from "knex"; - import { TDbClient } from "@app/db"; import { GatewaysSchema, TableName, TGateways } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { - buildFindFilter, - ormify, - selectAllTableCols, - sqlNestRelationships, - TFindFilter, - TFindOpt -} from "@app/lib/knex"; +import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex"; export type TGatewayDALFactory = ReturnType; export const gatewayDALFactory = (db: TDbClient) => { const orm = ormify(db, TableName.Gateway); - const find = async (filter: TFindFilter, { offset, limit, sort, tx }: TFindOpt = {}) => { + const find = async ( + filter: TFindFilter & { orgId?: string }, + { offset, limit, sort, tx }: TFindOpt = {} + ) => { try { const query = (tx || db)(TableName.Gateway) // eslint-disable-next-line @typescript-eslint/no-misused-promises - .where(buildFindFilter(filter)) + .where(buildFindFilter(filter, TableName.Gateway, ["orgId"])) .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Gateway}.identityId`) - .leftJoin(TableName.ProjectGateway, `${TableName.ProjectGateway}.gatewayId`, `${TableName.Gateway}.id`) - .leftJoin(TableName.Project, `${TableName.Project}.id`, `${TableName.ProjectGateway}.projectId`) + .join( + TableName.IdentityOrgMembership, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Gateway}.identityId` + ) .select(selectAllTableCols(TableName.Gateway)) - .select( - db.ref("name").withSchema(TableName.Identity).as("identityName"), - db.ref("name").withSchema(TableName.Project).as("projectName"), - db.ref("slug").withSchema(TableName.Project).as("projectSlug"), - db.ref("id").withSchema(TableName.Project).as("projectId") - ); + .select(db.ref("orgId").withSchema(TableName.IdentityOrgMembership).as("identityOrgId")) + .select(db.ref("name").withSchema(TableName.Identity).as("identityName")); + + if (filter.orgId) { + void query.where(`${TableName.IdentityOrgMembership}.orgId`, filter.orgId); + } if (limit) void query.limit(limit); if (offset) void query.offset(offset); if (sort) { @@ -39,48 +36,16 @@ export const gatewayDALFactory = (db: TDbClient) => { } const docs = await query; - return sqlNestRelationships({ - data: docs, - key: "id", - parentMapper: (data) => ({ - ...GatewaysSchema.parse(data), - identity: { id: data.identityId, name: data.identityName } - }), - childrenMapper: [ - { - key: "projectId", - label: "projects" as const, - mapper: ({ projectId, projectName, projectSlug }) => ({ - id: projectId, - name: projectName, - slug: projectSlug - }) - } - ] - }); + + return docs.map((el) => ({ + ...GatewaysSchema.parse(el), + orgId: el.identityOrgId as string, // todo(daniel): figure out why typescript is not inferring this as a string + identity: { id: el.identityId, name: el.identityName } + })); } catch (error) { throw new DatabaseError({ error, name: `${TableName.Gateway}: Find` }); } }; - const findByProjectId = async (projectId: string, tx?: Knex) => { - try { - const query = (tx || db)(TableName.Gateway) - .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Gateway}.identityId`) - .join(TableName.ProjectGateway, `${TableName.ProjectGateway}.gatewayId`, `${TableName.Gateway}.id`) - .select(selectAllTableCols(TableName.Gateway)) - .select( - db.ref("name").withSchema(TableName.Identity).as("identityName"), - db.ref("id").withSchema(TableName.ProjectGateway).as("projectGatewayId") - ) - .where({ [`${TableName.ProjectGateway}.projectId` as "projectId"]: projectId }); - - const docs = await query; - return docs.map((el) => ({ ...el, identity: { id: el.identityId, name: el.identityName } })); - } catch (error) { - throw new DatabaseError({ error, name: `${TableName.Gateway}: Find by project id` }); - } - }; - - return { ...orm, find, findByProjectId }; + return { ...orm, find }; }; diff --git a/backend/src/ee/services/gateway/gateway-service.ts b/backend/src/ee/services/gateway/gateway-service.ts index 5a17bc028..25f0b384a 100644 --- a/backend/src/ee/services/gateway/gateway-service.ts +++ b/backend/src/ee/services/gateway/gateway-service.ts @@ -4,7 +4,6 @@ import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import { z } from "zod"; -import { ActionProjectType } from "@app/db/schemas"; import { KeyStorePrefixes, PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -27,17 +26,14 @@ import { TGatewayDALFactory } from "./gateway-dal"; import { TExchangeAllocatedRelayAddressDTO, TGetGatewayByIdDTO, - TGetProjectGatewayByIdDTO, THeartBeatDTO, TListGatewaysDTO, TUpdateGatewayByIdDTO } from "./gateway-types"; import { TOrgGatewayConfigDALFactory } from "./org-gateway-config-dal"; -import { TProjectGatewayDALFactory } from "./project-gateway-dal"; type TGatewayServiceFactoryDep = { gatewayDAL: TGatewayDALFactory; - projectGatewayDAL: TProjectGatewayDALFactory; orgGatewayConfigDAL: Pick; licenseService: Pick; kmsService: Pick; @@ -57,8 +53,7 @@ export const gatewayServiceFactory = ({ kmsService, permissionService, orgGatewayConfigDAL, - keyStore, - projectGatewayDAL + keyStore }: TGatewayServiceFactoryDep) => { const $validateOrgAccessToGateway = async (orgId: string, actorId: string, actorAuthMethod: ActorAuthMethod) => { // if (!licenseService.onPremFeatures.gateway) { @@ -526,7 +521,7 @@ export const gatewayServiceFactory = ({ return gateway; }; - const updateGatewayById = async ({ orgPermission, id, name, projectIds }: TUpdateGatewayByIdDTO) => { + const updateGatewayById = async ({ orgPermission, id, name }: TUpdateGatewayByIdDTO) => { const { permission } = await permissionService.getOrgPermission( orgPermission.type, orgPermission.id, @@ -543,15 +538,6 @@ export const gatewayServiceFactory = ({ const [gateway] = await gatewayDAL.update({ id, orgGatewayRootCaId: orgGatewayConfig.id }, { name }); if (!gateway) throw new NotFoundError({ message: `Gateway with ID ${id} not found.` }); - if (projectIds) { - await projectGatewayDAL.transaction(async (tx) => { - await projectGatewayDAL.delete({ gatewayId: gateway.id }, tx); - await projectGatewayDAL.insertMany( - projectIds.map((el) => ({ gatewayId: gateway.id, projectId: el })), - tx - ); - }); - } return gateway; }; @@ -576,27 +562,7 @@ export const gatewayServiceFactory = ({ return gateway; }; - const getProjectGateways = async ({ projectId, projectPermission }: TGetProjectGatewayByIdDTO) => { - await permissionService.getProjectPermission({ - projectId, - actor: projectPermission.type, - actorId: projectPermission.id, - actorOrgId: projectPermission.orgId, - actorAuthMethod: projectPermission.authMethod, - actionProjectType: ActionProjectType.Any - }); - - const gateways = await gatewayDAL.findByProjectId(projectId); - return gateways; - }; - - // this has no permission check and used for dynamic secrets directly - // assumes permission check is already done - const fnGetGatewayClientTls = async (projectGatewayId: string) => { - const projectGateway = await projectGatewayDAL.findById(projectGatewayId); - if (!projectGateway) throw new NotFoundError({ message: `Project gateway with ID ${projectGatewayId} not found.` }); - - const { gatewayId } = projectGateway; + const fnGetGatewayClientTlsByGatewayId = async (gatewayId: string) => { const gateway = await gatewayDAL.findById(gatewayId); if (!gateway) throw new NotFoundError({ message: `Gateway with ID ${gatewayId} not found.` }); @@ -645,8 +611,7 @@ export const gatewayServiceFactory = ({ getGatewayById, updateGatewayById, deleteGatewayById, - getProjectGateways, - fnGetGatewayClientTls, + fnGetGatewayClientTlsByGatewayId, heartbeat }; }; diff --git a/backend/src/ee/services/gateway/gateway-types.ts b/backend/src/ee/services/gateway/gateway-types.ts index 220dc7147..823028154 100644 --- a/backend/src/ee/services/gateway/gateway-types.ts +++ b/backend/src/ee/services/gateway/gateway-types.ts @@ -20,7 +20,6 @@ export type TGetGatewayByIdDTO = { export type TUpdateGatewayByIdDTO = { id: string; name?: string; - projectIds?: string[]; orgPermission: OrgServiceActor; }; diff --git a/backend/src/ee/services/gateway/project-gateway-dal.ts b/backend/src/ee/services/gateway/project-gateway-dal.ts deleted file mode 100644 index 44c36f5f6..000000000 --- a/backend/src/ee/services/gateway/project-gateway-dal.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; - -export type TProjectGatewayDALFactory = ReturnType; - -export const projectGatewayDALFactory = (db: TDbClient) => { - const orm = ormify(db, TableName.ProjectGateway); - return orm; -}; diff --git a/backend/src/ee/services/github-org-sync/github-org-sync-service.ts b/backend/src/ee/services/github-org-sync/github-org-sync-service.ts index 22a078399..867feb4a4 100644 --- a/backend/src/ee/services/github-org-sync/github-org-sync-service.ts +++ b/backend/src/ee/services/github-org-sync/github-org-sync-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; import { Octokit } from "@octokit/core"; -import { paginateGraphQL } from "@octokit/plugin-paginate-graphql"; +import { paginateGraphql } from "@octokit/plugin-paginate-graphql"; import { Octokit as OctokitRest } from "@octokit/rest"; import { OrgMembershipRole } from "@app/db/schemas"; @@ -18,7 +18,7 @@ import { TPermissionServiceFactory } from "../permission/permission-service"; import { TGithubOrgSyncDALFactory } from "./github-org-sync-dal"; import { TCreateGithubOrgSyncDTO, TDeleteGithubOrgSyncDTO, TUpdateGithubOrgSyncDTO } from "./github-org-sync-types"; -const OctokitWithPlugin = Octokit.plugin(paginateGraphQL); +const OctokitWithPlugin = Octokit.plugin(paginateGraphql); type TGithubOrgSyncServiceFactoryDep = { githubOrgSyncDAL: TGithubOrgSyncDALFactory; diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 2458454da..801f52fc0 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -111,9 +111,9 @@ export const groupDALFactory = (db: TDbClient) => { } if (search) { - void query.andWhereRaw(`CONCAT_WS(' ', "firstName", "lastName", "username") ilike ?`, [`%${search}%`]); + void query.andWhereRaw(`CONCAT_WS(' ', "firstName", "lastName", lower("username")) ilike ?`, [`%${search}%`]); } else if (username) { - void query.andWhere(`${TableName.Users}.username`, "ilike", `%${username}%`); + void query.andWhereRaw(`lower("${TableName.Users}"."username") ilike ?`, `%${username}%`); } switch (filter) { @@ -157,10 +157,23 @@ export const groupDALFactory = (db: TDbClient) => { } }; + const findGroupsByProjectId = async (projectId: string, tx?: Knex) => { + try { + const docs = await (tx || db.replicaNode())(TableName.Groups) + .join(TableName.GroupProjectMembership, `${TableName.Groups}.id`, `${TableName.GroupProjectMembership}.groupId`) + .where(`${TableName.GroupProjectMembership}.projectId`, projectId) + .select(selectAllTableCols(TableName.Groups)); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "Find groups by project id" }); + } + }; + return { findGroups, findByOrgId, findAllGroupPossibleMembers, + findGroupsByProjectId, ...groupOrm }; }; diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index b9206771e..cc3125918 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -30,7 +30,7 @@ import { import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal"; type TGroupServiceFactoryDep = { - userDAL: Pick; + userDAL: Pick; groupDAL: Pick< TGroupDALFactory, "create" | "findOne" | "update" | "delete" | "findAllGroupPossibleMembers" | "findById" | "transaction" @@ -380,7 +380,10 @@ export const groupServiceFactory = ({ details: { missingPermissions: permissionBoundary.missingPermissions } }); - const user = await userDAL.findOne({ username }); + const usersWithUsername = await userDAL.findUserByUsername(username); + // akhilmhdh: case sensitive email resolution + const user = + usersWithUsername?.length > 1 ? usersWithUsername.find((el) => el.username === username) : usersWithUsername?.[0]; if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` }); const users = await addUsersToGroupByUserIds({ @@ -461,7 +464,10 @@ export const groupServiceFactory = ({ details: { missingPermissions: permissionBoundary.missingPermissions } }); - const user = await userDAL.findOne({ username }); + const usersWithUsername = await userDAL.findUserByUsername(username); + // akhilmhdh: case sensitive email resolution + const user = + usersWithUsername?.length > 1 ? usersWithUsername.find((el) => el.username === username) : usersWithUsername?.[0]; if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` }); const users = await removeUsersFromGroupByUserIds({ diff --git a/backend/src/ee/services/group/group-types.ts b/backend/src/ee/services/group/group-types.ts index 9424075ca..1d7c5fc71 100644 --- a/backend/src/ee/services/group/group-types.ts +++ b/backend/src/ee/services/group/group-types.ts @@ -42,6 +42,10 @@ export type TListGroupUsersDTO = { filter?: EFilterReturnedUsers; } & TGenericPermission; +export type TListProjectGroupUsersDTO = TListGroupUsersDTO & { + projectId: string; +}; + export type TAddUserToGroupDTO = { id: string; username: string; diff --git a/backend/src/ee/services/group/user-group-membership-dal.ts b/backend/src/ee/services/group/user-group-membership-dal.ts index be654b338..5ee97e457 100644 --- a/backend/src/ee/services/group/user-group-membership-dal.ts +++ b/backend/src/ee/services/group/user-group-membership-dal.ts @@ -176,7 +176,8 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => { db.ref("name").withSchema(TableName.Groups).as("groupName"), db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId"), db.ref("firstName").withSchema(TableName.Users).as("firstName"), - db.ref("lastName").withSchema(TableName.Users).as("lastName") + db.ref("lastName").withSchema(TableName.Users).as("lastName"), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug") ); return docs; diff --git a/backend/src/ee/services/hsm/hsm-fns.ts b/backend/src/ee/services/hsm/hsm-fns.ts index ef975a371..8eec7ceb7 100644 --- a/backend/src/ee/services/hsm/hsm-fns.ts +++ b/backend/src/ee/services/hsm/hsm-fns.ts @@ -24,9 +24,13 @@ export const initializeHsmModule = (envConfig: Pick; + export type TCreateLdapCfgDTO = { orgId: string; isActive: boolean; diff --git a/backend/src/ee/services/ldap-config/ldap-fns.ts b/backend/src/ee/services/ldap-config/ldap-fns.ts index 44af718ed..01b70b4db 100644 --- a/backend/src/ee/services/ldap-config/ldap-fns.ts +++ b/backend/src/ee/services/ldap-config/ldap-fns.ts @@ -2,15 +2,14 @@ import ldapjs from "ldapjs"; import { logger } from "@app/lib/logger"; -import { TLDAPConfig } from "./ldap-config-types"; +import { TLDAPConfig, TTestLDAPConfigDTO } from "./ldap-config-types"; export const isValidLdapFilter = (filter: string) => { try { ldapjs.parseFilter(filter); return true; } catch (error) { - logger.error("Invalid LDAP filter"); - logger.error(error); + logger.error(error, "Invalid LDAP filter"); return false; } }; @@ -20,7 +19,7 @@ export const isValidLdapFilter = (filter: string) => { * @param ldapConfig - The LDAP configuration to test * @returns {Boolean} isConnected - Whether or not the connection was successful */ -export const testLDAPConfig = async (ldapConfig: TLDAPConfig): Promise => { +export const testLDAPConfig = async (ldapConfig: TTestLDAPConfigDTO): Promise => { return new Promise((resolve) => { const ldapClient = ldapjs.createClient({ url: ldapConfig.url, diff --git a/backend/src/ee/services/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts index 6a8f807ad..5259d4616 100644 --- a/backend/src/ee/services/license/__mocks__/license-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -29,7 +29,9 @@ export const getDefaultOnPremFeatures = () => { secretApproval: true, secretRotation: true, caCrl: false, - sshHostGroups: false + sshHostGroups: false, + enterpriseSecretSyncs: false, + enterpriseAppConnections: false }; }; diff --git a/backend/src/ee/services/license/license-dal.ts b/backend/src/ee/services/license/license-dal.ts index cab428e86..88a2dadf6 100644 --- a/backend/src/ee/services/license/license-dal.ts +++ b/backend/src/ee/services/license/license-dal.ts @@ -19,7 +19,7 @@ export const licenseDALFactory = (db: TDbClient) => { .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) .where(`${TableName.Users}.isGhost`, false) .count(); - return Number(doc?.[0].count); + return Number(doc?.[0]?.count ?? 0); } catch (error) { throw new DatabaseError({ error, name: "Count of Org Members" }); } diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index b7ae6f7ee..c2db3e6e7 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -2,6 +2,7 @@ import axios, { AxiosError } from "axios"; import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; +import { logger } from "@app/lib/logger"; import { TFeatureSet } from "./license-types"; @@ -54,15 +55,25 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ projectTemplates: false, kmip: false, gateway: false, - sshHostGroups: false + sshHostGroups: false, + secretScanning: false, + enterpriseSecretSyncs: false, + enterpriseAppConnections: false }); -export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { +export const setupLicenseRequestWithStore = ( + baseURL: string, + refreshUrl: string, + licenseKey: string, + region?: string +) => { let token: string; const licenseReq = axios.create({ baseURL, - timeout: 35 * 1000 - // signal: AbortSignal.timeout(60 * 1000) + timeout: 35 * 1000, + headers: { + "x-region": region + } }); const refreshLicense = async () => { @@ -98,9 +109,10 @@ export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string (response) => response, async (err) => { const originalRequest = (err as AxiosError).config; - + const errStatusCode = Number((err as AxiosError)?.response?.status); + logger.error((err as AxiosError)?.response?.data, "License server call error"); // eslint-disable-next-line - if ((err as AxiosError)?.response?.status === 401 && !(originalRequest as any)._retry) { + if ((errStatusCode === 401 || errStatusCode === 403) && !(originalRequest as any)._retry) { // eslint-disable-next-line (originalRequest as any)._retry = true; // injected diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index cf9818658..80e58815e 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -17,7 +17,7 @@ import { TIdentityOrgDALFactory } from "@app/services/identity/identity-org-dal" import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; -import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { OrgPermissionBillingActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { BillingPlanRows, BillingPlanTableHead } from "./licence-enums"; import { TLicenseDALFactory } from "./license-dal"; @@ -77,13 +77,15 @@ export const licenseServiceFactory = ({ const licenseServerCloudApi = setupLicenseRequestWithStore( appCfg.LICENSE_SERVER_URL || "", LICENSE_SERVER_CLOUD_LOGIN, - appCfg.LICENSE_SERVER_KEY || "" + appCfg.LICENSE_SERVER_KEY || "", + appCfg.INTERNAL_REGION ); const licenseServerOnPremApi = setupLicenseRequestWithStore( appCfg.LICENSE_SERVER_URL || "", LICENSE_SERVER_ON_PREM_LOGIN, - appCfg.LICENSE_KEY || "" + appCfg.LICENSE_KEY || "", + appCfg.INTERNAL_REGION ); const syncLicenseKeyOnPremFeatures = async (shouldThrow: boolean = false) => { @@ -92,6 +94,10 @@ export const licenseServiceFactory = ({ const { data: { currentPlan } } = await licenseServerOnPremApi.request.get<{ currentPlan: TFeatureSet }>("/api/license/v1/plan"); + + const workspacesUsed = await projectDAL.countOfOrgProjects(null); + currentPlan.workspacesUsed = workspacesUsed; + onPremFeatures = currentPlan; logger.info("Successfully synchronized license key features"); } catch (error) { @@ -185,6 +191,14 @@ export const licenseServiceFactory = ({ } = await licenseServerCloudApi.request.get<{ currentPlan: TFeatureSet }>( `/api/license-server/v1/customers/${org.customerId}/cloud-plan` ); + const workspacesUsed = await projectDAL.countOfOrgProjects(orgId); + currentPlan.workspacesUsed = workspacesUsed; + + const membersUsed = await licenseDAL.countOfOrgMembers(orgId); + currentPlan.membersUsed = membersUsed; + const identityUsed = await licenseDAL.countOrgUsersAndIdentities(orgId); + currentPlan.identitiesUsed = identityUsed; + await keyStore.setItemWithExpiry( FEATURE_CACHE_KEY(org.id), LICENSE_SERVER_CLOUD_PLAN_TTL, @@ -274,7 +288,7 @@ export const licenseServiceFactory = ({ billingCycle }: TOrgPlansTableDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); const { data } = await licenseServerCloudApi.request.get( `/api/license-server/v1/cloud-products?billing-cycle=${billingCycle}` ); @@ -296,8 +310,10 @@ export const licenseServiceFactory = ({ success_url }: TStartOrgTrialDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Billing); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionBillingActions.ManageBilling, + OrgPermissionSubjects.Billing + ); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -324,8 +340,10 @@ export const licenseServiceFactory = ({ actorOrgId }: TCreateOrgPortalSession) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Billing); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionBillingActions.ManageBilling, + OrgPermissionSubjects.Billing + ); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -348,8 +366,8 @@ export const licenseServiceFactory = ({ } = await licenseServerCloudApi.request.post( `/api/license-server/v1/customers/${organization.customerId}/billing-details/payment-methods`, { - success_url: `${appCfg.SITE_URL}/dashboard`, - cancel_url: `${appCfg.SITE_URL}/dashboard` + success_url: `${appCfg.SITE_URL}/organization/billing`, + cancel_url: `${appCfg.SITE_URL}/organization/billing` } ); @@ -362,7 +380,7 @@ export const licenseServiceFactory = ({ } = await licenseServerCloudApi.request.post( `/api/license-server/v1/customers/${organization.customerId}/billing-details/billing-portal`, { - return_url: `${appCfg.SITE_URL}/dashboard` + return_url: `${appCfg.SITE_URL}/organization/billing` } ); @@ -371,7 +389,7 @@ export const licenseServiceFactory = ({ const getOrgBillingInfo = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -379,7 +397,7 @@ export const licenseServiceFactory = ({ message: `Organization with ID '${orgId}' not found` }); } - if (instanceType !== InstanceType.OnPrem && instanceType !== InstanceType.EnterpriseOnPremOffline) { + if (instanceType === InstanceType.Cloud) { const { data } = await licenseServerCloudApi.request.get( `/api/license-server/v1/customers/${organization.customerId}/cloud-plan/billing` ); @@ -399,7 +417,7 @@ export const licenseServiceFactory = ({ // returns org current plan feature table const getOrgPlanTable = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -407,11 +425,38 @@ export const licenseServiceFactory = ({ message: `Organization with ID '${orgId}' not found` }); } - if (instanceType !== InstanceType.OnPrem && instanceType !== InstanceType.EnterpriseOnPremOffline) { - const { data } = await licenseServerCloudApi.request.get( - `/api/license-server/v1/customers/${organization.customerId}/cloud-plan/table` - ); - return data; + + const orgMembersUsed = await orgDAL.countAllOrgMembers(orgId); + const identityUsed = await identityOrgMembershipDAL.countAllOrgIdentities({ orgId }); + const projects = await projectDAL.find({ orgId }); + const projectCount = projects.length; + + if (instanceType === InstanceType.Cloud) { + const { data } = await licenseServerCloudApi.request.get<{ + head: { name: string }[]; + rows: { name: string; allowed: boolean }[]; + }>(`/api/license-server/v1/customers/${organization.customerId}/cloud-plan/table`); + + const formattedData = { + head: data.head, + rows: data.rows.map((el) => { + let used = "-"; + + if (el.name === BillingPlanRows.MemberLimit.name) { + used = orgMembersUsed.toString(); + } else if (el.name === BillingPlanRows.WorkspaceLimit.name) { + used = projectCount.toString(); + } else if (el.name === BillingPlanRows.IdentityLimit.name) { + used = (identityUsed + orgMembersUsed).toString(); + } + + return { + ...el, + used + }; + }) + }; + return formattedData; } const mappedRows = await Promise.all( @@ -420,14 +465,11 @@ export const licenseServiceFactory = ({ let used = "-"; if (field === BillingPlanRows.MemberLimit.field) { - const orgMemberships = await orgDAL.countAllOrgMembers(orgId); - used = orgMemberships.toString(); + used = orgMembersUsed.toString(); } else if (field === BillingPlanRows.WorkspaceLimit.field) { - const projects = await projectDAL.find({ orgId }); - used = projects.length.toString(); + used = projectCount.toString(); } else if (field === BillingPlanRows.IdentityLimit.field) { - const identities = await identityOrgMembershipDAL.countAllOrgIdentities({ orgId }); - used = identities.toString(); + used = identityUsed.toString(); } return { @@ -446,7 +488,7 @@ export const licenseServiceFactory = ({ const getOrgBillingDetails = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -471,7 +513,10 @@ export const licenseServiceFactory = ({ email }: TUpdateOrgBillingDetailsDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionBillingActions.ManageBilling, + OrgPermissionSubjects.Billing + ); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -491,7 +536,7 @@ export const licenseServiceFactory = ({ const getOrgPmtMethods = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TOrgPmtMethodsDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -518,7 +563,10 @@ export const licenseServiceFactory = ({ cancel_url }: TAddOrgPmtMethodDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionBillingActions.ManageBilling, + OrgPermissionSubjects.Billing + ); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -547,7 +595,10 @@ export const licenseServiceFactory = ({ pmtMethodId }: TDelOrgPmtMethodDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionBillingActions.ManageBilling, + OrgPermissionSubjects.Billing + ); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -564,7 +615,7 @@ export const licenseServiceFactory = ({ const getOrgTaxIds = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgTaxIdDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -582,7 +633,10 @@ export const licenseServiceFactory = ({ const addOrgTaxId = async ({ actorId, actor, actorAuthMethod, actorOrgId, orgId, type, value }: TAddOrgTaxIdDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionBillingActions.ManageBilling, + OrgPermissionSubjects.Billing + ); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -603,7 +657,10 @@ export const licenseServiceFactory = ({ const delOrgTaxId = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId, taxId }: TDelOrgTaxIdDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionBillingActions.ManageBilling, + OrgPermissionSubjects.Billing + ); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -620,7 +677,7 @@ export const licenseServiceFactory = ({ const getOrgTaxInvoices = async ({ actorId, actor, actorOrgId, actorAuthMethod, orgId }: TOrgInvoiceDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -637,7 +694,7 @@ export const licenseServiceFactory = ({ const getOrgLicenses = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TOrgLicensesDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -652,6 +709,10 @@ export const licenseServiceFactory = ({ return licenses; }; + const invalidateGetPlan = async (orgId: string) => { + await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); + }; + return { generateOrgCustomerId, removeOrgCustomer, @@ -666,6 +727,7 @@ export const licenseServiceFactory = ({ return onPremFeatures; }, getPlan, + invalidateGetPlan, updateSubscriptionOrgMemberCount, refreshPlan, getOrgPlan, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 358849fb2..2937ac265 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -27,7 +27,7 @@ export type TFeatureSet = { slug: null; tier: -1; workspaceLimit: null; - workspacesUsed: 0; + workspacesUsed: number; dynamicSecret: false; memberLimit: null; membersUsed: number; @@ -72,6 +72,9 @@ export type TFeatureSet = { kmip: false; gateway: false; sshHostGroups: false; + secretScanning: false; + enterpriseSecretSyncs: false; + enterpriseAppConnections: false; }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index bc60dff25..d933835e4 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -171,8 +171,8 @@ export const oidcConfigServiceFactory = ({ }; const oidcLogin = async ({ - externalId, email, + externalId, firstName, lastName, orgId, @@ -714,13 +714,15 @@ export const oidcConfigServiceFactory = ({ } } + const groups = typeof claims.groups === "string" ? [claims.groups] : (claims.groups as string[] | undefined); + oidcLogin({ - email: claims.email, + email: claims.email.toLowerCase(), externalId: claims.sub, firstName: claims.given_name ?? "", lastName: claims.family_name ?? "", orgId: org.id, - groups: claims.groups as string[] | undefined, + groups, callbackPort, manageGroupMemberships: oidcCfg.manageGroupMemberships }) diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts new file mode 100644 index 000000000..40c5310ae --- /dev/null +++ b/backend/src/ee/services/permission/default-roles.ts @@ -0,0 +1,541 @@ +import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability"; + +import { + ProjectPermissionActions, + ProjectPermissionCertificateActions, + ProjectPermissionCmekActions, + ProjectPermissionDynamicSecretActions, + ProjectPermissionGroupActions, + ProjectPermissionIdentityActions, + ProjectPermissionKmipActions, + ProjectPermissionMemberActions, + ProjectPermissionPkiSubscriberActions, + ProjectPermissionPkiTemplateActions, + ProjectPermissionSecretActions, + ProjectPermissionSecretRotationActions, + ProjectPermissionSecretScanningConfigActions, + ProjectPermissionSecretScanningDataSourceActions, + ProjectPermissionSecretScanningFindingActions, + ProjectPermissionSecretSyncActions, + ProjectPermissionSet, + ProjectPermissionSshHostActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; + +const buildAdminPermissionRules = () => { + const { can, rules } = new AbilityBuilder>(createMongoAbility); + + // Admins get full access to everything + [ + ProjectPermissionSub.SecretFolders, + ProjectPermissionSub.SecretImports, + ProjectPermissionSub.Role, + ProjectPermissionSub.Integrations, + ProjectPermissionSub.Webhooks, + ProjectPermissionSub.ServiceTokens, + ProjectPermissionSub.Settings, + ProjectPermissionSub.Environments, + ProjectPermissionSub.Tags, + ProjectPermissionSub.AuditLogs, + ProjectPermissionSub.IpAllowList, + ProjectPermissionSub.CertificateAuthorities, + ProjectPermissionSub.PkiAlerts, + ProjectPermissionSub.PkiCollections, + ProjectPermissionSub.SshCertificateAuthorities, + ProjectPermissionSub.SshCertificates, + ProjectPermissionSub.SshCertificateTemplates, + ProjectPermissionSub.SshHostGroups + ].forEach((el) => { + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + el + ); + }); + + can( + [ + ProjectPermissionPkiTemplateActions.Read, + ProjectPermissionPkiTemplateActions.Edit, + ProjectPermissionPkiTemplateActions.Create, + ProjectPermissionPkiTemplateActions.Delete, + ProjectPermissionPkiTemplateActions.IssueCert, + ProjectPermissionPkiTemplateActions.ListCerts + ], + ProjectPermissionSub.CertificateTemplates + ); + + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + ProjectPermissionSub.SecretApproval + ); + + can( + [ + ProjectPermissionCertificateActions.Read, + ProjectPermissionCertificateActions.Edit, + ProjectPermissionCertificateActions.Create, + ProjectPermissionCertificateActions.Delete, + ProjectPermissionCertificateActions.ReadPrivateKey + ], + ProjectPermissionSub.Certificates + ); + + can( + [ + ProjectPermissionSshHostActions.Edit, + ProjectPermissionSshHostActions.Read, + ProjectPermissionSshHostActions.Create, + ProjectPermissionSshHostActions.Delete, + ProjectPermissionSshHostActions.IssueHostCert + ], + ProjectPermissionSub.SshHosts + ); + + can( + [ + ProjectPermissionPkiSubscriberActions.Edit, + ProjectPermissionPkiSubscriberActions.Read, + ProjectPermissionPkiSubscriberActions.Create, + ProjectPermissionPkiSubscriberActions.Delete, + ProjectPermissionPkiSubscriberActions.IssueCert, + ProjectPermissionPkiSubscriberActions.ListCerts + ], + ProjectPermissionSub.PkiSubscribers + ); + + can( + [ + ProjectPermissionMemberActions.Create, + ProjectPermissionMemberActions.Edit, + ProjectPermissionMemberActions.Delete, + ProjectPermissionMemberActions.Read, + ProjectPermissionMemberActions.GrantPrivileges, + ProjectPermissionMemberActions.AssumePrivileges + ], + ProjectPermissionSub.Member + ); + + can( + [ + ProjectPermissionGroupActions.Create, + ProjectPermissionGroupActions.Edit, + ProjectPermissionGroupActions.Delete, + ProjectPermissionGroupActions.Read, + ProjectPermissionGroupActions.GrantPrivileges + ], + ProjectPermissionSub.Groups + ); + + can( + [ + ProjectPermissionIdentityActions.Create, + ProjectPermissionIdentityActions.Edit, + ProjectPermissionIdentityActions.Delete, + ProjectPermissionIdentityActions.Read, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionIdentityActions.AssumePrivileges + ], + ProjectPermissionSub.Identity + ); + + can( + [ + ProjectPermissionSecretActions.DescribeSecret, + ProjectPermissionSecretActions.DescribeAndReadValue, + ProjectPermissionSecretActions.ReadValue, + ProjectPermissionSecretActions.Create, + ProjectPermissionSecretActions.Edit, + ProjectPermissionSecretActions.Delete + ], + ProjectPermissionSub.Secrets + ); + + can( + [ + ProjectPermissionDynamicSecretActions.ReadRootCredential, + ProjectPermissionDynamicSecretActions.EditRootCredential, + ProjectPermissionDynamicSecretActions.CreateRootCredential, + ProjectPermissionDynamicSecretActions.DeleteRootCredential, + ProjectPermissionDynamicSecretActions.Lease + ], + ProjectPermissionSub.DynamicSecrets + ); + + can([ProjectPermissionActions.Edit, ProjectPermissionActions.Delete], ProjectPermissionSub.Project); + can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback); + can([ProjectPermissionActions.Edit], ProjectPermissionSub.Kms); + can( + [ + ProjectPermissionCmekActions.Create, + ProjectPermissionCmekActions.Edit, + ProjectPermissionCmekActions.Delete, + ProjectPermissionCmekActions.Read, + ProjectPermissionCmekActions.Encrypt, + ProjectPermissionCmekActions.Decrypt, + ProjectPermissionCmekActions.Sign, + ProjectPermissionCmekActions.Verify + ], + ProjectPermissionSub.Cmek + ); + can( + [ + ProjectPermissionSecretSyncActions.Create, + ProjectPermissionSecretSyncActions.Edit, + ProjectPermissionSecretSyncActions.Delete, + ProjectPermissionSecretSyncActions.Read, + ProjectPermissionSecretSyncActions.SyncSecrets, + ProjectPermissionSecretSyncActions.ImportSecrets, + ProjectPermissionSecretSyncActions.RemoveSecrets + ], + ProjectPermissionSub.SecretSyncs + ); + + can( + [ + ProjectPermissionKmipActions.CreateClients, + ProjectPermissionKmipActions.UpdateClients, + ProjectPermissionKmipActions.DeleteClients, + ProjectPermissionKmipActions.ReadClients, + ProjectPermissionKmipActions.GenerateClientCertificates + ], + ProjectPermissionSub.Kmip + ); + + can( + [ + ProjectPermissionSecretRotationActions.Create, + ProjectPermissionSecretRotationActions.Edit, + ProjectPermissionSecretRotationActions.Delete, + ProjectPermissionSecretRotationActions.Read, + ProjectPermissionSecretRotationActions.ReadGeneratedCredentials, + ProjectPermissionSecretRotationActions.RotateSecrets + ], + ProjectPermissionSub.SecretRotation + ); + + can( + [ + ProjectPermissionSecretScanningDataSourceActions.Create, + ProjectPermissionSecretScanningDataSourceActions.Edit, + ProjectPermissionSecretScanningDataSourceActions.Delete, + ProjectPermissionSecretScanningDataSourceActions.Read, + ProjectPermissionSecretScanningDataSourceActions.TriggerScans, + ProjectPermissionSecretScanningDataSourceActions.ReadScans, + ProjectPermissionSecretScanningDataSourceActions.ReadResources + ], + ProjectPermissionSub.SecretScanningDataSources + ); + + can( + [ProjectPermissionSecretScanningFindingActions.Read, ProjectPermissionSecretScanningFindingActions.Update], + ProjectPermissionSub.SecretScanningFindings + ); + + can( + [ProjectPermissionSecretScanningConfigActions.Read, ProjectPermissionSecretScanningConfigActions.Update], + ProjectPermissionSub.SecretScanningConfigs + ); + + return rules; +}; + +const buildMemberPermissionRules = () => { + const { can, rules } = new AbilityBuilder>(createMongoAbility); + + can( + [ + ProjectPermissionSecretActions.DescribeSecret, + ProjectPermissionSecretActions.DescribeAndReadValue, + ProjectPermissionSecretActions.ReadValue, + ProjectPermissionSecretActions.Edit, + ProjectPermissionSecretActions.Create, + ProjectPermissionSecretActions.Delete + ], + ProjectPermissionSub.Secrets + ); + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + ProjectPermissionSub.SecretFolders + ); + can( + [ + ProjectPermissionDynamicSecretActions.ReadRootCredential, + ProjectPermissionDynamicSecretActions.EditRootCredential, + ProjectPermissionDynamicSecretActions.CreateRootCredential, + ProjectPermissionDynamicSecretActions.DeleteRootCredential, + ProjectPermissionDynamicSecretActions.Lease + ], + ProjectPermissionSub.DynamicSecrets + ); + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + ProjectPermissionSub.SecretImports + ); + + can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretApproval); + can([ProjectPermissionSecretRotationActions.Read], ProjectPermissionSub.SecretRotation); + + can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback); + + can([ProjectPermissionMemberActions.Read, ProjectPermissionMemberActions.Create], ProjectPermissionSub.Member); + + can([ProjectPermissionGroupActions.Read], ProjectPermissionSub.Groups); + + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + ProjectPermissionSub.Integrations + ); + + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + ProjectPermissionSub.Webhooks + ); + + can( + [ + ProjectPermissionIdentityActions.Read, + ProjectPermissionIdentityActions.Edit, + ProjectPermissionIdentityActions.Create, + ProjectPermissionIdentityActions.Delete + ], + ProjectPermissionSub.Identity + ); + + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + ProjectPermissionSub.ServiceTokens + ); + + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + ProjectPermissionSub.Settings + ); + + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + ProjectPermissionSub.Environments + ); + + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + ProjectPermissionSub.Tags + ); + + can([ProjectPermissionActions.Read], ProjectPermissionSub.Role); + can([ProjectPermissionActions.Read], ProjectPermissionSub.AuditLogs); + can([ProjectPermissionActions.Read], ProjectPermissionSub.IpAllowList); + + // double check if all CRUD are needed for CA and Certificates + can([ProjectPermissionActions.Read], ProjectPermissionSub.CertificateAuthorities); + + can( + [ + ProjectPermissionCertificateActions.Read, + ProjectPermissionCertificateActions.Edit, + ProjectPermissionCertificateActions.Create, + ProjectPermissionCertificateActions.Delete + ], + ProjectPermissionSub.Certificates + ); + + can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates); + + can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); + can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); + + can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificates); + can([ProjectPermissionActions.Create], ProjectPermissionSub.SshCertificates); + can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateTemplates); + + can([ProjectPermissionSshHostActions.Read], ProjectPermissionSub.SshHosts); + can([ProjectPermissionPkiSubscriberActions.Read], ProjectPermissionSub.PkiSubscribers); + + can( + [ + ProjectPermissionCmekActions.Create, + ProjectPermissionCmekActions.Edit, + ProjectPermissionCmekActions.Delete, + ProjectPermissionCmekActions.Read, + ProjectPermissionCmekActions.Encrypt, + ProjectPermissionCmekActions.Decrypt, + ProjectPermissionCmekActions.Sign, + ProjectPermissionCmekActions.Verify + ], + ProjectPermissionSub.Cmek + ); + + can( + [ + ProjectPermissionSecretSyncActions.Create, + ProjectPermissionSecretSyncActions.Edit, + ProjectPermissionSecretSyncActions.Delete, + ProjectPermissionSecretSyncActions.Read, + ProjectPermissionSecretSyncActions.SyncSecrets, + ProjectPermissionSecretSyncActions.ImportSecrets, + ProjectPermissionSecretSyncActions.RemoveSecrets + ], + ProjectPermissionSub.SecretSyncs + ); + + can( + [ + ProjectPermissionSecretScanningDataSourceActions.Read, + ProjectPermissionSecretScanningDataSourceActions.TriggerScans, + ProjectPermissionSecretScanningDataSourceActions.ReadScans, + ProjectPermissionSecretScanningDataSourceActions.ReadResources + ], + ProjectPermissionSub.SecretScanningDataSources + ); + + can( + [ProjectPermissionSecretScanningFindingActions.Read, ProjectPermissionSecretScanningFindingActions.Update], + ProjectPermissionSub.SecretScanningFindings + ); + + can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); + + return rules; +}; + +const buildViewerPermissionRules = () => { + const { can, rules } = new AbilityBuilder>(createMongoAbility); + + can( + [ProjectPermissionSecretActions.DescribeSecret, ProjectPermissionSecretActions.ReadValue], + ProjectPermissionSub.Secrets + ); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretFolders); + can(ProjectPermissionDynamicSecretActions.ReadRootCredential, ProjectPermissionSub.DynamicSecrets); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretImports); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); + can(ProjectPermissionSecretRotationActions.Read, ProjectPermissionSub.SecretRotation); + can(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); + can(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks); + can(ProjectPermissionIdentityActions.Read, ProjectPermissionSub.Identity); + can(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); + can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); + can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); + can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); + can(ProjectPermissionCertificateActions.Read, ProjectPermissionSub.Certificates); + can(ProjectPermissionPkiTemplateActions.Read, ProjectPermissionSub.CertificateTemplates); + can(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates); + can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs); + + can( + [ + ProjectPermissionSecretScanningDataSourceActions.Read, + ProjectPermissionSecretScanningDataSourceActions.ReadScans, + ProjectPermissionSecretScanningDataSourceActions.ReadResources + ], + ProjectPermissionSub.SecretScanningDataSources + ); + + can([ProjectPermissionSecretScanningFindingActions.Read], ProjectPermissionSub.SecretScanningFindings); + + can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); + + return rules; +}; + +const buildNoAccessProjectPermission = () => { + const { rules } = new AbilityBuilder>(createMongoAbility); + return rules; +}; + +const buildSshHostBootstrapPermissionRules = () => { + const { can, rules } = new AbilityBuilder>(createMongoAbility); + + can( + [ProjectPermissionSshHostActions.Create, ProjectPermissionSshHostActions.IssueHostCert], + ProjectPermissionSub.SshHosts + ); + + return rules; +}; + +const buildCryptographicOperatorPermissionRules = () => { + const { can, rules } = new AbilityBuilder>(createMongoAbility); + + can( + [ + ProjectPermissionCmekActions.Encrypt, + ProjectPermissionCmekActions.Decrypt, + ProjectPermissionCmekActions.Sign, + ProjectPermissionCmekActions.Verify + ], + ProjectPermissionSub.Cmek + ); + + return rules; +}; + +// General +export const projectAdminPermissions = buildAdminPermissionRules(); +export const projectMemberPermissions = buildMemberPermissionRules(); +export const projectViewerPermission = buildViewerPermissionRules(); +export const projectNoAccessPermissions = buildNoAccessProjectPermission(); + +// SSH +export const sshHostBootstrapPermissions = buildSshHostBootstrapPermissionRules(); + +// KMS +export const cryptographicOperatorPermissions = buildCryptographicOperatorPermissionRules(); diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 7026899c7..f0fe73d71 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -41,7 +41,8 @@ export enum OrgPermissionGatewayActions { CreateGateways = "create-gateways", ListGateways = "list-gateways", EditGateways = "edit-gateways", - DeleteGateways = "delete-gateways" + DeleteGateways = "delete-gateways", + AttachGateways = "attach-gateways" } export enum OrgPermissionIdentityActions { @@ -66,6 +67,11 @@ export enum OrgPermissionGroupActions { RemoveMembers = "remove-members" } +export enum OrgPermissionBillingActions { + Read = "read", + ManageBilling = "manage-billing" +} + export enum OrgPermissionSubjects { Workspace = "workspace", Role = "role", @@ -106,7 +112,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Ldap] | [OrgPermissionGroupActions, OrgPermissionSubjects.Groups] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] - | [OrgPermissionActions, OrgPermissionSubjects.Billing] + | [OrgPermissionBillingActions, OrgPermissionSubjects.Billing] | [OrgPermissionIdentityActions, OrgPermissionSubjects.Identity] | [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] @@ -297,10 +303,8 @@ const buildAdminPermission = () => { can(OrgPermissionGroupActions.AddMembers, OrgPermissionSubjects.Groups); can(OrgPermissionGroupActions.RemoveMembers, OrgPermissionSubjects.Groups); - can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); - can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing); - can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); - can(OrgPermissionActions.Delete, OrgPermissionSubjects.Billing); + can(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); + can(OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing); can(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); can(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); @@ -337,6 +341,7 @@ const buildAdminPermission = () => { can(OrgPermissionGatewayActions.CreateGateways, OrgPermissionSubjects.Gateway); can(OrgPermissionGatewayActions.EditGateways, OrgPermissionSubjects.Gateway); can(OrgPermissionGatewayActions.DeleteGateways, OrgPermissionSubjects.Gateway); + can(OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway); can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole); @@ -360,7 +365,7 @@ const buildMemberPermission = () => { can(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); can(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); - can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + can(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); can(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); @@ -378,6 +383,7 @@ const buildMemberPermission = () => { can(OrgPermissionAppConnectionActions.Connect, OrgPermissionSubjects.AppConnections); can(OrgPermissionGatewayActions.ListGateways, OrgPermissionSubjects.Gateway); can(OrgPermissionGatewayActions.CreateGateways, OrgPermissionSubjects.Gateway); + can(OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway); return rules; }; diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index 891d7193e..7a17108a2 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -132,7 +132,7 @@ export const permissionDALFactory = (db: TDbClient) => { } }; - const getProjectGroupPermissions = async (projectId: string) => { + const getProjectGroupPermissions = async (projectId: string, filterGroupId?: string) => { try { const docs = await db .replicaNode()(TableName.GroupProjectMembership) @@ -148,6 +148,11 @@ export const permissionDALFactory = (db: TDbClient) => { `groupCustomRoles.id` ) .where(`${TableName.GroupProjectMembership}.projectId`, "=", projectId) + .where((bd) => { + if (filterGroupId) { + void bd.where(`${TableName.GroupProjectMembership}.groupId`, "=", filterGroupId); + } + }) .select( db.ref("id").withSchema(TableName.GroupProjectMembership).as("membershipId"), db.ref("id").withSchema(TableName.Groups).as("groupId"), diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 3d2f96f82..a1acaeb21 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -12,6 +12,14 @@ import { TIdentityProjectMemberships, TProjectMemberships } from "@app/db/schemas"; +import { + cryptographicOperatorPermissions, + projectAdminPermissions, + projectMemberPermissions, + projectNoAccessPermissions, + projectViewerPermission, + sshHostBootstrapPermissions +} from "@app/ee/services/permission/default-roles"; import { conditionsMatcher } from "@app/lib/casl"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { objectify } from "@app/lib/fn"; @@ -32,14 +40,7 @@ import { TGetServiceTokenProjectPermissionArg, TGetUserProjectPermissionArg } from "./permission-service-types"; -import { - buildServiceTokenProjectPermission, - projectAdminPermissions, - projectMemberPermissions, - projectNoAccessPermissions, - ProjectPermissionSet, - projectViewerPermission -} from "./project-permission"; +import { buildServiceTokenProjectPermission, ProjectPermissionSet } from "./project-permission"; type TPermissionServiceFactoryDep = { orgRoleDAL: Pick; @@ -95,6 +96,10 @@ export const permissionServiceFactory = ({ return projectViewerPermission; case ProjectMembershipRole.NoAccess: return projectNoAccessPermissions; + case ProjectMembershipRole.SshHostBootstrapper: + return sshHostBootstrapPermissions; + case ProjectMembershipRole.KmsCryptographicOperator: + return cryptographicOperatorPermissions; case ProjectMembershipRole.Custom: { return unpackRules>>( permissions as PackRule>>[] @@ -625,6 +630,34 @@ export const permissionServiceFactory = ({ return { permission }; }; + const checkGroupProjectPermission = async ({ + groupId, + projectId, + checkPermissions + }: { + groupId: string; + projectId: string; + checkPermissions: ProjectPermissionSet; + }) => { + const rawGroupProjectPermissions = await permissionDAL.getProjectGroupPermissions(projectId, groupId); + const groupPermissions = rawGroupProjectPermissions.map((groupProjectPermission) => { + const rolePermissions = + groupProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || []; + const rules = buildProjectPermissionRules(rolePermissions); + const permission = createMongoAbility(rules, { + conditionsMatcher + }); + + return { + permission, + id: groupProjectPermission.groupId, + name: groupProjectPermission.username, + membershipId: groupProjectPermission.id + }; + }); + return groupPermissions.some((groupPermission) => groupPermission.permission.can(...checkPermissions)); + }; + return { getUserOrgPermission, getOrgPermission, @@ -634,6 +667,7 @@ export const permissionServiceFactory = ({ getOrgPermissionByRole, getProjectPermissionByRole, buildOrgPermission, - buildProjectPermissionRules + buildProjectPermissionRules, + checkGroupProjectPermission }; }; diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 993653045..d1ae69574 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -87,6 +87,24 @@ export enum ProjectPermissionSshHostActions { IssueHostCert = "issue-host-cert" } +export enum ProjectPermissionPkiTemplateActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + IssueCert = "issue-cert", + ListCerts = "list-certs" +} + +export enum ProjectPermissionPkiSubscriberActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + IssueCert = "issue-cert", + ListCerts = "list-certs" +} + export enum ProjectPermissionSecretSyncActions { Read = "read", Create = "create", @@ -114,6 +132,26 @@ export enum ProjectPermissionKmipActions { GenerateClientCertificates = "generate-client-certificates" } +export enum ProjectPermissionSecretScanningDataSourceActions { + Read = "read-data-sources", + Create = "create-data-sources", + Edit = "edit-data-sources", + Delete = "delete-data-sources", + TriggerScans = "trigger-data-source-scans", + ReadScans = "read-data-source-scans", + ReadResources = "read-data-source-resources" +} + +export enum ProjectPermissionSecretScanningFindingActions { + Read = "read-findings", + Update = "update-findings" +} + +export enum ProjectPermissionSecretScanningConfigActions { + Read = "read-configs", + Update = "update-configs" +} + export enum ProjectPermissionSub { Role = "role", Member = "member", @@ -143,12 +181,16 @@ export enum ProjectPermissionSub { SshCertificateTemplates = "ssh-certificate-templates", SshHosts = "ssh-hosts", SshHostGroups = "ssh-host-groups", + PkiSubscribers = "pki-subscribers", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", Cmek = "cmek", SecretSyncs = "secret-syncs", - Kmip = "kmip" + Kmip = "kmip", + SecretScanningDataSources = "secret-scanning-data-sources", + SecretScanningFindings = "secret-scanning-findings", + SecretScanningConfigs = "secret-scanning-configs" } export type SecretSubjectFields = { @@ -190,6 +232,16 @@ export type SshHostSubjectFields = { hostname: string; }; +export type PkiTemplateSubjectFields = { + name: string; + // (dangtony98): consider adding [commonName] as a subject field in the future +}; + +export type PkiSubscriberSubjectFields = { + name: string; + // (dangtony98): consider adding [commonName] as a subject field in the future +}; + export type ProjectPermissionSet = | [ ProjectPermissionSecretActions, @@ -241,7 +293,13 @@ export type ProjectPermissionSet = ] | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionCertificateActions, ProjectPermissionSub.Certificates] - | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] + | [ + ProjectPermissionPkiTemplateActions, + ( + | ProjectPermissionSub.CertificateTemplates + | (ForcedSubject & PkiTemplateSubjectFields) + ) + ] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] @@ -249,6 +307,13 @@ export type ProjectPermissionSet = ProjectPermissionSshHostActions, ProjectPermissionSub.SshHosts | (ForcedSubject & SshHostSubjectFields) ] + | [ + ProjectPermissionPkiSubscriberActions, + ( + | ProjectPermissionSub.PkiSubscribers + | (ForcedSubject & PkiSubscriberSubjectFields) + ) + ] | [ProjectPermissionActions, ProjectPermissionSub.SshHostGroups] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] @@ -259,7 +324,10 @@ export type ProjectPermissionSet = | [ProjectPermissionActions.Edit, ProjectPermissionSub.Project] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] | [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback] - | [ProjectPermissionActions.Edit, ProjectPermissionSub.Kms]; + | [ProjectPermissionActions.Edit, ProjectPermissionSub.Kms] + | [ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSub.SecretScanningDataSources] + | [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings] + | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]; const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'"; const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([ @@ -308,7 +376,8 @@ const DynamicSecretConditionV2Schema = z .object({ [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], - [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] }) .partial() ]), @@ -336,6 +405,23 @@ const DynamicSecretConditionV2Schema = z }) .partial(); +const SecretImportConditionSchema = z + .object({ + environment: 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() + ]), + secretPath: SECRET_PATH_PERMISSION_OPERATOR_SCHEMA + }) + .partial(); + const SecretConditionV2Schema = z .object({ environment: z.union([ @@ -399,6 +485,36 @@ const SshHostConditionSchema = z }) .partial(); +const PkiSubscriberConditionSchema = z + .object({ + name: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + }) + .partial() + ]) + }) + .partial(); + +const PkiTemplateConditionSchema = z + .object({ + name: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + }) + .partial() + ]) + }) + .partial(); + const GeneralPermissionSchema = [ z.object({ subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."), @@ -490,12 +606,6 @@ const GeneralPermissionSchema = [ "Describe what action an entity can take." ) }), - z.object({ - subject: z.literal(ProjectPermissionSub.CertificateTemplates).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( - "Describe what action an entity can take." - ) - }), z.object({ subject: z .literal(ProjectPermissionSub.SshCertificateAuthorities) @@ -565,10 +675,30 @@ const GeneralPermissionSchema = [ action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionKmipActions).describe( "Describe what action an entity can take." ) + }), + z.object({ + subject: z + .literal(ProjectPermissionSub.SecretScanningDataSources) + .describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretScanningDataSourceActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.SecretScanningFindings).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretScanningFindingActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.SecretScanningConfigs).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretScanningConfigActions).describe( + "Describe what action an entity can take." + ) }) ]; -// Do not update this schema anymore, as it's kept purely for backwards compatability. Update V2 schema only. +// Do not update this schema anymore, as it's kept purely for backwards compatibility. Update V2 schema only. export const ProjectPermissionV1Schema = z.discriminatedUnion("subject", [ z.object({ subject: z.literal(ProjectPermissionSub.Secrets).describe("The entity this permission pertains to."), @@ -629,7 +759,7 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( "Describe what action an entity can take." ), - conditions: SecretConditionV1Schema.describe( + conditions: SecretImportConditionSchema.describe( "When specified, only matching conditions will be allowed to access given resource." ).optional() }), @@ -663,6 +793,26 @@ 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.PkiSubscribers).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPkiSubscriberActions).describe( + "Describe what action an entity can take." + ), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + conditions: PkiSubscriberConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), + z.object({ + subject: z.literal(ProjectPermissionSub.CertificateTemplates).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPkiTemplateActions).describe( + "Describe what action an entity can take." + ), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + conditions: PkiTemplateConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), z.object({ subject: z.literal(ProjectPermissionSub.SecretRotation).describe("The entity this permission pertains to."), inverted: z.boolean().optional().describe("Whether rule allows or forbids."), @@ -673,408 +823,12 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + ...GeneralPermissionSchema ]); export type TProjectPermissionV2Schema = z.infer; -const buildAdminPermissionRules = () => { - const { can, rules } = new AbilityBuilder>(createMongoAbility); - - // Admins get full access to everything - [ - ProjectPermissionSub.SecretFolders, - ProjectPermissionSub.SecretImports, - ProjectPermissionSub.SecretApproval, - ProjectPermissionSub.Role, - ProjectPermissionSub.Integrations, - ProjectPermissionSub.Webhooks, - ProjectPermissionSub.ServiceTokens, - ProjectPermissionSub.Settings, - ProjectPermissionSub.Environments, - ProjectPermissionSub.Tags, - ProjectPermissionSub.AuditLogs, - ProjectPermissionSub.IpAllowList, - ProjectPermissionSub.CertificateAuthorities, - ProjectPermissionSub.CertificateTemplates, - ProjectPermissionSub.PkiAlerts, - ProjectPermissionSub.PkiCollections, - ProjectPermissionSub.SshCertificateAuthorities, - ProjectPermissionSub.SshCertificates, - ProjectPermissionSub.SshCertificateTemplates, - ProjectPermissionSub.SshHostGroups - ].forEach((el) => { - can( - [ - ProjectPermissionActions.Read, - ProjectPermissionActions.Edit, - ProjectPermissionActions.Create, - ProjectPermissionActions.Delete - ], - el - ); - }); - - can( - [ - ProjectPermissionCertificateActions.Read, - ProjectPermissionCertificateActions.Edit, - ProjectPermissionCertificateActions.Create, - ProjectPermissionCertificateActions.Delete, - ProjectPermissionCertificateActions.ReadPrivateKey - ], - ProjectPermissionSub.Certificates - ); - - can( - [ - ProjectPermissionSshHostActions.Edit, - ProjectPermissionSshHostActions.Read, - ProjectPermissionSshHostActions.Create, - ProjectPermissionSshHostActions.Delete, - ProjectPermissionSshHostActions.IssueHostCert - ], - ProjectPermissionSub.SshHosts - ); - - can( - [ - ProjectPermissionMemberActions.Create, - ProjectPermissionMemberActions.Edit, - ProjectPermissionMemberActions.Delete, - ProjectPermissionMemberActions.Read, - ProjectPermissionMemberActions.GrantPrivileges, - ProjectPermissionMemberActions.AssumePrivileges - ], - ProjectPermissionSub.Member - ); - - can( - [ - ProjectPermissionGroupActions.Create, - ProjectPermissionGroupActions.Edit, - ProjectPermissionGroupActions.Delete, - ProjectPermissionGroupActions.Read, - ProjectPermissionGroupActions.GrantPrivileges - ], - ProjectPermissionSub.Groups - ); - - can( - [ - ProjectPermissionIdentityActions.Create, - ProjectPermissionIdentityActions.Edit, - ProjectPermissionIdentityActions.Delete, - ProjectPermissionIdentityActions.Read, - ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionIdentityActions.AssumePrivileges - ], - ProjectPermissionSub.Identity - ); - - can( - [ - ProjectPermissionSecretActions.DescribeAndReadValue, - ProjectPermissionSecretActions.DescribeSecret, - ProjectPermissionSecretActions.ReadValue, - ProjectPermissionSecretActions.Create, - ProjectPermissionSecretActions.Edit, - ProjectPermissionSecretActions.Delete - ], - ProjectPermissionSub.Secrets - ); - - can( - [ - ProjectPermissionDynamicSecretActions.ReadRootCredential, - ProjectPermissionDynamicSecretActions.EditRootCredential, - ProjectPermissionDynamicSecretActions.CreateRootCredential, - ProjectPermissionDynamicSecretActions.DeleteRootCredential, - ProjectPermissionDynamicSecretActions.Lease - ], - ProjectPermissionSub.DynamicSecrets - ); - - can([ProjectPermissionActions.Edit, ProjectPermissionActions.Delete], ProjectPermissionSub.Project); - can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback); - can([ProjectPermissionActions.Edit], ProjectPermissionSub.Kms); - can( - [ - ProjectPermissionCmekActions.Create, - ProjectPermissionCmekActions.Edit, - ProjectPermissionCmekActions.Delete, - ProjectPermissionCmekActions.Read, - ProjectPermissionCmekActions.Encrypt, - ProjectPermissionCmekActions.Decrypt, - ProjectPermissionCmekActions.Sign, - ProjectPermissionCmekActions.Verify - ], - ProjectPermissionSub.Cmek - ); - can( - [ - ProjectPermissionSecretSyncActions.Create, - ProjectPermissionSecretSyncActions.Edit, - ProjectPermissionSecretSyncActions.Delete, - ProjectPermissionSecretSyncActions.Read, - ProjectPermissionSecretSyncActions.SyncSecrets, - ProjectPermissionSecretSyncActions.ImportSecrets, - ProjectPermissionSecretSyncActions.RemoveSecrets - ], - ProjectPermissionSub.SecretSyncs - ); - - can( - [ - ProjectPermissionKmipActions.CreateClients, - ProjectPermissionKmipActions.UpdateClients, - ProjectPermissionKmipActions.DeleteClients, - ProjectPermissionKmipActions.ReadClients, - ProjectPermissionKmipActions.GenerateClientCertificates - ], - ProjectPermissionSub.Kmip - ); - - can( - [ - ProjectPermissionSecretRotationActions.Create, - ProjectPermissionSecretRotationActions.Edit, - ProjectPermissionSecretRotationActions.Delete, - ProjectPermissionSecretRotationActions.Read, - ProjectPermissionSecretRotationActions.ReadGeneratedCredentials, - ProjectPermissionSecretRotationActions.RotateSecrets - ], - ProjectPermissionSub.SecretRotation - ); - - return rules; -}; - -export const projectAdminPermissions = buildAdminPermissionRules(); - -const buildMemberPermissionRules = () => { - const { can, rules } = new AbilityBuilder>(createMongoAbility); - - can( - [ - ProjectPermissionSecretActions.DescribeAndReadValue, - ProjectPermissionSecretActions.DescribeSecret, - ProjectPermissionSecretActions.ReadValue, - ProjectPermissionSecretActions.Edit, - ProjectPermissionSecretActions.Create, - ProjectPermissionSecretActions.Delete - ], - ProjectPermissionSub.Secrets - ); - can( - [ - ProjectPermissionActions.Read, - ProjectPermissionActions.Edit, - ProjectPermissionActions.Create, - ProjectPermissionActions.Delete - ], - ProjectPermissionSub.SecretFolders - ); - can( - [ - ProjectPermissionDynamicSecretActions.ReadRootCredential, - ProjectPermissionDynamicSecretActions.EditRootCredential, - ProjectPermissionDynamicSecretActions.CreateRootCredential, - ProjectPermissionDynamicSecretActions.DeleteRootCredential, - ProjectPermissionDynamicSecretActions.Lease - ], - ProjectPermissionSub.DynamicSecrets - ); - can( - [ - ProjectPermissionActions.Read, - ProjectPermissionActions.Edit, - ProjectPermissionActions.Create, - ProjectPermissionActions.Delete - ], - ProjectPermissionSub.SecretImports - ); - - can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretApproval); - can([ProjectPermissionSecretRotationActions.Read], ProjectPermissionSub.SecretRotation); - - can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback); - - can([ProjectPermissionMemberActions.Read, ProjectPermissionMemberActions.Create], ProjectPermissionSub.Member); - - can([ProjectPermissionGroupActions.Read], ProjectPermissionSub.Groups); - - can( - [ - ProjectPermissionActions.Read, - ProjectPermissionActions.Edit, - ProjectPermissionActions.Create, - ProjectPermissionActions.Delete - ], - ProjectPermissionSub.Integrations - ); - - can( - [ - ProjectPermissionActions.Read, - ProjectPermissionActions.Edit, - ProjectPermissionActions.Create, - ProjectPermissionActions.Delete - ], - ProjectPermissionSub.Webhooks - ); - - can( - [ - ProjectPermissionIdentityActions.Read, - ProjectPermissionIdentityActions.Edit, - ProjectPermissionIdentityActions.Create, - ProjectPermissionIdentityActions.Delete - ], - ProjectPermissionSub.Identity - ); - - can( - [ - ProjectPermissionActions.Read, - ProjectPermissionActions.Edit, - ProjectPermissionActions.Create, - ProjectPermissionActions.Delete - ], - ProjectPermissionSub.ServiceTokens - ); - - can( - [ - ProjectPermissionActions.Read, - ProjectPermissionActions.Edit, - ProjectPermissionActions.Create, - ProjectPermissionActions.Delete - ], - ProjectPermissionSub.Settings - ); - - can( - [ - ProjectPermissionActions.Read, - ProjectPermissionActions.Edit, - ProjectPermissionActions.Create, - ProjectPermissionActions.Delete - ], - ProjectPermissionSub.Environments - ); - - can( - [ - ProjectPermissionActions.Read, - ProjectPermissionActions.Edit, - ProjectPermissionActions.Create, - ProjectPermissionActions.Delete - ], - ProjectPermissionSub.Tags - ); - - can([ProjectPermissionActions.Read], ProjectPermissionSub.Role); - can([ProjectPermissionActions.Read], ProjectPermissionSub.AuditLogs); - can([ProjectPermissionActions.Read], ProjectPermissionSub.IpAllowList); - - // double check if all CRUD are needed for CA and Certificates - can([ProjectPermissionActions.Read], ProjectPermissionSub.CertificateAuthorities); - - can( - [ - ProjectPermissionCertificateActions.Read, - ProjectPermissionCertificateActions.Edit, - ProjectPermissionCertificateActions.Create, - ProjectPermissionCertificateActions.Delete - ], - ProjectPermissionSub.Certificates - ); - - can([ProjectPermissionActions.Read], ProjectPermissionSub.CertificateTemplates); - - can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); - can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); - - can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificates); - can([ProjectPermissionActions.Create], ProjectPermissionSub.SshCertificates); - can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateTemplates); - - can([ProjectPermissionSshHostActions.Read], ProjectPermissionSub.SshHosts); - - can( - [ - ProjectPermissionCmekActions.Create, - ProjectPermissionCmekActions.Edit, - ProjectPermissionCmekActions.Delete, - ProjectPermissionCmekActions.Read, - ProjectPermissionCmekActions.Encrypt, - ProjectPermissionCmekActions.Decrypt, - ProjectPermissionCmekActions.Sign, - ProjectPermissionCmekActions.Verify - ], - ProjectPermissionSub.Cmek - ); - - can( - [ - ProjectPermissionSecretSyncActions.Create, - ProjectPermissionSecretSyncActions.Edit, - ProjectPermissionSecretSyncActions.Delete, - ProjectPermissionSecretSyncActions.Read, - ProjectPermissionSecretSyncActions.SyncSecrets, - ProjectPermissionSecretSyncActions.ImportSecrets, - ProjectPermissionSecretSyncActions.RemoveSecrets - ], - ProjectPermissionSub.SecretSyncs - ); - - return rules; -}; - -export const projectMemberPermissions = buildMemberPermissionRules(); - -const buildViewerPermissionRules = () => { - const { can, rules } = new AbilityBuilder>(createMongoAbility); - - can(ProjectPermissionSecretActions.DescribeAndReadValue, ProjectPermissionSub.Secrets); - can(ProjectPermissionSecretActions.DescribeSecret, ProjectPermissionSub.Secrets); - can(ProjectPermissionSecretActions.ReadValue, ProjectPermissionSub.Secrets); - can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretFolders); - can(ProjectPermissionDynamicSecretActions.ReadRootCredential, ProjectPermissionSub.DynamicSecrets); - can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretImports); - can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); - can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); - can(ProjectPermissionSecretRotationActions.Read, ProjectPermissionSub.SecretRotation); - can(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); - can(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks); - can(ProjectPermissionIdentityActions.Read, ProjectPermissionSub.Identity); - can(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); - can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); - can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); - can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); - can(ProjectPermissionCertificateActions.Read, ProjectPermissionSub.Certificates); - can(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); - can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); - can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates); - can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs); - - return rules; -}; - -export const projectViewerPermission = buildViewerPermissionRules(); - -const buildNoAccessProjectPermission = () => { - const { rules } = new AbilityBuilder>(createMongoAbility); - return rules; -}; - export const buildServiceTokenProjectPermission = ( scopes: Array<{ secretPath: string; environment: string }>, permission: string[] @@ -1116,8 +870,6 @@ export const buildServiceTokenProjectPermission = ( return build({ conditionsMatcher }); }; -export const projectNoAccessPermissions = buildNoAccessProjectPermission(); - /* eslint-disable */ /** diff --git a/backend/src/ee/services/project-template/project-template-fns.ts b/backend/src/ee/services/project-template/project-template-fns.ts index 2ca78e876..8e8ebfa13 100644 --- a/backend/src/ee/services/project-template/project-template-fns.ts +++ b/backend/src/ee/services/project-template/project-template-fns.ts @@ -1,22 +1,27 @@ -import { ProjectTemplateDefaultEnvironments } from "@app/ee/services/project-template/project-template-constants"; +import { ProjectType } from "@app/db/schemas"; import { InfisicalProjectTemplate, TUnpackedPermission } from "@app/ee/services/project-template/project-template-types"; import { getPredefinedRoles } from "@app/services/project-role/project-role-fns"; -export const getDefaultProjectTemplate = (orgId: string) => ({ +import { ProjectTemplateDefaultEnvironments } from "./project-template-constants"; + +export const getDefaultProjectTemplate = (orgId: string, type: ProjectType) => ({ id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // random ID to appease zod + type, name: InfisicalProjectTemplate.Default, createdAt: new Date(), updatedAt: new Date(), - description: "Infisical's default project template", - environments: ProjectTemplateDefaultEnvironments, - roles: [...getPredefinedRoles("project-template")].map(({ name, slug, permissions }) => ({ - name, - slug, - permissions: permissions as TUnpackedPermission[] - })), + description: `Infisical's ${type} default project template`, + environments: type === ProjectType.SecretManager ? ProjectTemplateDefaultEnvironments : null, + roles: [...getPredefinedRoles({ projectId: "project-template", projectType: type })].map( + ({ name, slug, permissions }) => ({ + name, + slug, + permissions: permissions as TUnpackedPermission[] + }) + ), orgId }); diff --git a/backend/src/ee/services/project-template/project-template-service.ts b/backend/src/ee/services/project-template/project-template-service.ts index b2430ac14..5b6163977 100644 --- a/backend/src/ee/services/project-template/project-template-service.ts +++ b/backend/src/ee/services/project-template/project-template-service.ts @@ -1,10 +1,11 @@ import { ForbiddenError } from "@casl/ability"; import { packRules } from "@casl/ability/extra"; -import { TProjectTemplates } from "@app/db/schemas"; +import { ProjectType, TProjectTemplates } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectTemplateDefaultEnvironments } from "@app/ee/services/project-template/project-template-constants"; import { getDefaultProjectTemplate } from "@app/ee/services/project-template/project-template-fns"; import { TCreateProjectTemplateDTO, @@ -32,11 +33,13 @@ const $unpackProjectTemplate = ({ roles, environments, ...rest }: TProjectTempla ...rest, environments: environments as TProjectTemplateEnvironment[], roles: [ - ...getPredefinedRoles("project-template").map(({ name, slug, permissions }) => ({ - name, - slug, - permissions: permissions as TUnpackedPermission[] - })), + ...getPredefinedRoles({ projectId: "project-template", projectType: rest.type as ProjectType }).map( + ({ name, slug, permissions }) => ({ + name, + slug, + permissions: permissions as TUnpackedPermission[] + }) + ), ...(roles as TProjectTemplateRole[]).map((role) => ({ ...role, permissions: unpackPermissions(role.permissions) @@ -49,7 +52,7 @@ export const projectTemplateServiceFactory = ({ permissionService, projectTemplateDAL }: TProjectTemplatesServiceFactoryDep) => { - const listProjectTemplatesByOrg = async (actor: OrgServiceActor) => { + const listProjectTemplatesByOrg = async (actor: OrgServiceActor, type?: ProjectType) => { const plan = await licenseService.getPlan(actor.orgId); if (!plan.projectTemplates) @@ -68,11 +71,14 @@ export const projectTemplateServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.ProjectTemplates); const projectTemplates = await projectTemplateDAL.find({ - orgId: actor.orgId + orgId: actor.orgId, + ...(type ? { type } : {}) }); return [ - getDefaultProjectTemplate(actor.orgId), + ...(type + ? [getDefaultProjectTemplate(actor.orgId, type)] + : Object.values(ProjectType).map((projectType) => getDefaultProjectTemplate(actor.orgId, projectType))), ...projectTemplates.map((template) => $unpackProjectTemplate(template)) ]; }; @@ -134,7 +140,7 @@ export const projectTemplateServiceFactory = ({ }; const createProjectTemplate = async ( - { roles, environments, ...params }: TCreateProjectTemplateDTO, + { roles, environments, type, ...params }: TCreateProjectTemplateDTO, actor: OrgServiceActor ) => { const plan = await licenseService.getPlan(actor.orgId); @@ -154,6 +160,17 @@ export const projectTemplateServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.ProjectTemplates); + if (environments && type !== ProjectType.SecretManager) { + throw new BadRequestError({ message: "Cannot configure environments for non-SecretManager project templates" }); + } + + if (environments && plan.environmentLimit !== null && environments.length > plan.environmentLimit) { + throw new BadRequestError({ + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + message: `Failed to create project template due to environment count exceeding your current limit of ${plan.environmentLimit}. Contact Infisical to increase limit.` + }); + } + const isConflictingName = Boolean( await projectTemplateDAL.findOne({ name: params.name, @@ -169,8 +186,10 @@ export const projectTemplateServiceFactory = ({ const projectTemplate = await projectTemplateDAL.create({ ...params, roles: JSON.stringify(roles.map((role) => ({ ...role, permissions: packRules(role.permissions) }))), - environments: JSON.stringify(environments), - orgId: actor.orgId + environments: + type === ProjectType.SecretManager ? JSON.stringify(environments ?? ProjectTemplateDefaultEnvironments) : null, + orgId: actor.orgId, + type }); return $unpackProjectTemplate(projectTemplate); @@ -202,6 +221,19 @@ export const projectTemplateServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.ProjectTemplates); + if (projectTemplate.type !== ProjectType.SecretManager && environments) + throw new BadRequestError({ message: "Cannot configure environments for non-SecretManager project templates" }); + + if (projectTemplate.type === ProjectType.SecretManager && environments === null) + throw new BadRequestError({ message: "Environments cannot be removed for SecretManager project templates" }); + + if (environments && plan.environmentLimit !== null && environments.length > plan.environmentLimit) { + throw new BadRequestError({ + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + message: `Failed to update project template due to environment count exceeding your current limit of ${plan.environmentLimit}. Contact Infisical to increase limit.` + }); + } + if (params.name && projectTemplate.name !== params.name) { const isConflictingName = Boolean( await projectTemplateDAL.findOne({ diff --git a/backend/src/ee/services/project-template/project-template-types.ts b/backend/src/ee/services/project-template/project-template-types.ts index c2764dc53..d53b2375e 100644 --- a/backend/src/ee/services/project-template/project-template-types.ts +++ b/backend/src/ee/services/project-template/project-template-types.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { TProjectEnvironments } from "@app/db/schemas"; +import { ProjectType, TProjectEnvironments } from "@app/db/schemas"; import { TProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission"; import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission"; @@ -15,8 +15,9 @@ export type TProjectTemplateRole = { export type TCreateProjectTemplateDTO = { name: string; description?: string; + type: ProjectType; roles: TProjectTemplateRole[]; - environments: TProjectTemplateEnvironment[]; + environments?: TProjectTemplateEnvironment[] | null; }; export type TUpdateProjectTemplateDTO = Partial; 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 965e25344..f4d7f4e6a 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 @@ -9,6 +9,7 @@ import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/per import { ActorType } from "@app/services/auth/auth-type"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { TAccessApprovalRequestDALFactory } from "../access-approval-request/access-approval-request-dal"; import { constructPermissionErrorMessage, validatePrivilegeChangeOperation } from "../permission/permission-fns"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { @@ -16,6 +17,7 @@ import { ProjectPermissionSet, ProjectPermissionSub } from "../permission/project-permission"; +import { ApprovalStatus } from "../secret-approval-request/secret-approval-request-types"; import { TProjectUserAdditionalPrivilegeDALFactory } from "./project-user-additional-privilege-dal"; import { ProjectUserAdditionalPrivilegeTemporaryMode, @@ -30,6 +32,7 @@ type TProjectUserAdditionalPrivilegeServiceFactoryDep = { projectUserAdditionalPrivilegeDAL: TProjectUserAdditionalPrivilegeDALFactory; projectMembershipDAL: Pick; permissionService: Pick; + accessApprovalRequestDAL: Pick; }; export type TProjectUserAdditionalPrivilegeServiceFactory = ReturnType< @@ -44,7 +47,8 @@ const unpackPermissions = (permissions: unknown) => export const projectUserAdditionalPrivilegeServiceFactory = ({ projectUserAdditionalPrivilegeDAL, projectMembershipDAL, - permissionService + permissionService, + accessApprovalRequestDAL }: TProjectUserAdditionalPrivilegeServiceFactoryDep) => { const create = async ({ slug, @@ -279,6 +283,15 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member); + await accessApprovalRequestDAL.update( + { + privilegeId: userPrivilege.id + }, + { + privilegeDeletedAt: new Date(), + status: ApprovalStatus.REJECTED + } + ); const deletedPrivilege = await projectUserAdditionalPrivilegeDAL.deleteById(userPrivilege.id); return { ...deletedPrivilege, diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 84cced88f..4aad13ab8 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -342,7 +342,7 @@ export const scimServiceFactory = ({ orgMembership = await orgMembershipDAL.create( { userId: userAlias.userId, - inviteEmail: email, + inviteEmail: email.toLowerCase(), orgId, role, roleId, @@ -364,7 +364,7 @@ export const scimServiceFactory = ({ if (trustScimEmails) { user = await userDAL.findOne( { - email, + email: email.toLowerCase(), isEmailVerified: true }, tx @@ -379,8 +379,8 @@ export const scimServiceFactory = ({ ); user = await userDAL.create( { - username: trustScimEmails ? email : uniqueUsername, - email, + username: trustScimEmails ? email.toLowerCase() : uniqueUsername, + email: email.toLowerCase(), isEmailVerified: trustScimEmails, firstName, lastName, @@ -396,7 +396,7 @@ export const scimServiceFactory = ({ userId: user.id, aliasType, externalId, - emails: email ? [email] : [], + emails: email ? [email.toLowerCase()] : [], orgId }, tx @@ -418,7 +418,7 @@ export const scimServiceFactory = ({ orgMembership = await orgMembershipDAL.create( { userId: user.id, - inviteEmail: email, + inviteEmail: email.toLowerCase(), orgId, role, roleId, @@ -529,7 +529,7 @@ export const scimServiceFactory = ({ membership.userId, { firstName: scimUser.name.givenName, - email: scimUser.emails[0].value, + email: scimUser.emails[0].value.toLowerCase(), lastName: scimUser.name.familyName, isEmailVerified: hasEmailChanged ? trustScimEmails : undefined }, @@ -606,7 +606,7 @@ export const scimServiceFactory = ({ membership.userId, { firstName, - email, + email: email?.toLowerCase(), lastName, isEmailVerified: org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-approver-dal.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-approver-dal.ts index f32439499..1d8ae24a2 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-approver-dal.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-approver-dal.ts @@ -8,3 +8,10 @@ export const secretApprovalPolicyApproverDALFactory = (db: TDbClient) => { const sapApproverOrm = ormify(db, TableName.SecretApprovalPolicyApprover); return sapApproverOrm; }; + +export type TSecretApprovalPolicyBypasserDALFactory = ReturnType; + +export const secretApprovalPolicyBypasserDALFactory = (db: TDbClient) => { + const sapBypasserOrm = ormify(db, TableName.SecretApprovalPolicyBypasser); + return sapBypasserOrm; +}; diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts index 6644b14b8..fd8be93cf 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts @@ -1,11 +1,17 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { SecretApprovalPoliciesSchema, TableName, TSecretApprovalPolicies, TUsers } from "@app/db/schemas"; +import { + SecretApprovalPoliciesSchema, + TableName, + TSecretApprovalPolicies, + TUserGroupMembership, + TUsers +} from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, sqlNestRelationships, TFindFilter } from "@app/lib/knex"; -import { ApproverType } from "../access-approval-policy/access-approval-policy-types"; +import { ApproverType, BypasserType } from "../access-approval-policy/access-approval-policy-types"; export type TSecretApprovalPolicyDALFactory = ReturnType; @@ -43,6 +49,22 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalPolicyApprover}.approverUserId`, "secretApprovalPolicyApproverUser.id" ) + // Bypasser + .leftJoin( + TableName.SecretApprovalPolicyBypasser, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SecretApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.UserGroupMembership).as("bypasserUserGroupMembership"), + `${TableName.SecretApprovalPolicyBypasser}.bypasserGroupId`, + `bypasserUserGroupMembership.groupId` + ) + .leftJoin( + db(TableName.Users).as("secretApprovalPolicyBypasserUser"), + `${TableName.SecretApprovalPolicyBypasser}.bypasserUserId`, + "secretApprovalPolicyBypasserUser.id" + ) .leftJoin(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) .select( tx.ref("id").withSchema("secretApprovalPolicyApproverUser").as("approverUserId"), @@ -58,6 +80,20 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { tx.ref("firstName").withSchema(TableName.Users).as("approverGroupFirstName"), tx.ref("lastName").withSchema(TableName.Users).as("approverGroupLastName") ) + .select( + tx.ref("id").withSchema("secretApprovalPolicyBypasserUser").as("bypasserUserId"), + tx.ref("email").withSchema("secretApprovalPolicyBypasserUser").as("bypasserEmail"), + tx.ref("firstName").withSchema("secretApprovalPolicyBypasserUser").as("bypasserFirstName"), + tx.ref("username").withSchema("secretApprovalPolicyBypasserUser").as("bypasserUsername"), + tx.ref("lastName").withSchema("secretApprovalPolicyBypasserUser").as("bypasserLastName") + ) + .select( + tx.ref("bypasserGroupId").withSchema(TableName.SecretApprovalPolicyBypasser), + tx.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId"), + tx.ref("email").withSchema(TableName.Users).as("bypasserGroupEmail"), + tx.ref("firstName").withSchema(TableName.Users).as("bypasserGroupFirstName"), + tx.ref("lastName").withSchema(TableName.Users).as("bypasserGroupLastName") + ) .select( tx.ref("name").withSchema(TableName.Environment).as("envName"), tx.ref("slug").withSchema(TableName.Environment).as("envSlug"), @@ -143,7 +179,7 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { label: "approvers" as const, mapper: ({ approverUserId: id, approverUsername }) => ({ type: ApproverType.User, - name: approverUsername, + username: approverUsername, id }) }, @@ -155,6 +191,23 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { id }) }, + { + key: "bypasserUserId", + label: "bypassers" as const, + mapper: ({ bypasserUserId: id, bypasserUsername }) => ({ + type: BypasserType.User, + username: bypasserUsername, + id + }) + }, + { + key: "bypasserGroupId", + label: "bypassers" as const, + mapper: ({ bypasserGroupId: id }) => ({ + type: BypasserType.Group, + id + }) + }, { key: "approverUserId", label: "userApprovers" as const, diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts index 4c212e6cd..696caf311 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts @@ -10,11 +10,14 @@ import { containsGlobPatterns } from "@app/lib/picomatch"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; -import { ApproverType } from "../access-approval-policy/access-approval-policy-types"; +import { ApproverType, BypasserType } from "../access-approval-policy/access-approval-policy-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { TSecretApprovalRequestDALFactory } from "../secret-approval-request/secret-approval-request-dal"; import { RequestState } from "../secret-approval-request/secret-approval-request-types"; -import { TSecretApprovalPolicyApproverDALFactory } from "./secret-approval-policy-approver-dal"; +import { + TSecretApprovalPolicyApproverDALFactory, + TSecretApprovalPolicyBypasserDALFactory +} from "./secret-approval-policy-approver-dal"; import { TSecretApprovalPolicyDALFactory } from "./secret-approval-policy-dal"; import { TCreateSapDTO, @@ -36,6 +39,7 @@ type TSecretApprovalPolicyServiceFactoryDep = { projectEnvDAL: Pick; userDAL: Pick; secretApprovalPolicyApproverDAL: TSecretApprovalPolicyApproverDALFactory; + secretApprovalPolicyBypasserDAL: TSecretApprovalPolicyBypasserDALFactory; licenseService: Pick; secretApprovalRequestDAL: Pick; }; @@ -46,6 +50,7 @@ export const secretApprovalPolicyServiceFactory = ({ secretApprovalPolicyDAL, permissionService, secretApprovalPolicyApproverDAL, + secretApprovalPolicyBypasserDAL, projectEnvDAL, userDAL, licenseService, @@ -59,6 +64,7 @@ export const secretApprovalPolicyServiceFactory = ({ actorAuthMethod, approvals, approvers, + bypassers, projectId, secretPath, environment, @@ -74,7 +80,7 @@ export const secretApprovalPolicyServiceFactory = ({ .filter(Boolean) as string[]; const userApproverNames = approvers - .map((approver) => (approver.type === ApproverType.User ? approver.name : undefined)) + .map((approver) => (approver.type === ApproverType.User ? approver.username : undefined)) .filter(Boolean) as string[]; if (!groupApprovers.length && approvals > approvers.length) @@ -107,6 +113,44 @@ export const secretApprovalPolicyServiceFactory = ({ message: `Environment with slug '${environment}' not found in project with ID ${projectId}` }); + let groupBypassers: string[] = []; + let bypasserUserIds: string[] = []; + + if (bypassers && bypassers.length) { + groupBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.Group) + .map((bypasser) => bypasser.id) as string[]; + + const userBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.User) + .map((bypasser) => bypasser.id) + .filter(Boolean) as string[]; + + const userBypasserNames = bypassers + .map((bypasser) => (bypasser.type === BypasserType.User ? bypasser.username : undefined)) + .filter(Boolean) as string[]; + + bypasserUserIds = userBypassers; + if (userBypasserNames.length) { + const bypasserUsers = await userDAL.find({ + $in: { + username: userBypasserNames + } + }); + + const bypasserNamesFromDb = bypasserUsers.map((user) => user.username); + const invalidUsernames = userBypasserNames.filter((username) => !bypasserNamesFromDb.includes(username)); + + if (invalidUsernames.length) { + throw new BadRequestError({ + message: `Invalid bypasser user: ${invalidUsernames.join(", ")}` + }); + } + + bypasserUserIds = bypasserUserIds.concat(bypasserUsers.map((user) => user.id)); + } + } + const secretApproval = await secretApprovalPolicyDAL.transaction(async (tx) => { const doc = await secretApprovalPolicyDAL.create( { @@ -158,6 +202,27 @@ export const secretApprovalPolicyServiceFactory = ({ })), tx ); + + if (bypasserUserIds.length) { + await secretApprovalPolicyBypasserDAL.insertMany( + bypasserUserIds.map((userId) => ({ + bypasserUserId: userId, + policyId: doc.id + })), + tx + ); + } + + if (groupBypassers.length) { + await secretApprovalPolicyBypasserDAL.insertMany( + groupBypassers.map((groupId) => ({ + bypasserGroupId: groupId, + policyId: doc.id + })), + tx + ); + } + return doc; }); @@ -166,6 +231,7 @@ export const secretApprovalPolicyServiceFactory = ({ const updateSecretApprovalPolicy = async ({ approvers, + bypassers, secretPath, name, actorId, @@ -186,7 +252,7 @@ export const secretApprovalPolicyServiceFactory = ({ .filter(Boolean) as string[]; const userApproverNames = approvers - .map((approver) => (approver.type === ApproverType.User ? approver.name : undefined)) + .map((approver) => (approver.type === ApproverType.User ? approver.username : undefined)) .filter(Boolean) as string[]; const secretApprovalPolicy = await secretApprovalPolicyDAL.findById(secretPolicyId); @@ -214,6 +280,44 @@ export const secretApprovalPolicyServiceFactory = ({ }); } + let groupBypassers: string[] = []; + let bypasserUserIds: string[] = []; + + if (bypassers && bypassers.length) { + groupBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.Group) + .map((bypasser) => bypasser.id) as string[]; + + const userBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.User) + .map((bypasser) => bypasser.id) + .filter(Boolean) as string[]; + + const userBypasserNames = bypassers + .map((bypasser) => (bypasser.type === BypasserType.User ? bypasser.username : undefined)) + .filter(Boolean) as string[]; + + bypasserUserIds = userBypassers; + if (userBypasserNames.length) { + const bypasserUsers = await userDAL.find({ + $in: { + username: userBypasserNames + } + }); + + const bypasserNamesFromDb = bypasserUsers.map((user) => user.username); + const invalidUsernames = userBypasserNames.filter((username) => !bypasserNamesFromDb.includes(username)); + + if (invalidUsernames.length) { + throw new BadRequestError({ + message: `Invalid bypasser user: ${invalidUsernames.join(", ")}` + }); + } + + bypasserUserIds = bypasserUserIds.concat(bypasserUsers.map((user) => user.id)); + } + } + const updatedSap = await secretApprovalPolicyDAL.transaction(async (tx) => { const doc = await secretApprovalPolicyDAL.updateById( secretApprovalPolicy.id, @@ -272,6 +376,28 @@ export const secretApprovalPolicyServiceFactory = ({ ); } + await secretApprovalPolicyBypasserDAL.delete({ policyId: doc.id }, tx); + + if (bypasserUserIds.length) { + await secretApprovalPolicyBypasserDAL.insertMany( + bypasserUserIds.map((userId) => ({ + bypasserUserId: userId, + policyId: doc.id + })), + tx + ); + } + + if (groupBypassers.length) { + await secretApprovalPolicyBypasserDAL.insertMany( + groupBypassers.map((groupId) => ({ + bypasserGroupId: groupId, + policyId: doc.id + })), + tx + ); + } + return doc; }); return { diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts index a6fea6956..ed074336c 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts @@ -1,12 +1,16 @@ import { EnforcementLevel, TProjectPermission } from "@app/lib/types"; -import { ApproverType } from "../access-approval-policy/access-approval-policy-types"; +import { ApproverType, BypasserType } from "../access-approval-policy/access-approval-policy-types"; export type TCreateSapDTO = { approvals: number; secretPath?: string | null; environment: string; - approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; + approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; username?: string })[]; + bypassers?: ( + | { type: BypasserType.Group; id: string } + | { type: BypasserType.User; id?: string; username?: string } + )[]; projectId: string; name: string; enforcementLevel: EnforcementLevel; @@ -17,7 +21,11 @@ export type TUpdateSapDTO = { secretPolicyId: string; approvals?: number; secretPath?: string | null; - approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; + approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; username?: string })[]; + bypassers?: ( + | { type: BypasserType.Group; id: string } + | { type: BypasserType.User; id?: string; username?: string } + )[]; name?: string; enforcementLevel?: EnforcementLevel; allowedSelfApprovals?: boolean; diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index be4a7ab3b..3bd35c3c8 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -6,6 +6,7 @@ import { TableName, TSecretApprovalRequests, TSecretApprovalRequestsSecrets, + TUserGroupMembership, TUsers } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; @@ -58,16 +59,36 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalPolicyApprover}.approverUserId`, "secretApprovalPolicyApproverUser.id" ) - .leftJoin( - TableName.UserGroupMembership, + .leftJoin( + db(TableName.UserGroupMembership).as("approverUserGroupMembership"), `${TableName.SecretApprovalPolicyApprover}.approverGroupId`, - `${TableName.UserGroupMembership}.groupId` + `approverUserGroupMembership.groupId` ) .leftJoin( db(TableName.Users).as("secretApprovalPolicyGroupApproverUser"), - `${TableName.UserGroupMembership}.userId`, + `approverUserGroupMembership.userId`, `secretApprovalPolicyGroupApproverUser.id` ) + .leftJoin( + TableName.SecretApprovalPolicyBypasser, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SecretApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.Users).as("secretApprovalPolicyBypasserUser"), + `${TableName.SecretApprovalPolicyBypasser}.bypasserUserId`, + "secretApprovalPolicyBypasserUser.id" + ) + .leftJoin( + db(TableName.UserGroupMembership).as("bypasserUserGroupMembership"), + `${TableName.SecretApprovalPolicyBypasser}.bypasserGroupId`, + `bypasserUserGroupMembership.groupId` + ) + .leftJoin( + db(TableName.Users).as("secretApprovalPolicyGroupBypasserUser"), + `bypasserUserGroupMembership.userId`, + `secretApprovalPolicyGroupBypasserUser.id` + ) .leftJoin( TableName.SecretApprovalRequestReviewer, `${TableName.SecretApprovalRequest}.id`, @@ -81,7 +102,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .select(selectAllTableCols(TableName.SecretApprovalRequest)) .select( tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), - tx.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId"), + tx.ref("userId").withSchema("approverUserGroupMembership").as("approverGroupUserId"), tx.ref("email").withSchema("secretApprovalPolicyApproverUser").as("approverEmail"), tx.ref("email").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupEmail"), tx.ref("username").withSchema("secretApprovalPolicyApproverUser").as("approverUsername"), @@ -90,6 +111,20 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("firstName").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupFirstName"), tx.ref("lastName").withSchema("secretApprovalPolicyApproverUser").as("approverLastName"), tx.ref("lastName").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupLastName"), + + // Bypasser fields + tx.ref("bypasserUserId").withSchema(TableName.SecretApprovalPolicyBypasser), + tx.ref("bypasserGroupId").withSchema(TableName.SecretApprovalPolicyBypasser), + tx.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId"), + tx.ref("email").withSchema("secretApprovalPolicyBypasserUser").as("bypasserEmail"), + tx.ref("email").withSchema("secretApprovalPolicyGroupBypasserUser").as("bypasserGroupEmail"), + tx.ref("username").withSchema("secretApprovalPolicyBypasserUser").as("bypasserUsername"), + tx.ref("username").withSchema("secretApprovalPolicyGroupBypasserUser").as("bypasserGroupUsername"), + tx.ref("firstName").withSchema("secretApprovalPolicyBypasserUser").as("bypasserFirstName"), + tx.ref("firstName").withSchema("secretApprovalPolicyGroupBypasserUser").as("bypasserGroupFirstName"), + tx.ref("lastName").withSchema("secretApprovalPolicyBypasserUser").as("bypasserLastName"), + tx.ref("lastName").withSchema("secretApprovalPolicyGroupBypasserUser").as("bypasserGroupLastName"), + tx.ref("email").withSchema("statusChangedByUser").as("statusChangedByUserEmail"), tx.ref("username").withSchema("statusChangedByUser").as("statusChangedByUserUsername"), tx.ref("firstName").withSchema("statusChangedByUser").as("statusChangedByUserFirstName"), @@ -121,7 +156,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { try { const sql = findQuery({ [`${TableName.SecretApprovalRequest}.id` as "id"]: id }, tx || db.replicaNode()); const docs = await sql; - const formatedDoc = sqlNestRelationships({ + const formattedDoc = sqlNestRelationships({ data: docs, key: "id", parentMapper: (el) => ({ @@ -203,13 +238,51 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { lastName, username }) + }, + { + key: "bypasserUserId", + label: "bypassers" as const, + mapper: ({ + bypasserUserId: userId, + bypasserEmail: email, + bypasserUsername: username, + bypasserLastName: lastName, + bypasserFirstName: firstName + }) => ({ + userId, + email, + firstName, + lastName, + username + }) + }, + { + key: "bypasserGroupUserId", + label: "bypassers" as const, + mapper: ({ + bypasserGroupUserId: userId, + bypasserGroupEmail: email, + bypasserGroupUsername: username, + bypasserGroupLastName: lastName, + bypasserGroupFirstName: firstName + }) => ({ + userId, + email, + firstName, + lastName, + username + }) } ] }); - if (!formatedDoc?.[0]) return; + if (!formattedDoc?.[0]) return; return { - ...formatedDoc[0], - policy: { ...formatedDoc[0].policy, approvers: formatedDoc[0].approvers } + ...formattedDoc[0], + policy: { + ...formattedDoc[0].policy, + approvers: formattedDoc[0].approvers, + bypassers: formattedDoc[0].bypassers + } }; } catch (error) { throw new DatabaseError({ error, name: "FindByIdSAR" }); @@ -291,6 +364,16 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalPolicyApprover}.approverGroupId`, `${TableName.UserGroupMembership}.groupId` ) + .leftJoin( + TableName.SecretApprovalPolicyBypasser, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SecretApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.UserGroupMembership).as("bypasserUserGroupMembership"), + `${TableName.SecretApprovalPolicyBypasser}.bypasserGroupId`, + `bypasserUserGroupMembership.groupId` + ) .join( db(TableName.Users).as("committerUser"), `${TableName.SecretApprovalRequest}.committerUserId`, @@ -334,7 +417,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { db.ref("secretId").withSchema(TableName.SecretApprovalRequestSecret).as("commitSecretId"), db.ref("id").withSchema(TableName.SecretApprovalRequestSecret).as("commitId"), db.raw( - `DENSE_RANK() OVER (partition by ${TableName.Environment}."projectId" ORDER BY ${TableName.SecretApprovalRequest}."id" DESC) as rank` + `DENSE_RANK() OVER (PARTITION BY ${TableName.Environment}."projectId" ORDER BY ${TableName.SecretApprovalRequest}."createdAt" DESC) as rank` ), db.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), @@ -342,6 +425,11 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId"), + + // Bypasser fields + db.ref("bypasserUserId").withSchema(TableName.SecretApprovalPolicyBypasser), + db.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId"), + db.ref("email").withSchema("committerUser").as("committerUserEmail"), db.ref("username").withSchema("committerUser").as("committerUserUsername"), db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"), @@ -355,7 +443,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .from[number]>("w") .where("w.rank", ">=", offset) .andWhere("w.rank", "<", offset + limit); - const formatedDoc = sqlNestRelationships({ + const formattedDoc = sqlNestRelationships({ data: docs, key: "id", parentMapper: (el) => ({ @@ -403,12 +491,22 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { key: "approverGroupUserId", label: "approvers" as const, mapper: ({ approverGroupUserId }) => ({ userId: approverGroupUserId }) + }, + { + key: "bypasserUserId", + label: "bypassers" as const, + mapper: ({ bypasserUserId }) => ({ userId: bypasserUserId }) + }, + { + key: "bypasserGroupUserId", + label: "bypassers" as const, + mapper: ({ bypasserGroupUserId }) => ({ userId: bypasserGroupUserId }) } ] }); - return formatedDoc.map((el) => ({ + return formattedDoc.map((el) => ({ ...el, - policy: { ...el.policy, approvers: el.approvers } + policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers } })); } catch (error) { throw new DatabaseError({ error, name: "FindSAR" }); @@ -440,6 +538,16 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalPolicyApprover}.approverGroupId`, `${TableName.UserGroupMembership}.groupId` ) + .leftJoin( + TableName.SecretApprovalPolicyBypasser, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SecretApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.UserGroupMembership).as("bypasserUserGroupMembership"), + `${TableName.SecretApprovalPolicyBypasser}.bypasserGroupId`, + `bypasserUserGroupMembership.groupId` + ) .join( db(TableName.Users).as("committerUser"), `${TableName.SecretApprovalRequest}.committerUserId`, @@ -483,7 +591,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { db.ref("secretId").withSchema(TableName.SecretApprovalRequestSecretV2).as("commitSecretId"), db.ref("id").withSchema(TableName.SecretApprovalRequestSecretV2).as("commitId"), db.raw( - `DENSE_RANK() OVER (partition by ${TableName.Environment}."projectId" ORDER BY ${TableName.SecretApprovalRequest}."id" DESC) as rank` + `DENSE_RANK() OVER (PARTITION BY ${TableName.Environment}."projectId" ORDER BY ${TableName.SecretApprovalRequest}."createdAt" DESC) as rank` ), db.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), db.ref("allowedSelfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policyAllowedSelfApprovals"), @@ -491,6 +599,11 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId"), + + // Bypasser + db.ref("bypasserUserId").withSchema(TableName.SecretApprovalPolicyBypasser), + db.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId"), + db.ref("email").withSchema("committerUser").as("committerUserEmail"), db.ref("username").withSchema("committerUser").as("committerUserUsername"), db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"), @@ -504,7 +617,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .from[number]>("w") .where("w.rank", ">=", offset) .andWhere("w.rank", "<", offset + limit); - const formatedDoc = sqlNestRelationships({ + const formattedDoc = sqlNestRelationships({ data: docs, key: "id", parentMapper: (el) => ({ @@ -554,12 +667,24 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { mapper: ({ approverGroupUserId }) => ({ userId: approverGroupUserId }) + }, + { + key: "bypasserUserId", + label: "bypassers" as const, + mapper: ({ bypasserUserId }) => ({ userId: bypasserUserId }) + }, + { + key: "bypasserGroupUserId", + label: "bypassers" as const, + mapper: ({ bypasserGroupUserId }) => ({ + userId: bypasserGroupUserId + }) } ] }); - return formatedDoc.map((el) => ({ + return formattedDoc.map((el) => ({ ...el, - policy: { ...el.policy, approvers: el.approvers } + policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers } })); } catch (error) { throw new DatabaseError({ error, name: "FindSAR" }); 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 262e8e5cf..217181281 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 @@ -497,7 +497,7 @@ export const secretApprovalRequestServiceFactory = ({ }); } - const { policy, folderId, projectId } = secretApprovalRequest; + const { policy, folderId, projectId, bypassers } = secretApprovalRequest; if (policy.deletedAt) { throw new BadRequestError({ message: "The policy associated with this secret approval request has been deleted." @@ -530,8 +530,9 @@ export const secretApprovalRequestServiceFactory = ({ approverId ? reviewers[approverId] === ApprovalStatus.APPROVED : false ).length; const isSoftEnforcement = secretApprovalRequest.policy.enforcementLevel === EnforcementLevel.Soft; + const canBypass = !bypassers.length || bypassers.some((bypasser) => bypasser.userId === actorId); - if (!hasMinApproval && !isSoftEnforcement) + if (!hasMinApproval && !(isSoftEnforcement && canBypass)) throw new BadRequestError({ message: "Doesn't have minimum approvals needed" }); const { botKey, shouldUseSecretV2Bridge, project } = await projectBotService.getBotKey(projectId); 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 0fd01b753..07cf97a7e 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 @@ -1,4 +1,4 @@ -import ldap from "ldapjs"; +import ldap, { Client, SearchOptions } from "ldapjs"; import { TRotationFactory, @@ -8,26 +8,73 @@ import { TRotationFactoryRotateCredentials } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; import { logger } from "@app/lib/logger"; +import { DistinguishedNameRegex } from "@app/lib/regex"; import { encryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; import { getLdapConnectionClient, LdapProvider, TLdapConnection } from "@app/services/app-connection/ldap"; import { generatePassword } from "../shared/utils"; import { + LdapPasswordRotationMethod, TLdapPasswordRotationGeneratedCredentials, + TLdapPasswordRotationInput, TLdapPasswordRotationWithConnection } from "./ldap-password-rotation-types"; const getEncodedPassword = (password: string) => Buffer.from(`"${password}"`, "utf16le"); +const getDN = async (dn: string, client: Client): Promise => { + if (DistinguishedNameRegex.test(dn)) return dn; + + const opts: SearchOptions = { + filter: `(userPrincipalName=${dn})`, + scope: "sub", + attributes: ["dn"] + }; + + const base = dn + .split("@")[1] + .split(".") + .map((dc) => `dc=${dc}`) + .join(","); + + return new Promise((resolve, reject) => { + // Perform the search + client.search(base, opts, (err, res) => { + if (err) { + logger.error(err, "LDAP Failed to get DN"); + reject(new Error(`Provider Resolve DN Error: ${err.message}`)); + } + + let userDn: string | null; + + res.on("searchEntry", (entry) => { + userDn = entry.objectName; + }); + + res.on("error", (error) => { + logger.error(error, "LDAP Failed to get DN"); + reject(new Error(`Provider Resolve DN Error: ${error.message}`)); + }); + + res.on("end", () => { + if (userDn) { + resolve(userDn); + } else { + reject(new Error(`Unable to resolve DN for ${dn}.`)); + } + }); + }); + }); +}; + export const ldapPasswordRotationFactory: TRotationFactory< TLdapPasswordRotationWithConnection, - TLdapPasswordRotationGeneratedCredentials + TLdapPasswordRotationGeneratedCredentials, + TLdapPasswordRotationInput["temporaryParameters"] > = (secretRotation, appConnectionDAL, kmsService) => { - const { - connection, - parameters: { dn, passwordRequirements }, - secretsMapping - } = secretRotation; + const { connection, parameters, secretsMapping, activeIndex } = secretRotation; + + const { dn, passwordRequirements } = parameters; const $verifyCredentials = async (credentials: Pick) => { try { @@ -40,13 +87,21 @@ export const ldapPasswordRotationFactory: TRotationFactory< } }; - const $rotatePassword = async () => { + const $rotatePassword = async (currentPassword?: string) => { const { credentials, orgId } = connection; if (!credentials.url.startsWith("ldaps")) throw new Error("Password Rotation requires an LDAPS connection"); - const client = await getLdapConnectionClient(credentials); - const isPersonalRotation = credentials.dn === dn; + const client = await getLdapConnectionClient( + currentPassword + ? { + ...credentials, + password: currentPassword, + dn + } + : credentials + ); + const isConnectionRotation = credentials.dn === dn; const password = generatePassword(passwordRequirements); @@ -58,8 +113,8 @@ export const ldapPasswordRotationFactory: TRotationFactory< const encodedPassword = getEncodedPassword(password); // service account vs personal password rotation require different changes - if (isPersonalRotation) { - const currentEncodedPassword = getEncodedPassword(credentials.password); + if (isConnectionRotation || currentPassword) { + const currentEncodedPassword = getEncodedPassword(currentPassword || credentials.password); changes = [ new ldap.Change({ @@ -93,8 +148,9 @@ export const ldapPasswordRotationFactory: TRotationFactory< } try { + const userDn = await getDN(dn, client); await new Promise((resolve, reject) => { - client.modify(dn, changes, (err) => { + client.modify(userDn, changes, (err) => { if (err) { logger.error(err, "LDAP Password Rotation Failed"); reject(new Error(`Provider Modify Error: ${err.message}`)); @@ -110,7 +166,7 @@ export const ldapPasswordRotationFactory: TRotationFactory< await $verifyCredentials({ dn, password }); - if (isPersonalRotation) { + if (isConnectionRotation) { const updatedCredentials: TLdapConnection["credentials"] = { ...credentials, password @@ -128,29 +184,41 @@ export const ldapPasswordRotationFactory: TRotationFactory< return { dn, password }; }; - const issueCredentials: TRotationFactoryIssueCredentials = async ( - callback - ) => { - const credentials = await $rotatePassword(); + const issueCredentials: TRotationFactoryIssueCredentials< + TLdapPasswordRotationGeneratedCredentials, + TLdapPasswordRotationInput["temporaryParameters"] + > = async (callback, temporaryParameters) => { + const credentials = await $rotatePassword( + parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal + ? temporaryParameters?.password + : undefined + ); return callback(credentials); }; const revokeCredentials: TRotationFactoryRevokeCredentials = async ( - _, + credentialsToRevoke, callback ) => { + const currentPassword = credentialsToRevoke[activeIndex].password; + // we just rotate to a new password, essentially revoking old credentials - await $rotatePassword(); + await $rotatePassword( + parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal ? currentPassword : undefined + ); return callback(); }; const rotateCredentials: TRotationFactoryRotateCredentials = async ( _, - callback + callback, + activeCredentials ) => { - const credentials = await $rotatePassword(); + const credentials = await $rotatePassword( + parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal ? activeCredentials.password : undefined + ); return callback(credentials); }; diff --git a/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-schemas.ts index e99569d9a..741cd3ce1 100644 --- a/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-schemas.ts +++ b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-schemas.ts @@ -1,6 +1,6 @@ -import RE2 from "re2"; import { z } from "zod"; +import { LdapPasswordRotationMethod } from "@app/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-types"; import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; import { BaseCreateSecretRotationSchema, @@ -9,7 +9,7 @@ import { } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas"; import { PasswordRequirementsSchema } from "@app/ee/services/secret-rotation-v2/shared/general"; import { SecretRotations } from "@app/lib/api-docs"; -import { DistinguishedNameRegex } from "@app/lib/regex"; +import { DistinguishedNameRegex, UserPrincipalNameRegex } from "@app/lib/regex"; import { SecretNameSchema } from "@app/server/lib/schemas"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; @@ -26,10 +26,16 @@ const LdapPasswordRotationParametersSchema = z.object({ dn: z .string() .trim() - .regex(new RE2(DistinguishedNameRegex), "Invalid DN format, ie; CN=user,OU=users,DC=example,DC=com") - .min(1, "Distinguished Name (DN) Required") + .min(1, "DN/UPN required") + .refine((value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value), { + message: "Invalid DN/UPN format" + }) .describe(SecretRotations.PARAMETERS.LDAP_PASSWORD.dn), - passwordRequirements: PasswordRequirementsSchema.optional() + passwordRequirements: PasswordRequirementsSchema.optional(), + rotationMethod: z + .nativeEnum(LdapPasswordRotationMethod) + .optional() + .describe(SecretRotations.PARAMETERS.LDAP_PASSWORD.rotationMethod) }); const LdapPasswordRotationSecretsMappingSchema = z.object({ @@ -50,10 +56,28 @@ export const LdapPasswordRotationSchema = BaseSecretRotationSchema(SecretRotatio secretsMapping: LdapPasswordRotationSecretsMappingSchema }); -export const CreateLdapPasswordRotationSchema = BaseCreateSecretRotationSchema(SecretRotation.LdapPassword).extend({ - parameters: LdapPasswordRotationParametersSchema, - secretsMapping: LdapPasswordRotationSecretsMappingSchema -}); +export const CreateLdapPasswordRotationSchema = BaseCreateSecretRotationSchema(SecretRotation.LdapPassword) + .extend({ + parameters: LdapPasswordRotationParametersSchema, + secretsMapping: LdapPasswordRotationSecretsMappingSchema, + temporaryParameters: z + .object({ + password: z.string().min(1, "Password required").describe(SecretRotations.PARAMETERS.LDAP_PASSWORD.password) + }) + .optional() + }) + .superRefine((val, ctx) => { + if ( + val.parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal && + !val.temporaryParameters?.password + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Password required", + path: ["temporaryParameters", "password"] + }); + } + }); export const UpdateLdapPasswordRotationSchema = BaseUpdateSecretRotationSchema(SecretRotation.LdapPassword).extend({ parameters: LdapPasswordRotationParametersSchema.optional(), diff --git a/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-types.ts index cb15b0734..86437cac5 100644 --- a/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-types.ts +++ b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-types.ts @@ -9,6 +9,11 @@ import { LdapPasswordRotationSchema } from "./ldap-password-rotation-schemas"; +export enum LdapPasswordRotationMethod { + ConnectionPrincipal = "connection-principal", + TargetPrincipal = "target-principal" +} + export type TLdapPasswordRotation = z.infer; export type TLdapPasswordRotationInput = z.infer; diff --git a/backend/src/ee/services/secret-rotation-v2/mysql-credentials/index.ts b/backend/src/ee/services/secret-rotation-v2/mysql-credentials/index.ts new file mode 100644 index 000000000..dab424d74 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mysql-credentials/index.ts @@ -0,0 +1,3 @@ +export * from "./mysql-credentials-rotation-constants"; +export * from "./mysql-credentials-rotation-schemas"; +export * from "./mysql-credentials-rotation-types"; diff --git a/backend/src/ee/services/secret-rotation-v2/mysql-credentials/mysql-credentials-rotation-constants.ts b/backend/src/ee/services/secret-rotation-v2/mysql-credentials/mysql-credentials-rotation-constants.ts new file mode 100644 index 000000000..bae7a8166 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mysql-credentials/mysql-credentials-rotation-constants.ts @@ -0,0 +1,23 @@ +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 MYSQL_CREDENTIALS_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = { + name: "MySQL Credentials", + type: SecretRotation.MySqlCredentials, + connection: AppConnection.MySql, + template: { + createUserStatement: `-- create user +CREATE USER 'infisical_user'@'%' IDENTIFIED BY 'temporary_password'; + +-- grant all privileges +GRANT ALL PRIVILEGES ON my_database.* TO 'infisical_user'@'%'; + +-- apply the privilege changes +FLUSH PRIVILEGES;`, + secretsMapping: { + username: "MYSQL_USERNAME", + password: "MYSQL_PASSWORD" + } + } +}; diff --git a/backend/src/ee/services/secret-rotation-v2/mysql-credentials/mysql-credentials-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/mysql-credentials/mysql-credentials-rotation-schemas.ts new file mode 100644 index 000000000..8eb048d89 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mysql-credentials/mysql-credentials-rotation-schemas.ts @@ -0,0 +1,41 @@ +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 { + SqlCredentialsRotationParametersSchema, + SqlCredentialsRotationSecretsMappingSchema, + SqlCredentialsRotationTemplateSchema +} from "@app/ee/services/secret-rotation-v2/shared/sql-credentials"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const MySqlCredentialsRotationSchema = BaseSecretRotationSchema(SecretRotation.MySqlCredentials).extend({ + type: z.literal(SecretRotation.MySqlCredentials), + parameters: SqlCredentialsRotationParametersSchema, + secretsMapping: SqlCredentialsRotationSecretsMappingSchema +}); + +export const CreateMySqlCredentialsRotationSchema = BaseCreateSecretRotationSchema( + SecretRotation.MySqlCredentials +).extend({ + parameters: SqlCredentialsRotationParametersSchema, + secretsMapping: SqlCredentialsRotationSecretsMappingSchema +}); + +export const UpdateMySqlCredentialsRotationSchema = BaseUpdateSecretRotationSchema( + SecretRotation.MySqlCredentials +).extend({ + parameters: SqlCredentialsRotationParametersSchema.optional(), + secretsMapping: SqlCredentialsRotationSecretsMappingSchema.optional() +}); + +export const MySqlCredentialsRotationListItemSchema = z.object({ + name: z.literal("MySQL Credentials"), + connection: z.literal(AppConnection.MySql), + type: z.literal(SecretRotation.MySqlCredentials), + template: SqlCredentialsRotationTemplateSchema +}); diff --git a/backend/src/ee/services/secret-rotation-v2/mysql-credentials/mysql-credentials-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/mysql-credentials/mysql-credentials-rotation-types.ts new file mode 100644 index 000000000..ccbbe1256 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mysql-credentials/mysql-credentials-rotation-types.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { TMySqlConnection } from "@app/services/app-connection/mysql"; + +import { + CreateMySqlCredentialsRotationSchema, + MySqlCredentialsRotationListItemSchema, + MySqlCredentialsRotationSchema +} from "./mysql-credentials-rotation-schemas"; + +export type TMySqlCredentialsRotation = z.infer; + +export type TMySqlCredentialsRotationInput = z.infer; + +export type TMySqlCredentialsRotationListItem = z.infer; + +export type TMySqlCredentialsRotationWithConnection = TMySqlCredentialsRotation & { + connection: TMySqlConnection; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts index d67abea2b..a8c92e255 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts @@ -1,6 +1,7 @@ export enum SecretRotation { PostgresCredentials = "postgres-credentials", MsSqlCredentials = "mssql-credentials", + MySqlCredentials = "mysql-credentials", Auth0ClientSecret = "auth0-client-secret", AzureClientSecret = "azure-client-secret", AwsIamUserSecret = "aws-iam-user-secret", diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts index a25482c8c..ea1b99107 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts @@ -1,13 +1,15 @@ import { AxiosError } from "axios"; import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./auth0-client-secret"; import { AWS_IAM_USER_SECRET_ROTATION_LIST_OPTION } from "./aws-iam-user-secret"; import { AZURE_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./azure-client-secret"; -import { LDAP_PASSWORD_ROTATION_LIST_OPTION } from "./ldap-password"; +import { LDAP_PASSWORD_ROTATION_LIST_OPTION, TLdapPasswordRotation } from "./ldap-password"; import { MSSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mssql-credentials"; +import { MYSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mysql-credentials"; import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials"; import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums"; import { TSecretRotationV2ServiceFactoryDep } from "./secret-rotation-v2-service"; @@ -15,12 +17,14 @@ import { TSecretRotationV2, TSecretRotationV2GeneratedCredentials, TSecretRotationV2ListItem, - TSecretRotationV2Raw + TSecretRotationV2Raw, + TUpdateSecretRotationV2DTO } from "./secret-rotation-v2-types"; const SECRET_ROTATION_LIST_OPTIONS: Record = { [SecretRotation.PostgresCredentials]: POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION, [SecretRotation.MsSqlCredentials]: MSSQL_CREDENTIALS_ROTATION_LIST_OPTION, + [SecretRotation.MySqlCredentials]: MYSQL_CREDENTIALS_ROTATION_LIST_OPTION, [SecretRotation.Auth0ClientSecret]: AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION, [SecretRotation.AzureClientSecret]: AZURE_CLIENT_SECRET_ROTATION_LIST_OPTION, [SecretRotation.AwsIamUserSecret]: AWS_IAM_USER_SECRET_ROTATION_LIST_OPTION, @@ -228,3 +232,30 @@ export const parseRotationErrorMessage = (err: unknown): string => { ? errorMessage : `${errorMessage.substring(0, MAX_MESSAGE_LENGTH - 3)}...`; }; + +function haveUnequalProperties(obj1: T, obj2: T, properties: (keyof T)[]): boolean { + return properties.some((prop) => obj1[prop] !== obj2[prop]); +} + +export const throwOnImmutableParameterUpdate = ( + updatePayload: TUpdateSecretRotationV2DTO, + secretRotation: TSecretRotationV2Raw +) => { + if (!updatePayload.parameters) return; + + switch (updatePayload.type) { + case SecretRotation.LdapPassword: + if ( + haveUnequalProperties( + updatePayload.parameters as TLdapPasswordRotation["parameters"], + secretRotation.parameters as TLdapPasswordRotation["parameters"], + ["rotationMethod", "dn"] + ) + ) { + throw new BadRequestError({ message: "Cannot update rotation method or DN" }); + } + break; + default: + // do nothing + } +}; 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 f4ea75558..bd70336c4 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 @@ -4,6 +4,7 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums export const SECRET_ROTATION_NAME_MAP: Record = { [SecretRotation.PostgresCredentials]: "PostgreSQL Credentials", [SecretRotation.MsSqlCredentials]: "Microsoft SQL Server Credentials", + [SecretRotation.MySqlCredentials]: "MySQL Credentials", [SecretRotation.Auth0ClientSecret]: "Auth0 Client Secret", [SecretRotation.AzureClientSecret]: "Azure Client Secret", [SecretRotation.AwsIamUserSecret]: "AWS IAM User Secret", @@ -13,6 +14,7 @@ export const SECRET_ROTATION_NAME_MAP: Record = { export const SECRET_ROTATION_CONNECTION_MAP: Record = { [SecretRotation.PostgresCredentials]: AppConnection.Postgres, [SecretRotation.MsSqlCredentials]: AppConnection.MsSql, + [SecretRotation.MySqlCredentials]: AppConnection.MySql, [SecretRotation.Auth0ClientSecret]: AppConnection.Auth0, [SecretRotation.AzureClientSecret]: AppConnection.AzureClientSecrets, [SecretRotation.AwsIamUserSecret]: AppConnection.AWS, 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 69743f133..6bf9c9b77 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 @@ -25,7 +25,8 @@ import { getNextUtcRotationInterval, getSecretRotationRotateSecretJobOptions, listSecretRotationOptions, - parseRotationErrorMessage + parseRotationErrorMessage, + throwOnImmutableParameterUpdate } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-fns"; import { SECRET_ROTATION_CONNECTION_MAP, @@ -46,6 +47,7 @@ import { TSecretRotationV2, TSecretRotationV2GeneratedCredentials, TSecretRotationV2Raw, + TSecretRotationV2TemporaryParameters, TSecretRotationV2WithConnection, TUpdateSecretRotationV2DTO } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; @@ -112,11 +114,13 @@ const MAX_GENERATED_CREDENTIALS_LENGTH = 2; type TRotationFactoryImplementation = TRotationFactory< TSecretRotationV2WithConnection, - TSecretRotationV2GeneratedCredentials + TSecretRotationV2GeneratedCredentials, + TSecretRotationV2TemporaryParameters >; const SECRET_ROTATION_FACTORY_MAP: Record = { [SecretRotation.PostgresCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation, [SecretRotation.MsSqlCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation, + [SecretRotation.MySqlCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation, [SecretRotation.Auth0ClientSecret]: auth0ClientSecretRotationFactory as TRotationFactoryImplementation, [SecretRotation.AzureClientSecret]: azureClientSecretRotationFactory as TRotationFactoryImplementation, [SecretRotation.AwsIamUserSecret]: awsIamUserSecretRotationFactory as TRotationFactoryImplementation, @@ -400,6 +404,7 @@ export const secretRotationV2ServiceFactory = ({ environment, rotateAtUtc = { hours: 0, minutes: 0 }, secretsMapping, + temporaryParameters, ...payload }: TCreateSecretRotationV2DTO, actor: OrgServiceActor @@ -546,7 +551,7 @@ export const secretRotationV2ServiceFactory = ({ return createdRotation; }); - }); + }, temporaryParameters); await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folder.id); @@ -585,10 +590,7 @@ export const secretRotationV2ServiceFactory = ({ } }; - const updateSecretRotation = async ( - { type, rotationId, ...payload }: TUpdateSecretRotationV2DTO, - actor: OrgServiceActor - ) => { + const updateSecretRotation = async (dto: TUpdateSecretRotationV2DTO, actor: OrgServiceActor) => { const plan = await licenseService.getPlan(actor.orgId); if (!plan.secretRotation) @@ -596,6 +598,8 @@ export const secretRotationV2ServiceFactory = ({ message: "Failed to update secret rotation due to plan restriction. Upgrade plan to update secret rotations." }); + const { type, rotationId, ...payload } = dto; + const secretRotation = await secretRotationV2DAL.findById(rotationId); if (!secretRotation) @@ -603,6 +607,8 @@ export const secretRotationV2ServiceFactory = ({ message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID ${rotationId}` }); + throwOnImmutableParameterUpdate(dto, secretRotation); + const { folder, environment, projectId, folderId, connection } = secretRotation; const secretsMapping = secretRotation.secretsMapping as TSecretRotationV2["secretsMapping"]; @@ -877,6 +883,7 @@ export const secretRotationV2ServiceFactory = ({ const inactiveIndex = (activeIndex + 1) % MAX_GENERATED_CREDENTIALS_LENGTH; const inactiveCredentials = generatedCredentials[inactiveIndex]; + const activeCredentials = generatedCredentials[activeIndex]; const rotationFactory = SECRET_ROTATION_FACTORY_MAP[type as SecretRotation]( { @@ -887,73 +894,77 @@ export const secretRotationV2ServiceFactory = ({ kmsService ); - const updatedRotation = await rotationFactory.rotateCredentials(inactiveCredentials, async (newCredentials) => { - const updatedCredentials = [...generatedCredentials]; - updatedCredentials[inactiveIndex] = newCredentials; + const updatedRotation = await rotationFactory.rotateCredentials( + inactiveCredentials, + async (newCredentials) => { + const updatedCredentials = [...generatedCredentials]; + updatedCredentials[inactiveIndex] = newCredentials; - const encryptedUpdatedCredentials = await encryptSecretRotationCredentials({ - projectId, - generatedCredentials: updatedCredentials as TSecretRotationV2GeneratedCredentials, - kmsService - }); - - return secretRotationV2DAL.transaction(async (tx) => { - const secretsPayload = rotationFactory.getSecretsPayload(newCredentials); - - const { encryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.SecretManager, - projectId + const encryptedUpdatedCredentials = await encryptSecretRotationCredentials({ + projectId, + generatedCredentials: updatedCredentials as TSecretRotationV2GeneratedCredentials, + kmsService }); - // update mapped secrets with new credential values - await fnSecretBulkUpdate({ - folderId, - orgId: connection.orgId, - tx, - inputSecrets: secretsPayload.map(({ key, value }) => ({ - filter: { - key, - folderId, - type: SecretType.Shared - }, - data: { - encryptedValue: encryptor({ - plainText: Buffer.from(value) - }).cipherTextBlob, - references: [] - } - })), - secretDAL: secretV2BridgeDAL, - secretVersionDAL: secretVersionV2BridgeDAL, - secretVersionTagDAL: secretVersionTagV2BridgeDAL, - secretTagDAL, - resourceMetadataDAL - }); + return secretRotationV2DAL.transaction(async (tx) => { + const secretsPayload = rotationFactory.getSecretsPayload(newCredentials); - const currentTime = new Date(); + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); - return secretRotationV2DAL.updateById( - secretRotation.id, - { - encryptedGeneratedCredentials: encryptedUpdatedCredentials, - activeIndex: inactiveIndex, - isLastRotationManual: isManualRotation, - lastRotatedAt: currentTime, - lastRotationAttemptedAt: currentTime, - nextRotationAt: calculateNextRotationAt({ - ...(secretRotation as TSecretRotationV2), - rotationStatus: SecretRotationStatus.Success, + // update mapped secrets with new credential values + await fnSecretBulkUpdate({ + folderId, + orgId: connection.orgId, + tx, + inputSecrets: secretsPayload.map(({ key, value }) => ({ + filter: { + key, + folderId, + type: SecretType.Shared + }, + data: { + encryptedValue: encryptor({ + plainText: Buffer.from(value) + }).cipherTextBlob, + references: [] + } + })), + secretDAL: secretV2BridgeDAL, + secretVersionDAL: secretVersionV2BridgeDAL, + secretVersionTagDAL: secretVersionTagV2BridgeDAL, + secretTagDAL, + resourceMetadataDAL + }); + + const currentTime = new Date(); + + return secretRotationV2DAL.updateById( + secretRotation.id, + { + encryptedGeneratedCredentials: encryptedUpdatedCredentials, + activeIndex: inactiveIndex, + isLastRotationManual: isManualRotation, lastRotatedAt: currentTime, - isManualRotation - }), - rotationStatus: SecretRotationStatus.Success, - lastRotationJobId: jobId, - encryptedLastRotationMessage: null - }, - tx - ); - }); - }); + lastRotationAttemptedAt: currentTime, + nextRotationAt: calculateNextRotationAt({ + ...(secretRotation as TSecretRotationV2), + rotationStatus: SecretRotationStatus.Success, + lastRotatedAt: currentTime, + isManualRotation + }), + rotationStatus: SecretRotationStatus.Success, + lastRotationJobId: jobId, + encryptedLastRotationMessage: null + }, + tx + ); + }); + }, + activeCredentials + ); await auditLogService.createAuditLog({ ...(auditLogInfo ?? { diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts index ab715c406..3fe42a983 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts @@ -39,6 +39,12 @@ import { TMsSqlCredentialsRotationListItem, TMsSqlCredentialsRotationWithConnection } from "./mssql-credentials"; +import { + TMySqlCredentialsRotation, + TMySqlCredentialsRotationInput, + TMySqlCredentialsRotationListItem, + TMySqlCredentialsRotationWithConnection +} from "./mysql-credentials"; import { TPostgresCredentialsRotation, TPostgresCredentialsRotationInput, @@ -51,6 +57,7 @@ import { SecretRotation } from "./secret-rotation-v2-enums"; export type TSecretRotationV2 = | TPostgresCredentialsRotation | TMsSqlCredentialsRotation + | TMySqlCredentialsRotation | TAuth0ClientSecretRotation | TAzureClientSecretRotation | TLdapPasswordRotation @@ -59,6 +66,7 @@ export type TSecretRotationV2 = export type TSecretRotationV2WithConnection = | TPostgresCredentialsRotationWithConnection | TMsSqlCredentialsRotationWithConnection + | TMySqlCredentialsRotationWithConnection | TAuth0ClientSecretRotationWithConnection | TAzureClientSecretRotationWithConnection | TLdapPasswordRotationWithConnection @@ -74,6 +82,7 @@ export type TSecretRotationV2GeneratedCredentials = export type TSecretRotationV2Input = | TPostgresCredentialsRotationInput | TMsSqlCredentialsRotationInput + | TMySqlCredentialsRotationInput | TAuth0ClientSecretRotationInput | TAzureClientSecretRotationInput | TLdapPasswordRotationInput @@ -82,11 +91,14 @@ export type TSecretRotationV2Input = export type TSecretRotationV2ListItem = | TPostgresCredentialsRotationListItem | TMsSqlCredentialsRotationListItem + | TMySqlCredentialsRotationListItem | TAuth0ClientSecretRotationListItem | TAzureClientSecretRotationListItem | TLdapPasswordRotationListItem | TAwsIamUserSecretRotationListItem; +export type TSecretRotationV2TemporaryParameters = TLdapPasswordRotationInput["temporaryParameters"] | undefined; + export type TSecretRotationV2Raw = NonNullable>>; export type TListSecretRotationsV2ByProjectId = { @@ -120,6 +132,7 @@ export type TCreateSecretRotationV2DTO = Pick< environment: string; isAutoRotationEnabled?: boolean; rotateAtUtc?: TRotateAtUtc; + temporaryParameters?: TSecretRotationV2TemporaryParameters; }; export type TUpdateSecretRotationV2DTO = Partial< @@ -186,8 +199,12 @@ export type TSecretRotationSendNotificationJobPayload = { // transactional behavior. By passing in the rotation mutation, if this mutation fails we can roll back the // third party credential changes (when supported), preventing credentials getting out of sync -export type TRotationFactoryIssueCredentials = ( - callback: (newCredentials: T[number]) => Promise +export type TRotationFactoryIssueCredentials< + T extends TSecretRotationV2GeneratedCredentials, + P extends TSecretRotationV2TemporaryParameters = undefined +> = ( + callback: (newCredentials: T[number]) => Promise, + temporaryParameters?: P ) => Promise; export type TRotationFactoryRevokeCredentials = ( @@ -197,7 +214,8 @@ export type TRotationFactoryRevokeCredentials = ( credentialsToRevoke: T[number] | undefined, - callback: (newCredentials: T[number]) => Promise + callback: (newCredentials: T[number]) => Promise, + activeCredentials: T[number] ) => Promise; export type TRotationFactoryGetSecretsPayload = ( @@ -206,13 +224,14 @@ export type TRotationFactoryGetSecretsPayload = ( secretRotation: T, appConnectionDAL: Pick, kmsService: Pick ) => { - issueCredentials: TRotationFactoryIssueCredentials; + issueCredentials: TRotationFactoryIssueCredentials; revokeCredentials: TRotationFactoryRevokeCredentials; rotateCredentials: TRotationFactoryRotateCredentials; getSecretsPayload: TRotationFactoryGetSecretsPayload; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts index f6fdafe1d..cbbf44e7e 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts @@ -4,6 +4,7 @@ import { Auth0ClientSecretRotationSchema } from "@app/ee/services/secret-rotatio import { AzureClientSecretRotationSchema } from "@app/ee/services/secret-rotation-v2/azure-client-secret"; import { LdapPasswordRotationSchema } from "@app/ee/services/secret-rotation-v2/ldap-password"; import { MsSqlCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; +import { MySqlCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/mysql-credentials"; import { PostgresCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; import { AwsIamUserSecretRotationSchema } from "./aws-iam-user-secret"; @@ -11,6 +12,7 @@ import { AwsIamUserSecretRotationSchema } from "./aws-iam-user-secret"; export const SecretRotationV2Schema = z.discriminatedUnion("type", [ PostgresCredentialsRotationSchema, MsSqlCredentialsRotationSchema, + MySqlCredentialsRotationSchema, Auth0ClientSecretRotationSchema, AzureClientSecretRotationSchema, LdapPasswordRotationSchema, diff --git a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types.ts index 6eada6019..ab06074d7 100644 --- a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types.ts +++ b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types.ts @@ -1,13 +1,15 @@ import { z } from "zod"; import { TMsSqlCredentialsRotationWithConnection } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; +import { TMySqlCredentialsRotationWithConnection } from "@app/ee/services/secret-rotation-v2/mysql-credentials"; import { TPostgresCredentialsRotationWithConnection } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; import { SqlCredentialsRotationGeneratedCredentialsSchema } from "./sql-credentials-rotation-schemas"; export type TSqlCredentialsRotationWithConnection = | TPostgresCredentialsRotationWithConnection - | TMsSqlCredentialsRotationWithConnection; + | TMsSqlCredentialsRotationWithConnection + | TMySqlCredentialsRotationWithConnection; export type TSqlCredentialsRotationGeneratedCredentials = z.infer< typeof SqlCredentialsRotationGeneratedCredentialsSchema diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts index e3c6b6b5c..dd2b5a5ea 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts @@ -171,6 +171,13 @@ export const getDbSetQuery = (db: TDbProviderClients, variables: { username: str }; } + if (db === TDbProviderClients.MySql) { + return { + query: `ALTER USER ??@'%' IDENTIFIED BY '${variables.password}'`, + variables: [variables.username] + }; + } + // add more based on client return { query: `ALTER USER ?? IDENTIFIED BY '${variables.password}'`, diff --git a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-constants.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-constants.ts new file mode 100644 index 000000000..a8dd2eb42 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-constants.ts @@ -0,0 +1,9 @@ +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TSecretScanningDataSourceListItem } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const GITHUB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION: TSecretScanningDataSourceListItem = { + name: "GitHub", + type: SecretScanningDataSource.GitHub, + connection: AppConnection.GitHubRadar +}; diff --git a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts new file mode 100644 index 000000000..2dde97d7c --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts @@ -0,0 +1,230 @@ +import { join } from "path"; +import { ProbotOctokit } from "probot"; + +import { scanContentAndGetFindings } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns"; +import { SecretMatch } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; +import { + SecretScanningDataSource, + SecretScanningFindingSeverity, + SecretScanningResource +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + cloneRepository, + convertPatchLineToFileLineNumber, + replaceNonChangesWithNewlines +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-fns"; +import { + TSecretScanningFactoryGetDiffScanFindingsPayload, + TSecretScanningFactoryGetDiffScanResourcePayload, + TSecretScanningFactoryGetFullScanPath, + TSecretScanningFactoryInitialize, + TSecretScanningFactoryListRawResources, + TSecretScanningFactoryPostInitialization +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +import { titleCaseToCamelCase } from "@app/lib/fn"; +import { GitHubRepositoryRegex } from "@app/lib/regex"; +import { listGitHubRadarRepositories, TGitHubRadarConnection } from "@app/services/app-connection/github-radar"; + +import { TGitHubDataSourceWithConnection, TQueueGitHubResourceDiffScan } from "./github-secret-scanning-types"; + +export const GitHubSecretScanningFactory = () => { + const initialize: TSecretScanningFactoryInitialize = async ( + { connection, secretScanningV2DAL }, + callback + ) => { + const externalId = connection.credentials.installationId; + + const existingDataSource = await secretScanningV2DAL.dataSources.findOne({ + externalId, + type: SecretScanningDataSource.GitHub + }); + + if (existingDataSource) + throw new BadRequestError({ + message: `A Data Source already exists for this GitHub Radar Connection in the Project with ID "${existingDataSource.projectId}"` + }); + + return callback({ + externalId + }); + }; + + const postInitialization: TSecretScanningFactoryPostInitialization = async () => { + // no post-initialization required + }; + + const listRawResources: TSecretScanningFactoryListRawResources = async ( + dataSource + ) => { + const { + connection, + config: { includeRepos } + } = dataSource; + + const repos = await listGitHubRadarRepositories(connection); + + const filteredRepos: typeof repos = []; + if (includeRepos.includes("*")) { + filteredRepos.push(...repos); + } else { + filteredRepos.push(...repos.filter((repo) => includeRepos.includes(repo.full_name))); + } + + return filteredRepos.map(({ id, full_name }) => ({ + name: full_name, + externalId: id.toString(), + type: SecretScanningResource.Repository + })); + }; + + const getFullScanPath: TSecretScanningFactoryGetFullScanPath = async ({ + dataSource, + resourceName, + tempFolder + }) => { + const appCfg = getConfig(); + const { + connection: { + credentials: { installationId } + } + } = dataSource; + + const octokit = new ProbotOctokit({ + auth: { + appId: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_ID, + privateKey: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY, + installationId + } + }); + + const { + data: { token } + } = await octokit.apps.createInstallationAccessToken({ + installation_id: Number(installationId) + }); + + const repoPath = join(tempFolder, "repo.git"); + + if (!GitHubRepositoryRegex.test(resourceName)) { + throw new Error("Invalid GitHub repository name"); + } + + await cloneRepository({ + cloneUrl: `https://x-access-token:${token}@github.com/${resourceName}.git`, + repoPath + }); + + return repoPath; + }; + + const getDiffScanResourcePayload: TSecretScanningFactoryGetDiffScanResourcePayload< + TQueueGitHubResourceDiffScan["payload"] + > = ({ repository }) => { + return { + name: repository.full_name, + externalId: repository.id.toString(), + type: SecretScanningResource.Repository + }; + }; + + const getDiffScanFindingsPayload: TSecretScanningFactoryGetDiffScanFindingsPayload< + TGitHubDataSourceWithConnection, + TQueueGitHubResourceDiffScan["payload"] + > = async ({ dataSource, payload, resourceName, configPath }) => { + const appCfg = getConfig(); + const { + connection: { + credentials: { installationId } + } + } = dataSource; + + const octokit = new ProbotOctokit({ + auth: { + appId: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_ID, + privateKey: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY, + installationId + } + }); + + const { commits, repository } = payload; + + const [owner, repo] = repository.full_name.split("/"); + + const allFindings: SecretMatch[] = []; + + for (const commit of commits) { + // eslint-disable-next-line no-await-in-loop + const commitData = await octokit.repos.getCommit({ + owner, + repo, + ref: commit.id + }); + + // eslint-disable-next-line no-continue + if (!commitData.data.files) continue; + + for (const file of commitData.data.files) { + if ((file.status === "added" || file.status === "modified") && file.patch) { + // eslint-disable-next-line + const findings = await scanContentAndGetFindings( + replaceNonChangesWithNewlines(`\n${file.patch}`), + configPath + ); + + const adjustedFindings = findings.map((finding) => { + const startLine = convertPatchLineToFileLineNumber(file.patch!, finding.StartLine); + const endLine = + finding.StartLine === finding.EndLine + ? startLine + : convertPatchLineToFileLineNumber(file.patch!, finding.EndLine); + const startColumn = finding.StartColumn - 1; // subtract 1 for + + const endColumn = finding.EndColumn - 1; // subtract 1 for + + + return { + ...finding, + StartLine: startLine, + EndLine: endLine, + StartColumn: startColumn, + EndColumn: endColumn, + File: file.filename, + Commit: commit.id, + Author: commit.author.name, + Email: commit.author.email ?? "", + Message: commit.message, + Fingerprint: `${commit.id}:${file.filename}:${finding.RuleID}:${startLine}:${startColumn}`, + Date: commit.timestamp, + Link: `https://github.com/${resourceName}/blob/${commit.id}/${file.filename}#L${startLine}` + }; + }); + + allFindings.push(...adjustedFindings); + } + } + } + + return allFindings.map( + ({ + // discard match and secret as we don't want to store + Match, + Secret, + ...finding + }) => ({ + details: titleCaseToCamelCase(finding), + fingerprint: finding.Fingerprint, + severity: SecretScanningFindingSeverity.High, + rule: finding.RuleID + }) + ); + }; + + return { + initialize, + postInitialization, + listRawResources, + getFullScanPath, + getDiffScanResourcePayload, + getDiffScanFindingsPayload + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-schemas.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-schemas.ts new file mode 100644 index 000000000..f1eec125c --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-schemas.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +import { + SecretScanningDataSource, + SecretScanningResource +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + BaseCreateSecretScanningDataSourceSchema, + BaseSecretScanningDataSourceSchema, + BaseSecretScanningFindingSchema, + BaseUpdateSecretScanningDataSourceSchema, + GitRepositoryScanFindingDetailsSchema +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-schemas"; +import { SecretScanningDataSources } from "@app/lib/api-docs"; +import { GitHubRepositoryRegex } from "@app/lib/regex"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const GitHubDataSourceConfigSchema = z.object({ + includeRepos: z + .array( + z + .string() + .min(1) + .max(256) + .refine((value) => value === "*" || GitHubRepositoryRegex.test(value), "Invalid repository name format") + ) + .nonempty("One or more repositories required") + .max(100, "Cannot configure more than 100 repositories") + .default(["*"]) + .describe(SecretScanningDataSources.CONFIG.GITHUB.includeRepos) +}); + +export const GitHubDataSourceSchema = BaseSecretScanningDataSourceSchema({ + type: SecretScanningDataSource.GitHub, + isConnectionRequired: true +}) + .extend({ + config: GitHubDataSourceConfigSchema + }) + .describe( + JSON.stringify({ + title: "GitHub" + }) + ); + +export const CreateGitHubDataSourceSchema = BaseCreateSecretScanningDataSourceSchema({ + type: SecretScanningDataSource.GitHub, + isConnectionRequired: true +}) + .extend({ + config: GitHubDataSourceConfigSchema + }) + .describe( + JSON.stringify({ + title: "GitHub" + }) + ); + +export const UpdateGitHubDataSourceSchema = BaseUpdateSecretScanningDataSourceSchema(SecretScanningDataSource.GitHub) + .extend({ + config: GitHubDataSourceConfigSchema.optional() + }) + .describe( + JSON.stringify({ + title: "GitHub" + }) + ); + +export const GitHubDataSourceListItemSchema = z + .object({ + name: z.literal("GitHub"), + connection: z.literal(AppConnection.GitHubRadar), + type: z.literal(SecretScanningDataSource.GitHub) + }) + .describe( + JSON.stringify({ + title: "GitHub" + }) + ); + +export const GitHubFindingSchema = BaseSecretScanningFindingSchema.extend({ + resourceType: z.literal(SecretScanningResource.Repository), + dataSourceType: z.literal(SecretScanningDataSource.GitHub), + details: GitRepositoryScanFindingDetailsSchema +}); diff --git a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-service.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-service.ts new file mode 100644 index 000000000..8e38e04c1 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-service.ts @@ -0,0 +1,87 @@ +import { PushEvent } from "@octokit/webhooks-types"; + +import { TSecretScanningV2DALFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-dal"; +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TSecretScanningV2QueueServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-queue"; +import { logger } from "@app/lib/logger"; + +import { TGitHubDataSource } from "./github-secret-scanning-types"; + +export const githubSecretScanningService = ( + secretScanningV2DAL: TSecretScanningV2DALFactory, + secretScanningV2Queue: Pick +) => { + const handleInstallationDeletedEvent = async (installationId: number) => { + const dataSource = await secretScanningV2DAL.dataSources.findOne({ + externalId: String(installationId), + type: SecretScanningDataSource.GitHub + }); + + if (!dataSource) { + logger.error( + `secretScanningV2RemoveEvent: GitHub - Could not find data source [installationId=${installationId}]` + ); + return; + } + + logger.info( + `secretScanningV2RemoveEvent: GitHub - installation deleted [installationId=${installationId}] [dataSourceId=${dataSource.id}]` + ); + + await secretScanningV2DAL.dataSources.updateById(dataSource.id, { + isDisconnected: true + }); + }; + + const handlePushEvent = async (payload: PushEvent) => { + const { commits, repository, installation } = payload; + + if (!commits || !repository || !installation) { + logger.warn( + `secretScanningV2PushEvent: GitHub - Insufficient data [commits=${commits?.length ?? 0}] [repository=${repository.name}] [installationId=${installation?.id}]` + ); + return; + } + + const dataSource = (await secretScanningV2DAL.dataSources.findOne({ + externalId: String(installation.id), + type: SecretScanningDataSource.GitHub + })) as TGitHubDataSource | undefined; + + if (!dataSource) { + logger.error( + `secretScanningV2PushEvent: GitHub - Could not find data source [installationId=${installation.id}]` + ); + return; + } + + const { + isAutoScanEnabled, + config: { includeRepos } + } = dataSource; + + if (!isAutoScanEnabled) { + logger.info( + `secretScanningV2PushEvent: GitHub - ignoring due to auto scan disabled [dataSourceId=${dataSource.id}] [installationId=${installation.id}]` + ); + return; + } + + if (includeRepos.includes("*") || includeRepos.includes(repository.full_name)) { + await secretScanningV2Queue.queueResourceDiffScan({ + dataSourceType: SecretScanningDataSource.GitHub, + payload, + dataSourceId: dataSource.id + }); + } else { + logger.info( + `secretScanningV2PushEvent: GitHub - ignoring due to repository not being present in config [installationId=${installation.id}] [dataSourceId=${dataSource.id}]` + ); + } + }; + + return { + handlePushEvent, + handleInstallationDeletedEvent + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-types.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-types.ts new file mode 100644 index 000000000..90b910d44 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-types.ts @@ -0,0 +1,32 @@ +import { PushEvent } from "@octokit/webhooks-types"; +import { z } from "zod"; + +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TGitHubRadarConnection } from "@app/services/app-connection/github-radar"; + +import { + CreateGitHubDataSourceSchema, + GitHubDataSourceListItemSchema, + GitHubDataSourceSchema, + GitHubFindingSchema +} from "./github-secret-scanning-schemas"; + +export type TGitHubDataSource = z.infer; + +export type TGitHubDataSourceInput = z.infer; + +export type TGitHubDataSourceListItem = z.infer; + +export type TGitHubFinding = z.infer; + +export type TGitHubDataSourceWithConnection = TGitHubDataSource & { + connection: TGitHubRadarConnection; +}; + +export type TQueueGitHubResourceDiffScan = { + dataSourceType: SecretScanningDataSource.GitHub; + payload: PushEvent; + dataSourceId: string; + resourceId: string; + scanId: string; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/github/index.ts b/backend/src/ee/services/secret-scanning-v2/github/index.ts new file mode 100644 index 000000000..b8bc755a6 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/github/index.ts @@ -0,0 +1,3 @@ +export * from "./github-secret-scanning-constants"; +export * from "./github-secret-scanning-schemas"; +export * from "./github-secret-scanning-types"; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-dal.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-dal.ts new file mode 100644 index 000000000..447ffc22a --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-dal.ts @@ -0,0 +1,460 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { + SecretScanningResourcesSchema, + SecretScanningScansSchema, + TableName, + TSecretScanningDataSources +} from "@app/db/schemas"; +import { SecretScanningFindingStatus } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { DatabaseError } from "@app/lib/errors"; +import { + buildFindFilter, + ormify, + prependTableNameToFindFilter, + selectAllTableCols, + sqlNestRelationships, + TFindOpt +} from "@app/lib/knex"; + +export type TSecretScanningV2DALFactory = ReturnType; + +type TSecretScanningDataSourceFindFilter = Parameters>[0]; +type TSecretScanningDataSourceFindOptions = TFindOpt; + +const baseSecretScanningDataSourceQuery = ({ + filter = {}, + db, + tx +}: { + db: TDbClient; + filter?: TSecretScanningDataSourceFindFilter; + options?: TSecretScanningDataSourceFindOptions; + tx?: Knex; +}) => { + const query = (tx || db.replicaNode())(TableName.SecretScanningDataSource) + .join( + TableName.AppConnection, + `${TableName.SecretScanningDataSource}.connectionId`, + `${TableName.AppConnection}.id` + ) + .select(selectAllTableCols(TableName.SecretScanningDataSource)) + .select( + // entire connection + db.ref("name").withSchema(TableName.AppConnection).as("connectionName"), + db.ref("method").withSchema(TableName.AppConnection).as("connectionMethod"), + db.ref("app").withSchema(TableName.AppConnection).as("connectionApp"), + db.ref("orgId").withSchema(TableName.AppConnection).as("connectionOrgId"), + db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("connectionEncryptedCredentials"), + db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"), + db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"), + db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"), + db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"), + db + .ref("isPlatformManagedCredentials") + .withSchema(TableName.AppConnection) + .as("connectionIsPlatformManagedCredentials") + ); + + if (filter) { + /* eslint-disable @typescript-eslint/no-misused-promises */ + void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.SecretScanningDataSource, filter))); + } + + return query; +}; + +const expandSecretScanningDataSource = < + T extends Awaited>[number] +>( + dataSource: T +) => { + const { + connectionApp, + connectionName, + connectionId, + connectionOrgId, + connectionEncryptedCredentials, + connectionMethod, + connectionDescription, + connectionCreatedAt, + connectionUpdatedAt, + connectionVersion, + connectionIsPlatformManagedCredentials, + ...el + } = dataSource; + + return { + ...el, + connectionId, + connection: connectionId + ? { + app: connectionApp, + id: connectionId, + name: connectionName, + orgId: connectionOrgId, + encryptedCredentials: connectionEncryptedCredentials, + method: connectionMethod, + description: connectionDescription, + createdAt: connectionCreatedAt, + updatedAt: connectionUpdatedAt, + version: connectionVersion, + isPlatformManagedCredentials: connectionIsPlatformManagedCredentials + } + : undefined + }; +}; + +export const secretScanningV2DALFactory = (db: TDbClient) => { + const dataSourceOrm = ormify(db, TableName.SecretScanningDataSource); + const resourceOrm = ormify(db, TableName.SecretScanningResource); + const scanOrm = ormify(db, TableName.SecretScanningScan); + const findingOrm = ormify(db, TableName.SecretScanningFinding); + const configOrm = ormify(db, TableName.SecretScanningConfig); + + const findDataSource = async (filter: Parameters<(typeof dataSourceOrm)["find"]>[0], tx?: Knex) => { + try { + const dataSources = await baseSecretScanningDataSourceQuery({ filter, db, tx }); + + if (!dataSources.length) return []; + + return dataSources.map(expandSecretScanningDataSource); + } catch (error) { + throw new DatabaseError({ error, name: "Find - Secret Scanning Data Source" }); + } + }; + + const findDataSourceById = async (id: string, tx?: Knex) => { + try { + const dataSource = await baseSecretScanningDataSourceQuery({ filter: { id }, db, tx }).first(); + + if (dataSource) return expandSecretScanningDataSource(dataSource); + } catch (error) { + throw new DatabaseError({ error, name: "Find By ID - Secret Scanning Data Source" }); + } + }; + + const createDataSource = async (data: Parameters<(typeof dataSourceOrm)["create"]>[0], tx?: Knex) => { + const source = await dataSourceOrm.create(data, tx); + + const dataSource = (await baseSecretScanningDataSourceQuery({ + filter: { id: source.id }, + db, + tx + }).first())!; + + return expandSecretScanningDataSource(dataSource); + }; + + const updateDataSourceById = async ( + dataSourceId: string, + data: Parameters<(typeof dataSourceOrm)["updateById"]>[1], + tx?: Knex + ) => { + const source = await dataSourceOrm.updateById(dataSourceId, data, tx); + + const dataSource = (await baseSecretScanningDataSourceQuery({ + filter: { id: source.id }, + db, + tx + }).first())!; + + return expandSecretScanningDataSource(dataSource); + }; + + const deleteDataSourceById = async (dataSourceId: string, tx?: Knex) => { + const dataSource = (await baseSecretScanningDataSourceQuery({ + filter: { id: dataSourceId }, + db, + tx + }).first())!; + + await dataSourceOrm.deleteById(dataSourceId, tx); + + return expandSecretScanningDataSource(dataSource); + }; + + const findOneDataSource = async (filter: Parameters<(typeof dataSourceOrm)["findOne"]>[0], tx?: Knex) => { + try { + const dataSource = await baseSecretScanningDataSourceQuery({ filter, db, tx }).first(); + + if (dataSource) { + return expandSecretScanningDataSource(dataSource); + } + } catch (error) { + throw new DatabaseError({ error, name: "Find One - Secret Scanning Data Source" }); + } + }; + + const findDataSourceWithDetails = async (filter: Parameters<(typeof dataSourceOrm)["find"]>[0], tx?: Knex) => { + try { + // TODO (scott): this query will probably need to be optimized + + const dataSources = await baseSecretScanningDataSourceQuery({ filter, db, tx }) + .leftJoin( + TableName.SecretScanningResource, + `${TableName.SecretScanningResource}.dataSourceId`, + `${TableName.SecretScanningDataSource}.id` + ) + .leftJoin( + TableName.SecretScanningScan, + `${TableName.SecretScanningScan}.resourceId`, + `${TableName.SecretScanningResource}.id` + ) + .leftJoin( + TableName.SecretScanningFinding, + `${TableName.SecretScanningFinding}.scanId`, + `${TableName.SecretScanningScan}.id` + ) + .where((qb) => { + void qb + .where(`${TableName.SecretScanningFinding}.status`, SecretScanningFindingStatus.Unresolved) + .orWhereNull(`${TableName.SecretScanningFinding}.status`); + }) + .select( + db.ref("id").withSchema(TableName.SecretScanningScan).as("scanId"), + db.ref("status").withSchema(TableName.SecretScanningScan).as("scanStatus"), + db.ref("statusMessage").withSchema(TableName.SecretScanningScan).as("scanStatusMessage"), + db.ref("createdAt").withSchema(TableName.SecretScanningScan).as("scanCreatedAt"), + db.ref("status").withSchema(TableName.SecretScanningFinding).as("findingStatus"), + db.ref("id").withSchema(TableName.SecretScanningFinding).as("findingId") + ); + + if (!dataSources.length) return []; + + const results = sqlNestRelationships({ + data: dataSources, + key: "id", + parentMapper: (dataSource) => expandSecretScanningDataSource(dataSource), + childrenMapper: [ + { + key: "scanId", + label: "scans" as const, + mapper: ({ scanId, scanCreatedAt, scanStatus, scanStatusMessage }) => ({ + id: scanId, + createdAt: scanCreatedAt, + status: scanStatus, + statusMessage: scanStatusMessage + }) + }, + { + key: "findingId", + label: "findings" as const, + mapper: ({ findingId }) => ({ + id: findingId + }) + } + ] + }); + + return results.map(({ scans, findings, ...dataSource }) => { + const lastScan = + scans && scans.length + ? scans.reduce((latest, current) => { + return new Date(current.createdAt) > new Date(latest.createdAt) ? current : latest; + }) + : null; + + return { + ...dataSource, + lastScanStatus: lastScan?.status ?? null, + lastScanStatusMessage: lastScan?.statusMessage ?? null, + lastScannedAt: lastScan?.createdAt ?? null, + unresolvedFindings: scans.length ? findings.length : null + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find with Details - Secret Scanning Data Source" }); + } + }; + + const findResourcesWithDetails = async (filter: Parameters<(typeof resourceOrm)["find"]>[0], tx?: Knex) => { + try { + // TODO (scott): this query will probably need to be optimized + + const resources = await (tx || db.replicaNode())(TableName.SecretScanningResource) + .where((qb) => { + if (filter) + void qb.where(buildFindFilter(prependTableNameToFindFilter(TableName.SecretScanningResource, filter))); + }) + .leftJoin( + TableName.SecretScanningScan, + `${TableName.SecretScanningScan}.resourceId`, + `${TableName.SecretScanningResource}.id` + ) + .leftJoin( + TableName.SecretScanningFinding, + `${TableName.SecretScanningFinding}.scanId`, + `${TableName.SecretScanningScan}.id` + ) + .where((qb) => { + void qb + .where(`${TableName.SecretScanningFinding}.status`, SecretScanningFindingStatus.Unresolved) + .orWhereNull(`${TableName.SecretScanningFinding}.status`); + }) + .select(selectAllTableCols(TableName.SecretScanningResource)) + .select( + db.ref("id").withSchema(TableName.SecretScanningScan).as("scanId"), + db.ref("status").withSchema(TableName.SecretScanningScan).as("scanStatus"), + db.ref("type").withSchema(TableName.SecretScanningScan).as("scanType"), + db.ref("statusMessage").withSchema(TableName.SecretScanningScan).as("scanStatusMessage"), + db.ref("createdAt").withSchema(TableName.SecretScanningScan).as("scanCreatedAt"), + db.ref("status").withSchema(TableName.SecretScanningFinding).as("findingStatus"), + db.ref("id").withSchema(TableName.SecretScanningFinding).as("findingId") + ); + + if (!resources.length) return []; + + const results = sqlNestRelationships({ + data: resources, + key: "id", + parentMapper: (resource) => SecretScanningResourcesSchema.parse(resource), + childrenMapper: [ + { + key: "scanId", + label: "scans" as const, + mapper: ({ scanId, scanCreatedAt, scanStatus, scanStatusMessage, scanType }) => ({ + id: scanId, + type: scanType, + createdAt: scanCreatedAt, + status: scanStatus, + statusMessage: scanStatusMessage + }) + }, + { + key: "findingId", + label: "findings" as const, + mapper: ({ findingId }) => ({ + id: findingId + }) + } + ] + }); + + return results.map(({ scans, findings, ...resource }) => { + const lastScan = + scans && scans.length + ? scans.reduce((latest, current) => { + return new Date(current.createdAt) > new Date(latest.createdAt) ? current : latest; + }) + : null; + + return { + ...resource, + lastScanStatus: lastScan?.status ?? null, + lastScanStatusMessage: lastScan?.statusMessage ?? null, + lastScannedAt: lastScan?.createdAt ?? null, + unresolvedFindings: findings?.length ?? 0 + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find with Details - Secret Scanning Resource" }); + } + }; + + const findScansWithDetailsByDataSourceId = async (dataSourceId: string, tx?: Knex) => { + try { + // TODO (scott): this query will probably need to be optimized + + const scans = await (tx || db.replicaNode())(TableName.SecretScanningScan) + .leftJoin( + TableName.SecretScanningResource, + `${TableName.SecretScanningResource}.id`, + `${TableName.SecretScanningScan}.resourceId` + ) + .where(`${TableName.SecretScanningResource}.dataSourceId`, dataSourceId) + .leftJoin( + TableName.SecretScanningFinding, + `${TableName.SecretScanningFinding}.scanId`, + `${TableName.SecretScanningScan}.id` + ) + .select(selectAllTableCols(TableName.SecretScanningScan)) + .select( + db.ref("status").withSchema(TableName.SecretScanningFinding).as("findingStatus"), + db.ref("id").withSchema(TableName.SecretScanningFinding).as("findingId"), + db.ref("name").withSchema(TableName.SecretScanningResource).as("resourceName") + ); + + if (!scans.length) return []; + + const results = sqlNestRelationships({ + data: scans, + key: "id", + parentMapper: (scan) => SecretScanningScansSchema.parse(scan), + childrenMapper: [ + { + key: "findingId", + label: "findings" as const, + mapper: ({ findingId, findingStatus }) => ({ + id: findingId, + status: findingStatus + }) + }, + { + key: "resourceId", + label: "resources" as const, + mapper: ({ resourceName }) => ({ + name: resourceName + }) + } + ] + }); + + return results.map(({ findings, resources, ...scan }) => { + return { + ...scan, + unresolvedFindings: + findings?.filter((finding) => finding.status === SecretScanningFindingStatus.Unresolved).length ?? 0, + resolvedFindings: + findings?.filter((finding) => finding.status !== SecretScanningFindingStatus.Unresolved).length ?? 0, + resourceName: resources[0].name + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find with Details By Data Source ID - Secret Scanning Scan" }); + } + }; + + const findScansByDataSourceId = async (dataSourceId: string, tx?: Knex) => { + try { + const scans = await (tx || db.replicaNode())(TableName.SecretScanningScan) + .leftJoin( + TableName.SecretScanningResource, + `${TableName.SecretScanningResource}.id`, + `${TableName.SecretScanningScan}.resourceId` + ) + .where(`${TableName.SecretScanningResource}.dataSourceId`, dataSourceId) + + .select(selectAllTableCols(TableName.SecretScanningScan)); + + return scans; + } catch (error) { + throw new DatabaseError({ error, name: "Find By Data Source ID - Secret Scanning Scan" }); + } + }; + + return { + dataSources: { + ...dataSourceOrm, + find: findDataSource, + findById: findDataSourceById, + findOne: findOneDataSource, + create: createDataSource, + updateById: updateDataSourceById, + deleteById: deleteDataSourceById, + findWithDetails: findDataSourceWithDetails + }, + resources: { + ...resourceOrm, + findWithDetails: findResourcesWithDetails + }, + scans: { + ...scanOrm, + findWithDetailsByDataSourceId: findScansWithDetailsByDataSourceId, + findByDataSourceId: findScansByDataSourceId + }, + findings: findingOrm, + configs: configOrm + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts new file mode 100644 index 000000000..082f3d760 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts @@ -0,0 +1,33 @@ +export enum SecretScanningDataSource { + GitHub = "github" +} + +export enum SecretScanningScanStatus { + Completed = "completed", + Failed = "failed", + Queued = "queued", + Scanning = "scanning" +} + +export enum SecretScanningScanType { + FullScan = "full-scan", + DiffScan = "diff-scan" +} + +export enum SecretScanningFindingStatus { + Resolved = "resolved", + Unresolved = "unresolved", + FalsePositive = "false-positive", + Ignore = "ignore" +} + +export enum SecretScanningResource { + Repository = "repository", + Project = "project" +} + +export enum SecretScanningFindingSeverity { + High = "high", + Medium = "medium", + Low = "low" +} diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts new file mode 100644 index 000000000..109afe5f3 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts @@ -0,0 +1,19 @@ +import { GitHubSecretScanningFactory } from "@app/ee/services/secret-scanning-v2/github/github-secret-scanning-factory"; + +import { SecretScanningDataSource } from "./secret-scanning-v2-enums"; +import { + TQueueSecretScanningResourceDiffScan, + TSecretScanningDataSourceCredentials, + TSecretScanningDataSourceWithConnection, + TSecretScanningFactory +} from "./secret-scanning-v2-types"; + +type TSecretScanningFactoryImplementation = TSecretScanningFactory< + TSecretScanningDataSourceWithConnection, + TSecretScanningDataSourceCredentials, + TQueueSecretScanningResourceDiffScan["payload"] +>; + +export const SECRET_SCANNING_FACTORY_MAP: Record = { + [SecretScanningDataSource.GitHub]: GitHubSecretScanningFactory as TSecretScanningFactoryImplementation +}; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts new file mode 100644 index 000000000..64a0ba4ed --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts @@ -0,0 +1,140 @@ +import { AxiosError } from "axios"; +import { exec } from "child_process"; +import RE2 from "re2"; + +import { readFindingsFile } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns"; +import { SecretMatch } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; +import { GITHUB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION } from "@app/ee/services/secret-scanning-v2/github"; +import { titleCaseToCamelCase } from "@app/lib/fn"; + +import { SecretScanningDataSource, SecretScanningFindingSeverity } from "./secret-scanning-v2-enums"; +import { TCloneRepository, TGetFindingsPayload, TSecretScanningDataSourceListItem } from "./secret-scanning-v2-types"; + +const SECRET_SCANNING_SOURCE_LIST_OPTIONS: Record = { + [SecretScanningDataSource.GitHub]: GITHUB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION +}; + +export const listSecretScanningDataSourceOptions = () => { + return Object.values(SECRET_SCANNING_SOURCE_LIST_OPTIONS).sort((a, b) => a.name.localeCompare(b.name)); +}; + +export const cloneRepository = async ({ cloneUrl, repoPath }: TCloneRepository): Promise => { + const command = `git clone ${cloneUrl} ${repoPath} --bare`; + return new Promise((resolve, reject) => { + exec(command, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +}; + +export function scanDirectory(inputPath: string, outputPath: string, configPath?: string): Promise { + return new Promise((resolve, reject) => { + const command = `cd ${inputPath} && infisical scan --exit-code=77 -r "${outputPath}" ${configPath ? `-c ${configPath}` : ""}`; + exec(command, (error) => { + if (error && error.code !== 77) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +export const scanGitRepositoryAndGetFindings = async ( + scanPath: string, + findingsPath: string, + configPath?: string +): TGetFindingsPayload => { + await scanDirectory(scanPath, findingsPath, configPath); + + const findingsData = JSON.parse(await readFindingsFile(findingsPath)) as SecretMatch[]; + + return findingsData.map( + ({ + // discard match and secret as we don't want to store + Match, + Secret, + ...finding + }) => ({ + details: titleCaseToCamelCase(finding), + fingerprint: `${finding.Fingerprint}:${finding.StartColumn}`, + severity: SecretScanningFindingSeverity.High, + rule: finding.RuleID + }) + ); +}; + +export const replaceNonChangesWithNewlines = (patch: string) => { + return patch + .split("\n") + .map((line) => { + // Keep added lines (remove the + prefix) + if (line.startsWith("+") && !line.startsWith("+++")) { + return line.substring(1); + } + + // Replace everything else with newlines to maintain line positioning + + return ""; + }) + .join("\n"); +}; + +const HunkHeaderRegex = new RE2(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/); + +export const convertPatchLineToFileLineNumber = (patch: string, patchLineNumber: number) => { + const lines = patch.split("\n"); + let currentPatchLine = 0; + let currentNewLine = 0; + + for (const line of lines) { + currentPatchLine += 1; + + // Hunk header: @@ -a,b +c,d @@ + const hunkHeaderMatch = HunkHeaderRegex.match(line); + if (hunkHeaderMatch) { + const startLine = parseInt(hunkHeaderMatch[1], 10); + currentNewLine = startLine; + // eslint-disable-next-line no-continue + continue; + } + + if (currentPatchLine === patchLineNumber) { + return currentNewLine; + } + + if (line.startsWith("+++")) { + // eslint-disable-next-line no-continue + continue; // skip file metadata lines + } + + // Advance only if the line exists in the new file + if (line.startsWith("+") || line.startsWith(" ")) { + currentNewLine += 1; + } + } + + return currentNewLine; +}; + +const MAX_MESSAGE_LENGTH = 1024; + +export const parseScanErrorMessage = (err: unknown): string => { + let errorMessage: string; + + if (err instanceof AxiosError) { + errorMessage = err?.response?.data + ? JSON.stringify(err?.response?.data) + : (err?.message ?? "An unknown error occurred."); + } else { + errorMessage = (err as Error)?.message || "An unknown error occurred."; + } + + return errorMessage.length <= MAX_MESSAGE_LENGTH + ? errorMessage + : `${errorMessage.substring(0, MAX_MESSAGE_LENGTH - 3)}...`; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts new file mode 100644 index 000000000..f41a2b5c2 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts @@ -0,0 +1,14 @@ +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const SECRET_SCANNING_DATA_SOURCE_NAME_MAP: Record = { + [SecretScanningDataSource.GitHub]: "GitHub" +}; + +export const SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP: Record = { + [SecretScanningDataSource.GitHub]: AppConnection.GitHubRadar +}; + +export const AUTO_SYNC_DESCRIPTION_HELPER: Record = { + [SecretScanningDataSource.GitHub]: { verb: "push", noun: "repositories" } +}; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts new file mode 100644 index 000000000..3747af81f --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts @@ -0,0 +1,626 @@ +import { join } from "path"; + +import { ProjectMembershipRole, TSecretScanningFindings } from "@app/db/schemas"; +import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { + createTempFolder, + deleteTempFolder, + writeTextToFile +} from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns"; +import { + parseScanErrorMessage, + scanGitRepositoryAndGetFindings +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-fns"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns"; +import { TAppConnection } from "@app/services/app-connection/app-connection-types"; +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 { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; + +import { TSecretScanningV2DALFactory } from "./secret-scanning-v2-dal"; +import { + SecretScanningDataSource, + SecretScanningResource, + SecretScanningScanStatus, + SecretScanningScanType +} from "./secret-scanning-v2-enums"; +import { SECRET_SCANNING_FACTORY_MAP } from "./secret-scanning-v2-factory"; +import { + TFindingsPayload, + TQueueSecretScanningDataSourceFullScan, + TQueueSecretScanningResourceDiffScan, + TQueueSecretScanningSendNotification, + TSecretScanningDataSourceWithConnection +} from "./secret-scanning-v2-types"; + +type TSecretRotationV2QueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + secretScanningV2DAL: TSecretScanningV2DALFactory; + smtpService: Pick; + projectMembershipDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + auditLogService: Pick; + keyStore: Pick; +}; + +export type TSecretScanningV2QueueServiceFactory = Awaited>; + +export const secretScanningV2QueueServiceFactory = async ({ + queueService, + secretScanningV2DAL, + projectMembershipDAL, + projectDAL, + smtpService, + kmsService, + auditLogService, + keyStore +}: TSecretRotationV2QueueServiceFactoryDep) => { + const queueDataSourceFullScan = async ( + dataSource: TSecretScanningDataSourceWithConnection, + resourceExternalId?: string + ) => { + try { + const { type } = dataSource; + + const factory = SECRET_SCANNING_FACTORY_MAP[type](); + + const rawResources = await factory.listRawResources(dataSource); + + let filteredRawResources = rawResources; + + // TODO: should add individual resource fetch to factory + if (resourceExternalId) { + filteredRawResources = rawResources.filter((resource) => resource.externalId === resourceExternalId); + } + + if (!filteredRawResources.length) { + throw new BadRequestError({ + message: `${resourceExternalId ? `Resource with "ID" ${resourceExternalId} could not be found.` : "Data source has no resources to scan"}. Ensure your data source config is correct and not filtering out scanning resources.` + }); + } + + for (const resource of filteredRawResources) { + // eslint-disable-next-line no-await-in-loop + if (await keyStore.getItem(KeyStorePrefixes.SecretScanningLock(dataSource.id, resource.externalId))) { + throw new BadRequestError({ message: `A scan is already in progress for resource "${resource.name}"` }); + } + } + + await secretScanningV2DAL.resources.transaction(async (tx) => { + const resources = await secretScanningV2DAL.resources.upsert( + filteredRawResources.map((rawResource) => ({ + ...rawResource, + dataSourceId: dataSource.id + })), + ["externalId", "dataSourceId"], + tx + ); + + const scans = await secretScanningV2DAL.scans.insertMany( + resources.map((resource) => ({ + resourceId: resource.id, + type: SecretScanningScanType.FullScan + })), + tx + ); + + for (const scan of scans) { + // eslint-disable-next-line no-await-in-loop + await queueService.queuePg(QueueJobs.SecretScanningV2FullScan, { + scanId: scan.id, + resourceId: scan.resourceId, + dataSourceId: dataSource.id + }); + } + }); + } catch (error) { + logger.error(error, `Failed to queue full-scan for data source with ID "${dataSource.id}"`); + + if (error instanceof BadRequestError) throw error; + + throw new InternalServerError({ message: `Failed to queue scan: ${(error as Error).message}` }); + } + }; + + await queueService.startPg( + QueueJobs.SecretScanningV2FullScan, + async ([job]) => { + const { scanId, resourceId, dataSourceId } = job.data as TQueueSecretScanningDataSourceFullScan; + const { retryCount, retryLimit } = job; + + const logDetails = `[scanId=${scanId}] [resourceId=${resourceId}] [dataSourceId=${dataSourceId}] [jobId=${job.id}] retryCount=[${retryCount}/${retryLimit}]`; + + const tempFolder = await createTempFolder(); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) throw new Error(`Data source with ID "${dataSourceId}" not found`); + + const resource = await secretScanningV2DAL.resources.findById(resourceId); + + if (!resource) throw new Error(`Resource with ID "${resourceId}" not found`); + + let lock: Awaited> | undefined; + + try { + try { + lock = await keyStore.acquireLock( + [KeyStorePrefixes.SecretScanningLock(dataSource.id, resource.externalId)], + 60 * 1000 * 5 + ); + } catch (e) { + throw new Error("Failed to acquire scanning lock."); + } + + await secretScanningV2DAL.scans.update( + { id: scanId }, + { + status: SecretScanningScanStatus.Scanning + } + ); + + let connection: TAppConnection | null = null; + if (dataSource.connection) connection = await decryptAppConnection(dataSource.connection, kmsService); + + const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type as SecretScanningDataSource](); + + const findingsPath = join(tempFolder, "findings.json"); + + const scanPath = await factory.getFullScanPath({ + dataSource: { + ...dataSource, + connection + } as TSecretScanningDataSourceWithConnection, + resourceName: resource.name, + tempFolder + }); + + const config = await secretScanningV2DAL.configs.findOne({ + projectId: dataSource.projectId + }); + + let configPath: string | undefined; + + if (config && config.content) { + configPath = join(tempFolder, "infisical-scan.toml"); + await writeTextToFile(configPath, config.content); + } + + let findingsPayload: TFindingsPayload; + switch (resource.type) { + case SecretScanningResource.Repository: + case SecretScanningResource.Project: + findingsPayload = await scanGitRepositoryAndGetFindings(scanPath, findingsPath, configPath); + break; + default: + throw new Error("Unhandled resource type"); + } + + const allFindings = await secretScanningV2DAL.findings.transaction(async (tx) => { + let findings: TSecretScanningFindings[] = []; + if (findingsPayload.length) { + findings = await secretScanningV2DAL.findings.upsert( + findingsPayload.map((finding) => ({ + ...finding, + projectId: dataSource.projectId, + dataSourceName: dataSource.name, + dataSourceType: dataSource.type, + resourceName: resource.name, + resourceType: resource.type, + scanId + })), + ["projectId", "fingerprint"], + tx, + ["resourceName", "dataSourceName"] + ); + } + + await secretScanningV2DAL.scans.update( + { id: scanId }, + { + status: SecretScanningScanStatus.Completed, + statusMessage: null + } + ); + + return findings; + }); + + const newFindings = allFindings.filter((finding) => finding.scanId === scanId); + + if (newFindings.length) { + await queueService.queuePg(QueueJobs.SecretScanningV2SendNotification, { + status: SecretScanningScanStatus.Completed, + resourceName: resource.name, + isDiffScan: false, + dataSource, + numberOfSecrets: newFindings.length, + scanId + }); + } + + await auditLogService.createAuditLog({ + projectId: dataSource.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_SCAN, + metadata: { + dataSourceId: dataSource.id, + dataSourceType: dataSource.type, + resourceId: resource.id, + resourceType: resource.type, + scanId, + scanStatus: SecretScanningScanStatus.Completed, + scanType: SecretScanningScanType.FullScan, + numberOfSecretsDetected: findingsPayload.length + } + } + }); + + logger.info(`secretScanningV2Queue: Full Scan Complete ${logDetails} findings=[${findingsPayload.length}]`); + } catch (error) { + if (retryCount === retryLimit) { + const errorMessage = parseScanErrorMessage(error); + + await secretScanningV2DAL.scans.update( + { id: scanId }, + { + status: SecretScanningScanStatus.Failed, + statusMessage: errorMessage + } + ); + + await queueService.queuePg(QueueJobs.SecretScanningV2SendNotification, { + status: SecretScanningScanStatus.Failed, + resourceName: resource.name, + dataSource, + errorMessage + }); + + await auditLogService.createAuditLog({ + projectId: dataSource.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_SCAN, + metadata: { + dataSourceId: dataSource.id, + dataSourceType: dataSource.type, + resourceId: resource.id, + resourceType: resource.type, + scanId, + scanStatus: SecretScanningScanStatus.Failed, + scanType: SecretScanningScanType.FullScan + } + } + }); + } + + logger.error(error, `secretScanningV2Queue: Full Scan Failed ${logDetails}`); + throw error; + } finally { + await deleteTempFolder(tempFolder); + await lock?.release(); + } + }, + { + batchSize: 1, + workerCount: 20, + pollingIntervalSeconds: 1 + } + ); + + const queueResourceDiffScan = async ({ + payload, + dataSourceId, + dataSourceType + }: Pick) => { + const factory = SECRET_SCANNING_FACTORY_MAP[dataSourceType as SecretScanningDataSource](); + + const resourcePayload = factory.getDiffScanResourcePayload(payload); + + try { + const { resourceId, scanId } = await secretScanningV2DAL.resources.transaction(async (tx) => { + const [resource] = await secretScanningV2DAL.resources.upsert( + [ + { + ...resourcePayload, + dataSourceId + } + ], + ["externalId", "dataSourceId"], + tx + ); + + const scan = await secretScanningV2DAL.scans.create( + { + resourceId: resource.id, + type: SecretScanningScanType.DiffScan + }, + tx + ); + + return { + resourceId: resource.id, + scanId: scan.id + }; + }); + + await queueService.queuePg(QueueJobs.SecretScanningV2DiffScan, { + payload, + dataSourceId, + dataSourceType, + scanId, + resourceId + }); + } catch (error) { + logger.error( + error, + `secretScanningV2Queue: Failed to queue diff scan [dataSourceId=${dataSourceId}] [resourceExternalId=${resourcePayload.externalId}]` + ); + } + }; + + await queueService.startPg( + QueueJobs.SecretScanningV2DiffScan, + async ([job]) => { + const { payload, dataSourceId, resourceId, scanId } = job.data as TQueueSecretScanningResourceDiffScan; + const { retryCount, retryLimit } = job; + + const logDetails = `[dataSourceId=${dataSourceId}] [scanId=${scanId}] [resourceId=${resourceId}] [jobId=${job.id}] retryCount=[${retryCount}/${retryLimit}]`; + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) throw new Error(`Data source with ID "${dataSourceId}" not found`); + + const resource = await secretScanningV2DAL.resources.findById(resourceId); + + if (!resource) throw new Error(`Resource with ID "${resourceId}" not found`); + + const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type as SecretScanningDataSource](); + + const tempFolder = await createTempFolder(); + + try { + await secretScanningV2DAL.scans.update( + { id: scanId }, + { + status: SecretScanningScanStatus.Scanning + } + ); + + let connection: TAppConnection | null = null; + if (dataSource.connection) connection = await decryptAppConnection(dataSource.connection, kmsService); + + const config = await secretScanningV2DAL.configs.findOne({ + projectId: dataSource.projectId + }); + + let configPath: string | undefined; + + if (config && config.content) { + configPath = join(tempFolder, "infisical-scan.toml"); + await writeTextToFile(configPath, config.content); + } + + const findingsPayload = await factory.getDiffScanFindingsPayload({ + dataSource: { + ...dataSource, + connection + } as TSecretScanningDataSourceWithConnection, + resourceName: resource.name, + payload, + configPath + }); + + const allFindings = await secretScanningV2DAL.findings.transaction(async (tx) => { + let findings: TSecretScanningFindings[] = []; + + if (findingsPayload.length) { + findings = await secretScanningV2DAL.findings.upsert( + findingsPayload.map((finding) => ({ + ...finding, + projectId: dataSource.projectId, + dataSourceName: dataSource.name, + dataSourceType: dataSource.type, + resourceName: resource.name, + resourceType: resource.type, + scanId + })), + ["projectId", "fingerprint"], + tx, + ["resourceName", "dataSourceName"] + ); + } + + await secretScanningV2DAL.scans.update( + { id: scanId }, + { + status: SecretScanningScanStatus.Completed + } + ); + + return findings; + }); + + const newFindings = allFindings.filter((finding) => finding.scanId === scanId); + + if (newFindings.length) { + await queueService.queuePg(QueueJobs.SecretScanningV2SendNotification, { + status: SecretScanningScanStatus.Completed, + resourceName: resource.name, + isDiffScan: true, + dataSource, + numberOfSecrets: newFindings.length, + scanId + }); + } + + await auditLogService.createAuditLog({ + projectId: dataSource.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_SCAN, + metadata: { + dataSourceId: dataSource.id, + dataSourceType: dataSource.type, + resourceId, + resourceType: resource.type, + scanId, + scanStatus: SecretScanningScanStatus.Completed, + scanType: SecretScanningScanType.DiffScan, + numberOfSecretsDetected: findingsPayload.length + } + } + }); + + logger.info(`secretScanningV2Queue: Diff Scan Complete ${logDetails}`); + } catch (error) { + if (retryCount === retryLimit) { + const errorMessage = parseScanErrorMessage(error); + + await secretScanningV2DAL.scans.update( + { id: scanId }, + { + status: SecretScanningScanStatus.Failed, + statusMessage: errorMessage + } + ); + + await queueService.queuePg(QueueJobs.SecretScanningV2SendNotification, { + status: SecretScanningScanStatus.Failed, + resourceName: resource.name, + dataSource, + errorMessage + }); + + await auditLogService.createAuditLog({ + projectId: dataSource.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_SCAN, + metadata: { + dataSourceId: dataSource.id, + dataSourceType: dataSource.type, + resourceId: resource.id, + resourceType: resource.type, + scanId, + scanStatus: SecretScanningScanStatus.Failed, + scanType: SecretScanningScanType.DiffScan + } + } + }); + } + + logger.error(error, `secretScanningV2Queue: Diff Scan Failed ${logDetails}`); + throw error; + } finally { + await deleteTempFolder(tempFolder); + } + }, + { + batchSize: 1, + workerCount: 20, + pollingIntervalSeconds: 1 + } + ); + + await queueService.startPg( + QueueJobs.SecretScanningV2SendNotification, + async ([job]) => { + const { dataSource, resourceName, ...payload } = job.data as TQueueSecretScanningSendNotification; + + const appCfg = getConfig(); + + if (!appCfg.isSmtpConfigured) return; + + try { + const { projectId } = dataSource; + + logger.info( + `secretScanningV2Queue: Sending Status Notification [dataSourceId=${dataSource.id}] [resourceName=${resourceName}] [status=${payload.status}]` + ); + + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + const project = await projectDAL.findById(projectId); + + const projectAdmins = projectMembers.filter((member) => + member.roles.some((role) => role.role === ProjectMembershipRole.Admin) + ); + + const timestamp = new Date().toISOString(); + + await smtpService.sendMail({ + recipients: projectAdmins.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`, + substitutions: + payload.status === SecretScanningScanStatus.Completed + ? { + authorName: "Jim", + authorEmail: "jim@infisical.com", + resourceName, + numberOfSecrets: payload.numberOfSecrets, + isDiffScan: payload.isDiffScan, + url: encodeURI( + `${appCfg.SITE_URL}/secret-scanning/${projectId}/findings?search=scanId:${payload.scanId}` + ), + timestamp + } + : { + dataSourceName: dataSource.name, + resourceName, + projectName: project.name, + timestamp, + errorMessage: payload.errorMessage, + url: encodeURI( + `${appCfg.SITE_URL}/secret-scanning/${projectId}/data-sources/${dataSource.type}/${dataSource.id}` + ) + } + }); + } catch (error) { + logger.error( + error, + `secretScanningV2Queue: Failed to Send Status Notification [dataSourceId=${dataSource.id}] [resourceName=${resourceName}] [status=${payload.status}]` + ); + throw error; + } + }, + { + batchSize: 1, + workerCount: 5, + pollingIntervalSeconds: 1 + } + ); + + return { + queueDataSourceFullScan, + queueResourceDiffScan + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-schemas.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-schemas.ts new file mode 100644 index 000000000..832b73bda --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-schemas.ts @@ -0,0 +1,99 @@ +import { z } from "zod"; + +import { SecretScanningDataSourcesSchema, SecretScanningFindingsSchema } from "@app/db/schemas"; +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-maps"; +import { SecretScanningDataSources } from "@app/lib/api-docs"; +import { slugSchema } from "@app/server/lib/schemas"; + +type SecretScanningDataSourceSchemaOpts = { + type: SecretScanningDataSource; + isConnectionRequired: boolean; +}; + +export const BaseSecretScanningDataSourceSchema = ({ + type, + isConnectionRequired +}: SecretScanningDataSourceSchemaOpts) => + SecretScanningDataSourcesSchema.omit({ + // unique to provider + type: true, + connectionId: true, + config: true, + encryptedCredentials: true + }).extend({ + type: z.literal(type), + connectionId: isConnectionRequired ? z.string().uuid() : z.null(), + connection: isConnectionRequired + ? z.object({ + app: z.literal(SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[type]), + name: z.string(), + id: z.string().uuid() + }) + : z.null() + }); + +export const BaseCreateSecretScanningDataSourceSchema = ({ + type, + isConnectionRequired +}: SecretScanningDataSourceSchemaOpts) => + z.object({ + name: slugSchema({ field: "name" }).describe(SecretScanningDataSources.CREATE(type).name), + projectId: z + .string() + .trim() + .min(1, "Project ID required") + .describe(SecretScanningDataSources.CREATE(type).projectId), + description: z + .string() + .trim() + .max(256, "Description cannot exceed 256 characters") + .nullish() + .describe(SecretScanningDataSources.CREATE(type).description), + connectionId: isConnectionRequired + ? z.string().uuid().describe(SecretScanningDataSources.CREATE(type).connectionId) + : z.undefined(), + isAutoScanEnabled: z + .boolean() + .optional() + .default(true) + .describe(SecretScanningDataSources.CREATE(type).isAutoScanEnabled) + }); + +export const BaseUpdateSecretScanningDataSourceSchema = (type: SecretScanningDataSource) => + z.object({ + name: slugSchema({ field: "name" }).describe(SecretScanningDataSources.UPDATE(type).name).optional(), + description: z + .string() + .trim() + .max(256, "Description cannot exceed 256 characters") + .nullish() + .describe(SecretScanningDataSources.UPDATE(type).description), + isAutoScanEnabled: z.boolean().optional().describe(SecretScanningDataSources.UPDATE(type).isAutoScanEnabled) + }); + +export const GitRepositoryScanFindingDetailsSchema = z.object({ + description: z.string(), + startLine: z.number(), + endLine: z.number(), + startColumn: z.number(), + endColumn: z.number(), + file: z.string(), + link: z.string(), + symlinkFile: z.string(), + commit: z.string(), + entropy: z.number(), + author: z.string(), + email: z.string(), + date: z.string(), + message: z.string(), + tags: z.string().array(), + ruleID: z.string(), + fingerprint: z.string() +}); + +export const BaseSecretScanningFindingSchema = SecretScanningFindingsSchema.omit({ + dataSourceType: true, + resourceType: true, + details: true +}); 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 new file mode 100644 index 000000000..05449bd0d --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts @@ -0,0 +1,875 @@ +import { ForbiddenError } from "@casl/ability"; +import { join } from "path"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionSecretScanningConfigActions, + ProjectPermissionSecretScanningDataSourceActions, + ProjectPermissionSecretScanningFindingActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { + createTempFolder, + deleteTempFolder, + scanContentAndGetFindings, + writeTextToFile +} from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns"; +import { githubSecretScanningService } from "@app/ee/services/secret-scanning-v2/github/github-secret-scanning-service"; +import { SecretScanningFindingStatus } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { SECRET_SCANNING_FACTORY_MAP } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-factory"; +import { listSecretScanningDataSourceOptions } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-fns"; +import { + SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP, + SECRET_SCANNING_DATA_SOURCE_NAME_MAP +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-maps"; +import { + TCreateSecretScanningDataSourceDTO, + TDeleteSecretScanningDataSourceDTO, + TFindSecretScanningDataSourceByIdDTO, + TFindSecretScanningDataSourceByNameDTO, + TListSecretScanningDataSourcesByProjectId, + TSecretScanningDataSource, + TSecretScanningDataSourceWithConnection, + TSecretScanningDataSourceWithDetails, + TSecretScanningFinding, + TSecretScanningResourceWithDetails, + TSecretScanningScanWithDetails, + TTriggerSecretScanningDataSourceDTO, + TUpdateSecretScanningDataSourceDTO, + TUpdateSecretScanningFindingDTO, + TUpsertSecretScanningConfigDTO +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns"; +import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; +import { TAppConnection } from "@app/services/app-connection/app-connection-types"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { TSecretScanningV2DALFactory } from "./secret-scanning-v2-dal"; +import { TSecretScanningV2QueueServiceFactory } from "./secret-scanning-v2-queue"; + +export type TSecretScanningV2ServiceFactoryDep = { + secretScanningV2DAL: TSecretScanningV2DALFactory; + appConnectionService: Pick; + permissionService: Pick; + licenseService: Pick; + secretScanningV2Queue: Pick< + TSecretScanningV2QueueServiceFactory, + "queueDataSourceFullScan" | "queueResourceDiffScan" + >; + kmsService: Pick; +}; + +export type TSecretScanningV2ServiceFactory = ReturnType; + +export const secretScanningV2ServiceFactory = ({ + secretScanningV2DAL, + permissionService, + appConnectionService, + licenseService, + secretScanningV2Queue, + kmsService +}: TSecretScanningV2ServiceFactoryDep) => { + const $checkListSecretScanningDataSourcesByProjectIdPermissions = async ( + projectId: string, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Data Sources due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.Read, + ProjectPermissionSub.SecretScanningDataSources + ); + }; + + const listSecretScanningDataSourcesByProjectId = async ( + { projectId, type }: TListSecretScanningDataSourcesByProjectId, + actor: OrgServiceActor + ) => { + await $checkListSecretScanningDataSourcesByProjectIdPermissions(projectId, actor); + + const dataSources = await secretScanningV2DAL.dataSources.find({ + ...(type && { type }), + projectId + }); + + return dataSources as TSecretScanningDataSource[]; + }; + + const listSecretScanningDataSourcesWithDetailsByProjectId = async ( + { projectId, type }: TListSecretScanningDataSourcesByProjectId, + actor: OrgServiceActor + ) => { + await $checkListSecretScanningDataSourcesByProjectIdPermissions(projectId, actor); + + const dataSources = await secretScanningV2DAL.dataSources.findWithDetails({ + ...(type && { type }), + projectId + }); + + return dataSources as TSecretScanningDataSourceWithDetails[]; + }; + + const findSecretScanningDataSourceById = async ( + { type, dataSourceId }: TFindSecretScanningDataSourceByIdDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Data Source due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.Read, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + return dataSource as TSecretScanningDataSource; + }; + + const findSecretScanningDataSourceByName = async ( + { type, sourceName, projectId }: TFindSecretScanningDataSourceByNameDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Data Source due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + // we prevent conflicting names within a folder + const dataSource = await secretScanningV2DAL.dataSources.findOne({ + name: sourceName, + projectId + }); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with name "${sourceName}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.Read, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSource.id}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + return dataSource as TSecretScanningDataSource; + }; + + const createSecretScanningDataSource = async ( + payload: TCreateSecretScanningDataSourceDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to create Secret Scanning Data Source due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: payload.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.Create, + ProjectPermissionSub.SecretScanningDataSources + ); + + let connection: TAppConnection | null = null; + if (payload.connectionId) { + // validates permission to connect and app is valid for data source + connection = await appConnectionService.connectAppConnectionById( + SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[payload.type], + payload.connectionId, + actor + ); + } + + const factory = SECRET_SCANNING_FACTORY_MAP[payload.type](); + + try { + const createdDataSource = await factory.initialize( + { + payload, + connection: connection as TSecretScanningDataSourceWithConnection["connection"], + secretScanningV2DAL + }, + async ({ credentials, externalId }) => { + let encryptedCredentials: Buffer | null = null; + + if (credentials) { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: payload.projectId + }); + + const { cipherTextBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(credentials)) + }); + + encryptedCredentials = cipherTextBlob; + } + + return secretScanningV2DAL.dataSources.transaction(async (tx) => { + const dataSource = await secretScanningV2DAL.dataSources.create( + { + encryptedCredentials, + externalId, + ...payload + }, + tx + ); + + await factory.postInitialization({ + payload, + connection: connection as TSecretScanningDataSourceWithConnection["connection"], + dataSourceId: dataSource.id, + credentials + }); + + return dataSource; + }); + } + ); + + if (payload.isAutoScanEnabled) { + try { + await secretScanningV2Queue.queueDataSourceFullScan({ + ...createdDataSource, + connection + } as TSecretScanningDataSourceWithConnection); + } catch { + // silently fail, don't want to block creation, they'll try scanning when they don't see anything and get the error + } + } + + return createdDataSource as TSecretScanningDataSource; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ + message: `A Secret Scanning Data Source with the name "${payload.name}" already exists for the project with ID "${payload.projectId}"` + }); + } + + throw err; + } + }; + + const updateSecretScanningDataSource = async ( + { type, dataSourceId, ...payload }: TUpdateSecretScanningDataSourceDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to update Secret Scanning Data Source due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.Edit, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + try { + const updatedDataSource = await secretScanningV2DAL.dataSources.updateById(dataSourceId, payload); + + return updatedDataSource as TSecretScanningDataSource; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ + message: `A Secret Scanning Data Source with the name "${payload.name}" already exists for the project with ID "${dataSource.projectId}"` + }); + } + + throw err; + } + }; + + const deleteSecretScanningDataSource = async ( + { type, dataSourceId }: TDeleteSecretScanningDataSourceDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to delete Secret Scanning Data Source due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.Delete, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + // TODO: clean up webhooks + + await secretScanningV2DAL.dataSources.deleteById(dataSourceId); + + return dataSource as TSecretScanningDataSource; + }; + + const triggerSecretScanningDataSourceScan = async ( + { type, dataSourceId, resourceId }: TTriggerSecretScanningDataSourceDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to trigger scan for Secret Scanning Data Source due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.TriggerScans, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + let connection: TAppConnection | null = null; + if (dataSource.connection) connection = await decryptAppConnection(dataSource.connection, kmsService); + + let resourceExternalId: string | undefined; + + if (resourceId) { + const resource = await secretScanningV2DAL.resources.findOne({ id: resourceId, dataSourceId }); + if (!resource) { + throw new NotFoundError({ + message: `Could not find Secret Scanning Resource with ID "${resourceId}" for Data Source with ID "${dataSourceId}"` + }); + } + resourceExternalId = resource.externalId; + } + + await secretScanningV2Queue.queueDataSourceFullScan( + { + ...dataSource, + connection + } as TSecretScanningDataSourceWithConnection, + resourceExternalId + ); + + return dataSource as TSecretScanningDataSource; + }; + + const listSecretScanningResourcesByDataSourceId = async ( + { type, dataSourceId }: TFindSecretScanningDataSourceByIdDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Resources due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.ReadResources, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + const resources = await secretScanningV2DAL.resources.find({ + dataSourceId + }); + + return { resources, projectId: dataSource.projectId }; + }; + + const listSecretScanningScansByDataSourceId = async ( + { type, dataSourceId }: TFindSecretScanningDataSourceByIdDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Resources due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.ReadScans, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + const scans = await secretScanningV2DAL.scans.findByDataSourceId(dataSourceId); + + return { scans, projectId: dataSource.projectId }; + }; + + const listSecretScanningResourcesWithDetailsByDataSourceId = async ( + { type, dataSourceId }: TFindSecretScanningDataSourceByIdDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Resources due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.ReadResources, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + const resources = await secretScanningV2DAL.resources.findWithDetails({ dataSourceId }); + + return { resources: resources as TSecretScanningResourceWithDetails[], projectId: dataSource.projectId }; + }; + + const listSecretScanningScansWithDetailsByDataSourceId = async ( + { type, dataSourceId }: TFindSecretScanningDataSourceByIdDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Scans due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.ReadScans, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + const scans = await secretScanningV2DAL.scans.findWithDetailsByDataSourceId(dataSourceId); + + return { scans: scans as TSecretScanningScanWithDetails[], projectId: dataSource.projectId }; + }; + + const getSecretScanningUnresolvedFindingsCountByProjectId = async (projectId: string, actor: OrgServiceActor) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Findings due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningFindingActions.Read, + ProjectPermissionSub.SecretScanningFindings + ); + + const [finding] = await secretScanningV2DAL.findings.find( + { + projectId, + status: SecretScanningFindingStatus.Unresolved + }, + { count: true } + ); + + return Number(finding?.count ?? 0); + }; + + const listSecretScanningFindingsByProjectId = async (projectId: string, actor: OrgServiceActor) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Findings due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningFindingActions.Read, + ProjectPermissionSub.SecretScanningFindings + ); + + const findings = await secretScanningV2DAL.findings.find({ + projectId + }); + + return findings as TSecretScanningFinding[]; + }; + + const updateSecretScanningFindingById = async ( + { findingId, remarks, status }: TUpdateSecretScanningFindingDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Findings due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const finding = await secretScanningV2DAL.findings.findById(findingId); + + if (!finding) + throw new NotFoundError({ + message: `Could not find Secret Scanning Finding with ID "${findingId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: finding.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningFindingActions.Update, + ProjectPermissionSub.SecretScanningFindings + ); + + const updatedFinding = await secretScanningV2DAL.findings.updateById(findingId, { + remarks, + status + }); + + return { finding: updatedFinding as TSecretScanningFinding, projectId: finding.projectId }; + }; + + const findSecretScanningConfigByProjectId = async (projectId: string, actor: OrgServiceActor) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Configuration due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningConfigActions.Read, + ProjectPermissionSub.SecretScanningConfigs + ); + + const config = await secretScanningV2DAL.configs.findOne({ + projectId + }); + + return ( + config ?? { content: null, projectId, updatedAt: null } // using default config + ); + }; + + const upsertSecretScanningConfig = async ( + { projectId, content }: TUpsertSecretScanningConfigDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Configuration due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningConfigActions.Update, + ProjectPermissionSub.SecretScanningConfigs + ); + + if (content) { + const tempFolder = await createTempFolder(); + try { + const configPath = join(tempFolder, "infisical-scan.toml"); + await writeTextToFile(configPath, content); + + // just checking if config parses + await scanContentAndGetFindings("", configPath); + } catch (e) { + throw new BadRequestError({ + message: "Unable to parse configuration: Check syntax and formatting." + }); + } finally { + await deleteTempFolder(tempFolder); + } + } + + const [config] = await secretScanningV2DAL.configs.upsert( + [ + { + projectId, + content + } + ], + "projectId" + ); + + return config; + }; + + return { + listSecretScanningDataSourceOptions, + listSecretScanningDataSourcesByProjectId, + listSecretScanningDataSourcesWithDetailsByProjectId, + findSecretScanningDataSourceById, + findSecretScanningDataSourceByName, + createSecretScanningDataSource, + updateSecretScanningDataSource, + deleteSecretScanningDataSource, + triggerSecretScanningDataSourceScan, + listSecretScanningResourcesByDataSourceId, + listSecretScanningScansByDataSourceId, + listSecretScanningResourcesWithDetailsByDataSourceId, + listSecretScanningScansWithDetailsByDataSourceId, + getSecretScanningUnresolvedFindingsCountByProjectId, + listSecretScanningFindingsByProjectId, + updateSecretScanningFindingById, + findSecretScanningConfigByProjectId, + upsertSecretScanningConfig, + github: githubSecretScanningService(secretScanningV2DAL, secretScanningV2Queue) + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts new file mode 100644 index 000000000..3ee5851d7 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts @@ -0,0 +1,189 @@ +import { + TSecretScanningDataSources, + TSecretScanningFindingsInsert, + TSecretScanningResources, + TSecretScanningScans +} from "@app/db/schemas"; +import { + TGitHubDataSource, + TGitHubDataSourceInput, + TGitHubDataSourceListItem, + TGitHubDataSourceWithConnection, + TGitHubFinding, + TQueueGitHubResourceDiffScan +} from "@app/ee/services/secret-scanning-v2/github"; +import { TSecretScanningV2DALFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-dal"; +import { + SecretScanningDataSource, + SecretScanningFindingStatus, + SecretScanningScanStatus +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; + +export type TSecretScanningDataSource = TGitHubDataSource; + +export type TSecretScanningDataSourceWithDetails = TSecretScanningDataSource & { + lastScannedAt?: Date | null; + lastScanStatus?: SecretScanningScanStatus | null; + lastScanStatusMessage?: string | null; + unresolvedFindings: number; +}; + +export type TSecretScanningResourceWithDetails = TSecretScanningResources & { + lastScannedAt?: Date | null; + lastScanStatus?: SecretScanningScanStatus | null; + lastScanStatusMessage?: string | null; + unresolvedFindings: number; +}; + +export type TSecretScanningScanWithDetails = TSecretScanningScans & { + unresolvedFindings: number; + resolvedFindings: number; + resourceName: string; +}; + +export type TSecretScanningDataSourceWithConnection = TGitHubDataSourceWithConnection; + +export type TSecretScanningDataSourceInput = TGitHubDataSourceInput; + +export type TSecretScanningDataSourceListItem = TGitHubDataSourceListItem; + +export type TSecretScanningFinding = TGitHubFinding; + +export type TListSecretScanningDataSourcesByProjectId = { + projectId: string; + type?: SecretScanningDataSource; +}; + +export type TFindSecretScanningDataSourceByIdDTO = { + dataSourceId: string; + type: SecretScanningDataSource; +}; + +export type TFindSecretScanningDataSourceByNameDTO = { + sourceName: string; + projectId: string; + type: SecretScanningDataSource; +}; + +export type TCreateSecretScanningDataSourceDTO = Pick< + TSecretScanningDataSource, + "description" | "name" | "projectId" +> & { + connectionId?: string; + type: SecretScanningDataSource; + isAutoScanEnabled?: boolean; + config: Partial; +}; + +export type TUpdateSecretScanningDataSourceDTO = Partial< + Omit +> & { + dataSourceId: string; + type: SecretScanningDataSource; +}; + +export type TDeleteSecretScanningDataSourceDTO = { + type: SecretScanningDataSource; + dataSourceId: string; +}; + +export type TTriggerSecretScanningDataSourceDTO = { + type: SecretScanningDataSource; + dataSourceId: string; + resourceId?: string; +}; + +export type TQueueSecretScanningDataSourceFullScan = { + dataSourceId: string; + resourceId: string; + scanId: string; +}; + +export type TQueueSecretScanningResourceDiffScan = TQueueGitHubResourceDiffScan; + +export type TQueueSecretScanningSendNotification = { + dataSource: TSecretScanningDataSources; + resourceName: string; +} & ( + | { status: SecretScanningScanStatus.Failed; errorMessage: string } + | { status: SecretScanningScanStatus.Completed; numberOfSecrets: number; scanId: string; isDiffScan: boolean } +); + +export type TCloneRepository = { + cloneUrl: string; + repoPath: string; +}; + +export type TSecretScanningFactoryListRawResources = ( + dataSource: T +) => Promise[]>; + +export type TSecretScanningFactoryGetDiffScanResourcePayload< + P extends TQueueSecretScanningResourceDiffScan["payload"] +> = (payload: P) => Pick; + +export type TSecretScanningFactoryGetFullScanPath = (parameters: { + dataSource: T; + resourceName: string; + tempFolder: string; +}) => Promise; + +export type TSecretScanningFactoryGetDiffScanFindingsPayload< + T extends TSecretScanningDataSourceWithConnection, + P extends TQueueSecretScanningResourceDiffScan["payload"] +> = (parameters: { dataSource: T; resourceName: string; payload: P; configPath?: string }) => Promise; + +export type TSecretScanningDataSourceRaw = NonNullable< + Awaited> +>; + +export type TSecretScanningFactoryInitialize< + T extends TSecretScanningDataSourceWithConnection["connection"] | undefined = undefined, + C extends TSecretScanningDataSourceCredentials = undefined +> = ( + params: { + payload: TCreateSecretScanningDataSourceDTO; + connection: T; + secretScanningV2DAL: TSecretScanningV2DALFactory; + }, + callback: (parameters: { credentials?: C; externalId?: string }) => Promise +) => Promise; + +export type TSecretScanningFactoryPostInitialization< + T extends TSecretScanningDataSourceWithConnection["connection"] | undefined = undefined, + C extends TSecretScanningDataSourceCredentials = undefined +> = (params: { + payload: TCreateSecretScanningDataSourceDTO; + connection: T; + credentials: C; + dataSourceId: string; +}) => Promise; + +export type TSecretScanningFactory< + T extends TSecretScanningDataSourceWithConnection, + C extends TSecretScanningDataSourceCredentials, + P extends TQueueSecretScanningResourceDiffScan["payload"] +> = () => { + listRawResources: TSecretScanningFactoryListRawResources; + getFullScanPath: TSecretScanningFactoryGetFullScanPath; + initialize: TSecretScanningFactoryInitialize; + postInitialization: TSecretScanningFactoryPostInitialization; + getDiffScanResourcePayload: TSecretScanningFactoryGetDiffScanResourcePayload

; + getDiffScanFindingsPayload: TSecretScanningFactoryGetDiffScanFindingsPayload; +}; + +export type TFindingsPayload = Pick[]; +export type TGetFindingsPayload = Promise; + +export type TUpdateSecretScanningFindingDTO = { + status?: SecretScanningFindingStatus; + remarks?: string | null; + findingId: string; +}; + +export type TUpsertSecretScanningConfigDTO = { + projectId: string; + content: string | null; +}; + +export type TSecretScanningDataSourceCredentials = undefined; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts new file mode 100644 index 000000000..4f34791f8 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +import { GitHubDataSourceSchema, GitHubFindingSchema } from "@app/ee/services/secret-scanning-v2/github"; + +export const SecretScanningDataSourceSchema = z.discriminatedUnion("type", [GitHubDataSourceSchema]); + +export const SecretScanningFindingSchema = z.discriminatedUnion("resourceType", [GitHubFindingSchema]); diff --git a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns.ts b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns.ts index 2e74a1caf..ceb239adf 100644 --- a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns.ts +++ b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns.ts @@ -65,9 +65,9 @@ export function runInfisicalScanOnRepo(repoPath: string, outputPath: string): Pr }); } -export function runInfisicalScan(inputPath: string, outputPath: string): Promise { +export function runInfisicalScan(inputPath: string, outputPath: string, configPath?: string): Promise { return new Promise((resolve, reject) => { - const command = `cat "${inputPath}" | infisical scan --exit-code=77 --pipe -r "${outputPath}"`; + const command = `cat "${inputPath}" | infisical scan --exit-code=77 --pipe -r "${outputPath}" ${configPath ? `-c "${configPath}"` : ""}`; exec(command, (error) => { if (error && error.code !== 77) { reject(error); @@ -138,14 +138,14 @@ export async function scanFullRepoContentAndGetFindings( } } -export async function scanContentAndGetFindings(textContent: string): Promise { +export async function scanContentAndGetFindings(textContent: string, configPath?: string): Promise { const tempFolder = await createTempFolder(); const filePath = join(tempFolder, "content.txt"); const findingsPath = join(tempFolder, "findings.json"); try { await writeTextToFile(filePath, textContent); - await runInfisicalScan(filePath, findingsPath); + await runInfisicalScan(filePath, findingsPath, configPath); const findingsData = await readFindingsFile(findingsPath); return JSON.parse(findingsData) as SecretMatch[]; } finally { diff --git a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types.ts b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types.ts index 3f14a41e3..6990febc3 100644 --- a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types.ts +++ b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types.ts @@ -9,6 +9,7 @@ export type SecretMatch = { Match: string; Secret: string; File: string; + Link: string; SymlinkFile: string; Commit: string; Entropy: number; diff --git a/backend/src/ee/services/secret-sync/oci-vault/index.ts b/backend/src/ee/services/secret-sync/oci-vault/index.ts new file mode 100644 index 000000000..cee990de4 --- /dev/null +++ b/backend/src/ee/services/secret-sync/oci-vault/index.ts @@ -0,0 +1,4 @@ +export * from "./oci-vault-sync-constants"; +export * from "./oci-vault-sync-fns"; +export * from "./oci-vault-sync-schemas"; +export * from "./oci-vault-sync-types"; diff --git a/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-constants.ts b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-constants.ts new file mode 100644 index 000000000..b864e354b --- /dev/null +++ b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-constants.ts @@ -0,0 +1,11 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const OCI_VAULT_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "OCI Vault", + destination: SecretSync.OCIVault, + connection: AppConnection.OCI, + canImportSecrets: true, + enterprise: true +}; diff --git a/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-fns.ts b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-fns.ts new file mode 100644 index 000000000..5fb39a0ec --- /dev/null +++ b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-fns.ts @@ -0,0 +1,297 @@ +import { secrets, vault } from "oci-sdk"; + +import { getOCIProvider } from "@app/ee/services/app-connections/oci"; +import { + TCreateOCIVaultVariable, + TDeleteOCIVaultVariable, + TOCIVaultListVariables, + TOCIVaultSyncWithCredentials, + TUnmarkOCIVaultVariableFromDeletion, + TUpdateOCIVaultVariable +} from "@app/ee/services/secret-sync/oci-vault/oci-vault-sync-types"; +import { delay } from "@app/lib/delay"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +const listOCIVaultVariables = async ({ provider, compartmentId, vaultId, onlyActive }: TOCIVaultListVariables) => { + const vaultsClient = new vault.VaultsClient({ authenticationDetailsProvider: provider }); + const secretsClient = new secrets.SecretsClient({ authenticationDetailsProvider: provider }); + + const secretsRes = await vaultsClient.listSecrets({ + compartmentId, + vaultId, + lifecycleState: onlyActive ? vault.models.SecretSummary.LifecycleState.Active : undefined + }); + + const result: Record = {}; + + for await (const s of secretsRes.items) { + let secretValue = ""; + + if (s.lifecycleState === vault.models.SecretSummary.LifecycleState.Active) { + const secretBundle = await secretsClient.getSecretBundle({ + secretId: s.id + }); + + secretValue = Buffer.from(secretBundle.secretBundle.secretBundleContent?.content || "", "base64").toString( + "utf-8" + ); + } + + result[s.secretName] = { + ...s, + name: s.secretName, + value: secretValue + }; + } + + return result; +}; + +const createOCIVaultVariable = async ({ + provider, + compartmentId, + vaultId, + keyId, + name, + value +}: TCreateOCIVaultVariable) => { + if (!value) return; + + const vaultsClient = new vault.VaultsClient({ authenticationDetailsProvider: provider }); + + return vaultsClient.createSecret({ + createSecretDetails: { + compartmentId, + vaultId, + keyId, + secretName: name, + enableAutoGeneration: false, + secretContent: { + content: Buffer.from(value).toString("base64"), + contentType: "BASE64" + } + } + }); +}; + +const updateOCIVaultVariable = async ({ provider, secretId, value }: TUpdateOCIVaultVariable) => { + if (!value) return; + + const vaultsClient = new vault.VaultsClient({ authenticationDetailsProvider: provider }); + + return vaultsClient.updateSecret({ + secretId, + updateSecretDetails: { + enableAutoGeneration: false, + secretContent: { + content: Buffer.from(value).toString("base64"), + contentType: "BASE64" + } + } + }); +}; + +const deleteOCIVaultVariable = async ({ provider, secretId }: TDeleteOCIVaultVariable) => { + const vaultsClient = new vault.VaultsClient({ authenticationDetailsProvider: provider }); + + // Schedule a secret deletion 7 days from now. OCI Vault requires a MINIMUM buffer period of 7 days + return vaultsClient.scheduleSecretDeletion({ + secretId, + scheduleSecretDeletionDetails: { + timeOfDeletion: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) + } + }); +}; + +const unmarkOCIVaultVariableFromDeletion = async ({ provider, secretId }: TUnmarkOCIVaultVariableFromDeletion) => { + const vaultsClient = new vault.VaultsClient({ authenticationDetailsProvider: provider }); + + return vaultsClient.cancelSecretDeletion({ + secretId + }); +}; + +export const OCIVaultSyncFns = { + syncSecrets: async (secretSync: TOCIVaultSyncWithCredentials, secretMap: TSecretMap) => { + const { + connection, + environment, + destinationConfig: { compartmentOcid, vaultOcid, keyOcid } + } = secretSync; + + const provider = await getOCIProvider(connection); + const variables = await listOCIVaultVariables({ provider, compartmentId: compartmentOcid, vaultId: vaultOcid }); + + // Throw an error if any keys are updating in OCI vault to prevent skipped updates + if ( + Object.entries(variables).some( + ([, secret]) => + secret.lifecycleState === vault.models.SecretSummary.LifecycleState.Updating || + secret.lifecycleState === vault.models.SecretSummary.LifecycleState.CancellingDeletion || + secret.lifecycleState === vault.models.SecretSummary.LifecycleState.Creating || + secret.lifecycleState === vault.models.SecretSummary.LifecycleState.Deleting || + secret.lifecycleState === vault.models.SecretSummary.LifecycleState.SchedulingDeletion + ) + ) { + throw new SecretSyncError({ + error: "Cannot sync while keys are updating in OCI Vault." + }); + } + + // Create secrets + for await (const entry of Object.entries(secretMap)) { + const [key, { value }] = entry; + + // skip secrets that don't have a value set + if (!value) { + // eslint-disable-next-line no-continue + continue; + } + + const existingVariable = Object.values(variables).find((v) => v.secretName === key); + + if (!existingVariable) { + try { + await createOCIVaultVariable({ + compartmentId: compartmentOcid, + vaultId: vaultOcid, + provider, + keyId: keyOcid, + name: key, + value + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } else if (existingVariable.lifecycleState === vault.models.SecretSummary.LifecycleState.PendingDeletion) { + // If a secret exists but is pending deletion, cancel the deletion and update the secret + await unmarkOCIVaultVariableFromDeletion({ + provider, + compartmentId: compartmentOcid, + vaultId: vaultOcid, + secretId: existingVariable.id + }); + + const vaultsClient = new vault.VaultsClient({ authenticationDetailsProvider: provider }); + const MAX_RETRIES = 10; + + for (let i = 0; i < MAX_RETRIES; i += 1) { + // eslint-disable-next-line no-await-in-loop + await delay(5000); + + // eslint-disable-next-line no-await-in-loop + const secret = await vaultsClient.getSecret({ + secretId: existingVariable.id + }); + + if (secret.secret.lifecycleState === vault.models.SecretSummary.LifecycleState.Active) { + // eslint-disable-next-line no-await-in-loop + await updateOCIVaultVariable({ + provider, + compartmentId: compartmentOcid, + vaultId: vaultOcid, + secretId: existingVariable.id, + value + }); + break; + } + + if (i === MAX_RETRIES - 1) { + throw new SecretSyncError({ + error: "Failed to update secret after cancelling deletion.", + secretKey: key + }); + } + } + } + } + + // Update and delete secrets + for await (const [key, variable] of Object.entries(variables)) { + // eslint-disable-next-line no-continue + if (!matchesSchema(key, environment?.slug || "", secretSync.syncOptions.keySchema)) continue; + + // Only update / delete active secrets + if (variable.lifecycleState === vault.models.SecretSummary.LifecycleState.Active) { + if (key in secretMap && secretMap[key].value.length > 0) { + if (variable.value !== secretMap[key].value) { + try { + await updateOCIVaultVariable({ + compartmentId: compartmentOcid, + vaultId: vaultOcid, + provider, + secretId: variable.id, + value: secretMap[key].value + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + } else if (!secretSync.syncOptions.disableSecretDeletion) { + try { + await deleteOCIVaultVariable({ + compartmentId: compartmentOcid, + vaultId: vaultOcid, + provider, + secretId: variable.id + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + } + } + }, + removeSecrets: async (secretSync: TOCIVaultSyncWithCredentials, secretMap: TSecretMap) => { + const { + connection, + destinationConfig: { compartmentOcid, vaultOcid } + } = secretSync; + + const provider = await getOCIProvider(connection); + const variables = await listOCIVaultVariables({ + provider, + compartmentId: compartmentOcid, + vaultId: vaultOcid, + onlyActive: true + }); + + for await (const [key, variable] of Object.entries(variables)) { + if (key in secretMap) { + try { + await deleteOCIVaultVariable({ + compartmentId: compartmentOcid, + vaultId: vaultOcid, + provider, + secretId: variable.id + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + } + }, + getSecrets: async (secretSync: TOCIVaultSyncWithCredentials) => { + const { + connection, + destinationConfig: { compartmentOcid, vaultOcid } + } = secretSync; + + const provider = await getOCIProvider(connection); + return listOCIVaultVariables({ provider, compartmentId: compartmentOcid, vaultId: vaultOcid, onlyActive: true }); + } +}; diff --git a/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-schemas.ts b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-schemas.ts new file mode 100644 index 000000000..a0bd29382 --- /dev/null +++ b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-schemas.ts @@ -0,0 +1,71 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const OCIVaultSyncDestinationConfigSchema = z.object({ + compartmentOcid: z + .string() + .trim() + .min(1, "Compartment OCID required") + .refine( + (val) => new RE2("^ocid1\\.(tenancy|compartment)\\.oc1\\..+$").test(val), + "Invalid Compartment OCID format. Must start with ocid1.tenancy.oc1. or ocid1.compartment.oc1." + ) + .describe(SecretSyncs.DESTINATION_CONFIG.OCI_VAULT.compartmentOcid), + vaultOcid: z + .string() + .trim() + .min(1, "Vault OCID required") + .refine( + (val) => new RE2("^ocid1\\.vault\\.oc1\\..+$").test(val), + "Invalid Vault OCID format. Must start with ocid1.vault.oc1." + ) + .describe(SecretSyncs.DESTINATION_CONFIG.OCI_VAULT.vaultOcid), + keyOcid: z + .string() + .trim() + .min(1, "Key OCID required") + .refine( + (val) => new RE2("^ocid1\\.key\\.oc1\\..+$").test(val), + "Invalid Key OCID format. Must start with ocid1.key.oc1." + ) + .describe(SecretSyncs.DESTINATION_CONFIG.OCI_VAULT.keyOcid) +}); + +const OCIVaultSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const OCIVaultSyncSchema = BaseSecretSyncSchema(SecretSync.OCIVault, OCIVaultSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.OCIVault), + destinationConfig: OCIVaultSyncDestinationConfigSchema +}); + +export const CreateOCIVaultSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.OCIVault, + OCIVaultSyncOptionsConfig +).extend({ + destinationConfig: OCIVaultSyncDestinationConfigSchema +}); + +export const UpdateOCIVaultSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.OCIVault, + OCIVaultSyncOptionsConfig +).extend({ + destinationConfig: OCIVaultSyncDestinationConfigSchema.optional() +}); + +export const OCIVaultSyncListItemSchema = z.object({ + name: z.literal("OCI Vault"), + connection: z.literal(AppConnection.OCI), + destination: z.literal(SecretSync.OCIVault), + canImportSecrets: z.literal(true), + enterprise: z.boolean() +}); diff --git a/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-types.ts b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-types.ts new file mode 100644 index 000000000..8804b1322 --- /dev/null +++ b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-types.ts @@ -0,0 +1,48 @@ +import { SimpleAuthenticationDetailsProvider } from "oci-sdk"; +import { z } from "zod"; + +import { TOCIConnection } from "@app/ee/services/app-connections/oci"; + +import { CreateOCIVaultSyncSchema, OCIVaultSyncListItemSchema, OCIVaultSyncSchema } from "./oci-vault-sync-schemas"; + +export type TOCIVaultSync = z.infer; + +export type TOCIVaultSyncInput = z.infer; + +export type TOCIVaultSyncListItem = z.infer; + +export type TOCIVaultSyncWithCredentials = TOCIVaultSync & { + connection: TOCIConnection; +}; + +export type TOCIVaultVariable = { + id: string; + name: string; + value: string; +}; + +export type TOCIVaultListVariables = { + provider: SimpleAuthenticationDetailsProvider; + compartmentId: string; + vaultId: string; + onlyActive?: boolean; // Whether to filter for only active secrets. Removes deleted / scheduled for deletion secrets +}; + +export type TCreateOCIVaultVariable = TOCIVaultListVariables & { + keyId: string; + name: string; + value: string; +}; + +export type TUpdateOCIVaultVariable = TOCIVaultListVariables & { + secretId: string; + value: string; +}; + +export type TDeleteOCIVaultVariable = TOCIVaultListVariables & { + secretId: string; +}; + +export type TUnmarkOCIVaultVariableFromDeletion = TOCIVaultListVariables & { + secretId: string; +}; diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts index 08242d4cb..2f57cce3a 100644 --- a/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts @@ -28,6 +28,7 @@ export const sshHostGroupDALFactory = (db: TDbClient) => { `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` ) .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.Groups, `${TableName.SshHostLoginUserMapping}.groupId`, `${TableName.Groups}.id`) .where(`${TableName.SshHostGroup}.projectId`, projectId) .select( db.ref("id").withSchema(TableName.SshHostGroup).as("sshHostGroupId"), @@ -35,7 +36,8 @@ export const sshHostGroupDALFactory = (db: TDbClient) => { db.ref("name").withSchema(TableName.SshHostGroup), db.ref("loginUser").withSchema(TableName.SshHostLoginUser), db.ref("username").withSchema(TableName.Users), - db.ref("userId").withSchema(TableName.SshHostLoginUserMapping) + db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug") ) .orderBy(`${TableName.SshHostGroup}.updatedAt`, "desc"); @@ -69,7 +71,8 @@ export const sshHostGroupDALFactory = (db: TDbClient) => { const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ loginUser, allowedPrincipals: { - usernames: unique(entries.map((e) => e.username)).filter(Boolean) + usernames: unique(entries.map((e) => e.username)).filter(Boolean), + groups: unique(entries.map((e) => e.groupSlug)).filter(Boolean) } })); return { @@ -99,6 +102,7 @@ export const sshHostGroupDALFactory = (db: TDbClient) => { `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` ) .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.Groups, `${TableName.SshHostLoginUserMapping}.groupId`, `${TableName.Groups}.id`) .where(`${TableName.SshHostGroup}.id`, sshHostGroupId) .select( db.ref("id").withSchema(TableName.SshHostGroup).as("sshHostGroupId"), @@ -106,7 +110,8 @@ export const sshHostGroupDALFactory = (db: TDbClient) => { db.ref("name").withSchema(TableName.SshHostGroup), db.ref("loginUser").withSchema(TableName.SshHostLoginUser), db.ref("username").withSchema(TableName.Users), - db.ref("userId").withSchema(TableName.SshHostLoginUserMapping) + db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug") ); if (rows.length === 0) return null; @@ -121,7 +126,8 @@ export const sshHostGroupDALFactory = (db: TDbClient) => { const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ loginUser, allowedPrincipals: { - usernames: unique(entries.map((e) => e.username)).filter(Boolean) + usernames: unique(entries.map((e) => e.username)).filter(Boolean), + groups: unique(entries.map((e) => e.groupSlug)).filter(Boolean) } })); diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts index 751116895..1137660d6 100644 --- a/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts @@ -12,6 +12,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TGroupDALFactory } from "../group/group-dal"; import { TLicenseServiceFactory } from "../license/license-service"; import { createSshLoginMappings } from "../ssh-host/ssh-host-fns"; import { @@ -43,8 +44,12 @@ type TSshHostGroupServiceFactoryDep = { sshHostLoginUserDAL: Pick; sshHostLoginUserMappingDAL: Pick; userDAL: Pick; - permissionService: Pick; + permissionService: Pick< + TPermissionServiceFactory, + "getProjectPermission" | "getUserProjectPermission" | "checkGroupProjectPermission" + >; licenseService: Pick; + groupDAL: Pick; }; export type TSshHostGroupServiceFactory = ReturnType; @@ -58,7 +63,8 @@ export const sshHostGroupServiceFactory = ({ sshHostLoginUserMappingDAL, userDAL, permissionService, - licenseService + licenseService, + groupDAL }: TSshHostGroupServiceFactoryDep) => { const createSshHostGroup = async ({ projectId, @@ -127,6 +133,7 @@ export const sshHostGroupServiceFactory = ({ loginMappings, sshHostLoginUserDAL, sshHostLoginUserMappingDAL, + groupDAL, userDAL, permissionService, projectId, @@ -179,13 +186,42 @@ export const sshHostGroupServiceFactory = ({ }); const updatedSshHostGroup = await sshHostGroupDAL.transaction(async (tx) => { - await sshHostGroupDAL.updateById( - sshHostGroupId, - { - name - }, - tx - ); + if (name && name !== sshHostGroup.name) { + // (dangtony98): room to optimize check to ensure that + // the SSH host group name is unique across the whole org + const project = await projectDAL.findById(sshHostGroup.projectId, tx); + if (!project) throw new NotFoundError({ message: `Project with ID '${sshHostGroup.projectId}' not found` }); + const projects = await projectDAL.find( + { + orgId: project.orgId + }, + { tx } + ); + + const existingSshHostGroup = await sshHostGroupDAL.find( + { + name, + $in: { + projectId: projects.map((p) => p.id) + } + }, + { tx } + ); + + if (existingSshHostGroup.length) { + throw new BadRequestError({ + message: `SSH host group with name '${name}' already exists in the organization` + }); + } + await sshHostGroupDAL.updateById( + sshHostGroupId, + { + name + }, + tx + ); + } + if (loginMappings) { await sshHostLoginUserDAL.delete({ sshHostGroupId: sshHostGroup.id }, tx); if (loginMappings.length) { @@ -194,6 +230,7 @@ export const sshHostGroupServiceFactory = ({ loginMappings, sshHostLoginUserDAL, sshHostLoginUserMappingDAL, + groupDAL, userDAL, permissionService, projectId: sshHostGroup.projectId, diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts index 3485b5d26..52f805f02 100644 --- a/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts @@ -9,12 +9,7 @@ export type TCreateSshHostGroupDTO = { export type TUpdateSshHostGroupDTO = { sshHostGroupId: string; name?: string; - loginMappings?: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings?: TLoginMapping[]; } & Omit; export type TGetSshHostGroupDTO = { diff --git a/backend/src/ee/services/ssh-host/ssh-host-dal.ts b/backend/src/ee/services/ssh-host/ssh-host-dal.ts index e66f7da7a..3b8564ce2 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-dal.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-dal.ts @@ -31,8 +31,18 @@ export const sshHostDALFactory = (db: TDbClient) => { `${TableName.SshHostLoginUser}.id`, `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` ) + .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SshHostLoginUserMapping}.userId`) + .leftJoin( + TableName.UserGroupMembership, + `${TableName.UserGroupMembership}.groupId`, + `${TableName.SshHostLoginUserMapping}.groupId` + ) .whereIn(`${TableName.SshHost}.projectId`, projectIds) - .andWhere(`${TableName.SshHostLoginUserMapping}.userId`, userId) + .andWhere((bd) => { + void bd + .where(`${TableName.SshHostLoginUserMapping}.userId`, userId) + .orWhere(`${TableName.UserGroupMembership}.userId`, userId); + }) .select( db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), db.ref("projectId").withSchema(TableName.SshHost), @@ -58,8 +68,17 @@ export const sshHostDALFactory = (db: TDbClient) => { `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` ) .join(TableName.SshHost, `${TableName.SshHostGroupMembership}.sshHostId`, `${TableName.SshHost}.id`) + .leftJoin( + TableName.UserGroupMembership, + `${TableName.UserGroupMembership}.groupId`, + `${TableName.SshHostLoginUserMapping}.groupId` + ) .whereIn(`${TableName.SshHost}.projectId`, projectIds) - .andWhere(`${TableName.SshHostLoginUserMapping}.userId`, userId) + .andWhere((bd) => { + void bd + .where(`${TableName.SshHostLoginUserMapping}.userId`, userId) + .orWhere(`${TableName.UserGroupMembership}.userId`, userId); + }) .select( db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), db.ref("projectId").withSchema(TableName.SshHost), @@ -133,6 +152,7 @@ export const sshHostDALFactory = (db: TDbClient) => { `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` ) .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.Groups, `${TableName.SshHostLoginUserMapping}.groupId`, `${TableName.Groups}.id`) .where(`${TableName.SshHost}.projectId`, projectId) .select( db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), @@ -144,6 +164,7 @@ export const sshHostDALFactory = (db: TDbClient) => { db.ref("loginUser").withSchema(TableName.SshHostLoginUser), db.ref("username").withSchema(TableName.Users), db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug"), db.ref("userSshCaId").withSchema(TableName.SshHost), db.ref("hostSshCaId").withSchema(TableName.SshHost) ) @@ -163,10 +184,12 @@ export const sshHostDALFactory = (db: TDbClient) => { `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` ) .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.Groups, `${TableName.SshHostLoginUserMapping}.groupId`, `${TableName.Groups}.id`) .select( db.ref("sshHostId").withSchema(TableName.SshHostGroupMembership), db.ref("loginUser").withSchema(TableName.SshHostLoginUser), - db.ref("username").withSchema(TableName.Users) + db.ref("username").withSchema(TableName.Users), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug") ) .whereIn(`${TableName.SshHostGroupMembership}.sshHostId`, hostIds); @@ -185,7 +208,8 @@ export const sshHostDALFactory = (db: TDbClient) => { const directMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ loginUser, allowedPrincipals: { - usernames: unique(entries.map((e) => e.username)).filter(Boolean) + usernames: unique(entries.map((e) => e.username)).filter(Boolean), + groups: unique(entries.map((e) => e.groupSlug)).filter(Boolean) }, source: LoginMappingSource.HOST })); @@ -197,7 +221,8 @@ export const sshHostDALFactory = (db: TDbClient) => { const groupMappings = Object.entries(inheritedGrouped).map(([loginUser, entries]) => ({ loginUser, allowedPrincipals: { - usernames: unique(entries.map((e) => e.username)).filter(Boolean) + usernames: unique(entries.map((e) => e.username)).filter(Boolean), + groups: unique(entries.map((e) => e.groupSlug)).filter(Boolean) }, source: LoginMappingSource.HOST_GROUP })); @@ -229,6 +254,7 @@ export const sshHostDALFactory = (db: TDbClient) => { `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` ) .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.Groups, `${TableName.SshHostLoginUserMapping}.groupId`, `${TableName.Groups}.id`) .where(`${TableName.SshHost}.id`, sshHostId) .select( db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), @@ -241,7 +267,8 @@ export const sshHostDALFactory = (db: TDbClient) => { db.ref("username").withSchema(TableName.Users), db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), db.ref("userSshCaId").withSchema(TableName.SshHost), - db.ref("hostSshCaId").withSchema(TableName.SshHost) + db.ref("hostSshCaId").withSchema(TableName.SshHost), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug") ); if (rows.length === 0) return null; @@ -257,7 +284,8 @@ export const sshHostDALFactory = (db: TDbClient) => { const directMappings = Object.entries(directGrouped).map(([loginUser, entries]) => ({ loginUser, allowedPrincipals: { - usernames: unique(entries.map((e) => e.username)).filter(Boolean) + usernames: unique(entries.map((e) => e.username)).filter(Boolean), + groups: unique(entries.map((e) => e.groupSlug)).filter(Boolean) }, source: LoginMappingSource.HOST })); @@ -275,10 +303,12 @@ export const sshHostDALFactory = (db: TDbClient) => { `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` ) .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.Groups, `${TableName.SshHostLoginUserMapping}.groupId`, `${TableName.Groups}.id`) .where(`${TableName.SshHostGroupMembership}.sshHostId`, sshHostId) .select( db.ref("loginUser").withSchema(TableName.SshHostLoginUser), - db.ref("username").withSchema(TableName.Users) + db.ref("username").withSchema(TableName.Users), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug") ); const groupGrouped = groupBy( @@ -289,7 +319,8 @@ export const sshHostDALFactory = (db: TDbClient) => { const groupMappings = Object.entries(groupGrouped).map(([loginUser, entries]) => ({ loginUser, allowedPrincipals: { - usernames: unique(entries.map((e) => e.username)).filter(Boolean) + usernames: unique(entries.map((e) => e.username)).filter(Boolean), + groups: unique(entries.map((e) => e.groupSlug)).filter(Boolean) }, source: LoginMappingSource.HOST_GROUP })); diff --git a/backend/src/ee/services/ssh-host/ssh-host-fns.ts b/backend/src/ee/services/ssh-host/ssh-host-fns.ts index 9b9ce2642..dec15e093 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-fns.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-fns.ts @@ -3,6 +3,7 @@ import { Knex } from "knex"; import { ActionProjectType } from "@app/db/schemas"; import { BadRequestError } from "@app/lib/errors"; +import { ProjectPermissionSshHostActions, ProjectPermissionSub } from "../permission/project-permission"; import { TCreateSshLoginMappingsDTO } from "./ssh-host-types"; /** @@ -15,6 +16,7 @@ export const createSshLoginMappings = async ({ loginMappings, sshHostLoginUserDAL, sshHostLoginUserMappingDAL, + groupDAL, userDAL, permissionService, projectId, @@ -35,7 +37,7 @@ export const createSshLoginMappings = async ({ tx ); - if (allowedPrincipals.usernames.length > 0) { + if (allowedPrincipals.usernames && allowedPrincipals.usernames.length > 0) { const users = await userDAL.find( { $in: { @@ -74,6 +76,41 @@ export const createSshLoginMappings = async ({ tx ); } + + if (allowedPrincipals.groups && allowedPrincipals.groups.length > 0) { + const projectGroups = await groupDAL.findGroupsByProjectId(projectId); + const groups = projectGroups.filter((g) => allowedPrincipals.groups?.includes(g.slug)); + + if (groups.length !== allowedPrincipals.groups?.length) { + throw new BadRequestError({ + message: `Invalid group slugs: ${allowedPrincipals.groups + .filter((g) => !projectGroups.some((pg) => pg.slug === g)) + .join(", ")}` + }); + } + + for await (const group of groups) { + // check that each group has access to the SSH project and have read access to hosts + const hasPermission = await permissionService.checkGroupProjectPermission({ + groupId: group.id, + projectId, + checkPermissions: [ProjectPermissionSshHostActions.Read, ProjectPermissionSub.SshHosts] + }); + if (!hasPermission) { + throw new BadRequestError({ + message: `Group ${group.slug} does not have access to the SSH project` + }); + } + } + + await sshHostLoginUserMappingDAL.insertMany( + groups.map((group) => ({ + sshHostLoginUserId: sshHostLoginUser.id, + groupId: group.id + })), + tx + ); + } } }; diff --git a/backend/src/ee/services/ssh-host/ssh-host-schema.ts b/backend/src/ee/services/ssh-host/ssh-host-schema.ts index a9b674991..c8acb37bf 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-schema.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-schema.ts @@ -15,7 +15,24 @@ export const sanitizedSshHost = SshHostsSchema.pick({ export const loginMappingSchema = z.object({ loginUser: z.string().trim(), - allowedPrincipals: z.object({ - usernames: z.array(z.string().trim()).transform((usernames) => Array.from(new Set(usernames))) - }) + allowedPrincipals: z + .object({ + usernames: z + .array(z.string().trim()) + .transform((usernames) => Array.from(new Set(usernames))) + .optional(), + groups: z + .array(z.string().trim()) + .transform((groups) => Array.from(new Set(groups))) + .optional() + }) + .refine( + (data) => { + return (data.usernames && data.usernames.length > 0) || (data.groups && data.groups.length > 0); + }, + { + message: "At least one username or group must be provided", + path: ["allowedPrincipals"] + } + ) }); diff --git a/backend/src/ee/services/ssh-host/ssh-host-service.ts b/backend/src/ee/services/ssh-host/ssh-host-service.ts index 87f4862bb..e41a8b403 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-service.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-service.ts @@ -1,6 +1,7 @@ import { ForbiddenError, subject } from "@casl/ability"; import { ActionProjectType, ProjectType } from "@app/db/schemas"; +import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionSshHostActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; @@ -19,6 +20,7 @@ import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectSshConfigDALFactory } from "@app/services/project/project-ssh-config-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TUserGroupMembershipDALFactory } from "../group/user-group-membership-dal"; import { convertActorToPrincipals, createSshCert, @@ -39,12 +41,14 @@ import { type TSshHostServiceFactoryDep = { userDAL: Pick; + groupDAL: Pick; projectDAL: Pick; projectSshConfigDAL: Pick; sshCertificateAuthorityDAL: Pick; sshCertificateAuthoritySecretDAL: Pick; sshCertificateDAL: Pick; sshCertificateBodyDAL: Pick; + userGroupMembershipDAL: Pick; sshHostDAL: Pick< TSshHostDALFactory, | "transaction" @@ -58,7 +62,10 @@ type TSshHostServiceFactoryDep = { >; sshHostLoginUserDAL: TSshHostLoginUserDALFactory; sshHostLoginUserMappingDAL: TSshHostLoginUserMappingDALFactory; - permissionService: Pick; + permissionService: Pick< + TPermissionServiceFactory, + "getProjectPermission" | "getUserProjectPermission" | "checkGroupProjectPermission" + >; kmsService: Pick; }; @@ -66,6 +73,8 @@ export type TSshHostServiceFactory = ReturnType; export const sshHostServiceFactory = ({ userDAL, + userGroupMembershipDAL, + groupDAL, projectDAL, projectSshConfigDAL, sshCertificateAuthorityDAL, @@ -208,6 +217,7 @@ export const sshHostServiceFactory = ({ loginMappings, sshHostLoginUserDAL, sshHostLoginUserMappingDAL, + groupDAL, userDAL, permissionService, projectId, @@ -278,6 +288,7 @@ export const sshHostServiceFactory = ({ loginMappings, sshHostLoginUserDAL, sshHostLoginUserMappingDAL, + groupDAL, userDAL, permissionService, projectId: host.projectId, @@ -324,7 +335,7 @@ export const sshHostServiceFactory = ({ return host; }; - const getSshHost = async ({ sshHostId, actorId, actorAuthMethod, actor, actorOrgId }: TGetSshHostDTO) => { + const getSshHostById = async ({ sshHostId, actorId, actorAuthMethod, actor, actorOrgId }: TGetSshHostDTO) => { const host = await sshHostDAL.findSshHostByIdWithLoginMappings(sshHostId); if (!host) { throw new NotFoundError({ @@ -387,10 +398,14 @@ export const sshHostServiceFactory = ({ userDAL }); + const userGroups = await userGroupMembershipDAL.findGroupMembershipsByUserIdInOrg(actorId, actorOrgId); + const userGroupSlugs = userGroups.map((g) => g.groupSlug); + const mapping = host.loginMappings.find( (m) => m.loginUser === loginUser && - m.allowedPrincipals.usernames.some((allowed) => internalPrincipals.includes(allowed)) + (m.allowedPrincipals.usernames?.some((allowed) => internalPrincipals.includes(allowed)) || + m.allowedPrincipals.groups?.some((allowed) => userGroupSlugs.includes(allowed))) ); if (!mapping) { @@ -616,7 +631,7 @@ export const sshHostServiceFactory = ({ createSshHost, updateSshHost, deleteSshHost, - getSshHost, + getSshHostById, issueSshHostUserCert, issueSshHostHostCert, getSshHostUserCaPk, diff --git a/backend/src/ee/services/ssh-host/ssh-host-types.ts b/backend/src/ee/services/ssh-host/ssh-host-types.ts index 9846920b7..c0a780fbb 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-types.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-types.ts @@ -7,12 +7,15 @@ import { TProjectPermission } from "@app/lib/types"; import { ActorAuthMethod } from "@app/services/auth/auth-type"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TGroupDALFactory } from "../group/group-dal"; + export type TListSshHostsDTO = Omit; export type TLoginMapping = { loginUser: string; allowedPrincipals: { - usernames: string[]; + usernames?: string[]; + groups?: string[]; }; }; @@ -63,7 +66,8 @@ type BaseCreateSshLoginMappingsDTO = { sshHostLoginUserDAL: Pick; sshHostLoginUserMappingDAL: Pick; userDAL: Pick; - permissionService: Pick; + permissionService: Pick; + groupDAL: Pick; projectId: string; actorAuthMethod: ActorAuthMethod; actorOrgId: string; diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index ac28e9ade..c6ec5dccb 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -1,6 +1,7 @@ -import { Redis } from "ioredis"; - +import { buildRedisFromConfig, TRedisConfigKeys } from "@app/lib/config/redis"; import { pgAdvisoryLockHashText } from "@app/lib/crypto/hashtext"; +import { applyJitter } from "@app/lib/dates"; +import { delay as delayMs } from "@app/lib/delay"; import { Redlock, Settings } from "@app/lib/red-lock"; export const PgSqlLock = { @@ -9,7 +10,8 @@ export const PgSqlLock = { KmsRootKeyInit: 2025, OrgGatewayRootCaInit: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-root-ca:${orgId}`), OrgGatewayCertExchange: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-cert-exchange:${orgId}`), - SecretRotationV2Creation: (folderId: string) => pgAdvisoryLockHashText(`secret-rotation-v2-creation:${folderId}`) + SecretRotationV2Creation: (folderId: string) => pgAdvisoryLockHashText(`secret-rotation-v2-creation:${folderId}`), + CreateProject: (orgId: string) => pgAdvisoryLockHashText(`create-project:${orgId}`) } as const; export type TKeyStoreFactory = ReturnType; @@ -35,6 +37,10 @@ export const KeyStorePrefixes = { `sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const, SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const, SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const, + SecretScanningLock: (dataSourceId: string, resourceExternalId: string) => + `secret-scanning-v2-mutex-${dataSourceId}-${resourceExternalId}` as const, + CaOrderCertificateForSubscriberLock: (subscriberId: string) => + `ca-order-certificate-for-subscriber-lock-${subscriberId}` as const, SecretSyncLastRunTimestamp: (syncId: string) => `secret-sync-last-run-${syncId}` as const, IdentityAccessTokenStatusUpdate: (identityAccessTokenId: string) => `identity-access-token-status:${identityAccessTokenId}`, @@ -48,6 +54,13 @@ export const KeyStoreTtls = { AccessTokenStatusUpdateInSeconds: 120 }; +type TDeleteItems = { + pattern: string; + batchSize?: number; + delay?: number; + jitter?: number; +}; + type TWaitTillReady = { key: string; waitingCb?: () => void; @@ -57,8 +70,8 @@ type TWaitTillReady = { jitter?: number; }; -export const keyStoreFactory = (redisUrl: string) => { - const redis = new Redis(redisUrl); +export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys) => { + const redis = buildRedisFromConfig(redisConfigKeys); const redisLock = new Redlock([redis], { retryCount: 2, retryDelay: 200 }); const setItem = async (key: string, value: string | number | Buffer, prefix?: string) => @@ -75,6 +88,35 @@ export const keyStoreFactory = (redisUrl: string) => { const deleteItem = async (key: string) => redis.del(key); + const deleteItems = async ({ pattern, batchSize = 500, delay = 1500, jitter = 200 }: TDeleteItems) => { + let cursor = "0"; + let totalDeleted = 0; + + do { + // Await in loop is needed so that Redis is not overwhelmed + // eslint-disable-next-line no-await-in-loop + const [nextCursor, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 1000); // Count should be 1000 - 5000 for prod loads + cursor = nextCursor; + + for (let i = 0; i < keys.length; i += batchSize) { + const batch = keys.slice(i, i + batchSize); + const pipeline = redis.pipeline(); + for (const key of batch) { + pipeline.unlink(key); + } + // eslint-disable-next-line no-await-in-loop + await pipeline.exec(); + totalDeleted += batch.length; + console.log("BATCH DONE"); + + // eslint-disable-next-line no-await-in-loop + await delayMs(Math.max(0, applyJitter(delay, jitter))); + } + } while (cursor !== "0"); + + return totalDeleted; + }; + const incrementBy = async (key: string, value: number) => redis.incrby(key, value); const setExpiry = async (key: string, expiryInSeconds: number) => redis.expire(key, expiryInSeconds); @@ -94,7 +136,7 @@ export const keyStoreFactory = (redisUrl: string) => { // eslint-disable-next-line await new Promise((resolve) => { waitingCb?.(); - setTimeout(resolve, Math.max(0, delay + Math.floor((Math.random() * 2 - 1) * jitter))); + setTimeout(resolve, Math.max(0, applyJitter(delay, jitter))); }); attempts += 1; // eslint-disable-next-line @@ -108,6 +150,7 @@ export const keyStoreFactory = (redisUrl: string) => { setExpiry, setItemWithExpiry, deleteItem, + deleteItems, incrementBy, acquireLock(resources: string[], duration: number, settings?: Partial) { return redisLock.acquire(resources, duration, settings); diff --git a/backend/src/keystore/memory.ts b/backend/src/keystore/memory.ts index 10b28ffec..84cd06c03 100644 --- a/backend/src/keystore/memory.ts +++ b/backend/src/keystore/memory.ts @@ -1,3 +1,7 @@ +import RE2 from "re2"; + +import { applyJitter } from "@app/lib/dates"; +import { delay as delayMs } from "@app/lib/delay"; import { Lock } from "@app/lib/red-lock"; import { TKeyStoreFactory } from "./keystore"; @@ -19,6 +23,27 @@ export const inMemoryKeyStore = (): TKeyStoreFactory => { delete store[key]; return 1; }, + deleteItems: async ({ pattern, batchSize = 500, delay = 1500, jitter = 200 }) => { + const regex = new RE2(`^${pattern.replace(/[-[\]/{}()+?.\\^$|]/g, "\\$&").replace(/\*/g, ".*")}$`); + let totalDeleted = 0; + const keys = Object.keys(store); + + for (let i = 0; i < keys.length; i += batchSize) { + const batch = keys.slice(i, i + batchSize); + + for (const key of batch) { + if (regex.test(key)) { + delete store[key]; + totalDeleted += 1; + } + } + + // eslint-disable-next-line no-await-in-loop + await delayMs(Math.max(0, applyJitter(delay, jitter))); + } + + return totalDeleted; + }, getItem: async (key) => { const value = store[key]; if (typeof value === "string") { diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index ae6bbbcab..a10fcc67e 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -3,8 +3,16 @@ import { SECRET_ROTATION_CONNECTION_MAP, SECRET_ROTATION_NAME_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps"; +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + AUTO_SYNC_DESCRIPTION_HELPER, + SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP, + SECRET_SCANNING_DATA_SOURCE_NAME_MAP +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-maps"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { CERTIFICATE_AUTHORITIES_TYPE_MAP } from "@app/services/certificate-authority/certificate-authority-maps"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps"; @@ -14,10 +22,12 @@ export enum ApiDocsTags { UniversalAuth = "Universal Auth", GcpAuth = "GCP Auth", AwsAuth = "AWS Auth", + OciAuth = "OCI Auth", AzureAuth = "Azure Auth", KubernetesAuth = "Kubernetes Auth", JwtAuth = "JWT Auth", OidcAuth = "OIDC Auth", + LdapAuth = "LDAP Auth", Groups = "Groups", Organizations = "Organizations", Projects = "Projects", @@ -45,6 +55,7 @@ export enum ApiDocsTags { PkiCertificateTemplates = "PKI Certificate Templates", PkiCertificateCollections = "PKI Certificate Collections", PkiAlerting = "PKI Alerting", + PkiSubscribers = "PKI Subscribers", SshCertificates = "SSH Certificates", SshCertificateAuthorities = "SSH Certificate Authorities", SshCertificateTemplates = "SSH Certificate Templates", @@ -52,7 +63,8 @@ export enum ApiDocsTags { SshHostGroups = "SSH Host Groups", KmsKeys = "KMS Keys", KmsEncryption = "KMS Encryption", - KmsSigning = "KMS Signing" + KmsSigning = "KMS Signing", + SecretScanning = "Secret Scanning" } export const GROUPS = { @@ -77,6 +89,7 @@ export const GROUPS = { limit: "The number of users to return.", username: "The username to search for.", search: "The text string that user email or name will be filtered by.", + projectId: "The ID of the project the group belongs to.", filterUsers: "Whether to filter the list of returned users. 'existingMembers' will only return existing users in the group, 'nonMembers' will only return users not in the group, undefined will return all users in the organization." }, @@ -142,7 +155,9 @@ export const UNIVERSAL_AUTH = { accessTokenMaxTTL: "The maximum lifetime for an access token in seconds. This value will be referenced at renewal time.", accessTokenNumUsesLimit: - "The maximum number of times that an access token can be used; a value of 0 implies infinite number of uses." + "The maximum number of times that an access token can be used; a value of 0 implies infinite number of uses.", + accessTokenPeriod: + "The period for an access token in seconds. This value will be referenced at renewal time. Default value is 0." }, RETRIEVE: { identityId: "The ID of the identity to retrieve the auth method for." @@ -156,7 +171,8 @@ export const UNIVERSAL_AUTH = { accessTokenTrustedIps: "The new list of IPs or CIDR ranges that access tokens can be used from.", accessTokenTTL: "The new lifetime for an access token in seconds.", 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." + accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used.", + accessTokenPeriod: "The new period for an access token in seconds." }, CREATE_CLIENT_SECRET: { identityId: "The ID of the identity to create a client secret for.", @@ -184,6 +200,49 @@ export const UNIVERSAL_AUTH = { } } as const; +export const LDAP_AUTH = { + LOGIN: { + identityId: "The ID of the identity to login.", + username: "The username of the LDAP user to login.", + password: "The password of the LDAP user to login." + }, + ATTACH: { + identityId: "The ID of the identity to attach the configuration onto.", + url: "The URL of the LDAP server.", + allowedFields: + "The comma-separated array of key/value pairs of required fields that the LDAP entry must have in order to authenticate.", + searchBase: "The base DN to search for the LDAP user.", + searchFilter: "The filter to use to search for the LDAP user.", + bindDN: "The DN of the user to bind to the LDAP server.", + bindPass: "The password of the user to bind to the LDAP server.", + ldapCaCertificate: "The PEM-encoded CA certificate for the LDAP server.", + 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." + }, + UPDATE: { + identityId: "The ID of the identity to update the configuration for.", + url: "The new URL of the LDAP server.", + allowedFields: "The comma-separated list of allowed fields to return from the LDAP user.", + searchBase: "The new base DN to search for the LDAP user.", + searchFilter: "The new filter to use to search for the LDAP user.", + bindDN: "The new DN of the user to bind to the LDAP server.", + bindPass: "The new password of the user to bind to the LDAP server.", + ldapCaCertificate: "The new PEM-encoded CA certificate for the LDAP server.", + accessTokenTTL: "The new lifetime for an access token in seconds.", + 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." + }, + RETRIEVE: { + identityId: "The ID of the identity to retrieve the configuration for." + }, + REVOKE: { + identityId: "The ID of the identity to revoke the configuration for." + } +} as const; + export const AWS_AUTH = { LOGIN: { identityId: "The ID of the identity to login.", @@ -226,6 +285,40 @@ export const AWS_AUTH = { } } as const; +export const OCI_AUTH = { + LOGIN: { + identityId: "The ID of the identity to login.", + userOcid: "The OCID of the user attempting login.", + headers: "The headers of the signed request." + }, + ATTACH: { + identityId: "The ID of the identity to attach the configuration onto.", + tenancyOcid: "The OCID of your tenancy.", + allowedUsernames: + "The comma-separated list of trusted OCI account usernames that are allowed to authenticate with Infisical.", + 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." + }, + UPDATE: { + identityId: "The ID of the identity to update the auth method for.", + tenancyOcid: "The OCID of your tenancy.", + allowedUsernames: + "The comma-separated list of trusted OCI account usernames that are allowed to authenticate with Infisical.", + accessTokenTTL: "The new lifetime for an access token in seconds.", + 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." + }, + RETRIEVE: { + identityId: "The ID of the identity to retrieve the auth method for." + }, + REVOKE: { + identityId: "The ID of the identity to revoke the auth method for." + } +} as const; + export const AZURE_AUTH = { LOGIN: { identityId: "The ID of the identity to login." @@ -308,11 +401,14 @@ export const KUBERNETES_AUTH = { caCert: "The PEM-encoded CA cert for the Kubernetes API server.", tokenReviewerJwt: "Optional JWT token for accessing Kubernetes TokenReview API. If provided, this long-lived token will be used to validate service account tokens during authentication. If omitted, the client's own JWT will be used instead, which requires the client to have the system:auth-delegator ClusterRole binding.", + tokenReviewMode: + "The mode to use for token review. Must be one of: 'api', 'gateway'. If gateway is selected, the gateway must be deployed in Kubernetes, and the gateway must have the system:auth-delegator ClusterRole binding.", allowedNamespaces: "The comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical.", allowedNames: "The comma-separated list of trusted service account names that can authenticate with Infisical.", allowedAudience: "The optional audience claim that the service account JWT token must have to authenticate with Infisical.", + gatewayId: "The ID of the gateway to use when performing kubernetes API requests.", accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from.", accessTokenTTL: "The lifetime for an access token in seconds.", accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.", @@ -324,11 +420,14 @@ export const KUBERNETES_AUTH = { caCert: "The new PEM-encoded CA cert for the Kubernetes API server.", tokenReviewerJwt: "Optional JWT token for accessing Kubernetes TokenReview API. If provided, this long-lived token will be used to validate service account tokens during authentication. If omitted, the client's own JWT will be used instead, which requires the client to have the system:auth-delegator ClusterRole binding.", + tokenReviewMode: + "The mode to use for token review. Must be one of: 'api', 'gateway'. If gateway is selected, the gateway must be deployed in Kubernetes, and the gateway must have the system:auth-delegator ClusterRole binding.", allowedNamespaces: "The new comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical.", allowedNames: "The new comma-separated list of trusted service account names that can authenticate with Infisical.", allowedAudience: "The new optional audience claim that the service account JWT token must have to authenticate with Infisical.", + gatewayId: "The ID of the gateway to use when performing kubernetes API requests.", accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from.", accessTokenTTL: "The new lifetime for an acccess token in seconds.", accessTokenMaxTTL: "The new maximum lifetime for an acccess token in seconds.", @@ -526,7 +625,8 @@ export const PROJECTS = { projectDescription: "An optional description label for the project.", autoCapitalization: "Disable or enable auto-capitalization for the project.", slug: "An optional slug for the project. (must be unique within the organization)", - hasDeleteProtection: "Enable or disable delete protection for the project." + hasDeleteProtection: "Enable or disable delete protection for the project.", + secretSharing: "Enable or disable secret sharing for the project." }, GET_KEY: { workspaceId: "The ID of the project to get the key from." @@ -595,6 +695,9 @@ export const PROJECTS = { commonName: "The common name of the certificate to filter by.", offset: "The offset to start from. If you enter 10, it will start from the 10th certificate.", limit: "The number of certificates to return." + }, + LIST_PKI_SUBSCRIBERS: { + projectId: "The ID of the project to list PKI subscribers for." } } as const; @@ -1434,7 +1537,7 @@ export const SSH_HOSTS = { loginUser: "A login user on the remote machine (e.g. 'ec2-user', 'deploy', 'admin')", allowedPrincipals: "A list of allowed principals that can log in as the login user.", loginMappings: - "A list of login mappings for the SSH host. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users in the Infisical SSH project.", + "A list of login mappings for the SSH host. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users or groups slugs in the Infisical SSH project.", userSshCaId: "The ID of the SSH CA to use for user certificates. If not specified, the default user SSH CA will be used if it exists.", hostSshCaId: @@ -1449,7 +1552,7 @@ export const SSH_HOSTS = { loginUser: "A login user on the remote machine (e.g. 'ec2-user', 'deploy', 'admin')", allowedPrincipals: "A list of allowed principals that can log in as the login user.", loginMappings: - "A list of login mappings for the SSH host. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users in the Infisical SSH project." + "A list of login mappings for the SSH host. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users or groups slugs in the Infisical SSH project." }, DELETE: { sshHostId: "The ID of the SSH host to delete." @@ -1621,6 +1724,19 @@ export const CERTIFICATES = { certificateChain: "The certificate chain of the certificate.", serialNumberRes: "The serial number of the certificate.", privateKey: "The private key of the certificate." + }, + IMPORT: { + projectSlug: "Slug of the project to import the certificate into.", + certificatePem: "The PEM-encoded leaf certificate.", + privateKeyPem: "The PEM-encoded private key corresponding to the certificate.", + chainPem: "The PEM-encoded chain of intermediate certificates.", + friendlyName: "A friendly name for the certificate.", + pkiCollectionId: "The ID of the PKI collection to add the certificate to.", + + certificate: "The issued certificate.", + certificateChain: "The certificate chain of the issued certificate.", + privateKey: "The private key of the issued certificate.", + serialNumber: "The serial number of the issued certificate." } }; @@ -1687,6 +1803,79 @@ export const ALERTS = { } }; +export const PKI_SUBSCRIBERS = { + GET: { + subscriberName: "The name of the PKI subscriber to get.", + projectId: "The ID of the project to get the PKI subscriber for." + }, + GET_LATEST_CERT_BUNDLE: { + subscriberName: "The name of the PKI subscriber to get the active certificate bundle for.", + projectId: "The ID of the project to get the active certificate bundle for.", + certificate: "The active certificate for the subscriber.", + certificateChain: "The certificate chain of the active certificate for the subscriber.", + privateKey: "The private key of the active certificate for the subscriber.", + serialNumber: "The serial number of the active certificate for the subscriber." + }, + CREATE: { + projectId: "The ID of the project to create the PKI subscriber in.", + caId: "The ID of the CA that will issue certificates for the PKI subscriber.", + name: "The name of the PKI subscriber.", + commonName: "The common name (CN) to be used on certificates issued for this subscriber.", + status: "The status of the PKI subscriber. This can be one of active or disabled.", + ttl: "The time to live for the certificates issued for this subscriber such as 1m, 1h, 1d, 1y, ...", + subjectAlternativeNames: + "A list of Subject Alternative Names (SANs) to be used on certificates issued for this subscriber; these can be host names or email addresses.", + keyUsages: "The key usage extension to be used on certificates issued for this subscriber.", + extendedKeyUsages: "The extended key usage extension to be used on certificates issued for this subscriber.", + enableAutoRenewal: "Whether or not to enable auto renewal for the PKI subscriber.", + autoRenewalPeriodInDays: "The period in days to auto renew the PKI subscriber's certificates." + }, + UPDATE: { + projectId: "The ID of the project to update the PKI subscriber in.", + subscriberName: "The name of the PKI subscriber to update.", + caId: "The ID of the CA that will issue certificates for the PKI subscriber to update to.", + name: "The name of the PKI subscriber to update to.", + commonName: "The common name (CN) to be used on certificates issued for this subscriber to update to.", + status: "The status of the PKI subscriber to update to. This can be one of active or disabled.", + ttl: "The time to live for the certificates issued for this subscriber such as 1m, 1h, 1d, 1y, ...", + subjectAlternativeNames: + "A comma-delimited list of Subject Alternative Names (SANs) to be used on certificates issued for this subscriber; these can be host names or email addresses.", + keyUsages: "The key usage extension to be used on certificates issued for this subscriber to update to.", + extendedKeyUsages: + "The extended key usage extension to be used on certificates issued for this subscriber to update to.", + enableAutoRenewal: "Whether or not to enable auto renewal for the PKI subscriber.", + autoRenewalPeriodInDays: "The period in days to auto renew the PKI subscriber's certificates." + }, + DELETE: { + subscriberName: "The name of the PKI subscriber to delete.", + projectId: "The ID of the project of the PKI subscriber to delete." + }, + ISSUE_CERT: { + subscriberName: "The name of the PKI subscriber to issue the certificate for.", + projectId: "The ID of the project of the PKI subscriber to issue the certificate for.", + certificate: "The issued certificate.", + issuingCaCertificate: "The certificate of the issuing CA.", + certificateChain: "The certificate chain of the issued certificate.", + privateKey: "The private key of the issued certificate.", + serialNumber: "The serial number of the issued certificate." + }, + SIGN_CERT: { + subscriberName: "The name of the PKI subscriber to sign the certificate for.", + projectId: "The ID of the project of the PKI subscriber to sign the certificate for.", + csr: "The CSR to be used to sign the certificate.", + certificate: "The signed certificate.", + issuingCaCertificate: "The certificate of the issuing CA.", + certificateChain: "The certificate chain of the signed certificate.", + serialNumber: "The serial number of the signed certificate." + }, + LIST_CERTS: { + subscriberName: "The name of the PKI subscriber to list the certificates for.", + projectId: "The ID of the project of the PKI subscriber to list the certificates for.", + offset: "The offset to start from.", + limit: "The number of certificates to return." + } +}; + export const PKI_COLLECTIONS = { CREATE: { projectId: "The ID of the project to create the PKI collection in.", @@ -1822,8 +2011,12 @@ export const KMS = { }; export const ProjectTemplates = { + LIST: { + type: "The type of project template to list." + }, CREATE: { name: "The name of the project template to be created. Must be slug-friendly.", + type: "The type of project template to be created.", description: "An optional description of the project template.", roles: "The roles to be created when the template is applied to a project.", environments: "The environments to be created when the template is applied to a project." @@ -1840,6 +2033,47 @@ export const ProjectTemplates = { } }; +export const CertificateAuthorities = { + CREATE: (type: CaType) => ({ + name: `The name of the ${CERTIFICATE_AUTHORITIES_TYPE_MAP[type]} Certificate Authority to create. Must be slug-friendly.`, + projectId: `The ID of the project to create the Certificate Authority in.`, + enableDirectIssuance: `Whether or not to enable direct issuance of certificates for the ${CERTIFICATE_AUTHORITIES_TYPE_MAP[type]} Certificate Authority.`, + status: `The status of the ${CERTIFICATE_AUTHORITIES_TYPE_MAP[type]} Certificate Authority.` + }), + UPDATE: (type: CaType) => ({ + caId: `The ID of the ${CERTIFICATE_AUTHORITIES_TYPE_MAP[type]} Certificate Authority to update.`, + projectId: `The ID of the project to update the Certificate Authority in.`, + name: `The updated name of the ${CERTIFICATE_AUTHORITIES_TYPE_MAP[type]} Certificate Authority. Must be slug-friendly.`, + enableDirectIssuance: `Whether or not to enable direct issuance of certificates for the ${CERTIFICATE_AUTHORITIES_TYPE_MAP[type]} Certificate Authority.`, + status: `The updated status of the ${CERTIFICATE_AUTHORITIES_TYPE_MAP[type]} Certificate Authority.` + }), + CONFIGURATIONS: { + ACME: { + dnsAppConnectionId: `The ID of the App Connection to use for creating and managing DNS TXT records required for ACME domain validation. This connection must have permissions to create and delete TXT records in your DNS provider (e.g., Route53) for the ACME challenge process.`, + directoryUrl: `The directory URL for the ACME Certificate Authority.`, + accountEmail: `The email address for the ACME Certificate Authority.`, + provider: `The DNS provider for the ACME Certificate Authority.`, + hostedZoneId: `The hosted zone ID for the ACME Certificate Authority.` + }, + INTERNAL: { + type: "The type of CA to create.", + friendlyName: "A friendly name for the CA.", + organization: "The organization (O) for the CA.", + ou: "The organization unit (OU) for the CA.", + country: "The country name (C) for the CA.", + province: "The state of province name for the CA.", + locality: "The locality name for the CA.", + commonName: "The common name (CN) for the CA.", + notBefore: "The date and time when the CA becomes valid in YYYY-MM-DDTHH:mm:ss.sssZ format.", + notAfter: "The date and time when the CA expires in YYYY-MM-DDTHH:mm:ss.sssZ format.", + maxPathLength: + "The maximum number of intermediate CAs that may follow this CA in the certificate / CA chain. A maxPathLength of -1 implies no path limit on the chain.", + keyAlgorithm: + "The type of public key algorithm and size, in bits, of the key pair for the CA; when you create an intermediate CA, you must use a key algorithm supported by the parent CA." + } + } +}; + export const AppConnections = { GET_BY_ID: (app: AppConnection) => ({ connectionId: `The ID of the ${APP_CONNECTION_NAME_MAP[app]} Connection to retrieve.` @@ -1912,7 +2146,7 @@ export const AppConnections = { LDAP: { provider: "The type of LDAP provider. Determines provider-specific behaviors.", url: "The LDAP/LDAPS URL to connect to (e.g., 'ldap://domain-or-ip:389' or 'ldaps://domain-or-ip:636').", - dn: "The Distinguished Name (DN) of the principal to bind with (e.g., 'CN=John,CN=Users,DC=example,DC=com').", + dn: "The Distinguished Name (DN) or User Principal Name (UPN) of the principal to bind with (e.g., 'CN=John,CN=Users,DC=example,DC=com').", password: "The password to bind with for authentication.", sslRejectUnauthorized: "Whether or not to reject unauthorized SSL certificates (true/false) when using ldaps://. Set to false only in test environments.", @@ -1926,6 +2160,17 @@ export const AppConnections = { AZURE_CLIENT_SECRETS: { code: "The OAuth code to use to connect with Azure Client Secrets.", tenantId: "The Tenant ID to use to connect with Azure Client Secrets." + }, + OCI: { + userOcid: "The OCID (Oracle Cloud Identifier) of the user making the request.", + tenancyOcid: "The OCID (Oracle Cloud Identifier) of the tenancy in Oracle Cloud Infrastructure.", + region: "The region identifier in Oracle Cloud Infrastructure where the vault is located.", + fingerprint: "The fingerprint of the public key uploaded to the user's API keys.", + privateKey: "The private key content in PEM format used to sign API requests." + }, + ONEPASS: { + instanceUrl: "The URL of the 1Password Connect Server instance to authenticate with.", + apiToken: "The API token used to access the 1Password Connect Server." } } }; @@ -1989,6 +2234,7 @@ export const SecretSyncs = { const destinationName = SECRET_SYNC_NAME_MAP[destination]; return { initialSyncBehavior: `Specify how Infisical should resolve the initial sync to the ${destinationName} destination.`, + keySchema: `Specify the format to use for structuring secret keys in the ${destinationName} destination.`, disableSecretDeletion: `Enable this flag to prevent removal of secrets from the ${destinationName} destination when syncing.` }; }, @@ -2031,7 +2277,8 @@ export const SecretSyncs = { }, GCP: { scope: "The Google project scope that secrets should be synced to.", - projectId: "The ID of the Google project secrets should be synced to." + projectId: "The ID of the Google project secrets should be synced to.", + locationId: 'The ID of the Google project location secrets should be synced to (ie "us-west4").' }, DATABRICKS: { scope: "The Databricks secret scope that secrets should be synced to." @@ -2073,6 +2320,14 @@ export const SecretSyncs = { TEAMCITY: { project: "The TeamCity project to sync secrets to.", buildConfig: "The TeamCity build configuration to sync secrets to." + }, + OCI_VAULT: { + compartmentOcid: "The OCID (Oracle Cloud Identifier) of the compartment where the vault is located.", + vaultOcid: "The OCID (Oracle Cloud Identifier) of the vault to sync secrets to.", + keyOcid: "The OCID (Oracle Cloud Identifier) of the encryption key to use when creating secrets in the vault." + }, + ONEPASS: { + vaultId: "The ID of the 1Password vault to sync secrets to." } } }; @@ -2144,7 +2399,10 @@ export const SecretRotations = { clientId: "The client ID of the Azure Application to rotate the client secret for." }, LDAP_PASSWORD: { - dn: "The Distinguished Name (DN) of the principal to rotate the password for." + dn: "The Distinguished Name (DN) or User Principal Name (UPN) of the principal to rotate the password for.", + rotationMethod: + 'Whether the rotation should be performed by the LDAP "connection-principal" or the "target-principal" (defaults to \'connection-principal\').', + password: 'The password of the provided principal if "parameters.rotationMethod" is set to "target-principal".' }, GENERAL: { PASSWORD_REQUIREMENTS: { @@ -2178,7 +2436,7 @@ export const SecretRotations = { clientSecret: "The name of the secret that the rotated client secret will be mapped to." }, LDAP_PASSWORD: { - dn: "The name of the secret that the Distinguished Name (DN) of the principal will be mapped to.", + dn: "The name of the secret that the Distinguished Name (DN) or User Principal Name (UPN) of the principal will be mapped to.", password: "The name of the secret that the rotated password will be mapped to." }, AWS_IAM_USER_SECRET: { @@ -2187,3 +2445,81 @@ export const SecretRotations = { } } }; + +export const SecretScanningDataSources = { + LIST: (type?: SecretScanningDataSource) => ({ + projectId: `The ID of the project to list ${type ? SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type] : "Scanning"} Data Sources from.` + }), + GET_BY_ID: (type: SecretScanningDataSource) => ({ + dataSourceId: `The ID of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to retrieve.` + }), + GET_BY_NAME: (type: SecretScanningDataSource) => ({ + sourceName: `The name of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to retrieve.`, + projectId: `The ID of the project the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source is located in.` + }), + CREATE: (type: SecretScanningDataSource) => { + const sourceType = SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]; + const autoScanDescription = AUTO_SYNC_DESCRIPTION_HELPER[type]; + return { + name: `The name of the ${sourceType} Data Source to create. Must be slug-friendly.`, + description: `An optional description for the ${sourceType} Data Source.`, + projectId: `The ID of the project to create the ${sourceType} Data Source in.`, + connectionId: `The ID of the ${ + APP_CONNECTION_NAME_MAP[SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[type]] + } Connection to use for this Data Source.`, + isAutoScanEnabled: `Whether scans should be automatically performed when a ${autoScanDescription.verb} occurs to ${autoScanDescription.noun} associated with this Data Source.`, + config: `The configuration parameters to use for this Data Source.` + }; + }, + UPDATE: (type: SecretScanningDataSource) => { + const typeName = SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]; + const autoScanDescription = AUTO_SYNC_DESCRIPTION_HELPER[type]; + + return { + dataSourceId: `The ID of the ${typeName} Data Source to be updated.`, + name: `The updated name of the ${typeName} Data Source. Must be slug-friendly.`, + description: `The updated description of the ${typeName} Data Source.`, + isAutoScanEnabled: `Whether scans should be automatically performed when a ${autoScanDescription.verb} occurs to ${autoScanDescription.noun} associated with this Data Source.`, + config: `The updated configuration parameters to use for this Data Source.` + }; + }, + DELETE: (type: SecretScanningDataSource) => ({ + dataSourceId: `The ID of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to be deleted.` + }), + SCAN: (type: SecretScanningDataSource) => ({ + dataSourceId: `The ID of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to trigger a scan for.`, + resourceId: `The ID of the individual Data Source resource to trigger a scan for.` + }), + LIST_RESOURCES: (type: SecretScanningDataSource) => ({ + dataSourceId: `The ID of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to list resources from.` + }), + LIST_SCANS: (type: SecretScanningDataSource) => ({ + dataSourceId: `The ID of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to list scans for.` + }), + CONFIG: { + GITHUB: { + includeRepos: 'The repositories to include when scanning. Defaults to all repositories (["*"]).' + } + } +}; + +export const SecretScanningFindings = { + LIST: { + projectId: `The ID of the project to list Secret Scanning Findings from.` + }, + UPDATE: { + findingId: "The ID of the Secret Scanning Finding to update.", + status: "The updated status of the specified Secret Scanning Finding.", + remarks: "Remarks pertaining to the status of this finding." + } +}; + +export const SecretScanningConfigs = { + GET_BY_PROJECT_ID: { + projectId: `The ID of the project to retrieve the Secret Scanning Configuration for.` + }, + UPDATE: { + projectId: "The ID of the project to update the Secret Scanning Configuration for.", + content: "The contents of the Secret Scanning Configuration file." + } +}; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index e38dbcfb5..e2fc73d8a 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { QueueWorkerProfile } from "@app/lib/types"; + import { removeTrailingSlash } from "../fn"; import { CustomLogger } from "../logger/logger"; import { zpStr } from "../zod"; @@ -30,7 +32,19 @@ const envSchema = z .enum(["true", "false"]) .default("false") .transform((el) => el === "true"), - REDIS_URL: zpStr(z.string()), + REDIS_URL: zpStr(z.string().optional()), + REDIS_SENTINEL_HOSTS: zpStr( + z + .string() + .optional() + .describe("Comma-separated list of Sentinel host:port pairs. Eg: 192.168.65.254:26379,192.168.65.254:26380") + ), + REDIS_SENTINEL_MASTER_NAME: zpStr( + z.string().optional().default("mymaster").describe("The name of the Redis master set monitored by Sentinel") + ), + REDIS_SENTINEL_ENABLE_TLS: zodStrBool.optional().describe("Whether to use TLS/SSL for Redis Sentinel connection"), + REDIS_SENTINEL_USERNAME: zpStr(z.string().optional().describe("Authentication username for Redis Sentinel")), + REDIS_SENTINEL_PASSWORD: zpStr(z.string().optional().describe("Authentication password for Redis Sentinel")), HOST: zpStr(z.string().default("localhost")), DB_CONNECTION_URI: zpStr(z.string().describe("Postgres database connection string")).default( `postgresql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}` @@ -57,6 +71,7 @@ const envSchema = z ENCRYPTION_KEY: zpStr(z.string().optional()), ROOT_ENCRYPTION_KEY: zpStr(z.string().optional()), QUEUE_WORKERS_ENABLED: zodStrBool.default("true"), + QUEUE_WORKER_PROFILE: z.nativeEnum(QueueWorkerProfile).default(QueueWorkerProfile.All), HTTPS_ENABLED: zodStrBool, ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), // smtp options @@ -69,6 +84,9 @@ const envSchema = z SMTP_PASSWORD: zpStr(z.string().optional()), SMTP_FROM_ADDRESS: zpStr(z.string().optional()), SMTP_FROM_NAME: zpStr(z.string().optional().default("Infisical")), + SMTP_CUSTOM_CA_CERT: zpStr( + z.string().optional().describe("Base64 encoded custom CA certificate PEM(s) for the SMTP server") + ), COOKIE_SECRET_SIGN_KEY: z .string() .min(32) @@ -195,6 +213,12 @@ const envSchema = z GATEWAY_RELAY_AUTH_SECRET: zpStr(z.string().optional()), DYNAMIC_SECRET_ALLOW_INTERNAL_IP: zodStrBool.default("false"), + DYNAMIC_SECRET_AWS_ACCESS_KEY_ID: zpStr(z.string().optional()).default( + process.env.INF_APP_CONNECTION_AWS_ACCESS_KEY_ID + ), + DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY: zpStr(z.string().optional()).default( + process.env.INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY + ), /* ----------------------------------------------------------------------------- */ /* App Connections ----------------------------------------------------------------------------- */ @@ -215,6 +239,14 @@ const envSchema = z INF_APP_CONNECTION_GITHUB_APP_SLUG: zpStr(z.string().optional()), INF_APP_CONNECTION_GITHUB_APP_ID: zpStr(z.string().optional()), + // github radar app + INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_RADAR_APP_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET: zpStr(z.string().optional()), + // gcp app INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL: zpStr(z.string().optional()), @@ -230,7 +262,6 @@ const envSchema = z DATADOG_HOSTNAME: zpStr(z.string().optional()), /* CORS ----------------------------------------------------------------------------- */ - CORS_ALLOWED_ORIGINS: zpStr( z .string() @@ -240,7 +271,6 @@ const envSchema = z return JSON.parse(val) as string[]; }) ), - CORS_ALLOWED_HEADERS: zpStr( z .string() @@ -249,33 +279,51 @@ const envSchema = z if (!val) return undefined; return JSON.parse(val) as string[]; }) - ) + ), + + /* INTERNAL ----------------------------------------------------------------------------- */ + INTERNAL_REGION: zpStr(z.enum(["us", "eu"]).optional()) }) // To ensure that basic encryption is always possible. .refine( (data) => Boolean(data.ENCRYPTION_KEY) || Boolean(data.ROOT_ENCRYPTION_KEY), "Either ENCRYPTION_KEY or ROOT_ENCRYPTION_KEY must be defined." ) + .refine( + (data) => Boolean(data.REDIS_URL) || Boolean(data.REDIS_SENTINEL_HOSTS), + "Either REDIS_URL or REDIS_SENTINEL_HOSTS must be defined." + ) .transform((data) => ({ ...data, - DB_READ_REPLICAS: data.DB_READ_REPLICAS ? databaseReadReplicaSchema.parse(JSON.parse(data.DB_READ_REPLICAS)) : undefined, isCloud: Boolean(data.LICENSE_SERVER_KEY), isSmtpConfigured: Boolean(data.SMTP_HOST), - isRedisConfigured: Boolean(data.REDIS_URL), + isRedisConfigured: Boolean(data.REDIS_URL || data.REDIS_SENTINEL_HOSTS), isDevelopmentMode: data.NODE_ENV === "development", isRotationDevelopmentMode: data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE, isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED, - + isRedisSentinelMode: Boolean(data.REDIS_SENTINEL_HOSTS), + REDIS_SENTINEL_HOSTS: data.REDIS_SENTINEL_HOSTS?.trim() + ?.split(",") + .map((el) => { + const [host, port] = el.trim().split(":"); + return { host: host.trim(), port: Number(port.trim()) }; + }), isSecretScanningConfigured: Boolean(data.SECRET_SCANNING_GIT_APP_ID) && Boolean(data.SECRET_SCANNING_PRIVATE_KEY) && Boolean(data.SECRET_SCANNING_WEBHOOK_SECRET), + isSecretScanningV2Configured: + Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_ID) && + Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY) && + Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG) && + Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID) && + Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET) && + Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET), isHsmConfigured: Boolean(data.HSM_LIB_PATH) && Boolean(data.HSM_PIN) && Boolean(data.HSM_KEY_LABEL) && data.HSM_SLOT !== undefined, - samlDefaultOrgSlug: data.DEFAULT_SAML_ORG_SLUG, SECRET_SCANNING_ORG_WHITELIST: data.SECRET_SCANNING_ORG_WHITELIST?.split(",") })); @@ -298,6 +346,17 @@ export const initEnvConfig = (logger?: CustomLogger) => { }; export const formatSmtpConfig = () => { + const tlsOptions: { + rejectUnauthorized: boolean; + ca?: string | string[]; + } = { + rejectUnauthorized: envCfg.SMTP_TLS_REJECT_UNAUTHORIZED + }; + + if (envCfg.SMTP_CUSTOM_CA_CERT) { + tlsOptions.ca = Buffer.from(envCfg.SMTP_CUSTOM_CA_CERT, "base64").toString("utf-8"); + } + return { host: envCfg.SMTP_HOST, port: envCfg.SMTP_PORT, @@ -309,8 +368,6 @@ export const formatSmtpConfig = () => { from: `"${envCfg.SMTP_FROM_NAME}" <${envCfg.SMTP_FROM_ADDRESS}>`, ignoreTLS: envCfg.SMTP_IGNORE_TLS, requireTLS: envCfg.SMTP_REQUIRE_TLS, - tls: { - rejectUnauthorized: envCfg.SMTP_TLS_REJECT_UNAUTHORIZED - } + tls: tlsOptions }; }; diff --git a/backend/src/lib/config/redis.ts b/backend/src/lib/config/redis.ts new file mode 100644 index 000000000..987518dd5 --- /dev/null +++ b/backend/src/lib/config/redis.ts @@ -0,0 +1,24 @@ +import { Redis } from "ioredis"; + +export type TRedisConfigKeys = Partial<{ + REDIS_URL: string; + REDIS_SENTINEL_HOSTS: { host: string; port: number }[]; + REDIS_SENTINEL_MASTER_NAME: string; + REDIS_SENTINEL_ENABLE_TLS: boolean; + REDIS_SENTINEL_USERNAME: string; + REDIS_SENTINEL_PASSWORD: string; +}>; + +export const buildRedisFromConfig = (cfg: TRedisConfigKeys) => { + if (cfg.REDIS_URL) return new Redis(cfg.REDIS_URL, { maxRetriesPerRequest: null }); + + return new Redis({ + // refine at tope will catch this case + sentinels: cfg.REDIS_SENTINEL_HOSTS!, + name: cfg.REDIS_SENTINEL_MASTER_NAME!, + maxRetriesPerRequest: null, + sentinelUsername: cfg.REDIS_SENTINEL_USERNAME, + sentinelPassword: cfg.REDIS_SENTINEL_PASSWORD, + enableTLSForSentinelMode: cfg.REDIS_SENTINEL_ENABLE_TLS + }); +}; diff --git a/backend/src/lib/delay/index.ts b/backend/src/lib/delay/index.ts new file mode 100644 index 000000000..32cb8ebfc --- /dev/null +++ b/backend/src/lib/delay/index.ts @@ -0,0 +1,4 @@ +export const delay = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); diff --git a/backend/src/lib/fn/object.ts b/backend/src/lib/fn/object.ts index 87db80343..65d0b7859 100644 --- a/backend/src/lib/fn/object.ts +++ b/backend/src/lib/fn/object.ts @@ -32,3 +32,24 @@ export const shake = ( return acc; }, {} as T); }; + +export const titleCaseToCamelCase = (obj: unknown): unknown => { + if (typeof obj !== "object" || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item: object) => titleCaseToCamelCase(item)); + } + + const result: Record = {}; + + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + const camelKey = key.charAt(0).toLowerCase() + key.slice(1); + result[camelKey] = titleCaseToCamelCase((obj as Record)[key]); + } + } + + return result; +}; diff --git a/backend/src/lib/gateway/gateway.ts b/backend/src/lib/gateway/gateway.ts new file mode 100644 index 000000000..179c29fc8 --- /dev/null +++ b/backend/src/lib/gateway/gateway.ts @@ -0,0 +1,411 @@ +/* eslint-disable no-await-in-loop */ +import crypto from "node:crypto"; +import net from "node:net"; + +import quicDefault, * as quicModule from "@infisical/quic"; +import axios from "axios"; +import https from "https"; + +import { BadRequestError } from "../errors"; +import { logger } from "../logger"; +import { + GatewayProxyProtocol, + IGatewayProxyOptions, + IGatewayProxyServer, + TGatewayTlsOptions, + TPingGatewayAndVerifyDTO +} from "./types"; + +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_RETRY_DELAY = 1000; // 1 second + +const quic = quicDefault || quicModule; + +const parseSubjectDetails = (data: string) => { + const values: Record = {}; + data.split("\n").forEach((el) => { + const [key, value] = el.split("="); + values[key.trim()] = value.trim(); + }); + return values; +}; + +const createQuicConnection = async ( + relayHost: string, + relayPort: number, + tlsOptions: TGatewayTlsOptions, + identityId: string, + orgId: string +) => { + const client = await quic.QUICClient.createQUICClient({ + host: relayHost, + port: relayPort, + config: { + ca: tlsOptions.ca, + cert: tlsOptions.cert, + key: tlsOptions.key, + applicationProtos: ["infisical-gateway"], + verifyPeer: true, + verifyCallback: async (certs) => { + if (!certs || certs.length === 0) return quic.native.CryptoError.CertificateRequired; + const serverCertificate = new crypto.X509Certificate(Buffer.from(certs[0])); + const caCertificate = new crypto.X509Certificate(tlsOptions.ca); + const isValidServerCertificate = serverCertificate.verify(caCertificate.publicKey); + if (!isValidServerCertificate) return quic.native.CryptoError.BadCertificate; + + const subjectDetails = parseSubjectDetails(serverCertificate.subject); + if (subjectDetails.OU !== "Gateway" || subjectDetails.CN !== identityId || subjectDetails.O !== orgId) { + return quic.native.CryptoError.CertificateUnknown; + } + + if (new Date() > new Date(serverCertificate.validTo) || new Date() < new Date(serverCertificate.validFrom)) { + return quic.native.CryptoError.CertificateExpired; + } + + const formatedRelayHost = + process.env.NODE_ENV === "development" ? relayHost.replace("host.docker.internal", "127.0.0.1") : relayHost; + if (!serverCertificate.checkIP(formatedRelayHost)) return quic.native.CryptoError.BadCertificate; + }, + maxIdleTimeout: 90000, + keepAliveIntervalTime: 30000 + }, + crypto: { + ops: { + randomBytes: async (data) => { + crypto.getRandomValues(new Uint8Array(data)); + } + } + } + }); + return client; +}; + +export const pingGatewayAndVerify = async ({ + relayHost, + relayPort, + tlsOptions, + maxRetries = DEFAULT_MAX_RETRIES, + identityId, + orgId +}: TPingGatewayAndVerifyDTO) => { + let lastError: Error | null = null; + const quicClient = await createQuicConnection(relayHost, relayPort, tlsOptions, identityId, orgId).catch((err) => { + throw new BadRequestError({ + message: (err as Error)?.message, + error: err as Error + }); + }); + + for (let attempt = 1; attempt <= maxRetries; attempt += 1) { + try { + const stream = quicClient.connection.newStream("bidi"); + const pingWriter = stream.writable.getWriter(); + await pingWriter.write(Buffer.from("PING\n")); + pingWriter.releaseLock(); + + // Read PONG response + const reader = stream.readable.getReader(); + const { value, done } = await reader.read(); + + if (done) { + throw new Error("Gateway closed before receiving PONG"); + } + + const response = Buffer.from(value).toString(); + + if (response !== "PONG\n" && response !== "PONG") { + throw new Error(`Failed to Ping. Unexpected response: ${response}`); + } + + reader.releaseLock(); + return; + } catch (err) { + lastError = err as Error; + + if (attempt < maxRetries) { + await new Promise((resolve) => { + setTimeout(resolve, DEFAULT_RETRY_DELAY); + }); + } + } finally { + await quicClient.destroy(); + } + } + + logger.error(lastError); + throw new BadRequestError({ + message: `Failed to ping gateway after ${maxRetries} attempts. Last error: ${lastError?.message}` + }); +}; + +const setupProxyServer = async ({ + targetPort, + targetHost, + tlsOptions, + relayHost, + relayPort, + identityId, + orgId, + protocol = GatewayProxyProtocol.Tcp, + httpsAgent +}: { + targetHost: string; + targetPort: number; + relayPort: number; + relayHost: string; + tlsOptions: TGatewayTlsOptions; + identityId: string; + orgId: string; + protocol?: GatewayProxyProtocol; + httpsAgent?: https.Agent; +}): Promise => { + const quicClient = await createQuicConnection(relayHost, relayPort, tlsOptions, identityId, orgId).catch((err) => { + throw new BadRequestError({ + error: err as Error + }); + }); + const proxyErrorMsg = [""]; + + return new Promise((resolve, reject) => { + const server = net.createServer(); + + let streamClosed = false; + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + server.on("connection", async (clientConn) => { + try { + clientConn.setKeepAlive(true, 30000); // 30 seconds + clientConn.setNoDelay(true); + + const stream = quicClient.connection.newStream("bidi"); + + const forwardWriter = stream.writable.getWriter(); + let command: string; + + if (protocol === GatewayProxyProtocol.Http) { + const targetUrl = `${targetHost}:${targetPort}`; // note(daniel): targetHost MUST include the scheme (https|http) + command = `FORWARD-HTTP ${targetUrl}`; + logger.debug(`Using HTTP proxy mode: ${command.trim()}`); + + // extract ca certificate from httpsAgent if present + if (httpsAgent && targetHost.startsWith("https://")) { + const agentOptions = httpsAgent.options; + if (agentOptions && agentOptions.ca) { + const caCert = Array.isArray(agentOptions.ca) ? agentOptions.ca.join("\n") : agentOptions.ca; + const caB64 = Buffer.from(caCert as string).toString("base64"); + command += ` ca=${caB64}`; + + const rejectUnauthorized = agentOptions.rejectUnauthorized !== false; + command += ` verify=${rejectUnauthorized}`; + + logger.debug(`Using HTTP proxy mode [command=${command.trim()}]`); + } + } + + command += "\n"; + } else if (protocol === GatewayProxyProtocol.Tcp) { + // For TCP mode, send FORWARD-TCP with host:port + command = `FORWARD-TCP ${targetHost}:${targetPort}\n`; + logger.debug(`Using TCP proxy mode: ${command.trim()}`); + } else { + throw new BadRequestError({ + message: `Invalid protocol: ${protocol as string}` + }); + } + + await forwardWriter.write(Buffer.from(command)); + forwardWriter.releaseLock(); + + // Set up bidirectional copy + const setupCopy = () => { + // Client to QUIC + // eslint-disable-next-line + (async () => { + const writer = stream.writable.getWriter(); + + // Create a handler for client data + clientConn.on("data", (chunk) => { + writer.write(chunk).catch((err) => { + proxyErrorMsg.push((err as Error)?.message); + }); + }); + + // Handle client connection close + clientConn.on("end", () => { + if (!streamClosed) { + try { + writer.close().catch((err) => { + logger.debug(err, "Error closing writer (already closed)"); + }); + } catch (error) { + logger.debug(error, "Error in writer close"); + } + } + }); + + clientConn.on("error", (clientConnErr) => { + writer.abort(clientConnErr?.message).catch((err) => { + proxyErrorMsg.push((err as Error)?.message); + }); + }); + })(); + + // QUIC to Client + void (async () => { + try { + const reader = stream.readable.getReader(); + + let reading = true; + while (reading) { + const { value, done } = await reader.read(); + + if (done) { + reading = false; + clientConn.end(); // Close client connection when QUIC stream ends + break; + } + + // Write data to TCP client + const canContinue = clientConn.write(Buffer.from(value)); + + // Handle backpressure + if (!canContinue) { + await new Promise((res) => { + clientConn.once("drain", res); + }); + } + } + } catch (err) { + proxyErrorMsg.push((err as Error)?.message); + clientConn.destroy(); + } + })(); + }; + + setupCopy(); + // Handle connection closure + clientConn.on("close", () => { + if (!streamClosed) { + streamClosed = true; + stream.destroy().catch((err) => { + logger.debug(err, "Stream already destroyed during close event"); + }); + } + }); + + const cleanup = async () => { + try { + clientConn?.destroy(); + } catch (err) { + logger.debug(err, "Error destroying client connection"); + } + + if (!streamClosed) { + streamClosed = true; + try { + await stream.destroy(); + } catch (err) { + logger.debug(err, "Error destroying stream (might be already closed)"); + } + } + }; + + clientConn.on("error", (clientConnErr) => { + logger.error(clientConnErr, "Client socket error"); + cleanup().catch((err) => { + logger.error(err, "Client conn cleanup"); + }); + }); + + clientConn.on("end", () => { + cleanup().catch((err) => { + logger.error(err, "Client conn end"); + }); + }); + } catch (err) { + logger.error(err, "Failed to establish target connection:"); + clientConn.end(); + reject(err); + } + }); + + server.on("error", (err) => { + reject(err); + }); + + server.on("close", () => { + quicClient?.destroy().catch((err) => { + logger.error(err, "Failed to destroy quic client"); + }); + }); + + server.listen(0, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to get server port")); + return; + } + + logger.info(`Gateway proxy started on port ${address.port} (${protocol} mode)`); + resolve({ + server, + port: address.port, + cleanup: async () => { + try { + server.close(); + } catch (err) { + logger.debug(err, "Error closing server"); + } + + try { + await quicClient?.destroy(); + } catch (err) { + logger.debug(err, "Error destroying QUIC client"); + } + }, + getProxyError: () => proxyErrorMsg.join(",") + }); + }); + }); +}; + +export const withGatewayProxy = async ( + callback: (port: number, httpsAgent?: https.Agent) => Promise, + options: IGatewayProxyOptions +): Promise => { + const { relayHost, relayPort, targetHost, targetPort, tlsOptions, identityId, orgId, protocol, httpsAgent } = options; + + // Setup the proxy server + const { port, cleanup, getProxyError } = await setupProxyServer({ + targetHost, + targetPort, + relayPort, + relayHost, + tlsOptions, + identityId, + orgId, + protocol, + httpsAgent + }); + + try { + // Execute the callback with the allocated port + return await callback(port, httpsAgent); + } catch (err) { + const proxyErrorMessage = getProxyError(); + if (proxyErrorMessage) { + logger.error(new Error(proxyErrorMessage), "Failed to proxy"); + } + logger.error(err, "Failed to do gateway"); + let errorMessage = proxyErrorMessage || (err as Error)?.message; + if (axios.isAxiosError(err) && (err.response?.data as { message?: string })?.message) { + errorMessage = (err.response?.data as { message: string }).message; + } + + throw new BadRequestError({ message: errorMessage }); + } finally { + // Ensure cleanup happens regardless of success or failure + await cleanup(); + } +}; diff --git a/backend/src/lib/gateway/index.ts b/backend/src/lib/gateway/index.ts index 84d801dda..9292473e5 100644 --- a/backend/src/lib/gateway/index.ts +++ b/backend/src/lib/gateway/index.ts @@ -1,354 +1,2 @@ -/* eslint-disable no-await-in-loop */ -import crypto from "node:crypto"; -import net from "node:net"; - -import quicDefault, * as quicModule from "@infisical/quic"; - -import { BadRequestError } from "../errors"; -import { logger } from "../logger"; - -const DEFAULT_MAX_RETRIES = 3; -const DEFAULT_RETRY_DELAY = 1000; // 1 second - -const quic = quicDefault || quicModule; - -const parseSubjectDetails = (data: string) => { - const values: Record = {}; - data.split("\n").forEach((el) => { - const [key, value] = el.split("="); - values[key.trim()] = value.trim(); - }); - return values; -}; - -type TTlsOption = { ca: string; cert: string; key: string }; - -const createQuicConnection = async ( - relayHost: string, - relayPort: number, - tlsOptions: TTlsOption, - identityId: string, - orgId: string -) => { - const client = await quic.QUICClient.createQUICClient({ - host: relayHost, - port: relayPort, - config: { - ca: tlsOptions.ca, - cert: tlsOptions.cert, - key: tlsOptions.key, - applicationProtos: ["infisical-gateway"], - verifyPeer: true, - verifyCallback: async (certs) => { - if (!certs || certs.length === 0) return quic.native.CryptoError.CertificateRequired; - const serverCertificate = new crypto.X509Certificate(Buffer.from(certs[0])); - const caCertificate = new crypto.X509Certificate(tlsOptions.ca); - const isValidServerCertificate = serverCertificate.checkIssued(caCertificate); - if (!isValidServerCertificate) return quic.native.CryptoError.BadCertificate; - - const subjectDetails = parseSubjectDetails(serverCertificate.subject); - if (subjectDetails.OU !== "Gateway" || subjectDetails.CN !== identityId || subjectDetails.O !== orgId) { - return quic.native.CryptoError.CertificateUnknown; - } - - if (new Date() > new Date(serverCertificate.validTo) || new Date() < new Date(serverCertificate.validFrom)) { - return quic.native.CryptoError.CertificateExpired; - } - - const formatedRelayHost = - process.env.NODE_ENV === "development" ? relayHost.replace("host.docker.internal", "127.0.0.1") : relayHost; - if (!serverCertificate.checkIP(formatedRelayHost)) return quic.native.CryptoError.BadCertificate; - }, - maxIdleTimeout: 90000, - keepAliveIntervalTime: 30000 - }, - crypto: { - ops: { - randomBytes: async (data) => { - crypto.getRandomValues(new Uint8Array(data)); - } - } - } - }); - return client; -}; - -type TPingGatewayAndVerifyDTO = { - relayHost: string; - relayPort: number; - tlsOptions: TTlsOption; - maxRetries?: number; - identityId: string; - orgId: string; -}; - -export const pingGatewayAndVerify = async ({ - relayHost, - relayPort, - tlsOptions, - maxRetries = DEFAULT_MAX_RETRIES, - identityId, - orgId -}: TPingGatewayAndVerifyDTO) => { - let lastError: Error | null = null; - const quicClient = await createQuicConnection(relayHost, relayPort, tlsOptions, identityId, orgId).catch((err) => { - throw new BadRequestError({ - message: (err as Error)?.message, - error: err as Error - }); - }); - - for (let attempt = 1; attempt <= maxRetries; attempt += 1) { - try { - const stream = quicClient.connection.newStream("bidi"); - const pingWriter = stream.writable.getWriter(); - await pingWriter.write(Buffer.from("PING\n")); - pingWriter.releaseLock(); - - // Read PONG response - const reader = stream.readable.getReader(); - const { value, done } = await reader.read(); - - if (done) { - throw new Error("Gateway closed before receiving PONG"); - } - - const response = Buffer.from(value).toString(); - - if (response !== "PONG\n" && response !== "PONG") { - throw new Error(`Failed to Ping. Unexpected response: ${response}`); - } - - reader.releaseLock(); - return; - } catch (err) { - lastError = err as Error; - - if (attempt < maxRetries) { - await new Promise((resolve) => { - setTimeout(resolve, DEFAULT_RETRY_DELAY); - }); - } - } finally { - await quicClient.destroy(); - } - } - - logger.error(lastError); - throw new BadRequestError({ - message: `Failed to ping gateway after ${maxRetries} attempts. Last error: ${lastError?.message}` - }); -}; - -interface TProxyServer { - server: net.Server; - port: number; - cleanup: () => Promise; - getProxyError: () => string; -} - -const setupProxyServer = async ({ - targetPort, - targetHost, - tlsOptions, - relayHost, - relayPort, - identityId, - orgId -}: { - targetHost: string; - targetPort: number; - relayPort: number; - relayHost: string; - tlsOptions: TTlsOption; - identityId: string; - orgId: string; -}): Promise => { - const quicClient = await createQuicConnection(relayHost, relayPort, tlsOptions, identityId, orgId).catch((err) => { - throw new BadRequestError({ - error: err as Error - }); - }); - const proxyErrorMsg = [""]; - - return new Promise((resolve, reject) => { - const server = net.createServer(); - - // eslint-disable-next-line @typescript-eslint/no-misused-promises - server.on("connection", async (clientConn) => { - try { - clientConn.setKeepAlive(true, 30000); // 30 seconds - clientConn.setNoDelay(true); - - const stream = quicClient.connection.newStream("bidi"); - // Send FORWARD-TCP command - const forwardWriter = stream.writable.getWriter(); - await forwardWriter.write(Buffer.from(`FORWARD-TCP ${targetHost}:${targetPort}\n`)); - forwardWriter.releaseLock(); - - // Set up bidirectional copy - const setupCopy = () => { - // Client to QUIC - // eslint-disable-next-line - (async () => { - const writer = stream.writable.getWriter(); - - // Create a handler for client data - clientConn.on("data", (chunk) => { - writer.write(chunk).catch((err) => { - proxyErrorMsg.push((err as Error)?.message); - }); - }); - - // Handle client connection close - clientConn.on("end", () => { - writer.close().catch((err) => { - logger.error(err); - }); - }); - - clientConn.on("error", (clientConnErr) => { - writer.abort(clientConnErr?.message).catch((err) => { - proxyErrorMsg.push((err as Error)?.message); - }); - }); - })(); - - // QUIC to Client - void (async () => { - try { - const reader = stream.readable.getReader(); - - let reading = true; - while (reading) { - const { value, done } = await reader.read(); - - if (done) { - reading = false; - clientConn.end(); // Close client connection when QUIC stream ends - break; - } - - // Write data to TCP client - const canContinue = clientConn.write(Buffer.from(value)); - - // Handle backpressure - if (!canContinue) { - await new Promise((res) => { - clientConn.once("drain", res); - }); - } - } - } catch (err) { - proxyErrorMsg.push((err as Error)?.message); - clientConn.destroy(); - } - })(); - }; - - setupCopy(); - // Handle connection closure - clientConn.on("close", () => { - stream.destroy().catch((err) => { - proxyErrorMsg.push((err as Error)?.message); - }); - }); - - const cleanup = async () => { - clientConn?.destroy(); - await stream.destroy(); - }; - - clientConn.on("error", (clientConnErr) => { - logger.error(clientConnErr, "Client socket error"); - cleanup().catch((err) => { - logger.error(err, "Client conn cleanup"); - }); - }); - - clientConn.on("end", () => { - cleanup().catch((err) => { - logger.error(err, "Client conn end"); - }); - }); - } catch (err) { - logger.error(err, "Failed to establish target connection:"); - clientConn.end(); - reject(err); - } - }); - - server.on("error", (err) => { - reject(err); - }); - - server.on("close", () => { - quicClient?.destroy().catch((err) => { - logger.error(err, "Failed to destroy quic client"); - }); - }); - - server.listen(0, () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(); - reject(new Error("Failed to get server port")); - return; - } - - logger.info("Gateway proxy started"); - resolve({ - server, - port: address.port, - cleanup: async () => { - server.close(); - await quicClient?.destroy(); - }, - getProxyError: () => proxyErrorMsg.join(",") - }); - }); - }); -}; - -interface ProxyOptions { - targetHost: string; - targetPort: number; - relayHost: string; - relayPort: number; - tlsOptions: TTlsOption; - identityId: string; - orgId: string; -} - -export const withGatewayProxy = async ( - callback: (port: number) => Promise, - options: ProxyOptions -): Promise => { - const { relayHost, relayPort, targetHost, targetPort, tlsOptions, identityId, orgId } = options; - - // Setup the proxy server - const { port, cleanup, getProxyError } = await setupProxyServer({ - targetHost, - targetPort, - relayPort, - relayHost, - tlsOptions, - identityId, - orgId - }); - - try { - // Execute the callback with the allocated port - await callback(port); - } catch (err) { - const proxyErrorMessage = getProxyError(); - if (proxyErrorMessage) { - logger.error(new Error(proxyErrorMessage), "Failed to proxy"); - } - logger.error(err, "Failed to do gateway"); - throw new BadRequestError({ message: proxyErrorMessage || (err as Error)?.message }); - } finally { - // Ensure cleanup happens regardless of success or failure - await cleanup(); - } -}; +export { pingGatewayAndVerify, withGatewayProxy } from "./gateway"; +export { GatewayHttpProxyActions, GatewayProxyProtocol } from "./types"; diff --git a/backend/src/lib/gateway/types.ts b/backend/src/lib/gateway/types.ts new file mode 100644 index 000000000..5d0ac8237 --- /dev/null +++ b/backend/src/lib/gateway/types.ts @@ -0,0 +1,42 @@ +import net from "node:net"; + +import https from "https"; + +export type TGatewayTlsOptions = { ca: string; cert: string; key: string }; + +export enum GatewayProxyProtocol { + Http = "http", + Tcp = "tcp" +} + +export enum GatewayHttpProxyActions { + InjectGatewayK8sServiceAccountToken = "inject-k8s-sa-auth-token" +} + +export interface IGatewayProxyOptions { + targetHost: string; + targetPort: number; + relayHost: string; + relayPort: number; + tlsOptions: TGatewayTlsOptions; + identityId: string; + orgId: string; + protocol: GatewayProxyProtocol; + httpsAgent?: https.Agent; +} + +export type TPingGatewayAndVerifyDTO = { + relayHost: string; + relayPort: number; + tlsOptions: TGatewayTlsOptions; + maxRetries?: number; + identityId: string; + orgId: string; +}; + +export interface IGatewayProxyServer { + server: net.Server; + port: number; + cleanup: () => Promise; + getProxyError: () => string; +} diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index b1e011709..5949afe33 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -32,13 +32,13 @@ export const buildFindFilter = ( { $in, $notNull, $search, $complex, ...filter }: TFindFilter, tableName?: TableName, - excludeKeys?: Array + excludeKeys?: string[] ) => (bd: Knex.QueryBuilder) => { const processedFilter = tableName ? Object.fromEntries( Object.entries(filter) - .filter(([key]) => !excludeKeys || !excludeKeys.includes(key as keyof R)) + .filter(([key]) => !excludeKeys || !excludeKeys.includes(key)) .map(([key, value]) => [`${tableName}.${key}`, value]) ) : filter; @@ -179,13 +179,18 @@ export const ormify = (db: Kne throw new DatabaseError({ error, name: "batchInsert" }); } }, - upsert: async (data: readonly Tables[Tname]["insert"][], onConflictField: keyof Tables[Tname]["base"], tx?: Knex) => { + upsert: async ( + data: readonly Tables[Tname]["insert"][], + onConflictField: keyof Tables[Tname]["base"] | Array, + tx?: Knex, + mergeColumns?: (keyof Knex.ResolveTableType, "update">)[] | undefined + ) => { try { if (!data.length) return []; const res = await (tx || db)(tableName) .insert(data as never) .onConflict(onConflictField as never) - .merge() + .merge(mergeColumns) .returning("*"); return res; } catch (error) { diff --git a/backend/src/lib/knex/scim.ts b/backend/src/lib/knex/scim.ts index 64f7fc2f6..d522e2f5f 100644 --- a/backend/src/lib/knex/scim.ts +++ b/backend/src/lib/knex/scim.ts @@ -1,6 +1,8 @@ import { Knex } from "knex"; import { Compare, Filter, parse } from "scim2-parse-filter"; +import { TableName } from "@app/db/schemas"; + const appendParentToGroupingOperator = (parentPath: string, filter: Filter) => { if (filter.op !== "[]" && filter.op !== "and" && filter.op !== "or" && filter.op !== "not") { return { ...filter, attrPath: `${parentPath}.${(filter as Compare).attrPath}` }; @@ -27,8 +29,12 @@ const processDynamicQuery = ( const { scimFilterAst, query } = stack.pop()!; switch (scimFilterAst.op) { case "eq": { + let sanitizedValue = scimFilterAst.compValue; const attrPath = getAttributeField(scimFilterAst.attrPath); - if (attrPath) void query.where(attrPath, scimFilterAst.compValue); + if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") { + sanitizedValue = sanitizedValue.toLowerCase(); + } + if (attrPath) void query.where(attrPath, sanitizedValue); break; } case "pr": { @@ -62,18 +68,30 @@ const processDynamicQuery = ( break; } case "ew": { + let sanitizedValue = scimFilterAst.compValue; const attrPath = getAttributeField(scimFilterAst.attrPath); - if (attrPath) void query.whereILike(attrPath, `%${scimFilterAst.compValue}`); + if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") { + sanitizedValue = sanitizedValue.toLowerCase(); + } + if (attrPath) void query.whereILike(attrPath, `%${sanitizedValue}`); break; } case "co": { + let sanitizedValue = scimFilterAst.compValue; const attrPath = getAttributeField(scimFilterAst.attrPath); - if (attrPath) void query.whereILike(attrPath, `%${scimFilterAst.compValue}%`); + if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") { + sanitizedValue = sanitizedValue.toLowerCase(); + } + if (attrPath) void query.whereILike(attrPath, `%${sanitizedValue}%`); break; } case "ne": { + let sanitizedValue = scimFilterAst.compValue; const attrPath = getAttributeField(scimFilterAst.attrPath); - if (attrPath) void query.whereNot(attrPath, "=", scimFilterAst.compValue); + if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") { + sanitizedValue = sanitizedValue.toLowerCase(); + } + if (attrPath) void query.whereNot(attrPath, "=", sanitizedValue); break; } case "and": { diff --git a/backend/src/lib/logger/logger.ts b/backend/src/lib/logger/logger.ts index 170a0285f..219b4a9a7 100644 --- a/backend/src/lib/logger/logger.ts +++ b/backend/src/lib/logger/logger.ts @@ -84,7 +84,9 @@ const redactedKeys = [ "secrets", "key", "password", - "config" + "config", + "bindPass", + "bindDN" ]; const UNKNOWN_REQUEST_ID = "UNKNOWN_REQUEST_ID"; @@ -93,11 +95,20 @@ const extractReqId = () => { try { return requestContext.get("reqId") || UNKNOWN_REQUEST_ID; } catch (err) { + // eslint-disable-next-line no-console console.log("failed to get request context", err); return UNKNOWN_REQUEST_ID; } }; +const extractOrgId = () => { + try { + return requestContext.get("orgId"); + } catch { + return ""; + } +}; + export const initLogger = () => { const cfg = loggerConfig.parse(process.env); const targets: pino.TransportMultiOptions["targets"][number][] = [ @@ -133,22 +144,22 @@ export const initLogger = () => { const wrapLogger = (originalLogger: Logger): CustomLogger => { // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any originalLogger.info = (obj: unknown, msg?: string, ...args: any[]) => { - return originalLogger.child({ reqId: extractReqId() }).info(obj, msg, ...args); + return originalLogger.child({ reqId: extractReqId(), orgId: extractOrgId() }).info(obj, msg, ...args); }; // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any originalLogger.error = (obj: unknown, msg?: string, ...args: any[]) => { - return originalLogger.child({ reqId: extractReqId() }).error(obj, msg, ...args); + return originalLogger.child({ reqId: extractReqId(), orgId: extractOrgId() }).error(obj, msg, ...args); }; // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any originalLogger.warn = (obj: unknown, msg?: string, ...args: any[]) => { - return originalLogger.child({ reqId: extractReqId() }).warn(obj, msg, ...args); + return originalLogger.child({ reqId: extractReqId(), orgId: extractOrgId() }).warn(obj, msg, ...args); }; // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any originalLogger.debug = (obj: unknown, msg?: string, ...args: any[]) => { - return originalLogger.child({ reqId: extractReqId() }).debug(obj, msg, ...args); + return originalLogger.child({ reqId: extractReqId(), orgId: extractOrgId() }).debug(obj, msg, ...args); }; return originalLogger; diff --git a/backend/src/lib/regex/index.ts b/backend/src/lib/regex/index.ts index 68ba7671d..c472f8d5d 100644 --- a/backend/src/lib/regex/index.ts +++ b/backend/src/lib/regex/index.ts @@ -1,3 +1,13 @@ +import RE2 from "re2"; + export const DistinguishedNameRegex = // DN format, ie; CN=user,OU=users,DC=example,DC=com - /^(?:(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*)(?:,(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*))*)?$/; + new RE2( + /^(?:(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*)(?:,(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*))*)?$/ + ); + +export const UserPrincipalNameRegex = new RE2(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}$/); + +export const LdapUrlRegex = new RE2(/^ldaps?:\/\//); + +export const GitHubRepositoryRegex = new RE2(/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/); diff --git a/backend/src/lib/template/validate-handlebars.ts b/backend/src/lib/template/validate-handlebars.ts index a83c9efc2..4aa0d1f63 100644 --- a/backend/src/lib/template/validate-handlebars.ts +++ b/backend/src/lib/template/validate-handlebars.ts @@ -7,15 +7,38 @@ type SanitizationArg = { allowedExpressions?: (arg: string) => boolean; }; +const isValidExpression = (expression: string, dto: SanitizationArg): boolean => { + // Allow helper functions (replace, truncate) + const allowedHelpers = ["replace", "truncate", "random"]; + if (allowedHelpers.includes(expression)) { + return true; + } + + // Check regular allowed expressions + return dto?.allowedExpressions?.(expression) || false; +}; + export const validateHandlebarTemplate = (templateName: string, template: string, dto: SanitizationArg) => { const parsedAst = handlebars.parse(template); parsedAst.body.forEach((el) => { if (el.type === "ContentStatement") return; if (el.type === "MustacheStatement" && "path" in el) { const { path } = el as { type: "MustacheStatement"; path: { type: "PathExpression"; original: string } }; - if (path.type === "PathExpression" && dto?.allowedExpressions?.(path.original)) return; + if (path.type === "PathExpression" && isValidExpression(path.original, dto)) return; } logger.error(el, "Template sanitization failed"); throw new BadRequestError({ message: `Template sanitization failed: ${templateName}` }); }); }; + +export const isValidHandleBarTemplate = (template: string, dto: SanitizationArg) => { + const parsedAst = handlebars.parse(template); + return parsedAst.body.every((el) => { + if (el.type === "ContentStatement") return true; + if (el.type === "MustacheStatement" && "path" in el) { + const { path } = el as { type: "MustacheStatement"; path: { type: "PathExpression"; original: string } }; + if (path.type === "PathExpression" && isValidExpression(path.original, dto)) return true; + } + return false; + }); +}; diff --git a/backend/src/lib/types/index.ts b/backend/src/lib/types/index.ts index 9f063172f..49d8893be 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -78,3 +78,9 @@ export type OrgServiceActor = { authMethod: ActorAuthMethod; orgId: string; }; + +export enum QueueWorkerProfile { + All = "all", + Standard = "standard", + SecretScanning = "secret-scanning" +} diff --git a/backend/src/main.ts b/backend/src/main.ts index c3b5a0900..d141b62d5 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,7 +1,6 @@ import "./lib/telemetry/instrumentation"; import dotenv from "dotenv"; -import { Redis } from "ioredis"; import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; @@ -9,6 +8,7 @@ import { runMigrations } from "./auto-start-migrations"; import { initAuditLogDbConnection, initDbConnection } from "./db"; import { keyStoreFactory } from "./keystore/keystore"; import { formatSmtpConfig, initEnvConfig } from "./lib/config/env"; +import { buildRedisFromConfig } from "./lib/config/redis"; import { removeTemporaryBaseDirectory } from "./lib/files"; import { initLogger } from "./lib/logger"; import { queueServiceFactory } from "./queue"; @@ -44,15 +44,15 @@ const run = async () => { const smtp = smtpServiceFactory(formatSmtpConfig()); - const queue = queueServiceFactory(envConfig.REDIS_URL, { + const queue = queueServiceFactory(envConfig, { dbConnectionUrl: envConfig.DB_CONNECTION_URI, dbRootCert: envConfig.DB_ROOT_CERT }); await queue.initialize(); - const keyStore = keyStoreFactory(envConfig.REDIS_URL); - const redis = new Redis(envConfig.REDIS_URL); + const keyStore = keyStoreFactory(envConfig); + const redis = buildRedisFromConfig(envConfig); const hsmModule = initializeHsmModule(envConfig); hsmModule.initialize(); diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index ae1a3e821..e4d654998 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -1,5 +1,4 @@ import { Job, JobsOptions, Queue, QueueOptions, RepeatOptions, Worker, WorkerListener } from "bullmq"; -import Redis from "ioredis"; import PgBoss, { WorkOptions } from "pg-boss"; import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; @@ -12,8 +11,16 @@ import { TScanFullRepoEventPayload, TScanPushEventPayload } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; +import { + TQueueSecretScanningDataSourceFullScan, + TQueueSecretScanningResourceDiffScan, + TQueueSecretScanningSendNotification +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; import { getConfig } from "@app/lib/config/env"; +import { buildRedisFromConfig, TRedisConfigKeys } from "@app/lib/config/redis"; import { logger } from "@app/lib/logger"; +import { QueueWorkerProfile } from "@app/lib/types"; +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { TFailedIntegrationSyncEmailsPayload, TIntegrationSyncPayload, @@ -25,6 +32,7 @@ import { TQueueSecretSyncSyncSecretsByIdDTO, TQueueSendSecretSyncActionFailedNotificationsDTO } from "@app/services/secret-sync/secret-sync-types"; +import { CacheType } from "@app/services/super-admin/super-admin-types"; import { TWebhookPayloads } from "@app/services/webhook/webhook-types"; export enum QueueName { @@ -35,6 +43,7 @@ export enum QueueName { AuditLogPrune = "audit-log-prune", DailyResourceCleanUp = "daily-resource-cleanup", DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert", + PkiSubscriber = "pki-subscriber", TelemetryInstanceStats = "telemtry-self-hosted-stats", IntegrationSync = "sync-integrations", SecretWebhook = "secret-webhook", @@ -43,13 +52,16 @@ export enum QueueName { UpgradeProjectToGhost = "upgrade-project-to-ghost", DynamicSecretRevocation = "dynamic-secret-revocation", CaCrlRotation = "ca-crl-rotation", + 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 ProjectV3Migration = "project-v3-migration", AccessTokenStatusUpdate = "access-token-status-update", ImportSecretsFromExternalSource = "import-secrets-from-external-source", AppConnectionSecretSync = "app-connection-secret-sync", - SecretRotationV2 = "secret-rotation-v2" + SecretRotationV2 = "secret-rotation-v2", + InvalidateCache = "invalidate-cache", + SecretScanningV2 = "secret-scanning-v2" } export enum QueueJobs { @@ -81,7 +93,13 @@ export enum QueueJobs { SecretSyncSendActionFailedNotifications = "secret-sync-send-action-failed-notifications", SecretRotationV2QueueRotations = "secret-rotation-v2-queue-rotations", SecretRotationV2RotateSecrets = "secret-rotation-v2-rotate-secrets", - SecretRotationV2SendNotification = "secret-rotation-v2-send-notification" + SecretRotationV2SendNotification = "secret-rotation-v2-send-notification", + InvalidateCache = "invalidate-cache", + SecretScanningV2FullScan = "secret-scanning-v2-full-scan", + SecretScanningV2DiffScan = "secret-scanning-v2-diff-scan", + SecretScanningV2SendNotification = "secret-scanning-v2-notification", + CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber", + PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal" } export type TQueueJobTypes = { @@ -234,14 +252,77 @@ export type TQueueJobTypes = { name: QueueJobs.SecretRotationV2SendNotification; payload: TSecretRotationSendNotificationJobPayload; }; + [QueueName.InvalidateCache]: { + name: QueueJobs.InvalidateCache; + payload: { + data: { + type: CacheType; + }; + }; + }; + [QueueName.SecretScanningV2]: + | { + name: QueueJobs.SecretScanningV2FullScan; + payload: TQueueSecretScanningDataSourceFullScan; + } + | { + name: QueueJobs.SecretScanningV2DiffScan; + payload: TQueueSecretScanningResourceDiffScan; + } + | { + name: QueueJobs.SecretScanningV2SendNotification; + payload: TQueueSecretScanningSendNotification; + }; + [QueueName.CaLifecycle]: { + name: QueueJobs.CaOrderCertificateForSubscriber; + payload: { + subscriberId: string; + caType: CaType; + }; + }; + [QueueName.PkiSubscriber]: { + name: QueueJobs.PkiSubscriberDailyAutoRenewal; + payload: undefined; + }; +}; + +const SECRET_SCANNING_JOBS = [ + QueueJobs.SecretScanningV2FullScan, + QueueJobs.SecretScanningV2DiffScan, + QueueJobs.SecretScanningV2SendNotification, + QueueJobs.SecretScan +]; + +const NON_STANDARD_JOBS = [...SECRET_SCANNING_JOBS]; + +const SECRET_SCANNING_QUEUES = [ + QueueName.SecretScanningV2, + QueueName.SecretFullRepoScan, + QueueName.SecretPushEventScan +]; + +const NON_STANDARD_QUEUES = [...SECRET_SCANNING_QUEUES]; + +const isQueueEnabled = (name: QueueName) => { + const appCfg = getConfig(); + switch (appCfg.QUEUE_WORKER_PROFILE) { + case QueueWorkerProfile.Standard: + return !NON_STANDARD_QUEUES.includes(name); + case QueueWorkerProfile.SecretScanning: + return SECRET_SCANNING_QUEUES.includes(name); + case QueueWorkerProfile.All: + default: + // allow all + return true; + } }; export type TQueueServiceFactory = ReturnType; export const queueServiceFactory = ( - redisUrl: string, + redisCfg: TRedisConfigKeys, { dbConnectionUrl, dbRootCert }: { dbConnectionUrl: string; dbRootCert?: string } ) => { - const connection = new Redis(redisUrl, { maxRetriesPerRequest: null }); + const connection = buildRedisFromConfig(redisCfg); const queueContainer = {} as Record< QueueName, Queue @@ -292,7 +373,7 @@ export const queueServiceFactory = ( }); const appCfg = getConfig(); - if (appCfg.QUEUE_WORKERS_ENABLED) { + if (appCfg.QUEUE_WORKERS_ENABLED && isQueueEnabled(name)) { workerContainer[name] = new Worker(name, jobFn, { ...queueSettings, connection @@ -311,6 +392,30 @@ export const queueServiceFactory = ( throw new Error(`${jobName} queue is already initialized`); } + const appCfg = getConfig(); + + if (!appCfg.QUEUE_WORKERS_ENABLED) return; + + switch (appCfg.QUEUE_WORKER_PROFILE) { + case QueueWorkerProfile.Standard: + if (NON_STANDARD_JOBS.includes(jobName)) { + // only process standard jobs + return; + } + + break; + case QueueWorkerProfile.SecretScanning: + if (!SECRET_SCANNING_JOBS.includes(jobName)) { + // only process secret scanning jobs + return; + } + + break; + case QueueWorkerProfile.All: + default: + // allow all + } + await pgBoss.createQueue(jobName); queueContainerPg[jobName] = true; @@ -330,7 +435,7 @@ export const queueServiceFactory = ( listener: WorkerListener[U] ) => { const appCfg = getConfig(); - if (!appCfg.QUEUE_WORKERS_ENABLED) { + if (!appCfg.QUEUE_WORKERS_ENABLED || !isQueueEnabled(name)) { return; } diff --git a/backend/src/server/boot-strap-check.ts b/backend/src/server/boot-strap-check.ts index 7db2a71e8..91c52d871 100644 --- a/backend/src/server/boot-strap-check.ts +++ b/backend/src/server/boot-strap-check.ts @@ -1,9 +1,9 @@ /* eslint-disable no-console */ -import { Redis } from "ioredis"; import { Knex } from "knex"; import { createTransport } from "nodemailer"; import { formatSmtpConfig, getConfig } from "@app/lib/config/env"; +import { buildRedisFromConfig } from "@app/lib/config/redis"; import { logger } from "@app/lib/logger"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; @@ -65,12 +65,15 @@ export const bootstrapCheck = async ({ db }: BootstrapOpt) => { }); console.log("Testing redis connection"); - const redis = new Redis(appCfg.REDIS_URL); + const redis = buildRedisFromConfig(appCfg); const redisPing = await redis?.ping(); if (!redisPing) { console.error("Redis - Failed to connect"); } else { - console.error("Redis successfully connected"); + console.log("Redis successfully connected"); + if (appCfg.isRedisSentinelMode) { + console.log("Redis Sentinel Mode"); + } redis.disconnect(); } diff --git a/backend/src/server/config/rateLimiter.ts b/backend/src/server/config/rateLimiter.ts index 681442d1b..d3d3d3efd 100644 --- a/backend/src/server/config/rateLimiter.ts +++ b/backend/src/server/config/rateLimiter.ts @@ -1,19 +1,17 @@ import type { RateLimitOptions, RateLimitPluginOptions } from "@fastify/rate-limit"; -import { Redis } from "ioredis"; import { getConfig } from "@app/lib/config/env"; +import { buildRedisFromConfig } from "@app/lib/config/redis"; import { RateLimitError } from "@app/lib/errors"; export const globalRateLimiterCfg = (): RateLimitPluginOptions => { const appCfg = getConfig(); - const redis = appCfg.isRedisConfigured - ? new Redis(appCfg.REDIS_URL, { connectTimeout: 500, maxRetriesPerRequest: 1 }) - : null; + const redis = appCfg.isRedisConfigured ? buildRedisFromConfig(appCfg) : null; return { errorResponseBuilder: (_, context) => { throw new RateLimitError({ - message: `Rate limit exceeded. Please try again in ${context.after}` + message: `Rate limit exceeded. Please try again in ${Math.ceil(context.ttl / 1000)} seconds` }); }, timeWindow: 60 * 1000, @@ -100,3 +98,27 @@ export const publicSshCaLimit: RateLimitOptions = { max: 30, // conservative default keyGenerator: (req) => req.realIp }; + +export const invalidateCacheLimit: RateLimitOptions = { + timeWindow: 60 * 1000, + hook: "preValidation", + max: 2, + keyGenerator: (req) => req.realIp +}; + +// Makes spamming "request access" harder, preventing email DDoS +export const requestAccessLimit: RateLimitOptions = { + timeWindow: 60 * 1000, + hook: "preValidation", + max: 10, + keyGenerator: (req) => req.realIp +}; + +export const smtpRateLimit = ({ + keyGenerator = (req) => req.realIp +}: Pick = {}): RateLimitOptions => ({ + timeWindow: 40 * 1000, + hook: "preValidation", + max: 2, + keyGenerator +}); diff --git a/backend/src/server/lib/schemas.ts b/backend/src/server/lib/schemas.ts index 9f93eaea0..00651d2cc 100644 --- a/backend/src/server/lib/schemas.ts +++ b/backend/src/server/lib/schemas.ts @@ -9,7 +9,7 @@ interface SlugSchemaInputs { field?: string; } -export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInputs = {}) => { +export const slugSchema = ({ min = 1, max = 64, field = "Slug" }: SlugSchemaInputs = {}) => { return z .string() .trim() diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 57a1313c6..f065bfbed 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -123,6 +123,7 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { switch (authMode) { case AuthMode.JWT: { const { user, tokenVersionId, orgId } = await server.services.authToken.fnValidateJwtIdentity(token); + requestContext.set("orgId", orgId); req.auth = { authMode: AuthMode.JWT, user, @@ -138,6 +139,7 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { case AuthMode.IDENTITY_ACCESS_TOKEN: { const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(token, req.realIp); const serverCfg = await getServerCfg(); + requestContext.set("orgId", identity.orgId); req.auth = { authMode: AuthMode.IDENTITY_ACCESS_TOKEN, actor, @@ -153,10 +155,17 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { oidc: token?.identityAuth?.oidc }); } + if (token?.identityAuth?.kubernetes) { + requestContext.set("identityAuthInfo", { + identityId: identity.identityId, + kubernetes: token?.identityAuth?.kubernetes + }); + } break; } case AuthMode.SERVICE_TOKEN: { const serviceToken = await server.services.serviceToken.fnValidateServiceToken(token); + requestContext.set("orgId", serviceToken.orgId); req.auth = { orgId: serviceToken.orgId, authMode: AuthMode.SERVICE_TOKEN as const, @@ -181,6 +190,7 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { } case AuthMode.SCIM_TOKEN: { const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token); + requestContext.set("orgId", orgId); req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId, authMethod: null }; break; } diff --git a/backend/src/server/plugins/fastify-zod.ts b/backend/src/server/plugins/fastify-zod.ts index 4e898a9b5..f9b9f4f59 100644 --- a/backend/src/server/plugins/fastify-zod.ts +++ b/backend/src/server/plugins/fastify-zod.ts @@ -5,7 +5,7 @@ import type { FastifySchema, FastifySchemaCompiler, FastifyTypeProvider } from "fastify"; import type { FastifySerializerCompiler } from "fastify/types/schema"; import type { z, ZodAny, ZodTypeAny } from "zod"; -import { zodToJsonSchema } from "zod-to-json-schema"; +import { PostProcessCallback, zodToJsonSchema } from "zod-to-json-schema"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type FreeformRecord = Record; @@ -28,9 +28,25 @@ interface Schema extends FastifySchema { hide?: boolean; } +// Credit: https://github.com/StefanTerdell/zod-to-json-schema +const jsonDescription: PostProcessCallback = (jsonSchema, def) => { + if (def.description) { + try { + return { + ...jsonSchema, + description: undefined, + ...JSON.parse(def.description) + }; + } catch {} + } + + return jsonSchema; +}; + const zodToJsonSchemaOptions = { target: "openApi3", - $refStrategy: "none" + $refStrategy: "none", + postProcess: jsonDescription } as const; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/backend/src/server/plugins/secret-scanner-v2.ts b/backend/src/server/plugins/secret-scanner-v2.ts new file mode 100644 index 000000000..466450180 --- /dev/null +++ b/backend/src/server/plugins/secret-scanner-v2.ts @@ -0,0 +1,66 @@ +import type { EmitterWebhookEventName } from "@octokit/webhooks/dist-types/types"; +import { PushEvent } from "@octokit/webhooks-types"; +import { Probot } from "probot"; + +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { writeLimit } from "@app/server/config/rateLimiter"; + +export const registerSecretScanningV2Webhooks = async (server: FastifyZodProvider) => { + const probotApp = (app: Probot) => { + app.on("installation.deleted", async (context) => { + const { payload } = context; + const { installation } = payload; + + await server.services.secretScanningV2.github.handleInstallationDeletedEvent(installation.id); + }); + + app.on("installation", async (context) => { + const { payload } = context; + logger.info({ repositories: payload.repositories }, "Installed secret scanner to"); + }); + + app.on("push", async (context) => { + const { payload } = context; + await server.services.secretScanningV2.github.handlePushEvent(payload as PushEvent); + }); + }; + + const appCfg = getConfig(); + + if (!appCfg.isSecretScanningV2Configured) { + logger.info("Secret Scanning V2 is not configured. Skipping registration of secret scanning v2 webhooks."); + return; + } + + const probot = new Probot({ + appId: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_ID as string, + privateKey: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY as string, + secret: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET as string + }); + + await probot.load(probotApp); + + // github push event webhook + server.route({ + method: "POST", + url: "/github", + config: { + rateLimit: writeLimit + }, + handler: async (req, res) => { + const eventName = req.headers["x-github-event"] as EmitterWebhookEventName; + const signatureSHA256 = req.headers["x-hub-signature-256"] as string; + const id = req.headers["x-github-delivery"] as string; + + await probot.webhooks.verifyAndReceive({ + id, + name: eventName, + payload: JSON.stringify(req.body), + signature: signatureSHA256 + }); + + return res.send("ok"); + } + }); +}; diff --git a/backend/src/server/plugins/serve-ui.ts b/backend/src/server/plugins/serve-ui.ts index 9f91d9774..22c097726 100644 --- a/backend/src/server/plugins/serve-ui.ts +++ b/backend/src/server/plugins/serve-ui.ts @@ -57,7 +57,9 @@ export const registerServeUI = async ( reply.callNotFound(); return; } - return reply.sendFile("index.html"); + // reference: https://github.com/fastify/fastify-static?tab=readme-ov-file#managing-cache-control-headers + // to avoid ui bundle skew on new deployment + return reply.sendFile("index.html", { maxAge: 0, immutable: false }); } }); } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 03e23a69d..aa38bb0e8 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -6,7 +6,10 @@ import { z } from "zod"; import { registerCertificateEstRouter } from "@app/ee/routes/est/certificate-est-router"; import { registerV1EERoutes } from "@app/ee/routes/v1"; import { registerV2EERoutes } from "@app/ee/routes/v2"; -import { accessApprovalPolicyApproverDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-approver-dal"; +import { + accessApprovalPolicyApproverDALFactory, + accessApprovalPolicyBypasserDALFactory +} from "@app/ee/services/access-approval-policy/access-approval-policy-approver-dal"; import { accessApprovalPolicyDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-dal"; import { accessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service"; import { accessApprovalRequestDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-dal"; @@ -32,7 +35,6 @@ import { externalKmsServiceFactory } from "@app/ee/services/external-kms/externa import { gatewayDALFactory } from "@app/ee/services/gateway/gateway-dal"; import { gatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { orgGatewayConfigDALFactory } from "@app/ee/services/gateway/org-gateway-config-dal"; -import { projectGatewayDALFactory } from "@app/ee/services/gateway/project-gateway-dal"; import { githubOrgSyncDALFactory } from "@app/ee/services/github-org-sync/github-org-sync-dal"; import { githubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service"; import { groupDALFactory } from "@app/ee/services/group/group-dal"; @@ -68,7 +70,10 @@ import { samlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-d import { samlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; import { scimDALFactory } from "@app/ee/services/scim/scim-dal"; import { scimServiceFactory } from "@app/ee/services/scim/scim-service"; -import { secretApprovalPolicyApproverDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-approver-dal"; +import { + secretApprovalPolicyApproverDALFactory, + secretApprovalPolicyBypasserDALFactory +} from "@app/ee/services/secret-approval-policy/secret-approval-policy-approver-dal"; import { secretApprovalPolicyDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-dal"; import { secretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; import { secretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; @@ -87,6 +92,9 @@ import { gitAppInstallSessionDALFactory } from "@app/ee/services/secret-scanning import { secretScanningDALFactory } from "@app/ee/services/secret-scanning/secret-scanning-dal"; import { secretScanningQueueFactory } from "@app/ee/services/secret-scanning/secret-scanning-queue"; import { secretScanningServiceFactory } from "@app/ee/services/secret-scanning/secret-scanning-service"; +import { secretScanningV2DALFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-dal"; +import { secretScanningV2QueueServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-queue"; +import { secretScanningV2ServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-service"; import { secretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { snapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal"; import { snapshotFolderDALFactory } from "@app/ee/services/secret-snapshot/snapshot-folder-dal"; @@ -113,6 +121,7 @@ import { getConfig, TEnvConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { TQueueServiceFactory } from "@app/queue"; import { readLimit } from "@app/server/config/rateLimiter"; +import { registerSecretScanningV2Webhooks } from "@app/server/plugins/secret-scanner-v2"; import { accessTokenQueueServiceFactory } from "@app/services/access-token-queue/access-token-queue"; import { apiKeyDALFactory } from "@app/services/api-key/api-key-dal"; import { apiKeyServiceFactory } from "@app/services/api-key/api-key-service"; @@ -133,6 +142,10 @@ import { certificateAuthorityDALFactory } from "@app/services/certificate-author import { certificateAuthorityQueueFactory } from "@app/services/certificate-authority/certificate-authority-queue"; import { certificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; import { certificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; +import { externalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/external-certificate-authority-dal"; +import { internalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-dal"; +import { InternalCertificateAuthorityFns } from "@app/services/certificate-authority/internal/internal-certificate-authority-fns"; +import { internalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal"; import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; @@ -160,6 +173,10 @@ import { identityJwtAuthDALFactory } from "@app/services/identity-jwt-auth/ident import { identityJwtAuthServiceFactory } from "@app/services/identity-jwt-auth/identity-jwt-auth-service"; import { identityKubernetesAuthDALFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-dal"; import { identityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service"; +import { identityLdapAuthDALFactory } from "@app/services/identity-ldap-auth/identity-ldap-auth-dal"; +import { identityLdapAuthServiceFactory } from "@app/services/identity-ldap-auth/identity-ldap-auth-service"; +import { identityOciAuthDALFactory } from "@app/services/identity-oci-auth/identity-oci-auth-dal"; +import { identityOciAuthServiceFactory } from "@app/services/identity-oci-auth/identity-oci-auth-service"; import { identityOidcAuthDALFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-dal"; import { identityOidcAuthServiceFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-service"; import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; @@ -195,6 +212,11 @@ import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-servic import { pkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal"; import { pkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal"; import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service"; +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 { 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"; import { projectQueueFactory } from "@app/services/project/project-queue"; import { projectServiceFactory } from "@app/services/project/project-service"; @@ -242,6 +264,7 @@ import { projectSlackConfigDALFactory } from "@app/services/slack/project-slack- import { slackIntegrationDALFactory } from "@app/services/slack/slack-integration-dal"; import { slackServiceFactory } from "@app/services/slack/slack-service"; import { TSmtpService } from "@app/services/smtp/smtp-service"; +import { invalidateCacheQueueFactory } from "@app/services/super-admin/invalidate-cache-queue"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { getServerCfg, superAdminServiceFactory } from "@app/services/super-admin/super-admin-service"; import { telemetryDALFactory } from "@app/services/telemetry/telemetry-dal"; @@ -293,6 +316,9 @@ export const registerRoutes = async ( ) => { const appCfg = getConfig(); await server.register(registerSecretScannerGhApp, { prefix: "/ss-webhook" }); + await server.register(registerSecretScanningV2Webhooks, { + prefix: "/secret-scanning/webhooks" + }); // db layers const userDAL = userDALFactory(db); @@ -350,9 +376,11 @@ export const registerRoutes = async ( const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); const identityAwsAuthDAL = identityAwsAuthDALFactory(db); const identityGcpAuthDAL = identityGcpAuthDALFactory(db); + const identityOciAuthDAL = identityOciAuthDALFactory(db); const identityOidcAuthDAL = identityOidcAuthDALFactory(db); const identityJwtAuthDAL = identityJwtAuthDALFactory(db); const identityAzureAuthDAL = identityAzureAuthDALFactory(db); + const identityLdapAuthDAL = identityLdapAuthDALFactory(db); const auditLogDAL = auditLogDALFactory(auditLogDb ?? db); const auditLogStreamDAL = auditLogStreamDALFactory(db); @@ -372,9 +400,11 @@ export const registerRoutes = async ( const accessApprovalPolicyDAL = accessApprovalPolicyDALFactory(db); const accessApprovalRequestDAL = accessApprovalRequestDALFactory(db); const accessApprovalPolicyApproverDAL = accessApprovalPolicyApproverDALFactory(db); + const accessApprovalPolicyBypasserDAL = accessApprovalPolicyBypasserDALFactory(db); const accessApprovalRequestReviewerDAL = accessApprovalRequestReviewerDALFactory(db); const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db); + const sapBypasserDAL = secretApprovalPolicyBypasserDALFactory(db); const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db); const secretApprovalRequestDAL = secretApprovalRequestDALFactory(db); const secretApprovalRequestReviewerDAL = secretApprovalRequestReviewerDALFactory(db); @@ -430,13 +460,13 @@ export const registerRoutes = async ( const orgGatewayConfigDAL = orgGatewayConfigDALFactory(db); const gatewayDAL = gatewayDALFactory(db); - const projectGatewayDAL = projectGatewayDALFactory(db); const secretReminderRecipientsDAL = secretReminderRecipientsDALFactory(db); const githubOrgSyncDAL = githubOrgSyncDALFactory(db); const secretRotationV2DAL = secretRotationV2DALFactory(db, folderDAL); const microsoftTeamsIntegrationDAL = microsoftTeamsIntegrationDALFactory(db); const projectMicrosoftTeamsConfigDAL = projectMicrosoftTeamsConfigDALFactory(db); + const secretScanningV2DAL = secretScanningV2DALFactory(db); const permissionService = permissionServiceFactory({ permissionDAL, @@ -507,6 +537,7 @@ export const registerRoutes = async ( const secretApprovalPolicyService = secretApprovalPolicyServiceFactory({ projectEnvDAL, secretApprovalPolicyApproverDAL: sapApproverDAL, + secretApprovalPolicyBypasserDAL: sapBypasserDAL, permissionService, secretApprovalPolicyDAL, licenseService, @@ -611,9 +642,13 @@ export const registerRoutes = async ( queueService }); + const invalidateCacheQueue = invalidateCacheQueueFactory({ + keyStore, + queueService + }); + const userService = userServiceFactory({ userDAL, - userAliasDAL, orgMembershipDAL, tokenService, permissionService, @@ -714,15 +749,18 @@ export const registerRoutes = async ( userAliasDAL, identityTokenAuthDAL, identityAccessTokenDAL, + orgMembershipDAL, identityOrgMembershipDAL, authService: loginService, serverCfgDAL: superAdminDAL, kmsRootConfigDAL, orgService, keyStore, + orgDAL, licenseService, kmsService, - microsoftTeamsService + microsoftTeamsService, + invalidateCacheQueue }); const orgAdminService = orgAdminServiceFactory({ @@ -777,7 +815,8 @@ export const registerRoutes = async ( const projectUserAdditionalPrivilegeService = projectUserAdditionalPrivilegeServiceFactory({ permissionService, projectMembershipDAL, - projectUserAdditionalPrivilegeDAL + projectUserAdditionalPrivilegeDAL, + accessApprovalRequestDAL }); const projectKeyService = projectKeyServiceFactory({ permissionService, @@ -805,6 +844,8 @@ export const registerRoutes = async ( }); const certificateAuthorityDAL = certificateAuthorityDALFactory(db); + const internalCertificateAuthorityDAL = internalCertificateAuthorityDALFactory(db); + const externalCertificateAuthorityDAL = externalCertificateAuthorityDALFactory(db); const certificateAuthorityCertDAL = certificateAuthorityCertDALFactory(db); const certificateAuthoritySecretDAL = certificateAuthoritySecretDALFactory(db); const certificateAuthorityCrlDAL = certificateAuthorityCrlDALFactory(db); @@ -818,6 +859,8 @@ export const registerRoutes = async ( const pkiAlertDAL = pkiAlertDALFactory(db); const pkiCollectionDAL = pkiCollectionDALFactory(db); const pkiCollectionItemDAL = pkiCollectionItemDALFactory(db); + const pkiSubscriberDAL = pkiSubscriberDALFactory(db); + const pkiTemplatesDAL = pkiTemplatesDALFactory(db); const certificateService = certificateServiceFactory({ certificateDAL, @@ -829,17 +872,9 @@ export const registerRoutes = async ( certificateAuthoritySecretDAL, projectDAL, kmsService, - permissionService - }); - - const certificateAuthorityQueue = certificateAuthorityQueueFactory({ - certificateAuthorityCrlDAL, - certificateAuthorityDAL, - certificateAuthoritySecretDAL, - certificateDAL, - projectDAL, - kmsService, - queueService + permissionService, + pkiCollectionDAL, + pkiCollectionItemDAL }); const sshCertificateAuthorityService = sshCertificateAuthorityServiceFactory({ @@ -860,6 +895,8 @@ export const registerRoutes = async ( const sshHostService = sshHostServiceFactory({ userDAL, + groupDAL, + userGroupMembershipDAL, projectDAL, projectSshConfigDAL, sshCertificateAuthorityDAL, @@ -882,24 +919,8 @@ export const registerRoutes = async ( sshHostLoginUserMappingDAL, userDAL, permissionService, - licenseService - }); - - const certificateAuthorityService = certificateAuthorityServiceFactory({ - certificateAuthorityDAL, - certificateAuthorityCertDAL, - certificateAuthoritySecretDAL, - certificateAuthorityCrlDAL, - certificateTemplateDAL, - certificateAuthorityQueue, - certificateDAL, - certificateBodyDAL, - certificateSecretDAL, - pkiCollectionDAL, - pkiCollectionItemDAL, - projectDAL, - kmsService, - permissionService + licenseService, + groupDAL }); const certificateAuthorityCrlService = certificateAuthorityCrlServiceFactory({ @@ -921,17 +942,6 @@ export const registerRoutes = async ( licenseService }); - const certificateEstService = certificateEstServiceFactory({ - certificateAuthorityService, - certificateTemplateService, - certificateTemplateDAL, - certificateAuthorityCertDAL, - certificateAuthorityDAL, - projectDAL, - kmsService, - licenseService - }); - const pkiAlertService = pkiAlertServiceFactory({ pkiAlertDAL, pkiCollectionDAL, @@ -984,7 +994,8 @@ export const registerRoutes = async ( secretVersionV2BridgeDAL, secretVersionTagV2BridgeDAL, resourceMetadataDAL, - appConnectionDAL + appConnectionDAL, + licenseService }); const secretQueueService = secretQueueFactory({ @@ -1046,6 +1057,7 @@ export const registerRoutes = async ( projectRoleDAL, folderDAL, licenseService, + pkiSubscriberDAL, certificateAuthorityDAL, certificateDAL, pkiAlertDAL, @@ -1229,6 +1241,7 @@ export const registerRoutes = async ( const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({ accessApprovalPolicyDAL, accessApprovalPolicyApproverDAL, + accessApprovalPolicyBypasserDAL, groupDAL, permissionService, projectEnvDAL, @@ -1237,7 +1250,8 @@ export const registerRoutes = async ( userDAL, accessApprovalRequestDAL, additionalPrivilegeDAL: projectUserAdditionalPrivilegeDAL, - accessApprovalRequestReviewerDAL + accessApprovalRequestReviewerDAL, + orgMembershipDAL }); const accessApprovalRequestService = accessApprovalRequestServiceFactory({ @@ -1388,12 +1402,24 @@ export const registerRoutes = async ( identityUaDAL, licenseService }); + + const gatewayService = gatewayServiceFactory({ + permissionService, + gatewayDAL, + kmsService, + licenseService, + orgGatewayConfigDAL, + keyStore + }); + const identityKubernetesAuthService = identityKubernetesAuthServiceFactory({ identityKubernetesAuthDAL, identityOrgMembershipDAL, identityAccessTokenDAL, permissionService, licenseService, + gatewayService, + gatewayDAL, kmsService }); const identityGcpAuthService = identityGcpAuthServiceFactory({ @@ -1420,6 +1446,14 @@ export const registerRoutes = async ( licenseService }); + const identityOciAuthService = identityOciAuthServiceFactory({ + identityAccessTokenDAL, + identityOciAuthDAL, + identityOrgMembershipDAL, + licenseService, + permissionService + }); + const identityOidcAuthService = identityOidcAuthServiceFactory({ identityOidcAuthDAL, identityOrgMembershipDAL, @@ -1438,14 +1472,14 @@ export const registerRoutes = async ( kmsService }); - const gatewayService = gatewayServiceFactory({ + const identityLdapAuthService = identityLdapAuthServiceFactory({ + identityLdapAuthDAL, permissionService, - gatewayDAL, kmsService, + identityAccessTokenDAL, + identityOrgMembershipDAL, licenseService, - orgGatewayConfigDAL, - keyStore, - projectGatewayDAL + identityDAL }); const dynamicSecretProviders = buildDynamicSecretProviders({ @@ -1469,7 +1503,7 @@ export const registerRoutes = async ( permissionService, licenseService, kmsService, - projectGatewayDAL, + gatewayDAL, resourceMetadataDAL }); @@ -1482,7 +1516,9 @@ export const registerRoutes = async ( dynamicSecretProviders, folderDAL, licenseService, - kmsService + kmsService, + userDAL, + identityDAL }); const dailyResourceCleanUp = dailyResourceCleanUpQueueServiceFactory({ auditLogDAL, @@ -1580,7 +1616,8 @@ export const registerRoutes = async ( const appConnectionService = appConnectionServiceFactory({ appConnectionDAL, permissionService, - kmsService + kmsService, + licenseService }); const secretSyncService = secretSyncServiceFactory({ @@ -1591,7 +1628,54 @@ export const registerRoutes = async ( folderDAL, secretSyncQueue, projectBotService, - keyStore + keyStore, + 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({ @@ -1633,6 +1717,74 @@ export const registerRoutes = async ( appConnectionDAL }); + const certificateAuthorityService = certificateAuthorityServiceFactory({ + certificateAuthorityDAL, + projectDAL, + permissionService, + appConnectionDAL, + appConnectionService, + externalCertificateAuthorityDAL, + internalCertificateAuthorityService, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + pkiSubscriberDAL + }); + + const internalCaFns = InternalCertificateAuthorityFns({ + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + projectDAL, + kmsService + }); + + const pkiSubscriberQueue = pkiSubscriberQueueServiceFactory({ + queueService, + pkiSubscriberDAL, + certificateAuthorityDAL, + certificateAuthorityQueue, + certificateDAL, + auditLogService, + internalCaFns + }); + + const pkiSubscriberService = pkiSubscriberServiceFactory({ + pkiSubscriberDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + projectDAL, + kmsService, + permissionService, + certificateAuthorityQueue, + internalCaFns + }); + + const pkiTemplateService = pkiTemplatesServiceFactory({ + pkiTemplatesDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + projectDAL, + kmsService, + permissionService, + internalCaFns + }); + await secretRotationV2QueueServiceFactory({ secretRotationV2Service, secretRotationV2DAL, @@ -1642,6 +1794,26 @@ export const registerRoutes = async ( smtpService }); + const secretScanningV2Queue = await secretScanningV2QueueServiceFactory({ + auditLogService, + secretScanningV2DAL, + queueService, + projectDAL, + projectMembershipDAL, + smtpService, + kmsService, + keyStore + }); + + const secretScanningV2Service = secretScanningV2ServiceFactory({ + permissionService, + appConnectionService, + licenseService, + secretScanningV2DAL, + secretScanningV2Queue, + kmsService + }); + await superAdminService.initServerCfg(); // setup the communication with license key server @@ -1653,6 +1825,7 @@ export const registerRoutes = async ( await telemetryQueue.startTelemetryCheck(); await dailyResourceCleanUp.startCleanUp(); await dailyExpiringPkiItemAlert.startSendingAlerts(); + await pkiSubscriberQueue.startDailyAutoRenewalJob(); await kmsService.startService(); await microsoftTeamsService.start(); @@ -1696,8 +1869,10 @@ export const registerRoutes = async ( identityGcpAuth: identityGcpAuthService, identityAwsAuth: identityAwsAuthService, identityAzureAuth: identityAzureAuthService, + identityOciAuth: identityOciAuthService, identityOidcAuth: identityOidcAuthService, identityJwtAuth: identityJwtAuthService, + identityLdapAuth: identityLdapAuthService, accessApprovalPolicy: accessApprovalPolicyService, accessApprovalRequest: accessApprovalRequestService, secretApprovalPolicy: secretApprovalPolicyService, @@ -1716,11 +1891,14 @@ export const registerRoutes = async ( sshHost: sshHostService, sshHostGroup: sshHostGroupService, certificateAuthority: certificateAuthorityService, + internalCertificateAuthority: internalCertificateAuthorityService, certificateTemplate: certificateTemplateService, certificateAuthorityCrl: certificateAuthorityCrlService, certificateEst: certificateEstService, pkiAlert: pkiAlertService, pkiCollection: pkiCollectionService, + pkiSubscriber: pkiSubscriberService, + pkiTemplate: pkiTemplateService, secretScanning: secretScanningService, license: licenseService, trustedIp: trustedIpService, @@ -1750,7 +1928,8 @@ export const registerRoutes = async ( secretRotationV2: secretRotationV2Service, microsoftTeams: microsoftTeamsService, assumePrivileges: assumePrivilegeService, - githubOrgSync: githubOrgSyncConfigService + githubOrgSync: githubOrgSyncConfigService, + secretScanningV2: secretScanningV2Service }); const cronJobs: CronJob[] = []; @@ -1763,6 +1942,10 @@ export const registerRoutes = async ( if (licenseSyncJob) { cronJobs.push(licenseSyncJob); } + const microsoftTeamsSyncJob = await microsoftTeamsService.initializeBackgroundSync(); + if (microsoftTeamsSyncJob) { + cronJobs.push(microsoftTeamsSyncJob); + } } server.decorate("store", { diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index da300981c..a26293ac8 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -1,9 +1,11 @@ import { z } from "zod"; import { + CertificateAuthoritiesSchema, DynamicSecretsSchema, IdentityProjectAdditionalPrivilegeSchema, IntegrationAuthsSchema, + InternalCertificateAuthoritiesSchema, ProjectRolesSchema, ProjectsSchema, SecretApprovalPoliciesSchema, @@ -233,11 +235,9 @@ export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({ inputIV: true, inputTag: true, algorithm: true -}).merge( - z.object({ - metadata: ResourceMetadataSchema.optional() - }) -); +}).extend({ + metadata: ResourceMetadataSchema.optional() +}); export const SanitizedAuditLogStreamSchema = z.object({ id: z.string(), @@ -261,7 +261,8 @@ export const SanitizedProjectSchema = ProjectsSchema.pick({ pitVersionLimit: true, kmsCertificateKeyId: true, auditLogsRetentionDays: true, - hasDeleteProtection: true + hasDeleteProtection: true, + secretSharing: true }); export const SanitizedTagSchema = SecretTagsSchema.pick({ @@ -271,3 +272,15 @@ export const SanitizedTagSchema = SecretTagsSchema.pick({ }).extend({ name: z.string() }); + +export const InternalCertificateAuthorityResponseSchema = CertificateAuthoritiesSchema.merge( + InternalCertificateAuthoritiesSchema.omit({ + caId: true, + notAfter: true, + notBefore: true + }) +).extend({ + requireTemplateForIssuance: z.boolean().optional(), + notAfter: z.string().optional(), + notBefore: z.string().optional() +}); diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index a55aa2ba4..0bade9904 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -1,16 +1,23 @@ import DOMPurify from "isomorphic-dompurify"; import { z } from "zod"; -import { IdentitiesSchema, OrganizationsSchema, SuperAdminSchema, UsersSchema } from "@app/db/schemas"; +import { + IdentitiesSchema, + OrganizationsSchema, + OrgMembershipsSchema, + SuperAdminSchema, + UsersSchema +} from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; -import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; -import { LoginMethod } from "@app/services/super-admin/super-admin-types"; +import { CacheType, LoginMethod } from "@app/services/super-admin/super-admin-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; export const registerAdminRouter = async (server: FastifyZodProvider) => { @@ -160,6 +167,129 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/organization-management/organizations", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + searchTerm: z.string().default(""), + offset: z.coerce.number().default(0), + limit: z.coerce.number().max(100).default(20) + }), + response: { + 200: z.object({ + organizations: OrganizationsSchema.extend({ + members: z + .object({ + user: z.object({ + id: z.string(), + email: z.string().nullish(), + username: z.string(), + firstName: z.string().nullish(), + lastName: z.string().nullish() + }), + membershipId: z.string(), + role: z.string(), + roleId: z.string().nullish() + }) + .array(), + projects: z + .object({ + name: z.string(), + id: z.string(), + slug: z.string(), + createdAt: z.date() + }) + .array() + }).array() + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const organizations = await server.services.superAdmin.getOrganizations({ + ...req.query + }); + + return { + organizations + }; + } + }); + + server.route({ + method: "DELETE", + url: "/organization-management/organizations/:organizationId/memberships/:membershipId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + organizationId: z.string(), + membershipId: z.string() + }), + response: { + 200: z.object({ + organizationMembership: OrgMembershipsSchema + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const organizationMembership = await server.services.superAdmin.deleteOrganizationMembership( + req.params.organizationId, + req.params.membershipId, + req.permission.id, + req.permission.type + ); + + return { + organizationMembership + }; + } + }); + + server.route({ + method: "DELETE", + url: "/organization-management/organizations/:organizationId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + organizationId: z.string() + }), + response: { + 200: z.object({ + organization: OrganizationsSchema + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const organization = await server.services.superAdmin.deleteOrganization(req.params.organizationId); + + return { + organization + }; + } + }); + server.route({ method: "GET", url: "/identity-management/identities", @@ -548,4 +678,69 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }; } }); + + server.route({ + method: "POST", + url: "/invalidate-cache", + config: { + rateLimit: invalidateCacheLimit + }, + schema: { + body: z.object({ + type: z.nativeEnum(CacheType) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + await server.services.superAdmin.invalidateCache(req.body.type); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.InvalidateCache, + distinctId: getTelemetryDistinctId(req), + properties: { + ...req.auditLogInfo + } + }); + + return { + message: "Cache invalidation job started" + }; + } + }); + + server.route({ + method: "GET", + url: "/invalidating-cache-status", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + invalidating: z.boolean() + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async () => { + const invalidating = await server.services.superAdmin.checkIfInvalidatingCache(); + + return { + invalidating + }; + } + }); }; diff --git a/backend/src/server/routes/v1/app-connection-routers/1password-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/1password-connection-router.ts new file mode 100644 index 000000000..1100776d3 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/1password-connection-router.ts @@ -0,0 +1,60 @@ +import z from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { + CreateOnePassConnectionSchema, + SanitizedOnePassConnectionSchema, + UpdateOnePassConnectionSchema +} from "@app/services/app-connection/1password"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerOnePassConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.OnePass, + server, + sanitizedResponseSchema: SanitizedOnePassConnectionSchema, + createSchema: CreateOnePassConnectionSchema, + updateSchema: UpdateOnePassConnectionSchema + }); + + // The following endpoints are for internal Infisical App use only and not part of the public API + server.route({ + method: "GET", + url: `/:connectionId/vaults`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string(), + type: z.string(), + items: z.number(), + + attributeVersion: z.number(), + contentVersion: z.number(), + + // Corresponds to ISO8601 date string + createdAt: z.string(), + updatedAt: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const vaults = await server.services.appConnection.onepass.listVaults(connectionId, req.permission); + return vaults; + } + }); +}; 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 f6c260ea5..f523bb218 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,9 +1,14 @@ import { z } from "zod"; +import { OCIConnectionListItemSchema, SanitizedOCIConnectionSchema } from "@app/ee/services/app-connections/oci"; 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 { + OnePassConnectionListItemSchema, + SanitizedOnePassConnectionSchema +} from "@app/services/app-connection/1password"; import { Auth0ConnectionListItemSchema, SanitizedAuth0ConnectionSchema } from "@app/services/app-connection/auth0"; import { AwsConnectionListItemSchema, SanitizedAwsConnectionSchema } from "@app/services/app-connection/aws"; import { @@ -28,6 +33,10 @@ import { } from "@app/services/app-connection/databricks"; import { GcpConnectionListItemSchema, SanitizedGcpConnectionSchema } from "@app/services/app-connection/gcp"; import { GitHubConnectionListItemSchema, SanitizedGitHubConnectionSchema } from "@app/services/app-connection/github"; +import { + GitHubRadarConnectionListItemSchema, + SanitizedGitHubRadarConnectionSchema +} from "@app/services/app-connection/github-radar"; import { HCVaultConnectionListItemSchema, SanitizedHCVaultConnectionSchema @@ -38,6 +47,7 @@ import { } from "@app/services/app-connection/humanitec"; import { LdapConnectionListItemSchema, SanitizedLdapConnectionSchema } from "@app/services/app-connection/ldap"; import { MsSqlConnectionListItemSchema, SanitizedMsSqlConnectionSchema } from "@app/services/app-connection/mssql"; +import { MySqlConnectionListItemSchema, SanitizedMySqlConnectionSchema } from "@app/services/app-connection/mysql"; import { PostgresConnectionListItemSchema, SanitizedPostgresConnectionSchema @@ -61,6 +71,7 @@ import { AuthMode } from "@app/services/auth/auth-type"; const SanitizedAppConnectionSchema = z.union([ ...SanitizedAwsConnectionSchema.options, ...SanitizedGitHubConnectionSchema.options, + ...SanitizedGitHubRadarConnectionSchema.options, ...SanitizedGcpConnectionSchema.options, ...SanitizedAzureKeyVaultConnectionSchema.options, ...SanitizedAzureAppConfigurationConnectionSchema.options, @@ -70,18 +81,22 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedVercelConnectionSchema.options, ...SanitizedPostgresConnectionSchema.options, ...SanitizedMsSqlConnectionSchema.options, + ...SanitizedMySqlConnectionSchema.options, ...SanitizedCamundaConnectionSchema.options, ...SanitizedAuth0ConnectionSchema.options, ...SanitizedHCVaultConnectionSchema.options, ...SanitizedAzureClientSecretsConnectionSchema.options, ...SanitizedWindmillConnectionSchema.options, ...SanitizedLdapConnectionSchema.options, - ...SanitizedTeamCityConnectionSchema.options + ...SanitizedTeamCityConnectionSchema.options, + ...SanitizedOCIConnectionSchema.options, + ...SanitizedOnePassConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ AwsConnectionListItemSchema, GitHubConnectionListItemSchema, + GitHubRadarConnectionListItemSchema, GcpConnectionListItemSchema, AzureKeyVaultConnectionListItemSchema, AzureAppConfigurationConnectionListItemSchema, @@ -91,13 +106,16 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ VercelConnectionListItemSchema, PostgresConnectionListItemSchema, MsSqlConnectionListItemSchema, + MySqlConnectionListItemSchema, CamundaConnectionListItemSchema, Auth0ConnectionListItemSchema, HCVaultConnectionListItemSchema, AzureClientSecretsConnectionListItemSchema, WindmillConnectionListItemSchema, LdapConnectionListItemSchema, - TeamCityConnectionListItemSchema + TeamCityConnectionListItemSchema, + OCIConnectionListItemSchema, + OnePassConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/gcp-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/gcp-connection-router.ts index f92d5e668..c88308c64 100644 --- a/backend/src/server/routes/v1/app-connection-routers/gcp-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/gcp-connection-router.ts @@ -45,4 +45,37 @@ export const registerGcpConnectionRouter = async (server: FastifyZodProvider) => return projects; } }); + + server.route({ + method: "GET", + url: `/:connectionId/secret-manager-project-locations`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + querystring: z.object({ + projectId: z.string() + }), + response: { + 200: z.object({ displayName: z.string(), locationId: z.string() }).array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { + params: { connectionId }, + query: { projectId } + } = req; + + const locations = await server.services.appConnection.gcp.listSecretManagerProjectLocations( + { connectionId, projectId }, + req.permission + ); + + return locations; + } + }); }; diff --git a/backend/src/server/routes/v1/app-connection-routers/github-radar-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/github-radar-connection-router.ts new file mode 100644 index 000000000..086d986b7 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/github-radar-connection-router.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +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 { + CreateGitHubRadarConnectionSchema, + SanitizedGitHubRadarConnectionSchema, + UpdateGitHubRadarConnectionSchema +} from "@app/services/app-connection/github-radar"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerGitHubRadarConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.GitHubRadar, + server, + sanitizedResponseSchema: SanitizedGitHubRadarConnectionSchema, + createSchema: CreateGitHubRadarConnectionSchema, + updateSchema: UpdateGitHubRadarConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + + server.route({ + method: "GET", + url: `/:connectionId/repositories`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + repositories: z.object({ id: z.number(), name: z.string() }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const repositories = await server.services.appConnection.githubRadar.listRepositories( + connectionId, + req.permission + ); + + return { repositories }; + } + }); +}; 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 eeae5e5e3..7085b3364 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -1,5 +1,7 @@ +import { registerOCIConnectionRouter } from "@app/ee/routes/v1/app-connection-routers/oci-connection-router"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { registerOnePassConnectionRouter } from "./1password-connection-router"; import { registerAuth0ConnectionRouter } from "./auth0-connection-router"; import { registerAwsConnectionRouter } from "./aws-connection-router"; import { registerAzureAppConfigurationConnectionRouter } from "./azure-app-configuration-connection-router"; @@ -9,10 +11,12 @@ import { registerCamundaConnectionRouter } from "./camunda-connection-router"; import { registerDatabricksConnectionRouter } from "./databricks-connection-router"; import { registerGcpConnectionRouter } from "./gcp-connection-router"; import { registerGitHubConnectionRouter } from "./github-connection-router"; +import { registerGitHubRadarConnectionRouter } from "./github-radar-connection-router"; import { registerHCVaultConnectionRouter } from "./hc-vault-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; +import { registerMySqlConnectionRouter } from "./mysql-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; import { registerTeamCityConnectionRouter } from "./teamcity-connection-router"; import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; @@ -25,6 +29,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.MySql, + server, + sanitizedResponseSchema: SanitizedMySqlConnectionSchema, + createSchema: CreateMySqlConnectionSchema, + updateSchema: UpdateMySqlConnectionSchema + }); +}; diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts index f6538b797..47594ac87 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ import { z } from "zod"; -import { CertificateAuthoritiesSchema, CertificateTemplatesSchema } from "@app/db/schemas"; +import { CertificateTemplatesSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; @@ -10,13 +10,19 @@ 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 { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; -import { CaRenewalType, CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-types"; +import { + CaRenewalType, + CaStatus, + InternalCaType +} from "@app/services/certificate-authority/certificate-authority-enums"; import { validateAltNamesField, validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; +import { InternalCertificateAuthorityResponseSchema } from "../sanitizedSchemas"; + export const registerCaRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", @@ -32,7 +38,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { body: z .object({ projectSlug: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.projectSlug), - type: z.nativeEnum(CaType).describe(CERTIFICATE_AUTHORITIES.CREATE.type), + type: z.nativeEnum(InternalCaType).describe(CERTIFICATE_AUTHORITIES.CREATE.type), friendlyName: z.string().optional().describe(CERTIFICATE_AUTHORITIES.CREATE.friendlyName), commonName: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.commonName), organization: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.organization), @@ -68,16 +74,18 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { ), response: { 200: z.object({ - ca: CertificateAuthoritiesSchema + ca: InternalCertificateAuthorityResponseSchema }) } }, handler: async (req) => { - const ca = await server.services.certificateAuthority.createCa({ + const ca = await server.services.internalCertificateAuthority.createCa({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, + isInternal: false, actorOrgId: req.permission.orgId, + enableDirectIssuance: !req.body.requireTemplateForIssuance, ...req.body }); @@ -87,6 +95,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { event: { type: EventType.CREATE_CA, metadata: { + name: ca.name, caId: ca.id, dn: ca.dn } @@ -115,12 +124,12 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - ca: CertificateAuthoritiesSchema + ca: InternalCertificateAuthorityResponseSchema }) } }, handler: async (req) => { - const ca = await server.services.certificateAuthority.getCaById({ + const ca = await server.services.internalCertificateAuthority.getCaById({ caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, @@ -135,6 +144,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { type: EventType.GET_CA, metadata: { caId: ca.id, + name: ca.name, dn: ca.dn } } @@ -167,7 +177,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req, res) => { - const caCert = await server.services.certificateAuthority.getCaCertById(req.params); + const caCert = await server.services.internalCertificateAuthority.getCaCertById(req.params); res.header("Content-Type", "application/pkix-cert"); @@ -198,17 +208,19 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - ca: CertificateAuthoritiesSchema + ca: InternalCertificateAuthorityResponseSchema }) } }, handler: async (req) => { - const ca = await server.services.certificateAuthority.updateCaById({ + const ca = await server.services.internalCertificateAuthority.updateCaById({ caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, + isInternal: false, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, + enableDirectIssuance: !req.body.requireTemplateForIssuance, ...req.body }); @@ -220,6 +232,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { metadata: { caId: ca.id, dn: ca.dn, + name: ca.name, status: ca.status as CaStatus } } @@ -247,12 +260,12 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - ca: CertificateAuthoritiesSchema + ca: InternalCertificateAuthorityResponseSchema }) } }, handler: async (req) => { - const ca = await server.services.certificateAuthority.deleteCaById({ + const ca = await server.services.internalCertificateAuthority.deleteCaById({ caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, @@ -266,6 +279,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { event: { type: EventType.DELETE_CA, metadata: { + name: ca.name, caId: ca.id, dn: ca.dn } @@ -299,7 +313,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { ca, csr } = await server.services.certificateAuthority.getCaCsr({ + const { ca, csr } = await server.services.internalCertificateAuthority.getCaCsr({ caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, @@ -353,7 +367,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }, handler: async (req) => { const { certificate, certificateChain, serialNumber, ca } = - await server.services.certificateAuthority.renewCaCert({ + await server.services.internalCertificateAuthority.renewCaCert({ caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, @@ -408,7 +422,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { caCerts, ca } = await server.services.certificateAuthority.getCaCerts({ + const { caCerts, ca } = await server.services.internalCertificateAuthority.getCaCerts({ caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, @@ -455,13 +469,14 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { certificate, certificateChain, serialNumber, ca } = await server.services.certificateAuthority.getCaCert({ - caId: req.params.caId, - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId - }); + const { certificate, certificateChain, serialNumber, ca } = + await server.services.internalCertificateAuthority.getCaCert({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, @@ -517,7 +532,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }, handler: async (req) => { const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca } = - await server.services.certificateAuthority.signIntermediate({ + await server.services.internalCertificateAuthority.signIntermediate({ caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, @@ -574,7 +589,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { ca } = await server.services.certificateAuthority.importCertToCa({ + const { ca } = await server.services.internalCertificateAuthority.importCertToCa({ caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, @@ -653,7 +668,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }, handler: async (req) => { const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber, ca } = - await server.services.certificateAuthority.issueCertFromCa({ + await server.services.internalCertificateAuthority.issueCertFromCa({ caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, @@ -746,7 +761,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }, handler: async (req) => { const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca, commonName } = - await server.services.certificateAuthority.signCertFromCa({ + await server.services.internalCertificateAuthority.signCertFromCa({ isInternal: false, caId: req.params.caId, actor: req.permission.type, @@ -809,13 +824,15 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { certificateTemplates, ca } = await server.services.certificateAuthority.getCaCertificateTemplates({ - caId: req.params.caId, - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId - }); + const { certificateTemplates, ca } = await server.services.internalCertificateAuthority.getCaCertificateTemplates( + { + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + } + ); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, diff --git a/backend/src/server/routes/v1/certificate-authority-routers/acme-certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-routers/acme-certificate-authority-router.ts new file mode 100644 index 000000000..6e43fd2cf --- /dev/null +++ b/backend/src/server/routes/v1/certificate-authority-routers/acme-certificate-authority-router.ts @@ -0,0 +1,18 @@ +import { + AcmeCertificateAuthoritySchema, + CreateAcmeCertificateAuthoritySchema, + UpdateAcmeCertificateAuthoritySchema +} from "@app/services/certificate-authority/acme/acme-certificate-authority-schemas"; +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; + +import { registerCertificateAuthorityEndpoints } from "./certificate-authority-endpoints"; + +export const registerAcmeCertificateAuthorityRouter = async (server: FastifyZodProvider) => { + registerCertificateAuthorityEndpoints({ + caType: CaType.ACME, + server, + responseSchema: AcmeCertificateAuthoritySchema, + createSchema: CreateAcmeCertificateAuthoritySchema, + updateSchema: UpdateAcmeCertificateAuthoritySchema + }); +}; diff --git a/backend/src/server/routes/v1/certificate-authority-routers/certificate-authority-endpoints.ts b/backend/src/server/routes/v1/certificate-authority-routers/certificate-authority-endpoints.ts new file mode 100644 index 000000000..01952c7f4 --- /dev/null +++ b/backend/src/server/routes/v1/certificate-authority-routers/certificate-authority-endpoints.ts @@ -0,0 +1,258 @@ +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 { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { + TCertificateAuthority, + TCertificateAuthorityInput +} from "@app/services/certificate-authority/certificate-authority-types"; + +export const registerCertificateAuthorityEndpoints = < + T extends TCertificateAuthority, + I extends TCertificateAuthorityInput +>({ + server, + caType, + createSchema, + updateSchema, + responseSchema +}: { + caType: CaType; + server: FastifyZodProvider; + createSchema: z.ZodType<{ + name: string; + projectId: string; + status: CaStatus; + configuration: I["configuration"]; + enableDirectIssuance: boolean; + }>; + updateSchema: z.ZodType<{ + projectId: string; + name?: string; + status?: CaStatus; + configuration?: I["configuration"]; + enableDirectIssuance?: boolean; + }>; + responseSchema: z.ZodTypeAny; +}) => { + server.route({ + method: "GET", + url: `/`, + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required") + }), + response: { + 200: responseSchema.array() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId } + } = req; + + const certificateAuthorities = (await server.services.certificateAuthority.listCertificateAuthoritiesByProjectId( + { projectId, type: caType }, + req.permission + )) as T[]; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_CAS, + metadata: { + caIds: certificateAuthorities.map((ca) => ca.id) + } + } + }); + + return certificateAuthorities; + } + }); + + server.route({ + method: "GET", + url: "/:caName", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + params: z.object({ + caName: z.string() + }), + querystring: z.object({ + projectId: z.string().uuid() + }), + response: { + 200: responseSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { caName } = req.params; + const { projectId } = req.query; + + const certificateAuthority = + (await server.services.certificateAuthority.findCertificateAuthorityByNameAndProjectId( + { caName, type: caType, projectId }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateAuthority.projectId, + event: { + type: EventType.GET_CA, + metadata: { + caId: certificateAuthority.id, + name: certificateAuthority.name + } + } + }); + + return certificateAuthority; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + body: createSchema, + response: { + 200: responseSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateAuthority = (await server.services.certificateAuthority.createCertificateAuthority( + { ...req.body, type: caType }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateAuthority.projectId, + event: { + type: EventType.CREATE_CA, + metadata: { + name: certificateAuthority.name, + caId: certificateAuthority.id + } + } + }); + + return certificateAuthority; + } + }); + + server.route({ + method: "PATCH", + url: "/:caName", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + params: z.object({ + caName: z.string() + }), + body: updateSchema, + response: { + 200: responseSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { caName } = req.params; + + const certificateAuthority = (await server.services.certificateAuthority.updateCertificateAuthority( + { + ...req.body, + type: caType, + caName + }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateAuthority.projectId, + event: { + type: EventType.UPDATE_CA, + metadata: { + name: certificateAuthority.name, + caId: certificateAuthority.id, + status: certificateAuthority.status + } + } + }); + + return certificateAuthority; + } + }); + + server.route({ + method: "DELETE", + url: "/:caName", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + params: z.object({ + caName: z.string() + }), + body: z.object({ + projectId: z.string().uuid() + }), + response: { + 200: responseSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { caName } = req.params; + const { projectId } = req.body; + + const certificateAuthority = (await server.services.certificateAuthority.deleteCertificateAuthority( + { caName, type: caType, projectId }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateAuthority.projectId, + event: { + type: EventType.DELETE_CA, + metadata: { + name: certificateAuthority.name, + caId: certificateAuthority.id + } + } + }); + + return certificateAuthority; + } + }); +}; diff --git a/backend/src/server/routes/v1/certificate-authority-routers/index.ts b/backend/src/server/routes/v1/certificate-authority-routers/index.ts new file mode 100644 index 000000000..56a236911 --- /dev/null +++ b/backend/src/server/routes/v1/certificate-authority-routers/index.ts @@ -0,0 +1,12 @@ +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; + +import { registerAcmeCertificateAuthorityRouter } from "./acme-certificate-authority-router"; +import { registerInternalCertificateAuthorityRouter } from "./internal-certificate-authority-router"; + +export * from "./internal-certificate-authority-router"; + +export const CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP: Record Promise> = + { + [CaType.INTERNAL]: registerInternalCertificateAuthorityRouter, + [CaType.ACME]: registerAcmeCertificateAuthorityRouter + }; diff --git a/backend/src/server/routes/v1/certificate-authority-routers/internal-certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-routers/internal-certificate-authority-router.ts new file mode 100644 index 000000000..61dc3ed57 --- /dev/null +++ b/backend/src/server/routes/v1/certificate-authority-routers/internal-certificate-authority-router.ts @@ -0,0 +1,18 @@ +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { + CreateInternalCertificateAuthoritySchema, + InternalCertificateAuthoritySchema, + UpdateInternalCertificateAuthoritySchema +} from "@app/services/certificate-authority/internal/internal-certificate-authority-schemas"; + +import { registerCertificateAuthorityEndpoints } from "./certificate-authority-endpoints"; + +export const registerInternalCertificateAuthorityRouter = async (server: FastifyZodProvider) => { + registerCertificateAuthorityEndpoints({ + caType: CaType.INTERNAL, + server, + responseSchema: InternalCertificateAuthoritySchema, + createSchema: CreateInternalCertificateAuthoritySchema, + updateSchema: UpdateInternalCertificateAuthoritySchema + }); +}; diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index dad1d9a80..8194b9481 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -39,7 +39,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { cert, ca } = await server.services.certificate.getCert({ + const { cert } = await server.services.certificate.getCert({ serialNumber: req.params.serialNumber, actor: req.permission.type, actorId: req.permission.id, @@ -49,7 +49,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: ca.projectId, + projectId: cert.projectId, event: { type: EventType.GET_CERT, metadata: { @@ -86,7 +86,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { } }, handler: async (req, reply) => { - const { ca, cert, certPrivateKey } = await server.services.certificate.getCertPrivateKey({ + const { cert, certPrivateKey } = await server.services.certificate.getCertPrivateKey({ serialNumber: req.params.serialNumber, actor: req.permission.type, actorId: req.permission.id, @@ -96,7 +96,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: ca.projectId, + projectId: cert.projectId, event: { type: EventType.GET_CERT_PRIVATE_KEY, metadata: { @@ -131,14 +131,14 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate), - certificateChain: z.string().trim().nullish().describe(CERTIFICATES.GET_CERT.certificateChain), - privateKey: z.string().trim().describe(CERTIFICATES.GET_CERT.privateKey), + certificateChain: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.certificateChain), + privateKey: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.privateKey), serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes) }) } }, handler: async (req, reply) => { - const { certificate, certificateChain, serialNumber, cert, ca, privateKey } = + const { certificate, certificateChain, serialNumber, cert, privateKey } = await server.services.certificate.getCertBundle({ serialNumber: req.params.serialNumber, actor: req.permission.type, @@ -149,7 +149,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: ca.projectId, + projectId: cert.projectId, event: { type: EventType.GET_CERT_BUNDLE, metadata: { @@ -242,7 +242,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { }, handler: async (req) => { const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber, ca } = - await server.services.certificateAuthority.issueCertFromCa({ + await server.services.internalCertificateAuthority.issueCertFromCa({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -284,6 +284,68 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/import-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + description: "Import certificate", + body: z.object({ + projectSlug: z.string().trim().min(1).describe(CERTIFICATES.IMPORT.projectSlug), + + certificatePem: z.string().trim().min(1).describe(CERTIFICATES.IMPORT.certificatePem), + privateKeyPem: z.string().trim().min(1).describe(CERTIFICATES.IMPORT.privateKeyPem), + chainPem: z.string().trim().min(1).describe(CERTIFICATES.IMPORT.chainPem), + + friendlyName: z.string().trim().optional().describe(CERTIFICATES.IMPORT.friendlyName), + pkiCollectionId: z.string().trim().optional().describe(CERTIFICATES.IMPORT.pkiCollectionId) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATES.IMPORT.certificate), + certificateChain: z.string().trim().describe(CERTIFICATES.IMPORT.certificateChain), + privateKey: z.string().trim().describe(CERTIFICATES.IMPORT.privateKey), + serialNumber: z.string().trim().describe(CERTIFICATES.IMPORT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, privateKey, serialNumber, cert } = + await server.services.certificate.importCert({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: cert.projectId, + event: { + type: EventType.IMPORT_CERT, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber + } + } + }); + + return { + certificate, + certificateChain, + privateKey, + serialNumber + }; + } + }); + server.route({ method: "POST", url: "/sign-certificate", @@ -355,7 +417,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { }, handler: async (req) => { const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca, commonName } = - await server.services.certificateAuthority.signCertFromCa({ + await server.services.internalCertificateAuthority.signCertFromCa({ isInternal: false, actor: req.permission.type, actorId: req.permission.id, @@ -474,7 +536,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { deletedCert, ca } = await server.services.certificate.deleteCert({ + const { deletedCert } = await server.services.certificate.deleteCert({ serialNumber: req.params.serialNumber, actor: req.permission.type, actorId: req.permission.id, @@ -484,7 +546,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: ca.projectId, + projectId: deletedCert.projectId, event: { type: EventType.DELETE_CERT, metadata: { @@ -518,13 +580,13 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate), - certificateChain: z.string().trim().nullish().describe(CERTIFICATES.GET_CERT.certificateChain), + certificateChain: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.certificateChain), serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes) }) } }, handler: async (req) => { - const { certificate, certificateChain, serialNumber, cert, ca } = await server.services.certificate.getCertBody({ + const { certificate, certificateChain, serialNumber, cert } = await server.services.certificate.getCertBody({ serialNumber: req.params.serialNumber, actor: req.permission.type, actorId: req.permission.id, @@ -534,7 +596,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: ca.projectId, + projectId: cert.projectId, event: { type: EventType.GET_CERT_BODY, metadata: { diff --git a/backend/src/server/routes/v1/certificate-template-router.ts b/backend/src/server/routes/v1/certificate-template-router.ts index b0c186206..17f564be4 100644 --- a/backend/src/server/routes/v1/certificate-template-router.ts +++ b/backend/src/server/routes/v1/certificate-template-router.ts @@ -5,6 +5,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, CERTIFICATE_TEMPLATES } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; 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"; import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types"; @@ -72,7 +73,7 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid body: z.object({ caId: z.string().describe(CERTIFICATE_TEMPLATES.CREATE.caId), pkiCollectionId: z.string().optional().describe(CERTIFICATE_TEMPLATES.CREATE.pkiCollectionId), - name: z.string().min(1).describe(CERTIFICATE_TEMPLATES.CREATE.name), + name: slugSchema().describe(CERTIFICATE_TEMPLATES.CREATE.name), commonName: validateTemplateRegexField.describe(CERTIFICATE_TEMPLATES.CREATE.commonName), subjectAlternativeName: validateTemplateRegexField.describe( CERTIFICATE_TEMPLATES.CREATE.subjectAlternativeName @@ -141,7 +142,7 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid body: z.object({ caId: z.string().optional().describe(CERTIFICATE_TEMPLATES.UPDATE.caId), pkiCollectionId: z.string().optional().describe(CERTIFICATE_TEMPLATES.UPDATE.pkiCollectionId), - name: z.string().min(1).optional().describe(CERTIFICATE_TEMPLATES.UPDATE.name), + name: slugSchema().optional().describe(CERTIFICATE_TEMPLATES.UPDATE.name), commonName: validateTemplateRegexField.optional().describe(CERTIFICATE_TEMPLATES.UPDATE.commonName), subjectAlternativeName: validateTemplateRegexField .optional() diff --git a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts index 21759e0cd..5eb0c6990 100644 --- a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -3,10 +3,12 @@ import { z } from "zod"; import { IdentityKubernetesAuthsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, KUBERNETES_AUTH } from "@app/lib/api-docs"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; 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 { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { IdentityKubernetesAuthTokenReviewMode } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-types"; import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; const IdentityKubernetesAuthResponseSchema = IdentityKubernetesAuthsSchema.pick({ @@ -17,11 +19,13 @@ const IdentityKubernetesAuthResponseSchema = IdentityKubernetesAuthsSchema.pick( accessTokenTrustedIps: true, createdAt: true, updatedAt: true, + tokenReviewMode: true, identityId: true, kubernetesHost: true, allowedNamespaces: true, allowedNames: true, - allowedAudience: true + allowedAudience: true, + gatewayId: true }).extend({ caCert: z.string(), tokenReviewerJwt: z.string().optional().nullable() @@ -100,12 +104,36 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide }), body: z .object({ - kubernetesHost: z.string().trim().min(1).describe(KUBERNETES_AUTH.ATTACH.kubernetesHost), + kubernetesHost: z + .string() + .trim() + .min(1) + .describe(KUBERNETES_AUTH.ATTACH.kubernetesHost) + .refine( + (val) => + characterValidator([ + CharacterType.Alphabets, + CharacterType.Numbers, + CharacterType.Colon, + CharacterType.Period, + CharacterType.ForwardSlash, + CharacterType.Hyphen + ])(val), + { + message: + "Kubernetes host must only contain alphabets, numbers, colons, periods, hyphen, and forward slashes." + } + ), caCert: z.string().trim().default("").describe(KUBERNETES_AUTH.ATTACH.caCert), tokenReviewerJwt: z.string().trim().optional().describe(KUBERNETES_AUTH.ATTACH.tokenReviewerJwt), + tokenReviewMode: z + .nativeEnum(IdentityKubernetesAuthTokenReviewMode) + .default(IdentityKubernetesAuthTokenReviewMode.Api) + .describe(KUBERNETES_AUTH.ATTACH.tokenReviewMode), allowedNamespaces: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedNamespaces), // TODO: validation allowedNames: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedNames), allowedAudience: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedAudience), + gatewayId: z.string().uuid().optional().nullable().describe(KUBERNETES_AUTH.ATTACH.gatewayId), accessTokenTrustedIps: z .object({ ipAddress: z.string().trim() @@ -135,10 +163,22 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide .default(0) .describe(KUBERNETES_AUTH.ATTACH.accessTokenNumUsesLimit) }) - .refine( - (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, - "Access Token TTL cannot be greater than Access Token Max TTL." - ), + .superRefine((data, ctx) => { + if (data.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway && !data.gatewayId) { + ctx.addIssue({ + path: ["gatewayId"], + code: z.ZodIssueCode.custom, + message: "When token review mode is set to Gateway, a gateway must be selected" + }); + } + if (data.accessTokenTTL > data.accessTokenMaxTTL) { + ctx.addIssue({ + path: ["accessTokenTTL"], + code: z.ZodIssueCode.custom, + message: "Access Token TTL cannot be greater than Access Token Max TTL." + }); + } + }), response: { 200: z.object({ identityKubernetesAuth: IdentityKubernetesAuthResponseSchema @@ -199,12 +239,40 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide }), body: z .object({ - kubernetesHost: z.string().trim().min(1).optional().describe(KUBERNETES_AUTH.UPDATE.kubernetesHost), + kubernetesHost: z + .string() + .trim() + .min(1) + .optional() + .describe(KUBERNETES_AUTH.UPDATE.kubernetesHost) + .refine( + (val) => { + if (!val) return true; + + return characterValidator([ + CharacterType.Alphabets, + CharacterType.Numbers, + CharacterType.Colon, + CharacterType.Period, + CharacterType.ForwardSlash, + CharacterType.Hyphen + ])(val); + }, + { + message: + "Kubernetes host must only contain alphabets, numbers, colons, periods, hyphen, and forward slashes." + } + ), caCert: z.string().trim().optional().describe(KUBERNETES_AUTH.UPDATE.caCert), tokenReviewerJwt: z.string().trim().nullable().optional().describe(KUBERNETES_AUTH.UPDATE.tokenReviewerJwt), + tokenReviewMode: z + .nativeEnum(IdentityKubernetesAuthTokenReviewMode) + .optional() + .describe(KUBERNETES_AUTH.UPDATE.tokenReviewMode), allowedNamespaces: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedNamespaces), // TODO: validation allowedNames: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedNames), allowedAudience: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedAudience), + gatewayId: z.string().uuid().optional().nullable().describe(KUBERNETES_AUTH.UPDATE.gatewayId), accessTokenTrustedIps: z .object({ ipAddress: z.string().trim() @@ -234,10 +302,26 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide .optional() .describe(KUBERNETES_AUTH.UPDATE.accessTokenMaxTTL) }) - .refine( - (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), - "Access Token TTL cannot be greater than Access Token Max TTL." - ), + .superRefine((data, ctx) => { + if ( + data.tokenReviewMode && + data.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway && + !data.gatewayId + ) { + ctx.addIssue({ + path: ["gatewayId"], + code: z.ZodIssueCode.custom, + message: "When token review mode is set to Gateway, a gateway must be selected" + }); + } + if (data.accessTokenMaxTTL && data.accessTokenTTL ? data.accessTokenTTL > data.accessTokenMaxTTL : false) { + ctx.addIssue({ + path: ["accessTokenTTL"], + code: z.ZodIssueCode.custom, + message: "Access Token TTL cannot be greater than Access Token Max TTL." + }); + } + }), response: { 200: z.object({ identityKubernetesAuth: IdentityKubernetesAuthResponseSchema diff --git a/backend/src/server/routes/v1/identity-ldap-auth-router.ts b/backend/src/server/routes/v1/identity-ldap-auth-router.ts new file mode 100644 index 000000000..3da8a425b --- /dev/null +++ b/backend/src/server/routes/v1/identity-ldap-auth-router.ts @@ -0,0 +1,497 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +// All the any rules are disabled because passport typesense with fastify is really poor + +import { Authenticator } from "@fastify/passport"; +import fastifySession from "@fastify/session"; +import { FastifyRequest } from "fastify"; +import { IncomingMessage } from "http"; +import LdapStrategy from "passport-ldapauth"; +import { z } from "zod"; + +import { IdentityLdapAuthsSchema } from "@app/db/schemas/identity-ldap-auths"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { isValidLdapFilter } from "@app/ee/services/ldap-config/ldap-fns"; +import { ApiDocsTags, LDAP_AUTH } from "@app/lib/api-docs"; +import { getConfig } from "@app/lib/config/env"; +import { UnauthorizedError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +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 { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { AllowedFieldsSchema } from "@app/services/identity-ldap-auth/identity-ldap-auth-types"; +import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; + +export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + const passport = new Authenticator({ key: "ldap-identity-auth", userProperty: "passportMachineIdentity" }); + await server.register(fastifySession, { secret: appCfg.COOKIE_SECRET_SIGN_KEY }); + await server.register(passport.initialize()); + await server.register(passport.secureSession()); + + const getLdapPassportOpts = (req: FastifyRequest, done: any) => { + const { identityId } = req.body as { + identityId: string; + }; + + process.nextTick(async () => { + try { + const { ldapConfig, opts } = await server.services.identityLdapAuth.getLdapConfig(identityId); + req.ldapConfig = { + ...ldapConfig, + isActive: true, + groupSearchBase: "", + uniqueUserAttribute: "", + groupSearchFilter: "" + }; + + done(null, opts); + } catch (err) { + logger.error(err, "Error in LDAP verification callback"); + done(err); + } + }); + }; + + passport.use( + new LdapStrategy( + getLdapPassportOpts as any, + // eslint-disable-next-line + async (req: IncomingMessage, user, cb) => { + try { + const requestBody = (req as unknown as FastifyRequest).body as { + username: string; + password: string; + identityId: string; + }; + + if (!requestBody.username || !requestBody.password) { + return cb(new UnauthorizedError({ message: "Invalid request. Missing username or password." }), false); + } + + if (!requestBody.identityId) { + return cb(new UnauthorizedError({ message: "Invalid request. Missing identity ID." }), false); + } + + const { ldapConfig } = req as unknown as FastifyRequest; + + if (ldapConfig.allowedFields) { + for (const field of ldapConfig.allowedFields) { + if (!user[field.key]) { + return cb( + new UnauthorizedError({ message: `Invalid request. Missing field ${field.key} on user.` }), + false + ); + } + + const value = field.value.split(","); + + if (!value.includes(user[field.key])) { + return cb( + new UnauthorizedError({ + message: `Invalid request. User field '${field.key}' does not match required fields.` + }), + false + ); + } + } + } + + return cb(null, { identityId: requestBody.identityId, user }); + } catch (error) { + logger.error(error, "Error in LDAP verification callback"); + return cb(error, false); + } + } + ) + ); + + server.route({ + method: "POST", + url: "/ldap-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.LdapAuth], + description: "Login with LDAP Auth", + body: z.object({ + identityId: z.string().trim().describe(LDAP_AUTH.LOGIN.identityId), + username: z.string().describe(LDAP_AUTH.LOGIN.username), + password: z.string().describe(LDAP_AUTH.LOGIN.password) + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + preValidation: passport.authenticate("ldapauth", { + failWithError: true, + session: false + }) as any, + + errorHandler: (error) => { + if (error.name === "AuthenticationError") { + throw new UnauthorizedError({ message: "Invalid credentials" }); + } + + throw error; + }, + + handler: async (req) => { + if (!req.passportMachineIdentity?.identityId) { + throw new UnauthorizedError({ message: "Invalid request. Missing identity ID or LDAP entry details." }); + } + + const { identityId, user } = req.passportMachineIdentity; + + const { accessToken, identityLdapAuth, identityMembershipOrg } = await server.services.identityLdapAuth.login({ + identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, + event: { + type: EventType.LOGIN_IDENTITY_LDAP_AUTH, + metadata: { + identityId, + ldapEmail: user.mail, + ldapUsername: user.uid + } + } + }); + + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityLdapAuth.accessTokenTTL, + accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/ldap-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.LdapAuth], + description: "Attach LDAP Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(LDAP_AUTH.ATTACH.identityId) + }), + body: z + .object({ + url: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.url), + bindDN: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.bindDN), + bindPass: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.bindPass), + searchBase: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.searchBase), + searchFilter: z + .string() + .trim() + .min(1) + .default("(uid={{username}})") + .refine(isValidLdapFilter, "Invalid LDAP search filter") + .describe(LDAP_AUTH.ATTACH.searchFilter), + allowedFields: AllowedFieldsSchema.array().optional().describe(LDAP_AUTH.ATTACH.allowedFields), + ldapCaCertificate: z.string().trim().optional().describe(LDAP_AUTH.ATTACH.ldapCaCertificate), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) + .describe(LDAP_AUTH.ATTACH.accessTokenTrustedIps), + accessTokenTTL: z + .number() + .int() + .min(0) + .max(315360000) + .default(2592000) + .describe(LDAP_AUTH.ATTACH.accessTokenTTL), + accessTokenMaxTTL: z + .number() + .int() + .min(1) + .max(315360000) + .default(2592000) + .describe(LDAP_AUTH.ATTACH.accessTokenMaxTTL), + accessTokenNumUsesLimit: z.number().int().min(0).default(0).describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit) + }) + .refine( + (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, + "Access Token TTL cannot be greater than Access Token Max TTL." + ), + response: { + 200: z.object({ + identityLdapAuth: IdentityLdapAuthsSchema.omit({ + encryptedBindDN: true, + encryptedBindPass: true, + encryptedLdapCaCertificate: true + }) + }) + } + }, + handler: async (req) => { + const identityLdapAuth = await server.services.identityLdapAuth.attachLdapAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth) + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.ADD_IDENTITY_LDAP_AUTH, + metadata: { + identityId: req.params.identityId, + url: identityLdapAuth.url, + accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL, + accessTokenTTL: identityLdapAuth.accessTokenTTL, + accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, + allowedFields: req.body.allowedFields + } + } + }); + + return { identityLdapAuth }; + } + }); + + server.route({ + method: "PATCH", + url: "/ldap-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.LdapAuth], + description: "Update LDAP Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(LDAP_AUTH.UPDATE.identityId) + }), + body: z + .object({ + url: z.string().trim().min(1).optional().describe(LDAP_AUTH.UPDATE.url), + bindDN: z.string().trim().min(1).optional().describe(LDAP_AUTH.UPDATE.bindDN), + bindPass: z.string().trim().min(1).optional().describe(LDAP_AUTH.UPDATE.bindPass), + searchBase: z.string().trim().min(1).optional().describe(LDAP_AUTH.UPDATE.searchBase), + searchFilter: z + .string() + .trim() + .min(1) + .optional() + .refine((v) => v === undefined || isValidLdapFilter(v), "Invalid LDAP search filter") + .describe(LDAP_AUTH.UPDATE.searchFilter), + allowedFields: AllowedFieldsSchema.array().optional().describe(LDAP_AUTH.UPDATE.allowedFields), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .optional() + .describe(LDAP_AUTH.UPDATE.accessTokenTrustedIps), + accessTokenTTL: z.number().int().min(0).max(315360000).optional().describe(LDAP_AUTH.UPDATE.accessTokenTTL), + accessTokenNumUsesLimit: z + .number() + .int() + .min(0) + .optional() + .describe(LDAP_AUTH.UPDATE.accessTokenNumUsesLimit), + accessTokenMaxTTL: z + .number() + .int() + .max(315360000) + .min(0) + .optional() + .describe(LDAP_AUTH.UPDATE.accessTokenMaxTTL) + }) + .refine( + (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), + "Access Token TTL cannot be greater than Access Token Max TTL." + ), + response: { + 200: z.object({ + identityLdapAuth: IdentityLdapAuthsSchema.omit({ + encryptedBindDN: true, + encryptedBindPass: true, + encryptedLdapCaCertificate: true + }) + }) + } + }, + handler: async (req) => { + const identityLdapAuth = await server.services.identityLdapAuth.updateLdapAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.UPDATE_IDENTITY_LDAP_AUTH, + metadata: { + identityId: req.params.identityId, + url: identityLdapAuth.url, + accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL, + accessTokenTTL: identityLdapAuth.accessTokenTTL, + accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, + accessTokenTrustedIps: identityLdapAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + allowedFields: req.body.allowedFields + } + } + }); + + return { identityLdapAuth }; + } + }); + + server.route({ + method: "GET", + url: "/ldap-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.LdapAuth], + description: "Retrieve LDAP Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(LDAP_AUTH.RETRIEVE.identityId) + }), + response: { + 200: z.object({ + identityLdapAuth: IdentityLdapAuthsSchema.omit({ + encryptedBindDN: true, + encryptedBindPass: true, + encryptedLdapCaCertificate: true + }).extend({ + bindDN: z.string(), + bindPass: z.string(), + ldapCaCertificate: z.string().optional() + }) + }) + } + }, + handler: async (req) => { + const identityLdapAuth = await server.services.identityLdapAuth.getLdapAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GET_IDENTITY_LDAP_AUTH, + metadata: { + identityId: identityLdapAuth.identityId + } + } + }); + + return { identityLdapAuth }; + } + }); + + server.route({ + method: "DELETE", + url: "/ldap-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.LdapAuth], + description: "Delete LDAP Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(LDAP_AUTH.REVOKE.identityId) + }), + response: { + 200: z.object({ + identityLdapAuth: IdentityLdapAuthsSchema.omit({ + encryptedBindDN: true, + encryptedBindPass: true, + encryptedLdapCaCertificate: true + }) + }) + } + }, + handler: async (req) => { + const identityLdapAuth = await server.services.identityLdapAuth.revokeIdentityLdapAuth({ + 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: req.permission.orgId, + event: { + type: EventType.REVOKE_IDENTITY_LDAP_AUTH, + metadata: { + identityId: identityLdapAuth.identityId + } + } + }); + + return { identityLdapAuth }; + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-oci-auth-router.ts b/backend/src/server/routes/v1/identity-oci-auth-router.ts new file mode 100644 index 000000000..de9866c85 --- /dev/null +++ b/backend/src/server/routes/v1/identity-oci-auth-router.ts @@ -0,0 +1,338 @@ +import { z } from "zod"; + +import { IdentityOciAuthsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, OCI_AUTH } 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 { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { validateTenancy, validateUsernames } from "@app/services/identity-oci-auth/identity-oci-auth-validators"; +import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; + +export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/oci-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.OciAuth], + description: "Login with OCI Auth", + body: z.object({ + identityId: z.string().trim().describe(OCI_AUTH.LOGIN.identityId), + userOcid: z.string().trim().describe(OCI_AUTH.LOGIN.userOcid), + headers: z + .object({ + authorization: z.string(), + host: z.string(), + "x-date": z.string() + }) + .describe(OCI_AUTH.LOGIN.headers) + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identityOciAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityOciAuth.login(req.body); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, + event: { + type: EventType.LOGIN_IDENTITY_OCI_AUTH, + metadata: { + identityId: identityOciAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityOciAuthId: identityOciAuth.id + } + } + }); + + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityOciAuth.accessTokenTTL, + accessTokenMaxTTL: identityOciAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/oci-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.OciAuth], + description: "Attach OCI Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(OCI_AUTH.ATTACH.identityId) + }), + body: z + .object({ + tenancyOcid: validateTenancy.describe(OCI_AUTH.ATTACH.tenancyOcid), + allowedUsernames: validateUsernames.describe(OCI_AUTH.ATTACH.allowedUsernames), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) + .describe(OCI_AUTH.ATTACH.accessTokenTrustedIps), + accessTokenTTL: z + .number() + .int() + .min(0) + .max(315360000) + .default(2592000) + .describe(OCI_AUTH.ATTACH.accessTokenTTL), + accessTokenMaxTTL: z + .number() + .int() + .min(1) + .max(315360000) + .default(2592000) + .describe(OCI_AUTH.ATTACH.accessTokenMaxTTL), + accessTokenNumUsesLimit: z.number().int().min(0).default(0).describe(OCI_AUTH.ATTACH.accessTokenNumUsesLimit) + }) + .refine( + (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, + "Access Token TTL cannot be greater than Access Token Max TTL." + ), + response: { + 200: z.object({ + identityOciAuth: IdentityOciAuthsSchema + }) + } + }, + handler: async (req) => { + const identityOciAuth = await server.services.identityOciAuth.attachOciAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth) + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityOciAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_OCI_AUTH, + metadata: { + identityId: identityOciAuth.identityId, + tenancyOcid: identityOciAuth.tenancyOcid, + allowedUsernames: identityOciAuth.allowedUsernames || null, + accessTokenTTL: identityOciAuth.accessTokenTTL, + accessTokenMaxTTL: identityOciAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityOciAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityOciAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityOciAuth }; + } + }); + + server.route({ + method: "PATCH", + url: "/oci-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.OciAuth], + description: "Update OCI Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(OCI_AUTH.UPDATE.identityId) + }), + body: z + .object({ + tenancyOcid: validateTenancy.describe(OCI_AUTH.UPDATE.tenancyOcid), + allowedUsernames: validateUsernames.describe(OCI_AUTH.UPDATE.allowedUsernames), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .optional() + .describe(OCI_AUTH.UPDATE.accessTokenTrustedIps), + accessTokenTTL: z.number().int().min(0).max(315360000).optional().describe(OCI_AUTH.UPDATE.accessTokenTTL), + accessTokenNumUsesLimit: z.number().int().min(0).optional().describe(OCI_AUTH.UPDATE.accessTokenNumUsesLimit), + accessTokenMaxTTL: z + .number() + .int() + .max(315360000) + .min(0) + .optional() + .describe(OCI_AUTH.UPDATE.accessTokenMaxTTL) + }) + .refine( + (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), + "Access Token TTL cannot be greater than Access Token Max TTL." + ), + response: { + 200: z.object({ + identityOciAuth: IdentityOciAuthsSchema + }) + } + }, + handler: async (req) => { + const identityOciAuth = await server.services.identityOciAuth.updateOciAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId, + allowedUsernames: req.body.allowedUsernames || null + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityOciAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_OCI_AUTH, + metadata: { + identityId: identityOciAuth.identityId, + tenancyOcid: identityOciAuth.tenancyOcid, + allowedUsernames: identityOciAuth.allowedUsernames || null, + accessTokenTTL: identityOciAuth.accessTokenTTL, + accessTokenMaxTTL: identityOciAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityOciAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityOciAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityOciAuth }; + } + }); + + server.route({ + method: "GET", + url: "/oci-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.OciAuth], + description: "Retrieve OCI Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(OCI_AUTH.RETRIEVE.identityId) + }), + response: { + 200: z.object({ + identityOciAuth: IdentityOciAuthsSchema + }) + } + }, + handler: async (req) => { + const identityOciAuth = await server.services.identityOciAuth.getOciAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityOciAuth.orgId, + event: { + type: EventType.GET_IDENTITY_OCI_AUTH, + metadata: { + identityId: identityOciAuth.identityId + } + } + }); + return { identityOciAuth }; + } + }); + + server.route({ + method: "DELETE", + url: "/oci-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.OciAuth], + description: "Delete OCI Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(OCI_AUTH.REVOKE.identityId) + }), + response: { + 200: z.object({ + identityOciAuth: IdentityOciAuthsSchema + }) + } + }, + handler: async (req) => { + const identityOciAuth = await server.services.identityOciAuth.revokeIdentityOciAuth({ + 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: identityOciAuth.orgId, + event: { + type: EventType.REVOKE_IDENTITY_OCI_AUTH, + metadata: { + identityId: identityOciAuth.identityId + } + } + }); + + return { identityOciAuth }; + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index 7731aad98..0e127796a 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -52,7 +52,8 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ identity: IdentitiesSchema.extend({ - authMethods: z.array(z.string()) + authMethods: z.array(z.string()), + metadata: z.object({ id: z.string(), key: z.string(), value: z.string() }).array() }) }) } @@ -123,7 +124,9 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - identity: IdentitiesSchema + identity: IdentitiesSchema.extend({ + metadata: z.object({ id: z.string(), key: z.string(), value: z.string() }).array() + }) }) } }, @@ -227,8 +230,8 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { identity: IdentityOrgMembershipsSchema.extend({ metadata: z .object({ - key: z.string().trim().min(1), id: z.string().trim().min(1), + key: z.string().trim().min(1), value: z.string().trim().min(1) }) .array() diff --git a/backend/src/server/routes/v1/identity-universal-auth-router.ts b/backend/src/server/routes/v1/identity-universal-auth-router.ts index 6fe4c7a85..09fffbff8 100644 --- a/backend/src/server/routes/v1/identity-universal-auth-router.ts +++ b/backend/src/server/routes/v1/identity-universal-auth-router.ts @@ -47,8 +47,15 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { identityUa, accessToken, identityAccessToken, validClientSecretInfo, identityMembershipOrg } = - await server.services.identityUa.login(req.body.clientId, req.body.clientSecret, req.realIp); + const { + identityUa, + accessToken, + identityAccessToken, + validClientSecretInfo, + identityMembershipOrg, + accessTokenTTL, + accessTokenMaxTTL + } = await server.services.identityUa.login(req.body.clientId, req.body.clientSecret, req.realIp); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, @@ -63,11 +70,12 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { } } }); + return { accessToken, tokenType: "Bearer" as const, - expiresIn: identityUa.accessTokenTTL, - accessTokenMaxTTL: identityUa.accessTokenMaxTTL + expiresIn: accessTokenTTL, + accessTokenMaxTTL }; } }); @@ -128,7 +136,8 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { .int() .min(0) .default(0) - .describe(UNIVERSAL_AUTH.ATTACH.accessTokenNumUsesLimit) + .describe(UNIVERSAL_AUTH.ATTACH.accessTokenNumUsesLimit), + accessTokenPeriod: z.number().int().min(0).default(0).describe(UNIVERSAL_AUTH.ATTACH.accessTokenPeriod) }) .refine( (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, @@ -227,7 +236,14 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { .min(0) .max(315360000) .optional() - .describe(UNIVERSAL_AUTH.UPDATE.accessTokenMaxTTL) + .describe(UNIVERSAL_AUTH.UPDATE.accessTokenMaxTTL), + accessTokenPeriod: z + .number() + .int() + .min(0) + .max(315360000) + .optional() + .describe(UNIVERSAL_AUTH.UPDATE.accessTokenPeriod) }) .refine( (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index a50299555..76cf8761f 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -10,6 +10,7 @@ import { registerAdminRouter } from "./admin-router"; import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; 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 { registerExternalGroupOrgRoleMappingRouter } from "./external-group-org-role-mapping-router"; @@ -19,6 +20,8 @@ import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router"; import { registerIdentityGcpAuthRouter } from "./identity-gcp-auth-router"; import { registerIdentityJwtAuthRouter } from "./identity-jwt-auth-router"; import { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-router"; +import { registerIdentityLdapAuthRouter } from "./identity-ldap-auth-router"; +import { registerIdentityOciAuthRouter } from "./identity-oci-auth-router"; import { registerIdentityOidcAuthRouter } from "./identity-oidc-auth-router"; import { registerIdentityRouter } from "./identity-router"; import { registerIdentityTokenAuthRouter } from "./identity-token-auth-router"; @@ -32,6 +35,7 @@ import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; import { registerPkiAlertRouter } from "./pki-alert-router"; import { registerPkiCollectionRouter } from "./pki-collection-router"; +import { registerPkiSubscriberRouter } from "./pki-subscriber-router"; import { registerProjectEnvRouter } from "./project-env-router"; import { registerProjectKeyRouter } from "./project-key-router"; import { registerProjectMembershipRouter } from "./project-membership-router"; @@ -61,8 +65,10 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await authRouter.register(registerIdentityAccessTokenRouter); await authRouter.register(registerIdentityAwsAuthRouter); await authRouter.register(registerIdentityAzureAuthRouter); + await authRouter.register(registerIdentityOciAuthRouter); await authRouter.register(registerIdentityOidcAuthRouter); await authRouter.register(registerIdentityJwtAuthRouter); + await authRouter.register(registerIdentityLdapAuthRouter); }, { prefix: "/auth" } ); @@ -99,10 +105,21 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register( async (pkiRouter) => { await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); + await pkiRouter.register( + async (caRouter) => { + for await (const [caType, router] of Object.entries(CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP)) { + await caRouter.register(router, { prefix: `/${caType}` }); + } + }, + { + prefix: "/ca" + } + ); await pkiRouter.register(registerCertRouter, { prefix: "/certificates" }); await pkiRouter.register(registerCertificateTemplateRouter, { prefix: "/certificate-templates" }); await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" }); await pkiRouter.register(registerPkiCollectionRouter, { prefix: "/collections" }); + await pkiRouter.register(registerPkiSubscriberRouter, { prefix: "/subscribers" }); }, { prefix: "/pki" } ); diff --git a/backend/src/server/routes/v1/invite-org-router.ts b/backend/src/server/routes/v1/invite-org-router.ts index 501bebdab..525d51913 100644 --- a/backend/src/server/routes/v1/invite-org-router.ts +++ b/backend/src/server/routes/v1/invite-org-router.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { OrgMembershipRole, ProjectMembershipRole, UsersSchema } from "@app/db/schemas"; -import { inviteUserRateLimit } from "@app/server/config/rateLimiter"; +import { inviteUserRateLimit, smtpRateLimit } from "@app/server/config/rateLimiter"; 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"; @@ -11,12 +11,17 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { server.route({ url: "/signup", config: { - rateLimit: inviteUserRateLimit + rateLimit: smtpRateLimit() }, method: "POST", schema: { body: z.object({ - inviteeEmails: z.array(z.string().trim().email()), + inviteeEmails: z + .string() + .trim() + .email() + .array() + .refine((val) => val.every((el) => el === el.toLowerCase()), "Email must be lowercase"), organizationId: z.string().trim(), projects: z .object({ @@ -76,7 +81,10 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { server.route({ url: "/signup-resend", config: { - rateLimit: inviteUserRateLimit + rateLimit: smtpRateLimit({ + keyGenerator: (req) => + (req.body as { membershipId?: string })?.membershipId?.trim().substring(0, 100) ?? req.realIp + }) }, method: "POST", schema: { @@ -115,7 +123,11 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { }, schema: { body: z.object({ - email: z.string().trim().email(), + email: z + .string() + .trim() + .email() + .refine((val) => val === val.toLowerCase(), "Email must be lowercase"), organizationId: z.string().trim(), code: z.string().trim() }), diff --git a/backend/src/server/routes/v1/org-admin-router.ts b/backend/src/server/routes/v1/org-admin-router.ts index 2d28b09bd..d4b1ee188 100644 --- a/backend/src/server/routes/v1/org-admin-router.ts +++ b/backend/src/server/routes/v1/org-admin-router.ts @@ -2,9 +2,9 @@ import { z } from "zod"; import { ProjectMembershipsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { readLimit } from "@app/server/config/rateLimiter"; +import { readLimit, smtpRateLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { AuthMode } from "@app/services/auth/auth-type"; +import { ActorType, AuthMode } from "@app/services/auth/auth-type"; import { SanitizedProjectSchema } from "../sanitizedSchemas"; @@ -47,7 +47,9 @@ export const registerOrgAdminRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/projects/:projectId/grant-admin-access", config: { - rateLimit: readLimit + rateLimit: smtpRateLimit({ + keyGenerator: (req) => (req.auth.actor === ActorType.USER ? req.auth.userId : req.realIp) + }) }, schema: { params: z.object({ diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index da1a251ff..b3fceb201 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -275,6 +275,23 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }, { message: "Duration value must be at least 1" } ) + .optional(), + secretsProductEnabled: z.boolean().optional(), + pkiProductEnabled: z.boolean().optional(), + kmsProductEnabled: z.boolean().optional(), + sshProductEnabled: z.boolean().optional(), + scannerProductEnabled: z.boolean().optional(), + shareSecretsProductEnabled: z.boolean().optional(), + maxSharedSecretLifetime: z + .number() + .min(300, "Max Shared Secret lifetime cannot be under 5 minutes") + .max(2592000, "Max Shared Secret lifetime cannot exceed 30 days") + .optional(), + maxSharedSecretViewLimit: z + .number() + .min(1, "Max Shared Secret view count cannot be lower than 1") + .max(1000, "Max Shared Secret view count cannot exceed 1000") + .nullable() .optional() }), response: { @@ -295,8 +312,17 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { data: req.body }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.UPDATE_ORG, + metadata: req.body + } + }); + return { - message: "Successfully changed organization name", + message: "Successfully updated organization", organization }; } diff --git a/backend/src/server/routes/v1/password-router.ts b/backend/src/server/routes/v1/password-router.ts index 724468e02..eeb730f29 100644 --- a/backend/src/server/routes/v1/password-router.ts +++ b/backend/src/server/routes/v1/password-router.ts @@ -2,10 +2,10 @@ import { z } from "zod"; import { BackupPrivateKeySchema, UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; -import { authRateLimit } from "@app/server/config/rateLimiter"; +import { authRateLimit, smtpRateLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { validateSignUpAuthorization } from "@app/services/auth/auth-fns"; -import { AuthMode } from "@app/services/auth/auth-type"; +import { ActorType, AuthMode } from "@app/services/auth/auth-type"; import { UserEncryption } from "@app/services/user/user-types"; export const registerPasswordRouter = async (server: FastifyZodProvider) => { @@ -80,7 +80,9 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/email/password-reset", config: { - rateLimit: authRateLimit + rateLimit: smtpRateLimit({ + keyGenerator: (req) => (req.body as { email?: string })?.email?.trim().substring(0, 100) ?? req.realIp + }) }, schema: { body: z.object({ @@ -224,7 +226,9 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/email/password-setup", config: { - rateLimit: authRateLimit + rateLimit: smtpRateLimit({ + keyGenerator: (req) => (req.auth.actor === ActorType.USER ? req.auth.userId : req.realIp) + }) }, schema: { response: { @@ -233,6 +237,7 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { }) } }, + onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { await server.services.password.sendPasswordSetupEmail(req.permission); @@ -267,6 +272,7 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { }) } }, + onRequest: verifyAuth([AuthMode.JWT]), handler: async (req, res) => { await server.services.password.setupPassword(req.body, req.permission); diff --git a/backend/src/server/routes/v1/pki-subscriber-router.ts b/backend/src/server/routes/v1/pki-subscriber-router.ts new file mode 100644 index 000000000..761904fd1 --- /dev/null +++ b/backend/src/server/routes/v1/pki-subscriber-router.ts @@ -0,0 +1,611 @@ +import { z } from "zod"; + +import { CertificatesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, PKI_SUBSCRIBERS } from "@app/lib/api-docs"; +import { ms } from "@app/lib/ms"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { addNoCacheHeaders } from "@app/server/lib/caching"; +import { slugSchema } from "@app/server/lib/schemas"; +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 { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { validateAltNameField } from "@app/services/certificate-authority/certificate-authority-validators"; +import { sanitizedPkiSubscriber } from "@app/services/pki-subscriber/pki-subscriber-schema"; +import { PkiSubscriberStatus } from "@app/services/pki-subscriber/pki-subscriber-types"; +import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; + +export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:subscriberName", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSubscribers], + description: "Get PKI Subscriber", + params: z.object({ + subscriberName: z.string().describe(PKI_SUBSCRIBERS.GET.subscriberName) + }), + querystring: z.object({ + projectId: z.string().describe(PKI_SUBSCRIBERS.GET.projectId) + }), + response: { + 200: sanitizedPkiSubscriber + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const subscriber = await server.services.pkiSubscriber.getSubscriber({ + subscriberName: req.params.subscriberName, + projectId: req.query.projectId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: subscriber.projectId, + event: { + type: EventType.GET_PKI_SUBSCRIBER, + metadata: { + pkiSubscriberId: subscriber.id, + name: subscriber.name + } + } + }); + + return subscriber; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSubscribers], + description: "Create PKI Subscriber", + body: z.object({ + projectId: z.string().trim().describe(PKI_SUBSCRIBERS.CREATE.projectId), + caId: z + .string() + .trim() + .uuid("CA ID must be a valid UUID") + .min(1, "CA ID is required") + .describe(PKI_SUBSCRIBERS.CREATE.caId), + name: slugSchema({ min: 1, max: 64, field: "name" }).describe(PKI_SUBSCRIBERS.CREATE.name), + commonName: z.string().trim().min(1).describe(PKI_SUBSCRIBERS.CREATE.commonName), + status: z + .nativeEnum(PkiSubscriberStatus) + .default(PkiSubscriberStatus.ACTIVE) + .describe(PKI_SUBSCRIBERS.CREATE.status), + ttl: z + .string() + .trim() + .refine((val) => !val || ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(PKI_SUBSCRIBERS.CREATE.ttl), + subjectAlternativeNames: validateAltNameField + .array() + .default([]) + .transform((arr) => Array.from(new Set(arr))) + .describe(PKI_SUBSCRIBERS.CREATE.subjectAlternativeNames), + keyUsages: z + .nativeEnum(CertKeyUsage) + .array() + .default([CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]) + .transform((arr) => Array.from(new Set(arr))) + .describe(PKI_SUBSCRIBERS.CREATE.keyUsages), + extendedKeyUsages: z + .nativeEnum(CertExtendedKeyUsage) + .array() + .default([]) + .transform((arr) => Array.from(new Set(arr))) + .describe(PKI_SUBSCRIBERS.CREATE.extendedKeyUsages), + enableAutoRenewal: z.boolean().optional().describe(PKI_SUBSCRIBERS.CREATE.enableAutoRenewal), + autoRenewalPeriodInDays: z.number().min(1).optional().describe(PKI_SUBSCRIBERS.CREATE.autoRenewalPeriodInDays) + }), + response: { + 200: sanitizedPkiSubscriber + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const subscriber = await server.services.pkiSubscriber.createSubscriber({ + ...req.body, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: subscriber.projectId, + event: { + type: EventType.CREATE_PKI_SUBSCRIBER, + metadata: { + pkiSubscriberId: subscriber.id, + caId: subscriber.caId ?? undefined, + name: subscriber.name, + commonName: subscriber.commonName, + ttl: subscriber.ttl ?? undefined, + subjectAlternativeNames: subscriber.subjectAlternativeNames, + keyUsages: subscriber.keyUsages as CertKeyUsage[], + extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[] + } + } + }); + + return subscriber; + } + }); + + server.route({ + method: "PATCH", + url: "/:subscriberName", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiSubscribers], + description: "Update PKI Subscriber", + params: z.object({ + subscriberName: z.string().trim().describe(PKI_SUBSCRIBERS.UPDATE.subscriberName) + }), + body: z.object({ + projectId: z.string().trim().describe(PKI_SUBSCRIBERS.UPDATE.projectId), + caId: z + .string() + .trim() + .uuid("CA ID must be a valid UUID") + .min(1, "CA ID is required") + .optional() + .describe(PKI_SUBSCRIBERS.UPDATE.caId), + name: slugSchema({ min: 1, max: 64, field: "name" }).describe(PKI_SUBSCRIBERS.UPDATE.name).optional(), + commonName: z.string().trim().min(1).describe(PKI_SUBSCRIBERS.UPDATE.commonName).optional(), + status: z.nativeEnum(PkiSubscriberStatus).optional().describe(PKI_SUBSCRIBERS.UPDATE.status), + subjectAlternativeNames: validateAltNameField + .array() + .optional() + .describe(PKI_SUBSCRIBERS.UPDATE.subjectAlternativeNames), + ttl: z + .string() + .trim() + .refine((val) => !val || ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(PKI_SUBSCRIBERS.UPDATE.ttl), + keyUsages: z + .nativeEnum(CertKeyUsage) + .array() + .transform((arr) => Array.from(new Set(arr))) + .optional() + .describe(PKI_SUBSCRIBERS.UPDATE.keyUsages), + extendedKeyUsages: z + .nativeEnum(CertExtendedKeyUsage) + .array() + .transform((arr) => Array.from(new Set(arr))) + .optional() + .describe(PKI_SUBSCRIBERS.UPDATE.extendedKeyUsages), + enableAutoRenewal: z.boolean().optional().describe(PKI_SUBSCRIBERS.UPDATE.enableAutoRenewal), + autoRenewalPeriodInDays: z.number().min(1).optional().describe(PKI_SUBSCRIBERS.UPDATE.autoRenewalPeriodInDays) + }), + response: { + 200: sanitizedPkiSubscriber + } + }, + handler: async (req) => { + const subscriber = await server.services.pkiSubscriber.updateSubscriber({ + subscriberName: req.params.subscriberName, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: subscriber.projectId, + event: { + type: EventType.UPDATE_PKI_SUBSCRIBER, + metadata: { + pkiSubscriberId: subscriber.id, + caId: subscriber.caId ?? undefined, + name: subscriber.name, + commonName: subscriber.commonName, + ttl: subscriber.ttl ?? undefined, + subjectAlternativeNames: subscriber.subjectAlternativeNames, + keyUsages: subscriber.keyUsages as CertKeyUsage[], + extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[] + } + } + }); + + return subscriber; + } + }); + + server.route({ + method: "DELETE", + url: "/:subscriberName", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSubscribers], + description: "Delete PKI Subscriber", + params: z.object({ + subscriberName: z.string().describe(PKI_SUBSCRIBERS.DELETE.subscriberName) + }), + body: z.object({ + projectId: z.string().trim().describe(PKI_SUBSCRIBERS.DELETE.projectId) + }), + response: { + 200: sanitizedPkiSubscriber + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const subscriber = await server.services.pkiSubscriber.deleteSubscriber({ + subscriberName: req.params.subscriberName, + projectId: req.body.projectId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: subscriber.projectId, + event: { + type: EventType.DELETE_PKI_SUBSCRIBER, + metadata: { + pkiSubscriberId: subscriber.id, + name: subscriber.name + } + } + }); + + return subscriber; + } + }); + + server.route({ + method: "POST", + url: "/:subscriberName/order-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiSubscribers], + description: "Order certificate", + params: z.object({ + subscriberName: z.string().describe(PKI_SUBSCRIBERS.ISSUE_CERT.subscriberName) + }), + body: z.object({ + projectId: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.projectId) + }), + response: { + 200: z.object({ + message: z.string().trim() + }) + } + }, + handler: async (req) => { + const subscriber = await server.services.pkiSubscriber.orderSubscriberCert({ + subscriberName: req.params.subscriberName, + projectId: req.body.projectId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: subscriber.projectId, + event: { + type: EventType.ISSUE_PKI_SUBSCRIBER_CERT, + metadata: { + subscriberId: subscriber.id, + name: subscriber.name + } + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.IssueCert, + distinctId: getTelemetryDistinctId(req), + properties: { + subscriberId: subscriber.id, + commonName: subscriber.commonName, + ...req.auditLogInfo + } + }); + + return { + message: "Successfully placed order for certificate" + }; + } + }); + + server.route({ + method: "POST", + url: "/:subscriberName/issue-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiSubscribers], + description: "Issue certificate", + params: z.object({ + subscriberName: z.string().describe(PKI_SUBSCRIBERS.ISSUE_CERT.subscriberName) + }), + body: z.object({ + projectId: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.projectId) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.certificate), + issuingCaCertificate: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.issuingCaCertificate), + certificateChain: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.certificateChain), + privateKey: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.privateKey), + serialNumber: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber, subscriber } = + await server.services.pkiSubscriber.issueSubscriberCert({ + subscriberName: req.params.subscriberName, + projectId: req.body.projectId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: subscriber.projectId, + event: { + type: EventType.ISSUE_PKI_SUBSCRIBER_CERT, + metadata: { + subscriberId: subscriber.id, + name: subscriber.name, + serialNumber + } + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.IssueCert, + distinctId: getTelemetryDistinctId(req), + properties: { + subscriberId: subscriber.id, + commonName: subscriber.commonName, + ...req.auditLogInfo + } + }); + + return { + certificate, + certificateChain, + issuingCaCertificate, + privateKey, + serialNumber + }; + } + }); + + server.route({ + method: "POST", + url: "/:subscriberName/sign-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiSubscribers], + description: "Sign certificate", + params: z.object({ + subscriberName: z.string().describe(PKI_SUBSCRIBERS.SIGN_CERT.subscriberName) + }), + body: z.object({ + projectId: z.string().trim().describe(PKI_SUBSCRIBERS.SIGN_CERT.projectId), + csr: z.string().trim().min(1).max(3000).describe(PKI_SUBSCRIBERS.SIGN_CERT.csr) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(PKI_SUBSCRIBERS.SIGN_CERT.certificate), + issuingCaCertificate: z.string().trim().describe(PKI_SUBSCRIBERS.SIGN_CERT.issuingCaCertificate), + certificateChain: z.string().trim().describe(PKI_SUBSCRIBERS.SIGN_CERT.certificateChain), + serialNumber: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, issuingCaCertificate, serialNumber, subscriber } = + await server.services.pkiSubscriber.signSubscriberCert({ + subscriberName: req.params.subscriberName, + projectId: req.body.projectId, + csr: req.body.csr, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: subscriber.projectId, + event: { + type: EventType.SIGN_PKI_SUBSCRIBER_CERT, + metadata: { + subscriberId: subscriber.id, + name: subscriber.name, + serialNumber + } + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SignCert, + distinctId: getTelemetryDistinctId(req), + properties: { + subscriberId: subscriber.id, + commonName: subscriber.commonName, + ...req.auditLogInfo + } + }); + + return { + certificate, + certificateChain, + issuingCaCertificate, + serialNumber + }; + } + }); + + server.route({ + method: "GET", + url: "/:subscriberName/latest-certificate-bundle", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSubscribers], + description: "Get latest certificate bundle of a subscriber", + params: z.object({ + subscriberName: z.string().describe(PKI_SUBSCRIBERS.GET_LATEST_CERT_BUNDLE.subscriberName) + }), + querystring: z.object({ + projectId: z.string().trim().describe(PKI_SUBSCRIBERS.GET_LATEST_CERT_BUNDLE.projectId) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(PKI_SUBSCRIBERS.GET_LATEST_CERT_BUNDLE.certificate), + certificateChain: z + .string() + .trim() + .nullable() + .describe(PKI_SUBSCRIBERS.GET_LATEST_CERT_BUNDLE.certificateChain), + privateKey: z.string().trim().describe(PKI_SUBSCRIBERS.GET_LATEST_CERT_BUNDLE.privateKey), + serialNumber: z.string().trim().describe(PKI_SUBSCRIBERS.GET_LATEST_CERT_BUNDLE.serialNumber) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req, reply) => { + const { certificate, certificateChain, serialNumber, cert, privateKey, subscriber } = + await server.services.pkiSubscriber.getSubscriberActiveCertBundle({ + subscriberName: req.params.subscriberName, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: cert.projectId, + event: { + type: EventType.GET_SUBSCRIBER_ACTIVE_CERT_BUNDLE, + metadata: { + subscriberId: subscriber.id, + name: subscriber.name, + certId: cert.id, + serialNumber: cert.serialNumber + } + } + }); + + addNoCacheHeaders(reply); + + return { + certificate, + certificateChain, + serialNumber, + privateKey + }; + } + }); + + server.route({ + method: "GET", + url: "/:subscriberName/certificates", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSubscribers], + description: "List PKI Subscriber certificates", + params: z.object({ + subscriberName: z.string().describe(PKI_SUBSCRIBERS.GET.subscriberName) + }), + querystring: z.object({ + projectId: z.string().trim().describe(PKI_SUBSCRIBERS.LIST_CERTS.projectId), + offset: z.coerce.number().min(0).max(100).default(0).describe(PKI_SUBSCRIBERS.LIST_CERTS.offset), + limit: z.coerce.number().min(1).max(100).default(25).describe(PKI_SUBSCRIBERS.LIST_CERTS.limit) + }), + response: { + 200: z.object({ + certificates: z.array(CertificatesSchema), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { totalCount, certificates } = await server.services.pkiSubscriber.listSubscriberCerts({ + subscriberName: req.params.subscriberName, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.LIST_PKI_SUBSCRIBER_CERTS, + metadata: { + subscriberId: req.params.subscriberName, + name: req.params.subscriberName, + projectId: req.query.projectId + } + } + }); + + return { + certificates, + totalCount + }; + } + }); +}; diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index fdc729548..2a868864e 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -19,7 +19,7 @@ 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 { readLimit, requestAccessLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode } from "@app/services/auth/auth-type"; import { validateMicrosoftTeamsChannelsSchema } from "@app/services/microsoft-teams/microsoft-teams-fns"; @@ -160,7 +160,14 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { .default("false") .transform((value) => value === "true"), type: z - .enum([ProjectType.SecretManager, ProjectType.KMS, ProjectType.CertificateManager, ProjectType.SSH, "all"]) + .enum([ + ProjectType.SecretManager, + ProjectType.KMS, + ProjectType.CertificateManager, + ProjectType.SSH, + ProjectType.SecretScanning, + "all" + ]) .optional() }), response: { @@ -173,7 +180,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + 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, @@ -263,6 +270,17 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { 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 }; } }); @@ -297,6 +315,17 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { 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 @@ -346,7 +375,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { "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) + .describe(PROJECTS.UPDATE.slug), + secretSharing: z.boolean().optional().describe(PROJECTS.UPDATE.secretSharing) }), response: { 200: z.object({ @@ -366,13 +396,25 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { description: req.body.description, autoCapitalization: req.body.autoCapitalization, hasDeleteProtection: req.body.hasDeleteProtection, - slug: req.body.slug + slug: req.body.slug, + secretSharing: req.body.secretSharing }, 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 }; @@ -409,6 +451,17 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { 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 @@ -446,6 +499,17 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { 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 @@ -484,6 +548,16 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { 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 @@ -511,7 +585,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const workspace = await server.services.project.updateAuditLogsRetention({ actorId: req.permission.id, @@ -522,6 +596,16 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { 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 @@ -1006,7 +1090,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/:workspaceId/project-access", config: { - rateLimit: writeLimit + rateLimit: requestAccessLimit }, schema: { params: z.object({ diff --git a/backend/src/server/routes/v1/secret-sharing-router.ts b/backend/src/server/routes/v1/secret-sharing-router.ts index 37c8a052f..653103938 100644 --- a/backend/src/server/routes/v1/secret-sharing-router.ts +++ b/backend/src/server/routes/v1/secret-sharing-router.ts @@ -88,7 +88,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => sharedSecretId: req.params.id, hashedHex: req.body.hashedHex, password: req.body.password, - orgId: req.permission?.orgId + orgId: req.permission?.orgId, + actorId: req.permission?.id }); if (sharedSecret.secret?.orgId) { @@ -151,7 +152,14 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => secretValue: z.string(), expiresAt: z.string(), expiresAfterViews: z.number().min(1).optional(), - accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization) + accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization), + emails: z + .string() + .email() + .array() + .max(100) + .optional() + .transform((val) => (val ? [...new Set(val)] : undefined)) }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/secret-sync-routers/1password-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/1password-sync-router.ts new file mode 100644 index 000000000..a6f5cc73f --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/1password-sync-router.ts @@ -0,0 +1,17 @@ +import { + CreateOnePassSyncSchema, + OnePassSyncSchema, + UpdateOnePassSyncSchema +} from "@app/services/secret-sync/1password"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerOnePassSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.OnePass, + server, + responseSchema: OnePassSyncSchema, + createSchema: CreateOnePassSyncSchema, + updateSchema: UpdateOnePassSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index 75b3ac68e..fbc636ffc 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -1,5 +1,7 @@ +import { registerOCIVaultSyncRouter } from "@app/ee/routes/v1/secret-sync-routers/oci-vault-sync-router"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { registerOnePassSyncRouter } from "./1password-sync-router"; import { registerAwsParameterStoreSyncRouter } from "./aws-parameter-store-sync-router"; import { registerAwsSecretsManagerSyncRouter } from "./aws-secrets-manager-sync-router"; import { registerAzureAppConfigurationSyncRouter } from "./azure-app-configuration-sync-router"; @@ -31,5 +33,7 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index a97f11be4..a0c3592f7 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -46,6 +46,54 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/duplicate-accounts", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + users: UsersSchema.extend({ + isMyAccount: z.boolean(), + organizations: z.object({ name: z.string(), slug: z.string() }).array() + }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }), + handler: async (req) => { + if (req.auth.authMode === AuthMode.JWT && req.auth.user.email) { + const users = await server.services.user.getAllMyAccounts(req.auth.user.email, req.permission.id); + return { users }; + } + return { users: [] }; + } + }); + + server.route({ + method: "POST", + url: "/remove-duplicate-accounts", + config: { + rateLimit: writeLimit + }, + schema: { + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }), + handler: async (req) => { + if (req.auth.authMode === AuthMode.JWT && req.auth.user.email) { + await server.services.user.removeMyDuplicateAccounts(req.auth.user.email, req.permission.id); + } + return { message: "Removed all duplicate accounts" }; + } + }); + server.route({ method: "GET", url: "/private-key", diff --git a/backend/src/server/routes/v2/certificate-authority-router.ts b/backend/src/server/routes/v2/certificate-authority-router.ts new file mode 100644 index 000000000..d8b434fdf --- /dev/null +++ b/backend/src/server/routes/v2/certificate-authority-router.ts @@ -0,0 +1,71 @@ +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 { AuthMode } from "@app/services/auth/auth-type"; +import { AcmeCertificateAuthoritySchema } from "@app/services/certificate-authority/acme/acme-certificate-authority-schemas"; +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { InternalCertificateAuthoritySchema } from "@app/services/certificate-authority/internal/internal-certificate-authority-schemas"; + +const CertificateAuthoritySchema = z.discriminatedUnion("type", [ + InternalCertificateAuthoritySchema, + AcmeCertificateAuthoritySchema +]); + +export const registerCaRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + description: "Get Certificate Authorities", + querystring: z.object({ + projectId: z.string() + }), + response: { + 200: z.object({ + certificateAuthorities: CertificateAuthoritySchema.array() + }) + } + }, + handler: async (req) => { + const internalCas = await server.services.certificateAuthority.listCertificateAuthoritiesByProjectId( + { + projectId: req.query.projectId, + type: CaType.INTERNAL + }, + req.permission + ); + + const acmeCas = await server.services.certificateAuthority.listCertificateAuthoritiesByProjectId( + { + projectId: req.query.projectId, + type: CaType.ACME + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.GET_CAS, + metadata: { + caIds: [...(internalCas ?? []).map((ca) => ca.id), ...(acmeCas ?? []).map((ca) => ca.id)] + } + } + }); + + return { + certificateAuthorities: [...(internalCas ?? []), ...(acmeCas ?? [])] + }; + } + }); +}; diff --git a/backend/src/server/routes/v2/group-project-router.ts b/backend/src/server/routes/v2/group-project-router.ts index 5a081a3d9..d07e3bd8b 100644 --- a/backend/src/server/routes/v2/group-project-router.ts +++ b/backend/src/server/routes/v2/group-project-router.ts @@ -4,9 +4,11 @@ import { GroupProjectMembershipsSchema, GroupsSchema, ProjectMembershipRole, - ProjectUserMembershipRolesSchema + ProjectUserMembershipRolesSchema, + UsersSchema } from "@app/db/schemas"; -import { ApiDocsTags, PROJECTS } from "@app/lib/api-docs"; +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"; @@ -301,4 +303,61 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) => 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/index.ts b/backend/src/server/routes/v2/index.ts index cece502da..93c422d15 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -1,9 +1,11 @@ +import { registerCaRouter } from "./certificate-authority-router"; import { registerGroupProjectRouter } from "./group-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 { registerServiceTokenRouter } from "./service-token-router"; @@ -14,6 +16,15 @@ export const registerV2Routes = async (server: FastifyZodProvider) => { await server.register(registerUserRouter, { prefix: "/users" }); await server.register(registerServiceTokenRouter, { prefix: "/service-token" }); await server.register(registerPasswordRouter, { prefix: "/password" }); + + await server.register( + async (pkiRouter) => { + await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); + await pkiRouter.register(registerPkiTemplatesRouter, { prefix: "/certificate-templates" }); + }, + { prefix: "/pki" } + ); + await server.register( async (orgRouter) => { await orgRouter.register(registerOrgRouter); diff --git a/backend/src/server/routes/v2/pki-templates-router.ts b/backend/src/server/routes/v2/pki-templates-router.ts new file mode 100644 index 000000000..e481af0a2 --- /dev/null +++ b/backend/src/server/routes/v2/pki-templates-router.ts @@ -0,0 +1,309 @@ +import { z } from "zod"; + +import { CertificateTemplatesSchema } from "@app/db/schemas"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { ms } from "@app/lib/ms"; +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"; +import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { + validateAltNamesField, + validateCaDateField +} from "@app/services/certificate-authority/certificate-authority-validators"; +import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; + +export const registerPkiTemplatesRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + body: z.object({ + name: slugSchema(), + caName: slugSchema({ field: "caName" }), + projectId: z.string(), + commonName: validateTemplateRegexField, + subjectAlternativeName: validateTemplateRegexField, + ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), + keyUsages: z + .nativeEnum(CertKeyUsage) + .array() + .optional() + .default([CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional().default([]) + }), + response: { + 200: z.object({ + certificateTemplate: CertificateTemplatesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.pkiTemplate.createTemplate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "PATCH", + url: "/:templateName", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + templateName: slugSchema() + }), + body: z.object({ + name: slugSchema().optional(), + caName: slugSchema(), + projectId: z.string(), + commonName: validateTemplateRegexField.optional(), + subjectAlternativeName: validateTemplateRegexField.optional(), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional(), + keyUsages: z + .nativeEnum(CertKeyUsage) + .array() + .optional() + .default([CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional().default([]) + }), + response: { + 200: z.object({ + certificateTemplate: CertificateTemplatesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.pkiTemplate.updateTemplate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + templateName: req.params.templateName, + ...req.body + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "DELETE", + url: "/:templateName", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + templateName: z.string().min(1) + }), + body: z.object({ + projectId: z.string() + }), + response: { + 200: z.object({ + certificateTemplate: CertificateTemplatesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.pkiTemplate.deleteTemplate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + templateName: req.params.templateName, + projectId: req.body.projectId + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "GET", + url: "/:templateName", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + templateName: slugSchema() + }), + querystring: z.object({ + projectId: z.string() + }), + response: { + 200: z.object({ + certificateTemplate: CertificateTemplatesSchema.extend({ + ca: z.object({ id: z.string(), name: z.string() }) + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.pkiTemplate.getTemplateByName({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + templateName: req.params.templateName, + projectId: req.query.projectId + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + querystring: z.object({ + projectId: z.string(), + limit: z.coerce.number().default(100), + offset: z.coerce.number().default(0) + }), + response: { + 200: z.object({ + certificateTemplates: CertificateTemplatesSchema.extend({ + ca: z.object({ id: z.string(), name: z.string() }) + }).array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplates, totalCount } = await server.services.pkiTemplate.listTemplate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + return { certificateTemplates, totalCount }; + } + }); + + server.route({ + method: "POST", + url: "/:templateName/issue-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + templateName: slugSchema() + }), + body: z.object({ + projectId: z.string(), + commonName: validateTemplateRegexField, + ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), + keyUsages: z.nativeEnum(CertKeyUsage).array().optional(), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional(), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + altNames: validateAltNamesField + }), + response: { + 200: z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + privateKey: z.string().trim(), + serialNumber: z.string().trim() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.pkiTemplate.issueCertificate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + templateName: req.params.templateName, + ...req.body + }); + + return data; + } + }); + + server.route({ + method: "POST", + url: "/:templateName/sign-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + templateName: slugSchema() + }), + body: z.object({ + projectId: z.string(), + ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), + csr: z.string().trim().min(1).max(4096) + }), + response: { + 200: z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + serialNumber: z.string().trim() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.pkiTemplate.signCertificate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + templateName: req.params.templateName, + ...req.body + }); + + return data; + } + }); +}; diff --git a/backend/src/server/routes/v2/project-membership-router.ts b/backend/src/server/routes/v2/project-membership-router.ts index a1a1cfc96..76f1e9c5e 100644 --- a/backend/src/server/routes/v2/project-membership-router.ts +++ b/backend/src/server/routes/v2/project-membership-router.ts @@ -27,8 +27,19 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider projectId: z.string().describe(PROJECT_USERS.INVITE_MEMBER.projectId) }), body: z.object({ - emails: z.string().email().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.emails), - usernames: z.string().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.usernames), + emails: z + .string() + .email() + .array() + .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: { @@ -92,8 +103,19 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider projectId: z.string().describe(PROJECT_USERS.REMOVE_MEMBER.projectId) }), body: z.object({ - emails: z.string().email().array().default([]).describe(PROJECT_USERS.REMOVE_MEMBER.emails), - usernames: z.string().array().default([]).describe(PROJECT_USERS.REMOVE_MEMBER.usernames) + 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({ diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index a223004a9..d14a75ded 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { - CertificateAuthoritiesSchema, CertificatesSchema, PkiAlertsSchema, PkiCollectionsSchema, @@ -22,12 +21,13 @@ import { slugSchema } from "@app/server/lib/schemas"; 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 { CaStatus } from "@app/services/certificate-authority/certificate-authority-types"; +import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums"; import { sanitizedCertificateTemplate } from "@app/services/certificate-template/certificate-template-schema"; +import { sanitizedPkiSubscriber } from "@app/services/pki-subscriber/pki-subscriber-schema"; import { ProjectFilterType } from "@app/services/project/project-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; -import { SanitizedProjectSchema } from "../sanitizedSchemas"; +import { InternalCertificateAuthorityResponseSchema, SanitizedProjectSchema } from "../sanitizedSchemas"; const projectWithEnv = SanitizedProjectSchema.extend({ _id: z.string(), @@ -170,7 +170,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { .optional() .default(InfisicalProjectTemplate.Default) .describe(PROJECTS.CREATE.template), - type: z.nativeEnum(ProjectType).default(ProjectType.SecretManager) + type: z.nativeEnum(ProjectType).default(ProjectType.SecretManager), + shouldCreateDefaultEnvs: z.boolean().optional().default(true) }), response: { 200: z.object({ @@ -190,7 +191,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { slug: req.body.slug, kmsKeyId: req.body.kmsKeyId, template: req.body.template, - type: req.body.type + type: req.body.type, + createDefaultEnvs: req.body.shouldCreateDefaultEnvs }); await server.services.telemetry.sendPostHogEvents({ @@ -203,19 +205,18 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); - if (req.body.template) { - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - orgId: req.permission.orgId, - event: { - type: EventType.APPLY_PROJECT_TEMPLATE, - metadata: { - template: req.body.template, - projectId: project.id - } + 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 }; } @@ -259,6 +260,16 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actor: req.permission.type }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: project.id, + event: { + type: EventType.DELETE_PROJECT, + metadata: project + } + }); + return project; } }); @@ -272,7 +283,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ - slug: slugSchema({ min: 5, max: 36 }).describe("The slug of the project to get.") + slug: slugSchema({ max: 36 }).describe("The slug of the project to get.") }), response: { 200: projectWithEnv @@ -338,6 +349,16 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorOrgId: req.permission.orgId }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: project.id, + event: { + type: EventType.UPDATE_PROJECT, + metadata: req.body + } + }); + return project; } }); @@ -363,7 +384,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - cas: z.array(CertificateAuthoritiesSchema) + cas: z.array(InternalCertificateAuthorityResponseSchema) }) } }, @@ -488,6 +509,38 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); + 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", @@ -626,6 +679,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], params: z.object({ projectId: z.string().trim().describe(PROJECTS.LIST_SSH_HOSTS.projectId) }), @@ -664,6 +719,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], params: z.object({ projectId: z.string().trim().describe(PROJECTS.LIST_SSH_HOST_GROUPS.projectId) }), diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index 027f527fc..bbd566334 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { AuthTokenSessionsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { ApiKeysSchema } from "@app/db/schemas/api-keys"; -import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { authRateLimit, readLimit, smtpRateLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMethod, AuthMode, MfaMethod } from "@app/services/auth/auth-type"; import { sanitizedOrganizationSchema } from "@app/services/org/org-schema"; @@ -12,7 +12,9 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/me/emails/code", config: { - rateLimit: authRateLimit + rateLimit: smtpRateLimit({ + keyGenerator: (req) => (req.body as { username?: string })?.username?.trim().substring(0, 100) ?? req.realIp + }) }, schema: { body: z.object({ diff --git a/backend/src/server/routes/v3/signup-router.ts b/backend/src/server/routes/v3/signup-router.ts index 552253cde..c249e7dbe 100644 --- a/backend/src/server/routes/v3/signup-router.ts +++ b/backend/src/server/routes/v3/signup-router.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { ForbiddenRequestError } from "@app/lib/errors"; -import { authRateLimit } from "@app/server/config/rateLimiter"; +import { authRateLimit, smtpRateLimit } from "@app/server/config/rateLimiter"; import { GenericResourceNameSchema } from "@app/server/lib/schemas"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; @@ -13,7 +13,9 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { url: "/email/signup", method: "POST", config: { - rateLimit: authRateLimit + rateLimit: smtpRateLimit({ + keyGenerator: (req) => (req.body as { email?: string })?.email?.trim().substring(0, 100) ?? req.realIp + }) }, schema: { body: z.object({ diff --git a/backend/src/services/app-connection/1password/1password-connection-enums.ts b/backend/src/services/app-connection/1password/1password-connection-enums.ts new file mode 100644 index 000000000..85b28ee5a --- /dev/null +++ b/backend/src/services/app-connection/1password/1password-connection-enums.ts @@ -0,0 +1,3 @@ +export enum OnePassConnectionMethod { + ApiToken = "api-token" +} diff --git a/backend/src/services/app-connection/1password/1password-connection-fns.ts b/backend/src/services/app-connection/1password/1password-connection-fns.ts new file mode 100644 index 000000000..d8a18576f --- /dev/null +++ b/backend/src/services/app-connection/1password/1password-connection-fns.ts @@ -0,0 +1,66 @@ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { OnePassConnectionMethod } from "./1password-connection-enums"; +import { TOnePassConnection, TOnePassConnectionConfig, TOnePassVault } from "./1password-connection-types"; + +export const getOnePassInstanceUrl = async (config: TOnePassConnectionConfig) => { + const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl); + + await blockLocalAndPrivateIpAddresses(instanceUrl); + + return instanceUrl; +}; + +export const getOnePassConnectionListItem = () => { + return { + name: "1Password" as const, + app: AppConnection.OnePass as const, + methods: Object.values(OnePassConnectionMethod) as [OnePassConnectionMethod.ApiToken] + }; +}; + +export const validateOnePassConnectionCredentials = async (config: TOnePassConnectionConfig) => { + const instanceUrl = await getOnePassInstanceUrl(config); + + const { apiToken } = config.credentials; + + try { + await request.get(`${instanceUrl}/v1/vaults`, { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + }); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + + return config.credentials; +}; + +export const listOnePassVaults = async (appConnection: TOnePassConnection) => { + const instanceUrl = await getOnePassInstanceUrl(appConnection); + const { apiToken } = appConnection.credentials; + + const resp = await request.get(`${instanceUrl}/v1/vaults`, { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + }); + + return resp.data; +}; diff --git a/backend/src/services/app-connection/1password/1password-connection-schemas.ts b/backend/src/services/app-connection/1password/1password-connection-schemas.ts new file mode 100644 index 000000000..da63dc32a --- /dev/null +++ b/backend/src/services/app-connection/1password/1password-connection-schemas.ts @@ -0,0 +1,64 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { OnePassConnectionMethod } from "./1password-connection-enums"; + +export const OnePassConnectionAccessTokenCredentialsSchema = z.object({ + apiToken: z.string().trim().min(1, "API Token required").describe(AppConnections.CREDENTIALS.ONEPASS.apiToken), + instanceUrl: z + .string() + .trim() + .url("Invalid Connect Server instance URL") + .min(1, "Instance URL required") + .describe(AppConnections.CREDENTIALS.ONEPASS.instanceUrl) +}); + +const BaseOnePassConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.OnePass) }); + +export const OnePassConnectionSchema = BaseOnePassConnectionSchema.extend({ + method: z.literal(OnePassConnectionMethod.ApiToken), + credentials: OnePassConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedOnePassConnectionSchema = z.discriminatedUnion("method", [ + BaseOnePassConnectionSchema.extend({ + method: z.literal(OnePassConnectionMethod.ApiToken), + credentials: OnePassConnectionAccessTokenCredentialsSchema.pick({ + instanceUrl: true + }) + }) +]); + +export const ValidateOnePassConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(OnePassConnectionMethod.ApiToken).describe(AppConnections.CREATE(AppConnection.OnePass).method), + credentials: OnePassConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.OnePass).credentials + ) + }) +]); + +export const CreateOnePassConnectionSchema = ValidateOnePassConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.OnePass) +); + +export const UpdateOnePassConnectionSchema = z + .object({ + credentials: OnePassConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.OnePass).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.OnePass)); + +export const OnePassConnectionListItemSchema = z.object({ + name: z.literal("1Password"), + app: z.literal(AppConnection.OnePass), + methods: z.nativeEnum(OnePassConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/1password/1password-connection-service.ts b/backend/src/services/app-connection/1password/1password-connection-service.ts new file mode 100644 index 000000000..8e1df9536 --- /dev/null +++ b/backend/src/services/app-connection/1password/1password-connection-service.ts @@ -0,0 +1,30 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listOnePassVaults } from "./1password-connection-fns"; +import { TOnePassConnection } from "./1password-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const onePassConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listVaults = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.OnePass, connectionId, actor); + + try { + const vaults = await listOnePassVaults(appConnection); + return vaults; + } catch (error) { + logger.error(error, "Failed to establish connection with 1Password"); + return []; + } + }; + + return { + listVaults + }; +}; diff --git a/backend/src/services/app-connection/1password/1password-connection-types.ts b/backend/src/services/app-connection/1password/1password-connection-types.ts new file mode 100644 index 000000000..99d6bf94a --- /dev/null +++ b/backend/src/services/app-connection/1password/1password-connection-types.ts @@ -0,0 +1,35 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateOnePassConnectionSchema, + OnePassConnectionSchema, + ValidateOnePassConnectionCredentialsSchema +} from "./1password-connection-schemas"; + +export type TOnePassConnection = z.infer; + +export type TOnePassConnectionInput = z.infer & { + app: AppConnection.OnePass; +}; + +export type TValidateOnePassConnectionCredentialsSchema = typeof ValidateOnePassConnectionCredentialsSchema; + +export type TOnePassConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type TOnePassVault = { + id: string; + name: string; + type: string; + items: number; + + attributeVersion: number; + contentVersion: number; + + createdAt: string; + updatedAt: string; +}; diff --git a/backend/src/services/app-connection/1password/index.ts b/backend/src/services/app-connection/1password/index.ts new file mode 100644 index 000000000..333cc347e --- /dev/null +++ b/backend/src/services/app-connection/1password/index.ts @@ -0,0 +1,4 @@ +export * from "./1password-connection-enums"; +export * from "./1password-connection-fns"; +export * from "./1password-connection-schemas"; +export * from "./1password-connection-types"; diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index c2912c2b6..227818bf0 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -1,5 +1,6 @@ export enum AppConnection { GitHub = "github", + GitHubRadar = "github-radar", AWS = "aws", Databricks = "databricks", GCP = "gcp", @@ -11,12 +12,15 @@ export enum AppConnection { Vercel = "vercel", Postgres = "postgres", MsSql = "mssql", + MySql = "mysql", Camunda = "camunda", Windmill = "windmill", Auth0 = "auth0", HCVault = "hashicorp-vault", LDAP = "ldap", - TeamCity = "teamcity" + TeamCity = "teamcity", + OCI = "oci", + OnePass = "1password" } export enum AWSRegion { @@ -65,3 +69,8 @@ export enum AWSRegion { // South America SA_EAST_1 = "sa-east-1" // Sao Paulo } + +export enum AppConnectionPlanType { + Enterprise = "enterprise", + Regular = "regular" +} diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 95afdcbd2..4597d8f45 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -1,14 +1,25 @@ import { TAppConnections } from "@app/db/schemas/app-connections"; +import { + getOCIConnectionListItem, + OCIConnectionMethod, + validateOCIConnectionCredentials +} from "@app/ee/services/app-connections/oci"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { generateHash } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; -import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; +import { APP_CONNECTION_NAME_MAP, APP_CONNECTION_PLAN_MAP } from "@app/services/app-connection/app-connection-maps"; import { transferSqlConnectionCredentialsToPlatform, validateSqlConnectionCredentials } from "@app/services/app-connection/shared/sql"; import { KmsDataKey } from "@app/services/kms/kms-types"; -import { AppConnection } from "./app-connection-enums"; +import { + getOnePassConnectionListItem, + OnePassConnectionMethod, + validateOnePassConnectionCredentials +} from "./1password"; +import { AppConnection, AppConnectionPlanType } from "./app-connection-enums"; import { TAppConnectionServiceFactoryDep } from "./app-connection-service"; import { TAppConnection, @@ -41,6 +52,11 @@ import { } from "./databricks"; import { GcpConnectionMethod, getGcpConnectionListItem, validateGcpConnectionCredentials } from "./gcp"; import { getGitHubConnectionListItem, GitHubConnectionMethod, validateGitHubConnectionCredentials } from "./github"; +import { + getGitHubRadarConnectionListItem, + GitHubRadarConnectionMethod, + validateGitHubRadarConnectionCredentials +} from "./github-radar"; import { getHCVaultConnectionListItem, HCVaultConnectionMethod, @@ -53,6 +69,8 @@ import { } from "./humanitec"; import { getLdapConnectionListItem, LdapConnectionMethod, validateLdapConnectionCredentials } from "./ldap"; import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; +import { MySqlConnectionMethod } from "./mysql/mysql-connection-enums"; +import { getMySqlConnectionListItem } from "./mysql/mysql-connection-fns"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; import { getTeamCityConnectionListItem, @@ -76,6 +94,7 @@ export const listAppConnectionOptions = () => { return [ getAwsConnectionListItem(), getGitHubConnectionListItem(), + getGitHubRadarConnectionListItem(), getGcpConnectionListItem(), getAzureKeyVaultConnectionListItem(), getAzureAppConfigurationConnectionListItem(), @@ -85,13 +104,16 @@ export const listAppConnectionOptions = () => { getVercelConnectionListItem(), getPostgresConnectionListItem(), getMsSqlConnectionListItem(), + getMySqlConnectionListItem(), getCamundaConnectionListItem(), getAzureClientSecretsConnectionListItem(), getWindmillConnectionListItem(), getAuth0ConnectionListItem(), getHCVaultConnectionListItem(), getLdapConnectionListItem(), - getTeamCityConnectionListItem() + getTeamCityConnectionListItem(), + getOCIConnectionListItem(), + getOnePassConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -144,6 +166,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.AWS]: validateAwsConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Databricks]: validateDatabricksConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.GitHub]: validateGitHubConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.GitHubRadar]: validateGitHubRadarConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.GCP]: validateGcpConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.AzureKeyVault]: validateAzureKeyVaultConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.AzureAppConfiguration]: @@ -153,6 +176,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.MySql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Camunda]: validateCamundaConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Vercel]: validateVercelConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.TerraformCloud]: validateTerraformCloudConnectionCredentials as TAppConnectionCredentialsValidator, @@ -160,7 +184,9 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Windmill]: validateWindmillConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.HCVault]: validateHCVaultConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.LDAP]: validateLdapConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.TeamCity]: validateTeamCityConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.TeamCity]: validateTeamCityConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.OCI]: validateOCIConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.OnePass]: validateOnePassConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); @@ -169,6 +195,7 @@ export const validateAppConnectionCredentials = async ( export const getAppConnectionMethodName = (method: TAppConnection["method"]) => { switch (method) { case GitHubConnectionMethod.App: + case GitHubRadarConnectionMethod.App: return "GitHub App"; case AzureKeyVaultConnectionMethod.OAuth: case AzureAppConfigurationConnectionMethod.OAuth: @@ -176,6 +203,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case GitHubConnectionMethod.OAuth: return "OAuth"; case AwsConnectionMethod.AccessKey: + case OCIConnectionMethod.AccessKey: return "Access Key"; case AwsConnectionMethod.AssumeRole: return "Assume Role"; @@ -188,9 +216,11 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case HumanitecConnectionMethod.ApiToken: case TerraformCloudConnectionMethod.ApiToken: case VercelConnectionMethod.ApiToken: + case OnePassConnectionMethod.ApiToken: return "API Token"; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: + case MySqlConnectionMethod.UsernameAndPassword: return "Username & Password"; case WindmillConnectionMethod.AccessToken: case HCVaultConnectionMethod.AccessToken: @@ -236,12 +266,14 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.AWS]: platformManagedCredentialsNotSupported, [AppConnection.Databricks]: platformManagedCredentialsNotSupported, [AppConnection.GitHub]: platformManagedCredentialsNotSupported, + [AppConnection.GitHubRadar]: platformManagedCredentialsNotSupported, [AppConnection.GCP]: platformManagedCredentialsNotSupported, [AppConnection.AzureKeyVault]: platformManagedCredentialsNotSupported, [AppConnection.AzureAppConfiguration]: platformManagedCredentialsNotSupported, [AppConnection.Humanitec]: platformManagedCredentialsNotSupported, [AppConnection.Postgres]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, [AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, + [AppConnection.MySql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, [AppConnection.TerraformCloud]: platformManagedCredentialsNotSupported, [AppConnection.Camunda]: platformManagedCredentialsNotSupported, [AppConnection.Vercel]: platformManagedCredentialsNotSupported, @@ -250,5 +282,22 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Auth0]: platformManagedCredentialsNotSupported, [AppConnection.HCVault]: platformManagedCredentialsNotSupported, [AppConnection.LDAP]: platformManagedCredentialsNotSupported, // we could support this in the future - [AppConnection.TeamCity]: platformManagedCredentialsNotSupported + [AppConnection.TeamCity]: platformManagedCredentialsNotSupported, + [AppConnection.OCI]: platformManagedCredentialsNotSupported, + [AppConnection.OnePass]: platformManagedCredentialsNotSupported +}; + +export const enterpriseAppCheck = async ( + licenseService: Pick, + appConnection: AppConnection, + orgId: string, + errorMessage: string +) => { + if (APP_CONNECTION_PLAN_MAP[appConnection] === AppConnectionPlanType.Enterprise) { + const plan = await licenseService.getPlan(orgId); + if (!plan.enterpriseAppConnections) + throw new BadRequestError({ + message: errorMessage + }); + } }; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 05e00446c..0042fdf42 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -1,8 +1,9 @@ -import { AppConnection } from "./app-connection-enums"; +import { AppConnection, AppConnectionPlanType } from "./app-connection-enums"; export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.AWS]: "AWS", [AppConnection.GitHub]: "GitHub", + [AppConnection.GitHubRadar]: "GitHub Radar", [AppConnection.GCP]: "GCP", [AppConnection.AzureKeyVault]: "Azure Key Vault", [AppConnection.AzureAppConfiguration]: "Azure App Configuration", @@ -13,10 +14,38 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Vercel]: "Vercel", [AppConnection.Postgres]: "PostgreSQL", [AppConnection.MsSql]: "Microsoft SQL Server", + [AppConnection.MySql]: "MySQL", [AppConnection.Camunda]: "Camunda", [AppConnection.Windmill]: "Windmill", [AppConnection.Auth0]: "Auth0", [AppConnection.HCVault]: "Hashicorp Vault", [AppConnection.LDAP]: "LDAP", - [AppConnection.TeamCity]: "TeamCity" + [AppConnection.TeamCity]: "TeamCity", + [AppConnection.OCI]: "OCI", + [AppConnection.OnePass]: "1Password" +}; + +export const APP_CONNECTION_PLAN_MAP: Record = { + [AppConnection.AWS]: AppConnectionPlanType.Regular, + [AppConnection.GitHub]: AppConnectionPlanType.Regular, + [AppConnection.GitHubRadar]: AppConnectionPlanType.Regular, + [AppConnection.GCP]: AppConnectionPlanType.Regular, + [AppConnection.AzureKeyVault]: AppConnectionPlanType.Regular, + [AppConnection.AzureAppConfiguration]: AppConnectionPlanType.Regular, + [AppConnection.AzureClientSecrets]: AppConnectionPlanType.Regular, + [AppConnection.Databricks]: AppConnectionPlanType.Regular, + [AppConnection.Humanitec]: AppConnectionPlanType.Regular, + [AppConnection.TerraformCloud]: AppConnectionPlanType.Regular, + [AppConnection.Vercel]: AppConnectionPlanType.Regular, + [AppConnection.Postgres]: AppConnectionPlanType.Regular, + [AppConnection.MsSql]: AppConnectionPlanType.Regular, + [AppConnection.Camunda]: AppConnectionPlanType.Regular, + [AppConnection.Windmill]: AppConnectionPlanType.Regular, + [AppConnection.Auth0]: AppConnectionPlanType.Regular, + [AppConnection.HCVault]: AppConnectionPlanType.Regular, + [AppConnection.LDAP]: AppConnectionPlanType.Regular, + [AppConnection.TeamCity]: AppConnectionPlanType.Regular, + [AppConnection.OCI]: AppConnectionPlanType.Enterprise, + [AppConnection.OnePass]: AppConnectionPlanType.Regular, + [AppConnection.MySql]: AppConnectionPlanType.Regular }; diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 7a8b1a09c..e91aefd4b 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -1,5 +1,8 @@ import { ForbiddenError, subject } from "@casl/ability"; +import { ValidateOCIConnectionCredentialsSchema } from "@app/ee/services/app-connections/oci"; +import { ociConnectionService } from "@app/ee/services/app-connections/oci/oci-connection-service"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionAppConnectionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { generateHash } from "@app/lib/crypto/encryption"; @@ -9,14 +12,18 @@ import { DiscriminativePick, OrgServiceActor } from "@app/lib/types"; import { decryptAppConnection, encryptAppConnectionCredentials, + enterpriseAppCheck, getAppConnectionMethodName, listAppConnectionOptions, TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM, validateAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; import { auth0ConnectionService } from "@app/services/app-connection/auth0/auth0-connection-service"; +import { githubRadarConnectionService } from "@app/services/app-connection/github-radar/github-radar-connection-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { ValidateOnePassConnectionCredentialsSchema } from "./1password"; +import { onePassConnectionService } from "./1password/1password-connection-service"; import { TAppConnectionDALFactory } from "./app-connection-dal"; import { AppConnection } from "./app-connection-enums"; import { APP_CONNECTION_NAME_MAP } from "./app-connection-maps"; @@ -43,12 +50,14 @@ import { ValidateGcpConnectionCredentialsSchema } from "./gcp"; import { gcpConnectionService } from "./gcp/gcp-connection-service"; import { ValidateGitHubConnectionCredentialsSchema } from "./github"; import { githubConnectionService } from "./github/github-connection-service"; +import { ValidateGitHubRadarConnectionCredentialsSchema } from "./github-radar"; import { ValidateHCVaultConnectionCredentialsSchema } from "./hc-vault"; import { hcVaultConnectionService } from "./hc-vault/hc-vault-connection-service"; import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; import { humanitecConnectionService } from "./humanitec/humanitec-connection-service"; import { ValidateLdapConnectionCredentialsSchema } from "./ldap"; import { ValidateMsSqlConnectionCredentialsSchema } from "./mssql"; +import { ValidateMySqlConnectionCredentialsSchema } from "./mysql"; import { ValidatePostgresConnectionCredentialsSchema } from "./postgres"; import { ValidateTeamCityConnectionCredentialsSchema } from "./teamcity"; import { teamcityConnectionService } from "./teamcity/teamcity-connection-service"; @@ -63,6 +72,7 @@ export type TAppConnectionServiceFactoryDep = { appConnectionDAL: TAppConnectionDALFactory; permissionService: Pick; kmsService: Pick; + licenseService: Pick; }; export type TAppConnectionServiceFactory = ReturnType; @@ -70,6 +80,7 @@ export type TAppConnectionServiceFactory = ReturnType = { [AppConnection.AWS]: ValidateAwsConnectionCredentialsSchema, [AppConnection.GitHub]: ValidateGitHubConnectionCredentialsSchema, + [AppConnection.GitHubRadar]: ValidateGitHubRadarConnectionCredentialsSchema, [AppConnection.GCP]: ValidateGcpConnectionCredentialsSchema, [AppConnection.AzureKeyVault]: ValidateAzureKeyVaultConnectionCredentialsSchema, [AppConnection.AzureAppConfiguration]: ValidateAzureAppConfigurationConnectionCredentialsSchema, @@ -79,19 +90,23 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record { const listAppConnectionsByOrg = async (actor: OrgServiceActor, app?: AppConnection) => { const { permission } = await permissionService.getOrgPermission( @@ -188,6 +203,13 @@ export const appConnectionServiceFactory = ({ OrgPermissionSubjects.AppConnections ); + await enterpriseAppCheck( + licenseService, + app, + actor.orgId, + "Failed to create app connection due to plan restriction. Upgrade plan to access enterprise app connections." + ); + const validatedCredentials = await validateAppConnectionCredentials({ app, credentials, @@ -250,6 +272,13 @@ export const appConnectionServiceFactory = ({ if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); + await enterpriseAppCheck( + licenseService, + appConnection.app as AppConnection, + actor.orgId, + "Failed to update app connection due to plan restriction. Upgrade plan to access enterprise app connections." + ); + const { permission } = await permissionService.getOrgPermission( actor.type, actor.id, @@ -396,6 +425,13 @@ export const appConnectionServiceFactory = ({ if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); + await enterpriseAppCheck( + licenseService, + app, + actor.orgId, + "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, @@ -453,6 +489,7 @@ export const appConnectionServiceFactory = ({ connectAppConnectionById, listAvailableAppConnectionsForUser, github: githubConnectionService(connectAppConnectionById), + githubRadar: githubRadarConnectionService(connectAppConnectionById), gcp: gcpConnectionService(connectAppConnectionById), databricks: databricksConnectionService(connectAppConnectionById, appConnectionDAL, kmsService), aws: awsConnectionService(connectAppConnectionById), @@ -464,6 +501,8 @@ export const appConnectionServiceFactory = ({ auth0: auth0ConnectionService(connectAppConnectionById, appConnectionDAL, kmsService), hcvault: hcVaultConnectionService(connectAppConnectionById), windmill: windmillConnectionService(connectAppConnectionById), - teamcity: teamcityConnectionService(connectAppConnectionById) + teamcity: teamcityConnectionService(connectAppConnectionById), + oci: ociConnectionService(connectAppConnectionById, licenseService), + onepass: onePassConnectionService(connectAppConnectionById) }; }; diff --git a/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts index 36eeca4b1..9af833010 100644 --- a/backend/src/services/app-connection/app-connection-types.ts +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -1,7 +1,19 @@ +import { + TOCIConnection, + TOCIConnectionConfig, + TOCIConnectionInput, + TValidateOCIConnectionCredentialsSchema +} from "@app/ee/services/app-connections/oci"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { TSqlConnectionConfig } from "@app/services/app-connection/shared/sql/sql-connection-types"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + TOnePassConnection, + TOnePassConnectionConfig, + TOnePassConnectionInput, + TValidateOnePassConnectionCredentialsSchema +} from "./1password"; import { AWSRegion } from "./app-connection-enums"; import { TAuth0Connection, @@ -57,6 +69,12 @@ import { TGitHubConnectionInput, TValidateGitHubConnectionCredentialsSchema } from "./github"; +import { + TGitHubRadarConnection, + TGitHubRadarConnectionConfig, + TGitHubRadarConnectionInput, + TValidateGitHubRadarConnectionCredentialsSchema +} from "./github-radar"; import { THCVaultConnection, THCVaultConnectionConfig, @@ -76,6 +94,7 @@ import { TValidateLdapConnectionCredentialsSchema } from "./ldap"; import { TMsSqlConnection, TMsSqlConnectionInput, TValidateMsSqlConnectionCredentialsSchema } from "./mssql"; +import { TMySqlConnection, TMySqlConnectionInput, TValidateMySqlConnectionCredentialsSchema } from "./mysql"; import { TPostgresConnection, TPostgresConnectionInput, @@ -109,6 +128,7 @@ import { export type TAppConnection = { id: string } & ( | TAwsConnection | TGitHubConnection + | TGitHubRadarConnection | TGcpConnection | TAzureKeyVaultConnection | TAzureAppConfigurationConnection @@ -118,6 +138,7 @@ export type TAppConnection = { id: string } & ( | TVercelConnection | TPostgresConnection | TMsSqlConnection + | TMySqlConnection | TCamundaConnection | TAzureClientSecretsConnection | TWindmillConnection @@ -125,15 +146,18 @@ export type TAppConnection = { id: string } & ( | THCVaultConnection | TLdapConnection | TTeamCityConnection + | TOCIConnection + | TOnePassConnection ); export type TAppConnectionRaw = NonNullable>>; -export type TSqlConnection = TPostgresConnection | TMsSqlConnection; +export type TSqlConnection = TPostgresConnection | TMsSqlConnection | TMySqlConnection; export type TAppConnectionInput = { id: string } & ( | TAwsConnectionInput | TGitHubConnectionInput + | TGitHubRadarConnectionInput | TGcpConnectionInput | TAzureKeyVaultConnectionInput | TAzureAppConfigurationConnectionInput @@ -143,6 +167,7 @@ export type TAppConnectionInput = { id: string } & ( | TVercelConnectionInput | TPostgresConnectionInput | TMsSqlConnectionInput + | TMySqlConnectionInput | TCamundaConnectionInput | TAzureClientSecretsConnectionInput | TWindmillConnectionInput @@ -150,9 +175,11 @@ export type TAppConnectionInput = { id: string } & ( | THCVaultConnectionInput | TLdapConnectionInput | TTeamCityConnectionInput + | TOCIConnectionInput + | TOnePassConnectionInput ); -export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput; +export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput | TMySqlConnectionInput; export type TCreateAppConnectionDTO = Pick< TAppConnectionInput, @@ -166,6 +193,7 @@ export type TUpdateAppConnectionDTO = Partial { + const accessToken = await getGcpConnectionAuthToken(appConnection); + + let gcpLocations: GCPLocation[] = []; + + const pageSize = 100; + let pageToken: string | undefined; + let hasMorePages = true; + + while (hasMorePages) { + const params = new URLSearchParams({ + pageSize: String(pageSize), + ...(pageToken ? { pageToken } : {}) + }); + + // eslint-disable-next-line no-await-in-loop + const { data } = await request.get( + `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${projectId}/locations`, + { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + gcpLocations = gcpLocations.concat(data.locations); + + if (!data.nextPageToken) { + hasMorePages = false; + } + + pageToken = data.nextPageToken; + } + + return gcpLocations.sort((a, b) => a.displayName.localeCompare(b.displayName)); +}; + export const validateGcpConnectionCredentials = async (appConnection: TGcpConnectionConfig) => { // Check if provided service account email suffix matches organization ID. // We do this to mitigate confused deputy attacks in multi-tenant instances diff --git a/backend/src/services/app-connection/gcp/gcp-connection-service.ts b/backend/src/services/app-connection/gcp/gcp-connection-service.ts index 96b795a8f..74f2ab2c4 100644 --- a/backend/src/services/app-connection/gcp/gcp-connection-service.ts +++ b/backend/src/services/app-connection/gcp/gcp-connection-service.ts @@ -1,8 +1,8 @@ import { OrgServiceActor } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; -import { getGcpSecretManagerProjects } from "./gcp-connection-fns"; -import { TGcpConnection } from "./gcp-connection-types"; +import { getGcpSecretManagerProjectLocations, getGcpSecretManagerProjects } from "./gcp-connection-fns"; +import { TGcpConnection, TGetGCPProjectLocationsDTO } from "./gcp-connection-types"; type TGetAppConnectionFunc = ( app: AppConnection, @@ -23,7 +23,23 @@ export const gcpConnectionService = (getAppConnection: TGetAppConnectionFunc) => } }; + const listSecretManagerProjectLocations = async ( + { connectionId, projectId }: TGetGCPProjectLocationsDTO, + actor: OrgServiceActor + ) => { + const appConnection = await getAppConnection(AppConnection.GCP, connectionId, actor); + + try { + const locations = await getGcpSecretManagerProjectLocations(projectId, appConnection); + + return locations; + } catch (error) { + return []; + } + }; + return { - listSecretManagerProjects + listSecretManagerProjects, + listSecretManagerProjectLocations }; }; diff --git a/backend/src/services/app-connection/gcp/gcp-connection-types.ts b/backend/src/services/app-connection/gcp/gcp-connection-types.ts index 2bb518820..4dc4bd131 100644 --- a/backend/src/services/app-connection/gcp/gcp-connection-types.ts +++ b/backend/src/services/app-connection/gcp/gcp-connection-types.ts @@ -38,6 +38,22 @@ export type GCPGetProjectsRes = { nextPageToken?: string; }; +export type GCPLocation = { + name: string; + locationId: string; + displayName: string; +}; + +export type GCPGetProjectLocationsRes = { + locations: GCPLocation[]; + nextPageToken?: string; +}; + +export type TGetGCPProjectLocationsDTO = { + projectId: string; + connectionId: string; +}; + export type GCPGetServiceRes = { name: string; parent: string; diff --git a/backend/src/services/app-connection/github-radar/github-radar-connection-enums.ts b/backend/src/services/app-connection/github-radar/github-radar-connection-enums.ts new file mode 100644 index 000000000..6e02cbc20 --- /dev/null +++ b/backend/src/services/app-connection/github-radar/github-radar-connection-enums.ts @@ -0,0 +1,3 @@ +export enum GitHubRadarConnectionMethod { + App = "github-app" +} diff --git a/backend/src/services/app-connection/github-radar/github-radar-connection-fns.ts b/backend/src/services/app-connection/github-radar/github-radar-connection-fns.ts new file mode 100644 index 000000000..84d7a1ce1 --- /dev/null +++ b/backend/src/services/app-connection/github-radar/github-radar-connection-fns.ts @@ -0,0 +1,166 @@ +import { createAppAuth } from "@octokit/auth-app"; +import { Octokit } from "@octokit/rest"; +import { AxiosResponse } from "axios"; + +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; +import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors"; +import { getAppConnectionMethodName } from "@app/services/app-connection/app-connection-fns"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { AppConnection } from "../app-connection-enums"; +import { GitHubRadarConnectionMethod } from "./github-radar-connection-enums"; +import { + TGitHubRadarConnection, + TGitHubRadarConnectionConfig, + TGitHubRadarRepository +} from "./github-radar-connection-types"; + +export const getGitHubRadarConnectionListItem = () => { + const { INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG } = getConfig(); + + return { + name: "GitHub Radar" as const, + app: AppConnection.GitHubRadar as const, + methods: Object.values(GitHubRadarConnectionMethod) as [GitHubRadarConnectionMethod.App], + appClientSlug: INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG + }; +}; + +export const getGitHubRadarClient = (appConnection: TGitHubRadarConnection) => { + const appCfg = getConfig(); + + const { method, credentials } = appConnection; + + let client: Octokit; + + switch (method) { + case GitHubRadarConnectionMethod.App: + if (!appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_ID || !appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY) { + throw new InternalServerError({ + message: `GitHub ${getAppConnectionMethodName(method).replace( + "GitHub", + "" + )} environment variables have not been configured` + }); + } + + client = new Octokit({ + authStrategy: createAppAuth, + auth: { + appId: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_ID, + privateKey: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY, + installationId: credentials.installationId + } + }); + break; + default: + throw new InternalServerError({ + message: `Unhandled GitHub Radar connection method: ${method as GitHubRadarConnectionMethod}` + }); + } + + return client; +}; + +export const listGitHubRadarRepositories = async (appConnection: TGitHubRadarConnection) => { + const client = getGitHubRadarClient(appConnection); + + const repositories: TGitHubRadarRepository[] = await client.paginate("GET /installation/repositories"); + + return repositories; +}; + +type TokenRespData = { + access_token: string; + scope: string; + token_type: string; + error?: string; +}; + +export const validateGitHubRadarConnectionCredentials = async (config: TGitHubRadarConnectionConfig) => { + const { credentials, method } = config; + + const { INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID, INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET, SITE_URL } = + getConfig(); + + if (!INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID || !INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET) { + throw new InternalServerError({ + message: `GitHub ${getAppConnectionMethodName(method).replace( + "GitHub", + "" + )} environment variables have not been configured` + }); + } + + let tokenResp: AxiosResponse; + + try { + tokenResp = await request.get("https://github.com/login/oauth/access_token", { + params: { + client_id: INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID, + client_secret: INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET, + code: credentials.code, + redirect_uri: `${SITE_URL}/organization/app-connections/github-radar/oauth/callback` + }, + headers: { + Accept: "application/json", + "Accept-Encoding": "application/json" + } + }); + } catch (e: unknown) { + throw new BadRequestError({ + message: `Unable to validate connection: verify credentials` + }); + } + + if (tokenResp.status !== 200) { + throw new BadRequestError({ + message: `Unable to validate credentials: GitHub responded with a status code of ${tokenResp.status} (${tokenResp.statusText}). Verify credentials and try again.` + }); + } + + if (method === GitHubRadarConnectionMethod.App) { + const installationsResp = await request.get<{ + installations: { + id: number; + account: { + login: string; + type: string; + id: number; + }; + }[]; + }>(IntegrationUrls.GITHUB_USER_INSTALLATIONS, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${tokenResp.data.access_token}`, + "Accept-Encoding": "application/json" + } + }); + + const matchingInstallation = installationsResp.data.installations.find( + (installation) => installation.id === +credentials.installationId + ); + + if (!matchingInstallation) { + throw new ForbiddenRequestError({ + message: "User does not have access to the provided installation" + }); + } + } + + if (!tokenResp.data.access_token) { + throw new InternalServerError({ message: `Missing access token: ${tokenResp.data.error}` }); + } + + switch (method) { + case GitHubRadarConnectionMethod.App: + return { + installationId: credentials.installationId + }; + default: + throw new InternalServerError({ + message: `Unhandled GitHub connection method: ${method as GitHubRadarConnectionMethod}` + }); + } +}; diff --git a/backend/src/services/app-connection/github-radar/github-radar-connection-schemas.ts b/backend/src/services/app-connection/github-radar/github-radar-connection-schemas.ts new file mode 100644 index 000000000..ebdfa45e1 --- /dev/null +++ b/backend/src/services/app-connection/github-radar/github-radar-connection-schemas.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { GitHubRadarConnectionMethod } from "./github-radar-connection-enums"; + +export const GitHubRadarConnectionInputCredentialsSchema = z.object({ + code: z.string().trim().min(1, "GitHub Radar App code required"), + installationId: z.string().min(1, "GitHub Radar App Installation ID required") +}); + +export const GitHubRadarConnectionOutputCredentialsSchema = z.object({ + installationId: z.string() +}); + +export const ValidateGitHubRadarConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(GitHubRadarConnectionMethod.App) + .describe(AppConnections.CREATE(AppConnection.GitHubRadar).method), + credentials: GitHubRadarConnectionInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.GitHubRadar).credentials + ) + }) +]); + +export const CreateGitHubRadarConnectionSchema = ValidateGitHubRadarConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.GitHubRadar) +); + +export const UpdateGitHubRadarConnectionSchema = z + .object({ + credentials: GitHubRadarConnectionInputCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.GitHubRadar).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.GitHubRadar)); + +const BaseGitHubRadarConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.GitHubRadar) }); + +export const GitHubRadarConnectionSchema = BaseGitHubRadarConnectionSchema.extend({ + method: z.literal(GitHubRadarConnectionMethod.App), + credentials: GitHubRadarConnectionOutputCredentialsSchema +}); + +export const SanitizedGitHubRadarConnectionSchema = z.discriminatedUnion("method", [ + BaseGitHubRadarConnectionSchema.extend({ + method: z.literal(GitHubRadarConnectionMethod.App), + credentials: GitHubRadarConnectionOutputCredentialsSchema.pick({}) + }) +]); + +export const GitHubRadarConnectionListItemSchema = z.object({ + name: z.literal("GitHub Radar"), + app: z.literal(AppConnection.GitHubRadar), + // the below is preferable but currently breaks with our zod to json schema parser + // methods: z.tuple([z.literal(GitHubConnectionMethod.App), z.literal(GitHubConnectionMethod.OAuth)]), + methods: z.nativeEnum(GitHubRadarConnectionMethod).array(), + appClientSlug: z.string().optional() +}); diff --git a/backend/src/services/app-connection/github-radar/github-radar-connection-service.ts b/backend/src/services/app-connection/github-radar/github-radar-connection-service.ts new file mode 100644 index 000000000..583c43952 --- /dev/null +++ b/backend/src/services/app-connection/github-radar/github-radar-connection-service.ts @@ -0,0 +1,24 @@ +import { OrgServiceActor } from "@app/lib/types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { listGitHubRadarRepositories } from "@app/services/app-connection/github-radar/github-radar-connection-fns"; +import { TGitHubRadarConnection } from "@app/services/app-connection/github-radar/github-radar-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const githubRadarConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listRepositories = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.GitHubRadar, connectionId, actor); + + const repositories = await listGitHubRadarRepositories(appConnection); + + return repositories.map((repo) => ({ id: repo.id, name: repo.full_name })); + }; + + return { + listRepositories + }; +}; diff --git a/backend/src/services/app-connection/github-radar/github-radar-connection-types.ts b/backend/src/services/app-connection/github-radar/github-radar-connection-types.ts new file mode 100644 index 000000000..c9e7e5aa8 --- /dev/null +++ b/backend/src/services/app-connection/github-radar/github-radar-connection-types.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateGitHubRadarConnectionSchema, + GitHubRadarConnectionSchema, + ValidateGitHubRadarConnectionCredentialsSchema +} from "./github-radar-connection-schemas"; + +export type TGitHubRadarConnection = z.infer; + +export type TGitHubRadarConnectionInput = z.infer & { + app: AppConnection.GitHubRadar; +}; + +export type TValidateGitHubRadarConnectionCredentialsSchema = typeof ValidateGitHubRadarConnectionCredentialsSchema; + +export type TGitHubRadarConnectionConfig = DiscriminativePick< + TGitHubRadarConnectionInput, + "method" | "app" | "credentials" +>; + +export type TGitHubRadarRepository = { + id: number; + full_name: string; +}; diff --git a/backend/src/services/app-connection/github-radar/index.ts b/backend/src/services/app-connection/github-radar/index.ts new file mode 100644 index 000000000..3f2a5f663 --- /dev/null +++ b/backend/src/services/app-connection/github-radar/index.ts @@ -0,0 +1,4 @@ +export * from "./github-radar-connection-enums"; +export * from "./github-radar-connection-fns"; +export * from "./github-radar-connection-schemas"; +export * from "./github-radar-connection-types"; diff --git a/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts b/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts index 91884b914..134b9667b 100644 --- a/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts +++ b/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts @@ -1,8 +1,7 @@ -import RE2 from "re2"; import { z } from "zod"; import { AppConnections } from "@app/lib/api-docs"; -import { DistinguishedNameRegex } from "@app/lib/regex"; +import { DistinguishedNameRegex, LdapUrlRegex, UserPrincipalNameRegex } from "@app/lib/regex"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { BaseAppConnectionSchema, @@ -18,13 +17,15 @@ export const LdapConnectionSimpleBindCredentialsSchema = z.object({ .string() .trim() .min(1, "URL required") - .regex(new RE2(/^ldaps?:\/\//)) + .refine((value) => LdapUrlRegex.test(value), "Invalid LDAP URL") .describe(AppConnections.CREDENTIALS.LDAP.url), dn: z .string() .trim() - .regex(new RE2(DistinguishedNameRegex), "Invalid DN format, ie; CN=user,OU=users,DC=example,DC=com") - .min(1, "Distinguished Name (DN) required") + .min(1, "DN/UPN required") + .refine((value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value), { + message: "Invalid DN/UPN format" + }) .describe(AppConnections.CREDENTIALS.LDAP.dn), password: z.string().trim().min(1, "Password required").describe(AppConnections.CREDENTIALS.LDAP.password), sslRejectUnauthorized: z.boolean().optional().describe(AppConnections.CREDENTIALS.LDAP.sslRejectUnauthorized), diff --git a/backend/src/services/app-connection/mysql/index.ts b/backend/src/services/app-connection/mysql/index.ts new file mode 100644 index 000000000..68c4d4c02 --- /dev/null +++ b/backend/src/services/app-connection/mysql/index.ts @@ -0,0 +1,4 @@ +export * from "./mysql-connection-enums"; +export * from "./mysql-connection-fns"; +export * from "./mysql-connection-schemas"; +export * from "./mysql-connection-types"; diff --git a/backend/src/services/app-connection/mysql/mysql-connection-enums.ts b/backend/src/services/app-connection/mysql/mysql-connection-enums.ts new file mode 100644 index 000000000..e46fd9ba5 --- /dev/null +++ b/backend/src/services/app-connection/mysql/mysql-connection-enums.ts @@ -0,0 +1,3 @@ +export enum MySqlConnectionMethod { + UsernameAndPassword = "username-and-password" +} diff --git a/backend/src/services/app-connection/mysql/mysql-connection-fns.ts b/backend/src/services/app-connection/mysql/mysql-connection-fns.ts new file mode 100644 index 000000000..c74037257 --- /dev/null +++ b/backend/src/services/app-connection/mysql/mysql-connection-fns.ts @@ -0,0 +1,12 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { MySqlConnectionMethod } from "./mysql-connection-enums"; + +export const getMySqlConnectionListItem = () => { + return { + name: "MySQL" as const, + app: AppConnection.MySql as const, + methods: Object.values(MySqlConnectionMethod) as [MySqlConnectionMethod.UsernameAndPassword], + supportsPlatformManagement: true as const + }; +}; diff --git a/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts b/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts new file mode 100644 index 000000000..082bac557 --- /dev/null +++ b/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts @@ -0,0 +1,66 @@ +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 { BaseSqlUsernameAndPasswordConnectionSchema } from "../shared/sql"; +import { MySqlConnectionMethod } from "./mysql-connection-enums"; + +export const MySqlConnectionAccessTokenCredentialsSchema = BaseSqlUsernameAndPasswordConnectionSchema; + +const BaseMySqlConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.MySql) }); + +export const MySqlConnectionSchema = BaseMySqlConnectionSchema.extend({ + method: z.literal(MySqlConnectionMethod.UsernameAndPassword), + credentials: MySqlConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedMySqlConnectionSchema = z.discriminatedUnion("method", [ + BaseMySqlConnectionSchema.extend({ + method: z.literal(MySqlConnectionMethod.UsernameAndPassword), + credentials: MySqlConnectionAccessTokenCredentialsSchema.pick({ + host: true, + database: true, + port: true, + username: true, + sslEnabled: true, + sslRejectUnauthorized: true, + sslCertificate: true + }) + }) +]); + +export const ValidateMySqlConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(MySqlConnectionMethod.UsernameAndPassword) + .describe(AppConnections.CREATE(AppConnection.MySql).method), + credentials: MySqlConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.MySql).credentials + ) + }) +]); + +export const CreateMySqlConnectionSchema = ValidateMySqlConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.MySql, { supportsPlatformManagedCredentials: true }) +); + +export const UpdateMySqlConnectionSchema = z + .object({ + credentials: MySqlConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.MySql).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.MySql, { supportsPlatformManagedCredentials: true })); + +export const MySqlConnectionListItemSchema = z.object({ + name: z.literal("MySQL"), + app: z.literal(AppConnection.MySql), + methods: z.nativeEnum(MySqlConnectionMethod).array(), + supportsPlatformManagement: z.literal(true) +}); diff --git a/backend/src/services/app-connection/mysql/mysql-connection-types.ts b/backend/src/services/app-connection/mysql/mysql-connection-types.ts new file mode 100644 index 000000000..0d8c0f6be --- /dev/null +++ b/backend/src/services/app-connection/mysql/mysql-connection-types.ts @@ -0,0 +1,16 @@ +import z from "zod"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateMySqlConnectionSchema, + MySqlConnectionSchema, + ValidateMySqlConnectionCredentialsSchema +} from "./mysql-connection-schemas"; + +export type TMySqlConnection = z.infer; + +export type TMySqlConnectionInput = z.infer & { + app: AppConnection.MySql; +}; + +export type TValidateMySqlConnectionCredentialsSchema = typeof ValidateMySqlConnectionCredentialsSchema; diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts index bc98e9bcc..7df1929ba 100644 --- a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts +++ b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts @@ -15,7 +15,8 @@ const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; const SQL_CONNECTION_CLIENT_MAP = { [AppConnection.Postgres]: "pg", - [AppConnection.MsSql]: "mssql" + [AppConnection.MsSql]: "mssql", + [AppConnection.MySql]: "mysql2" }; const getConnectionConfig = ({ @@ -45,6 +46,17 @@ const getConnectionConfig = ({ : { encrypt: false } }; } + case AppConnection.MySql: { + return { + ssl: sslEnabled + ? { + rejectUnauthorized: sslRejectUnauthorized, + ca: sslCertificate, + servername: host + } + : false + }; + } default: throw new Error(`Unhandled SQL Connection Config: ${app as AppConnection}`); } @@ -101,7 +113,8 @@ export const SQL_CONNECTION_ALTER_LOGIN_STATEMENT: Record< (credentials: TSqlCredentialsRotationGeneratedCredentials[number]) => [string, Knex.RawBinding] > = { [AppConnection.Postgres]: ({ username, password }) => [`ALTER USER ?? WITH PASSWORD '${password}';`, [username]], - [AppConnection.MsSql]: ({ username, password }) => [`ALTER LOGIN ?? WITH PASSWORD = '${password}';`, [username]] + [AppConnection.MsSql]: ({ username, password }) => [`ALTER LOGIN ?? WITH PASSWORD = '${password}';`, [username]], + [AppConnection.MySql]: ({ username, password }) => [`ALTER USER ??@'%' IDENTIFIED BY '${password}';`, [username]] }; export const transferSqlConnectionCredentialsToPlatform = async ( diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index fdbd5ccd8..64ba573d5 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -199,9 +199,12 @@ export const authLoginServiceFactory = ({ providerAuthToken, clientPublicKey }: TLoginGenServerPublicKeyDTO) => { - const userEnc = await userDAL.findUserEncKeyByUsername({ + // akhilmhdh: case sensitive email resolution + const usersByUsername = await userDAL.findUserEncKeyByUsername({ username: email }); + const userEnc = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; const serverCfg = await getServerCfg(); @@ -250,9 +253,12 @@ export const authLoginServiceFactory = ({ }: TLoginClientProofDTO) => { const appCfg = getConfig(); - const userEnc = await userDAL.findUserEncKeyByUsername({ + // akhilmhdh: case sensitive email resolution + const usersByUsername = await userDAL.findUserEncKeyByUsername({ username: email }); + const userEnc = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; if (!userEnc) throw new Error("Failed to find user"); const user = await userDAL.findById(userEnc.userId); const cfg = getConfig(); @@ -391,7 +397,7 @@ export const authLoginServiceFactory = ({ // Check if the user actually has access to the specified organization. const userOrgs = await orgDAL.findAllOrgsByUserId(user.id); - const hasOrganizationMembership = userOrgs.some((org) => org.id === organizationId); + const hasOrganizationMembership = userOrgs.some((org) => org.id === organizationId && org.userStatus !== "invited"); const selectedOrg = await orgDAL.findById(organizationId); if (!hasOrganizationMembership) { @@ -649,10 +655,12 @@ export const authLoginServiceFactory = ({ * OAuth2 login for google,github, and other oauth2 provider * */ const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => { - let user = await userDAL.findUserByUsername(email); + // akhilmhdh: case sensitive email resolution + const usersByUsername = await userDAL.findUserByUsername(email); + let user = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; const serverCfg = await getServerCfg(); - if (serverCfg.enabledLoginMethods) { + if (serverCfg.enabledLoginMethods && user) { switch (authMethod) { case AuthMethod.GITHUB: { if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITHUB)) { @@ -715,8 +723,8 @@ export const authLoginServiceFactory = ({ } user = await userDAL.create({ - username: email, - email, + username: email.trim().toLowerCase(), + email: email.trim().toLowerCase(), isEmailVerified: true, firstName, lastName, @@ -814,11 +822,14 @@ export const authLoginServiceFactory = ({ ? decodedProviderToken.orgId : undefined; - const userEnc = await userDAL.findUserEncKeyByUsername({ + // akhilmhdh: case sensitive email resolution + const usersByUsername = await userDAL.findUserEncKeyByUsername({ username: email }); - if (!userEnc) throw new BadRequestError({ message: "Invalid token" }); - if (!userEnc.serverEncryptedPrivateKey) + const userEnc = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; + + if (!userEnc?.serverEncryptedPrivateKey) throw new BadRequestError({ message: "Key handoff incomplete. Please try logging in again." }); const token = await generateUserTokens({ diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index 14fb58258..5e2f8c7b3 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -121,7 +121,10 @@ export const authPaswordServiceFactory = ({ */ const sendPasswordResetEmail = async (email: string) => { const sendEmail = async () => { - const user = await userDAL.findUserByUsername(email); + const users = await userDAL.findUserByUsername(email); + // akhilmhdh: case sensitive email resolution + const user = users?.length > 1 ? users.find((el) => el.username === email) : users?.[0]; + if (!user) throw new BadRequestError({ message: "Failed to find user data" }); if (user && user.isAccepted) { const cfg = getConfig(); @@ -152,7 +155,10 @@ export const authPaswordServiceFactory = ({ * */ const verifyPasswordResetEmail = async (email: string, code: string) => { const cfg = getConfig(); - const user = await userDAL.findUserByUsername(email); + const users = await userDAL.findUserByUsername(email); + // akhilmhdh: case sensitive email resolution + const user = users?.length > 1 ? users.find((el) => el.username === email) : users?.[0]; + if (!user) throw new BadRequestError({ message: "Failed to find user data" }); const userEnc = await userDAL.findUserEncKeyByUserId(user.id); @@ -189,16 +195,15 @@ export const authPaswordServiceFactory = ({ throw new BadRequestError({ message: `User encryption key not found for user with ID '${userId}'` }); } - if (!user.hashedPassword) { - throw new BadRequestError({ message: "Unable to reset password, no password is set" }); - } - if (!user.authMethods?.includes(AuthMethod.EMAIL)) { throw new BadRequestError({ message: "Unable to reset password, no email authentication method is configured" }); } // we check the old password if the user is resetting their password while logged in if (type === ResetPasswordV2Type.LoggedInReset) { + if (!user.hashedPassword) { + throw new BadRequestError({ message: "Unable to change password, no password is set" }); + } if (!oldPassword) { throw new BadRequestError({ message: "Current password is required." }); } diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 4d8c98205..7e11f25cb 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -73,18 +73,27 @@ export const authSignupServiceFactory = ({ }: TAuthSignupDep) => { // first step of signup. create user and send email const beginEmailSignupProcess = async (email: string) => { - const isEmailInvalid = await isDisposableEmail(email); + const sanitizedEmail = email.trim().toLowerCase(); + const isEmailInvalid = await isDisposableEmail(sanitizedEmail); if (isEmailInvalid) { throw new Error("Provided a disposable email"); } - let user = await userDAL.findUserByUsername(email); + // akhilmhdh: case sensitive email resolution + const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail); + let user = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0]; if (user && user.isAccepted) { // TODO(akhilmhdh-pg): copy as old one. this needs to be changed due to security issues - throw new Error("Failed to send verification code for complete account"); + throw new BadRequestError({ message: "Failed to send verification code for complete account" }); } if (!user) { - user = await userDAL.create({ authMethods: [AuthMethod.EMAIL], username: email, email, isGhost: false }); + user = await userDAL.create({ + authMethods: [AuthMethod.EMAIL], + username: sanitizedEmail, + email: sanitizedEmail, + isGhost: false + }); } if (!user) throw new Error("Failed to create user"); @@ -96,7 +105,7 @@ export const authSignupServiceFactory = ({ await smtpService.sendMail({ template: SmtpTemplates.SignupEmailVerification, subjectLine: "Infisical confirmation code", - recipients: [user.email as string], + recipients: [sanitizedEmail], substitutions: { code: token } @@ -104,11 +113,15 @@ export const authSignupServiceFactory = ({ }; const verifyEmailSignup = async (email: string, code: string) => { - const user = await userDAL.findUserByUsername(email); + const sanitizedEmail = email.trim().toLowerCase(); + const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail); + const user = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0]; if (!user || (user && user.isAccepted)) { // TODO(akhilmhdh): copy as old one. this needs to be changed due to security issues throw new Error("Failed to send verification code for complete account"); } + const appCfg = getConfig(); await tokenService.validateTokenForUser({ type: TokenType.TOKEN_EMAIL_CONFIRMATION, @@ -153,12 +166,15 @@ export const authSignupServiceFactory = ({ authorization, useDefaultOrg }: TCompleteAccountSignupDTO) => { + const sanitizedEmail = email.trim().toLowerCase(); const appCfg = getConfig(); const serverCfg = await getServerCfg(); - const user = await userDAL.findOne({ username: email }); + const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail); + const user = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0]; if (!user || (user && user.isAccepted)) { - throw new Error("Failed to complete account for complete user"); + throw new BadRequestError({ message: "Failed to complete account for complete user" }); } let organizationId: string | null = null; @@ -315,7 +331,7 @@ export const authSignupServiceFactory = ({ } const updatedMembersips = await orgDAL.updateMembership( - { inviteEmail: email, status: OrgMembershipStatus.Invited }, + { inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited }, { userId: user.id, status: OrgMembershipStatus.Accepted } ); const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))]; @@ -382,9 +398,9 @@ export const authSignupServiceFactory = ({ * User signup flow when they are invited to join the org * */ const completeAccountInvite = async ({ + email, ip, salt, - email, password, verifier, firstName, @@ -399,7 +415,10 @@ export const authSignupServiceFactory = ({ encryptedPrivateKeyTag, authorization }: TCompleteAccountInviteDTO) => { - const user = await userDAL.findUserByUsername(email); + const sanitizedEmail = email.trim().toLowerCase(); + const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail); + const user = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0]; if (!user || (user && user.isAccepted)) { throw new Error("Failed to complete account for complete user"); } @@ -407,7 +426,7 @@ export const authSignupServiceFactory = ({ validateSignUpAuthorization(authorization, user.id); const [orgMembership] = await orgDAL.findMembership({ - inviteEmail: email, + inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited }); if (!orgMembership) @@ -454,7 +473,7 @@ export const authSignupServiceFactory = ({ const serverGeneratedPrivateKey = await getUserPrivateKey(serverGeneratedPassword, { ...systemGeneratedUserEncryptionKey }); - const encKeys = await generateUserSrpKeys(email, password, { + const encKeys = await generateUserSrpKeys(sanitizedEmail, password, { publicKey: systemGeneratedUserEncryptionKey.publicKey, privateKey: serverGeneratedPrivateKey }); @@ -505,7 +524,7 @@ export const authSignupServiceFactory = ({ } const updatedMembersips = await orgDAL.updateMembership( - { inviteEmail: email, status: OrgMembershipStatus.Invited }, + { inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited }, { userId: us.id, status: OrgMembershipStatus.Accepted }, tx ); diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts new file mode 100644 index 000000000..9f6fbe752 --- /dev/null +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts @@ -0,0 +1,3 @@ +export enum AcmeDnsProvider { + Route53 = "route53" +} 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 new file mode 100644 index 000000000..8e0372953 --- /dev/null +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts @@ -0,0 +1,521 @@ +import { ChangeResourceRecordSetsCommand, Route53Client } from "@aws-sdk/client-route-53"; +import * as x509 from "@peculiar/x509"; +import acme from "acme-client"; +import { KeyObject } from "crypto"; + +import { TableName } from "@app/db/schemas"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns"; +import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; +import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; +import { TAwsConnection, TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; +import { + CertExtendedKeyUsage, + CertKeyAlgorithm, + CertKeyUsage, + CertStatus +} 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 { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { TCertificateAuthorityDALFactory } from "../certificate-authority-dal"; +import { CaStatus, CaType } from "../certificate-authority-enums"; +import { keyAlgorithmToAlgCfg } from "../certificate-authority-fns"; +import { TExternalCertificateAuthorityDALFactory } from "../external-certificate-authority-dal"; +import { AcmeDnsProvider } from "./acme-certificate-authority-enums"; +import { AcmeCertificateAuthorityCredentialsSchema } from "./acme-certificate-authority-schemas"; +import { + TAcmeCertificateAuthority, + TCreateAcmeCertificateAuthorityDTO, + TUpdateAcmeCertificateAuthorityDTO +} from "./acme-certificate-authority-types"; + +type TAcmeCertificateAuthorityFnsDeps = { + appConnectionDAL: Pick; + appConnectionService: Pick; + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + "create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" + >; + externalCertificateAuthorityDAL: Pick; + certificateDAL: Pick; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + kmsService: Pick< + TKmsServiceFactory, + "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey" + >; + pkiSubscriberDAL: Pick; + projectDAL: Pick; +}; + +type DBConfigurationColumn = { + dnsProvider: string; + directoryUrl: string; + accountEmail: string; + hostedZoneId: string; +}; + +export const castDbEntryToAcmeCertificateAuthority = ( + ca: Awaited> +): TAcmeCertificateAuthority & { credentials: unknown } => { + if (!ca.externalCa?.id) { + throw new BadRequestError({ message: "Malformed ACME certificate authority" }); + } + + const dbConfigurationCol = ca.externalCa.configuration as DBConfigurationColumn; + + return { + id: ca.id, + type: CaType.ACME, + enableDirectIssuance: ca.enableDirectIssuance, + name: ca.name, + projectId: ca.projectId, + credentials: ca.externalCa.credentials, + configuration: { + dnsAppConnectionId: ca.externalCa.dnsAppConnectionId as string, + dnsProviderConfig: { + provider: dbConfigurationCol.dnsProvider as AcmeDnsProvider, + hostedZoneId: dbConfigurationCol.hostedZoneId + }, + directoryUrl: dbConfigurationCol.directoryUrl, + accountEmail: dbConfigurationCol.accountEmail + }, + status: ca.status as CaStatus + }; +}; + +export const route53InsertTxtRecord = async ( + connection: TAwsConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { + const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global + const route53Client = new Route53Client({ + credentials: config.credentials!, + region: config.region + }); + + const command = new ChangeResourceRecordSetsCommand({ + HostedZoneId: hostedZoneId, + ChangeBatch: { + Comment: "Set ACME challenge TXT record", + Changes: [ + { + Action: "UPSERT", + ResourceRecordSet: { + Name: domain, + Type: "TXT", + TTL: 30, + ResourceRecords: [{ Value: value }] + } + } + ] + } + }); + + await route53Client.send(command); +}; + +export const route53DeleteTxtRecord = async ( + connection: TAwsConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { + const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global + const route53Client = new Route53Client({ + credentials: config.credentials!, + region: config.region + }); + + const command = new ChangeResourceRecordSetsCommand({ + HostedZoneId: hostedZoneId, + ChangeBatch: { + Comment: "Delete ACME challenge TXT record", + Changes: [ + { + Action: "DELETE", + ResourceRecordSet: { + Name: domain, + Type: "TXT", + TTL: 30, + ResourceRecords: [{ Value: value }] + } + } + ] + } + }); + + await route53Client.send(command); +}; + +export const AcmeCertificateAuthorityFns = ({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + pkiSubscriberDAL +}: TAcmeCertificateAuthorityFnsDeps) => { + const createCertificateAuthority = async ({ + name, + projectId, + configuration, + enableDirectIssuance, + actor, + status + }: { + status: CaStatus; + name: string; + projectId: string; + configuration: TCreateAcmeCertificateAuthorityDTO["configuration"]; + enableDirectIssuance: boolean; + actor: OrgServiceActor; + }) => { + const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig } = configuration; + const appConnection = await appConnectionDAL.findById(dnsAppConnectionId); + + if (!appConnection) { + throw new NotFoundError({ message: `App connection with ID '${dnsAppConnectionId}' not found` }); + } + + if (dnsProviderConfig.provider === AcmeDnsProvider.Route53 && appConnection.app !== AppConnection.AWS) { + throw new BadRequestError({ + message: `App connection with ID '${dnsAppConnectionId}' is not an AWS connection` + }); + } + + // validates permission to connect + await appConnectionService.connectAppConnectionById(appConnection.app as AppConnection, dnsAppConnectionId, actor); + + const caEntity = await certificateAuthorityDAL.transaction(async (tx) => { + try { + const ca = await certificateAuthorityDAL.create( + { + projectId, + enableDirectIssuance, + name, + status + }, + tx + ); + + await externalCertificateAuthorityDAL.create( + { + caId: ca.id, + dnsAppConnectionId, + type: CaType.ACME, + configuration: { + directoryUrl, + accountEmail, + dnsProvider: dnsProviderConfig.provider, + hostedZoneId: dnsProviderConfig.hostedZoneId + } + }, + tx + ); + + return await certificateAuthorityDAL.findByIdWithAssociatedCa(ca.id, tx); + } catch (error) { + // @ts-expect-error We're expecting a database error + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (error?.error?.code === "23505") { + throw new BadRequestError({ + message: "Certificate authority with the same name already exists in your project" + }); + } + throw error; + } + }); + + if (!caEntity.externalCa?.id) { + throw new BadRequestError({ message: "Failed to create external certificate authority" }); + } + + return castDbEntryToAcmeCertificateAuthority(caEntity); + }; + + const updateCertificateAuthority = async ({ + id, + status, + configuration, + enableDirectIssuance, + actor, + name + }: { + id: string; + status?: CaStatus; + configuration: TUpdateAcmeCertificateAuthorityDTO["configuration"]; + enableDirectIssuance?: boolean; + actor: OrgServiceActor; + name?: string; + }) => { + const updatedCa = await certificateAuthorityDAL.transaction(async (tx) => { + if (configuration) { + const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig } = configuration; + const appConnection = await appConnectionDAL.findById(dnsAppConnectionId); + + if (!appConnection) { + throw new NotFoundError({ message: `App connection with ID '${dnsAppConnectionId}' not found` }); + } + + if (dnsProviderConfig.provider === AcmeDnsProvider.Route53 && appConnection.app !== AppConnection.AWS) { + throw new BadRequestError({ + message: `App connection with ID '${dnsAppConnectionId}' is not an AWS connection` + }); + } + + // validates permission to connect + await appConnectionService.connectAppConnectionById( + appConnection.app as AppConnection, + dnsAppConnectionId, + actor + ); + + await externalCertificateAuthorityDAL.update( + { + caId: id, + type: CaType.ACME + }, + { + dnsAppConnectionId, + configuration: { + directoryUrl, + accountEmail, + dnsProvider: dnsProviderConfig.provider, + hostedZoneId: dnsProviderConfig.hostedZoneId + } + }, + tx + ); + } + + if (name || status || enableDirectIssuance) { + await certificateAuthorityDAL.updateById( + id, + { + name, + status, + enableDirectIssuance + }, + tx + ); + } + + return certificateAuthorityDAL.findByIdWithAssociatedCa(id, tx); + }); + + if (!updatedCa.externalCa?.id) { + throw new BadRequestError({ message: "Failed to update external certificate authority" }); + } + + return castDbEntryToAcmeCertificateAuthority(updatedCa); + }; + + const listCertificateAuthorities = async ({ projectId }: { projectId: string }) => { + const cas = await certificateAuthorityDAL.findWithAssociatedCa({ + [`${TableName.CertificateAuthority}.projectId` as "projectId"]: projectId, + [`${TableName.ExternalCertificateAuthority}.type` as "type"]: CaType.ACME + }); + + return cas.map(castDbEntryToAcmeCertificateAuthority); + }; + + const orderSubscriberCertificate = async (subscriberId: string) => { + const subscriber = await pkiSubscriberDAL.findById(subscriberId); + if (!subscriber.caId) { + throw new BadRequestError({ message: "Subscriber does not have a CA" }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId); + if (!ca.externalCa || ca.externalCa.type !== CaType.ACME) { + throw new BadRequestError({ message: "CA is not an ACME CA" }); + } + + const acmeCa = castDbEntryToAcmeCertificateAuthority(ca); + if (acmeCa.status !== CaStatus.ACTIVE) { + throw new BadRequestError({ message: "CA is disabled" }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + let accountKey: Buffer | undefined; + if (acmeCa.credentials) { + const decryptedCredentials = await kmsDecryptor({ + cipherTextBlob: acmeCa.credentials as Buffer + }); + + const parsedCredentials = await AcmeCertificateAuthorityCredentialsSchema.parseAsync( + JSON.parse(decryptedCredentials.toString("utf8")) + ); + + accountKey = Buffer.from(parsedCredentials.accountKey, "base64"); + } + if (!accountKey) { + accountKey = await acme.crypto.createPrivateRsaKey(); + const newCredentials = { + accountKey: accountKey.toString("base64") + }; + const { cipherTextBlob: encryptedNewCredentials } = await kmsEncryptor({ + plainText: Buffer.from(JSON.stringify(newCredentials)) + }); + await externalCertificateAuthorityDAL.update( + { + caId: acmeCa.id + }, + { + credentials: encryptedNewCredentials + } + ); + } + + await blockLocalAndPrivateIpAddresses(acmeCa.configuration.directoryUrl); + + const acmeClient = new acme.Client({ + directoryUrl: acmeCa.configuration.directoryUrl, + accountKey + }); + + const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); + const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const skLeafObj = KeyObject.from(leafKeys.privateKey); + const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + + const [, certificateCsr] = await acme.crypto.createCsr( + { + altNames: subscriber.subjectAlternativeNames, + commonName: subscriber.commonName + }, + skLeaf + ); + + const appConnection = await appConnectionDAL.findById(acmeCa.configuration.dnsAppConnectionId); + const connection = await decryptAppConnection(appConnection, kmsService); + + const pem = await acmeClient.auto({ + csr: certificateCsr, + email: acmeCa.configuration.accountEmail, + challengePriority: ["dns-01"], + termsOfServiceAgreed: true, + + challengeCreateFn: async (authz, challenge, keyAuthorization) => { + if (challenge.type !== "dns-01") { + throw new Error("Unsupported challenge type"); + } + + const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" + const recordValue = `"${keyAuthorization}"`; // must be double quoted + + if (acmeCa.configuration.dnsProviderConfig.provider === AcmeDnsProvider.Route53) { + await route53InsertTxtRecord( + connection as TAwsConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + } + }, + challengeRemoveFn: async (authz, challenge, keyAuthorization) => { + const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" + const recordValue = `"${keyAuthorization}"`; // must be double quoted + + if (acmeCa.configuration.dnsProviderConfig.provider === AcmeDnsProvider.Route53) { + await route53DeleteTxtRecord( + connection as TAwsConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + } + } + }); + + const [leafCert, parentCert] = acme.crypto.splitPemChain(pem); + const certObj = new x509.X509Certificate(leafCert); + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(certObj.rawData)) + }); + + const certificateChainPem = parentCert.trim(); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: Buffer.from(skLeaf) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + pkiSubscriberId: subscriber.id, + status: CertStatus.ACTIVE, + friendlyName: subscriber.commonName, + commonName: subscriber.commonName, + altNames: subscriber.subjectAlternativeNames.join(","), + serialNumber: certObj.serialNumber, + notBefore: certObj.notBefore, + notAfter: certObj.notAfter, + keyUsages: subscriber.keyUsages as CertKeyUsage[], + extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[], + projectId: ca.projectId + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateSecretDAL.create( + { + certId: cert.id, + encryptedPrivateKey + }, + tx + ); + }); + }; + + return { + createCertificateAuthority, + updateCertificateAuthority, + listCertificateAuthorities, + orderSubscriberCertificate + }; +}; diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-schemas.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-schemas.ts new file mode 100644 index 000000000..56b3118cf --- /dev/null +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-schemas.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +import { CertificateAuthorities } from "@app/lib/api-docs/constants"; + +import { CaType } from "../certificate-authority-enums"; +import { + BaseCertificateAuthoritySchema, + GenericCreateCertificateAuthorityFieldsSchema, + GenericUpdateCertificateAuthorityFieldsSchema +} from "../certificate-authority-schemas"; +import { AcmeDnsProvider } from "./acme-certificate-authority-enums"; + +export const AcmeCertificateAuthorityConfigurationSchema = z.object({ + dnsAppConnectionId: z.string().uuid().trim().describe(CertificateAuthorities.CONFIGURATIONS.ACME.dnsAppConnectionId), + // soon, differentiate via the provider property + dnsProviderConfig: z.object({ + provider: z.nativeEnum(AcmeDnsProvider).describe(CertificateAuthorities.CONFIGURATIONS.ACME.provider), + hostedZoneId: z.string().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.hostedZoneId) + }), + directoryUrl: z.string().url().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.directoryUrl), + accountEmail: z.string().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.accountEmail) +}); + +export const AcmeCertificateAuthorityCredentialsSchema = z.object({ + accountKey: z.string() +}); + +export const AcmeCertificateAuthoritySchema = BaseCertificateAuthoritySchema.extend({ + type: z.literal(CaType.ACME), + configuration: AcmeCertificateAuthorityConfigurationSchema +}); + +export const CreateAcmeCertificateAuthoritySchema = GenericCreateCertificateAuthorityFieldsSchema(CaType.ACME).extend({ + configuration: AcmeCertificateAuthorityConfigurationSchema +}); + +export const UpdateAcmeCertificateAuthoritySchema = GenericUpdateCertificateAuthorityFieldsSchema(CaType.ACME).extend({ + configuration: AcmeCertificateAuthorityConfigurationSchema.optional() +}); diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-types.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-types.ts new file mode 100644 index 000000000..dc6c45971 --- /dev/null +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-types.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +import { + AcmeCertificateAuthoritySchema, + CreateAcmeCertificateAuthoritySchema, + UpdateAcmeCertificateAuthoritySchema +} from "./acme-certificate-authority-schemas"; + +export type TAcmeCertificateAuthority = z.infer; + +export type TAcmeCertificateAuthorityInput = z.infer; + +export type TCreateAcmeCertificateAuthorityDTO = z.infer; + +export type TUpdateAcmeCertificateAuthorityDTO = z.infer; diff --git a/backend/src/services/certificate-authority/certificate-authority-dal.ts b/backend/src/services/certificate-authority/certificate-authority-dal.ts index 837bbcf37..d5a45ce50 100644 --- a/backend/src/services/certificate-authority/certificate-authority-dal.ts +++ b/backend/src/services/certificate-authority/certificate-authority-dal.ts @@ -1,13 +1,188 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { CertificateAuthoritiesSchema, TableName, TCertificateAuthorities } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify } from "@app/lib/knex"; +import { buildFindFilter, ormify, selectAllTableCols, TFindOpt } from "@app/lib/knex"; export type TCertificateAuthorityDALFactory = ReturnType; +export type TCertificateAuthorityWithAssociatedCa = Awaited< + ReturnType +>; + export const certificateAuthorityDALFactory = (db: TDbClient) => { const caOrm = ormify(db, TableName.CertificateAuthority); + const findByNameAndProjectIdWithAssociatedCa = async (caName: string, projectId: string, tx?: Knex) => { + const result = await (tx || db.replicaNode())(TableName.CertificateAuthority) + .leftJoin( + TableName.InternalCertificateAuthority, + `${TableName.CertificateAuthority}.id`, + `${TableName.InternalCertificateAuthority}.caId` + ) + .leftJoin( + TableName.ExternalCertificateAuthority, + `${TableName.CertificateAuthority}.id`, + `${TableName.ExternalCertificateAuthority}.caId` + ) + .where(`${TableName.CertificateAuthority}.name`, caName) + .where(`${TableName.CertificateAuthority}.projectId`, projectId) + .select(selectAllTableCols(TableName.CertificateAuthority)) + .select( + db.ref("id").withSchema(TableName.InternalCertificateAuthority).as("internalCaId"), + db.ref("parentCaId").withSchema(TableName.InternalCertificateAuthority).as("internalParentCaId"), + db.ref("type").withSchema(TableName.InternalCertificateAuthority).as("internalType"), + db.ref("friendlyName").withSchema(TableName.InternalCertificateAuthority).as("internalFriendlyName"), + db.ref("organization").withSchema(TableName.InternalCertificateAuthority).as("internalOrganization"), + db.ref("ou").withSchema(TableName.InternalCertificateAuthority).as("internalOu"), + db.ref("country").withSchema(TableName.InternalCertificateAuthority).as("internalCountry"), + db.ref("province").withSchema(TableName.InternalCertificateAuthority).as("internalProvince"), + db.ref("locality").withSchema(TableName.InternalCertificateAuthority).as("internalLocality"), + db.ref("commonName").withSchema(TableName.InternalCertificateAuthority).as("internalCommonName"), + db.ref("dn").withSchema(TableName.InternalCertificateAuthority).as("internalDn"), + db.ref("serialNumber").withSchema(TableName.InternalCertificateAuthority).as("internalSerialNumber"), + db.ref("maxPathLength").withSchema(TableName.InternalCertificateAuthority).as("internalMaxPathLength"), + db.ref("keyAlgorithm").withSchema(TableName.InternalCertificateAuthority).as("internalKeyAlgorithm"), + db.ref("notBefore").withSchema(TableName.InternalCertificateAuthority).as("internalNotBefore"), + db.ref("notAfter").withSchema(TableName.InternalCertificateAuthority).as("internalNotAfter"), + db.ref("activeCaCertId").withSchema(TableName.InternalCertificateAuthority).as("internalActiveCaCertId") + ) + .select( + db.ref("id").withSchema(TableName.ExternalCertificateAuthority).as("externalCaId"), + db.ref("type").withSchema(TableName.ExternalCertificateAuthority).as("externalType"), + db.ref("configuration").withSchema(TableName.ExternalCertificateAuthority).as("externalConfiguration"), + db.ref("credentials").withSchema(TableName.ExternalCertificateAuthority).as("externalCredentials"), + db + .ref("dnsAppConnectionId") + .withSchema(TableName.ExternalCertificateAuthority) + .as("externalDnsAppConnectionId"), + db.ref("appConnectionId").withSchema(TableName.ExternalCertificateAuthority).as("externalAppConnectionId") + ) + .first(); + + const data = { + ...CertificateAuthoritiesSchema.parse(result), + internalCa: result + ? { + id: result.internalCaId, + parentCaId: result.internalParentCaId, + type: result.internalType, + friendlyName: result.internalFriendlyName, + organization: result.internalOrganization, + ou: result.internalOu, + country: result.internalCountry, + province: result.internalProvince, + locality: result.internalLocality, + commonName: result.internalCommonName, + dn: result.internalDn, + serialNumber: result.internalSerialNumber, + maxPathLength: result.internalMaxPathLength, + keyAlgorithm: result.internalKeyAlgorithm, + notBefore: result.internalNotBefore?.toISOString(), + notAfter: result.internalNotAfter?.toISOString(), + activeCaCertId: result.internalActiveCaCertId + } + : undefined, + externalCa: result + ? { + id: result.externalCaId, + type: result.externalType, + configuration: result.externalConfiguration, + dnsAppConnectionId: result.externalDnsAppConnectionId, + appConnectionId: result.externalAppConnectionId, + credentials: result.externalCredentials + } + : undefined + }; + + return data; + }; + + const findByIdWithAssociatedCa = async (caId: string, tx?: Knex) => { + const result = await (tx || db.replicaNode())(TableName.CertificateAuthority) + .leftJoin( + TableName.InternalCertificateAuthority, + `${TableName.CertificateAuthority}.id`, + `${TableName.InternalCertificateAuthority}.caId` + ) + .leftJoin( + TableName.ExternalCertificateAuthority, + `${TableName.CertificateAuthority}.id`, + `${TableName.ExternalCertificateAuthority}.caId` + ) + .where(`${TableName.CertificateAuthority}.id`, caId) + .select(selectAllTableCols(TableName.CertificateAuthority)) + .select( + db.ref("id").withSchema(TableName.InternalCertificateAuthority).as("internalCaId"), + db.ref("parentCaId").withSchema(TableName.InternalCertificateAuthority).as("internalParentCaId"), + db.ref("type").withSchema(TableName.InternalCertificateAuthority).as("internalType"), + db.ref("friendlyName").withSchema(TableName.InternalCertificateAuthority).as("internalFriendlyName"), + db.ref("organization").withSchema(TableName.InternalCertificateAuthority).as("internalOrganization"), + db.ref("ou").withSchema(TableName.InternalCertificateAuthority).as("internalOu"), + db.ref("country").withSchema(TableName.InternalCertificateAuthority).as("internalCountry"), + db.ref("province").withSchema(TableName.InternalCertificateAuthority).as("internalProvince"), + db.ref("locality").withSchema(TableName.InternalCertificateAuthority).as("internalLocality"), + db.ref("commonName").withSchema(TableName.InternalCertificateAuthority).as("internalCommonName"), + db.ref("dn").withSchema(TableName.InternalCertificateAuthority).as("internalDn"), + db.ref("serialNumber").withSchema(TableName.InternalCertificateAuthority).as("internalSerialNumber"), + db.ref("maxPathLength").withSchema(TableName.InternalCertificateAuthority).as("internalMaxPathLength"), + db.ref("keyAlgorithm").withSchema(TableName.InternalCertificateAuthority).as("internalKeyAlgorithm"), + db.ref("notBefore").withSchema(TableName.InternalCertificateAuthority).as("internalNotBefore"), + db.ref("notAfter").withSchema(TableName.InternalCertificateAuthority).as("internalNotAfter"), + db.ref("activeCaCertId").withSchema(TableName.InternalCertificateAuthority).as("internalActiveCaCertId") + ) + .select( + db.ref("id").withSchema(TableName.ExternalCertificateAuthority).as("externalCaId"), + db.ref("type").withSchema(TableName.ExternalCertificateAuthority).as("externalType"), + db.ref("configuration").withSchema(TableName.ExternalCertificateAuthority).as("externalConfiguration"), + db.ref("credentials").withSchema(TableName.ExternalCertificateAuthority).as("externalCredentials"), + db + .ref("dnsAppConnectionId") + .withSchema(TableName.ExternalCertificateAuthority) + .as("externalDnsAppConnectionId"), + db.ref("appConnectionId").withSchema(TableName.ExternalCertificateAuthority).as("externalAppConnectionId") + ) + .first(); + + const data = { + ...CertificateAuthoritiesSchema.parse(result), + internalCa: result + ? { + id: result.internalCaId, + parentCaId: result.internalParentCaId, + type: result.internalType, + friendlyName: result.internalFriendlyName, + organization: result.internalOrganization, + ou: result.internalOu, + country: result.internalCountry, + province: result.internalProvince, + locality: result.internalLocality, + commonName: result.internalCommonName, + dn: result.internalDn, + serialNumber: result.internalSerialNumber, + maxPathLength: result.internalMaxPathLength, + keyAlgorithm: result.internalKeyAlgorithm, + notBefore: result.internalNotBefore?.toISOString(), + notAfter: result.internalNotAfter?.toISOString(), + activeCaCertId: result.internalActiveCaCertId + } + : undefined, + externalCa: result + ? { + id: result.externalCaId, + type: result.externalType, + configuration: result.externalConfiguration, + dnsAppConnectionId: result.externalDnsAppConnectionId, + appConnectionId: result.externalAppConnectionId, + credentials: result.externalCredentials + } + : undefined + }; + + return data; + }; + // note: not used const buildCertificateChain = async (caId: string) => { try { @@ -42,8 +217,113 @@ export const certificateAuthorityDALFactory = (db: TDbClient) => { } }; + const findWithAssociatedCa = async ( + filter: Parameters<(typeof caOrm)["find"]>[0] & { dn?: string; type?: string }, + { offset, limit, sort = [["createdAt", "desc"]] }: TFindOpt = {}, + tx?: Knex + ) => { + try { + const query = (tx || db.replicaNode())(TableName.CertificateAuthority) + .leftJoin( + TableName.InternalCertificateAuthority, + `${TableName.CertificateAuthority}.id`, + `${TableName.InternalCertificateAuthority}.caId` + ) + .leftJoin( + TableName.ExternalCertificateAuthority, + `${TableName.CertificateAuthority}.id`, + `${TableName.ExternalCertificateAuthority}.caId` + ) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter(filter)) + .select(selectAllTableCols(TableName.CertificateAuthority)) + .select( + db.ref("id").withSchema(TableName.InternalCertificateAuthority).as("internalCaId"), + db.ref("parentCaId").withSchema(TableName.InternalCertificateAuthority).as("internalParentCaId"), + db.ref("type").withSchema(TableName.InternalCertificateAuthority).as("internalType"), + db.ref("friendlyName").withSchema(TableName.InternalCertificateAuthority).as("internalFriendlyName"), + db.ref("organization").withSchema(TableName.InternalCertificateAuthority).as("internalOrganization"), + db.ref("ou").withSchema(TableName.InternalCertificateAuthority).as("internalOu"), + db.ref("country").withSchema(TableName.InternalCertificateAuthority).as("internalCountry"), + db.ref("province").withSchema(TableName.InternalCertificateAuthority).as("internalProvince"), + db.ref("locality").withSchema(TableName.InternalCertificateAuthority).as("internalLocality"), + db.ref("commonName").withSchema(TableName.InternalCertificateAuthority).as("internalCommonName"), + db.ref("dn").withSchema(TableName.InternalCertificateAuthority).as("internalDn"), + db.ref("serialNumber").withSchema(TableName.InternalCertificateAuthority).as("internalSerialNumber"), + db.ref("maxPathLength").withSchema(TableName.InternalCertificateAuthority).as("internalMaxPathLength"), + db.ref("keyAlgorithm").withSchema(TableName.InternalCertificateAuthority).as("internalKeyAlgorithm"), + db.ref("notBefore").withSchema(TableName.InternalCertificateAuthority).as("internalNotBefore"), + db.ref("notAfter").withSchema(TableName.InternalCertificateAuthority).as("internalNotAfter"), + db.ref("activeCaCertId").withSchema(TableName.InternalCertificateAuthority).as("internalActiveCaCertId") + ) + .select( + db.ref("id").withSchema(TableName.ExternalCertificateAuthority).as("externalCaId"), + db.ref("type").withSchema(TableName.ExternalCertificateAuthority).as("externalType"), + db.ref("configuration").withSchema(TableName.ExternalCertificateAuthority).as("externalConfiguration"), + db + .ref("dnsAppConnectionId") + .withSchema(TableName.ExternalCertificateAuthority) + .as("externalDnsAppConnectionId"), + db.ref("credentials").withSchema(TableName.ExternalCertificateAuthority).as("externalCredentials"), + db.ref("appConnectionId").withSchema(TableName.ExternalCertificateAuthority).as("externalAppConnectionId") + ); + + if (limit) void query.limit(limit); + if (offset) void query.offset(offset); + if (sort) { + void query.orderBy( + sort.map(([column, order, nulls]) => ({ + column, + order, + nulls + })) + ); + } + + return (await query).map((ca) => ({ + ...CertificateAuthoritiesSchema.parse(ca), + internalCa: ca + ? { + id: ca.internalCaId, + parentCaId: ca.internalParentCaId, + type: ca.internalType, + friendlyName: ca.internalFriendlyName, + organization: ca.internalOrganization, + ou: ca.internalOu, + country: ca.internalCountry, + province: ca.internalProvince, + locality: ca.internalLocality, + commonName: ca.internalCommonName, + dn: ca.internalDn, + serialNumber: ca.internalSerialNumber, + maxPathLength: ca.internalMaxPathLength, + keyAlgorithm: ca.internalKeyAlgorithm, + notBefore: ca.internalNotBefore?.toISOString(), + notAfter: ca.internalNotAfter?.toISOString(), + activeCaCertId: ca.internalActiveCaCertId + } + : undefined, + externalCa: ca + ? { + id: ca.externalCaId, + type: ca.externalType, + configuration: ca.externalConfiguration, + dnsAppConnectionId: ca.externalDnsAppConnectionId, + appConnectionId: ca.externalAppConnectionId, + credentials: ca.externalCredentials + } + : undefined + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find - Certificate Authority" }); + } + }; + return { ...caOrm, - buildCertificateChain + findWithAssociatedCa, + buildCertificateChain, + findByIdWithAssociatedCa, + findByNameAndProjectIdWithAssociatedCa }; }; diff --git a/backend/src/services/certificate-authority/certificate-authority-enums.ts b/backend/src/services/certificate-authority/certificate-authority-enums.ts new file mode 100644 index 000000000..8de80495e --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-enums.ts @@ -0,0 +1,19 @@ +export enum CaType { + INTERNAL = "internal", + ACME = "acme" +} + +export enum InternalCaType { + ROOT = "root", + INTERMEDIATE = "intermediate" +} + +export enum CaStatus { + ACTIVE = "active", + DISABLED = "disabled", + PENDING_CERTIFICATE = "pending-certificate" +} + +export enum CaRenewalType { + EXISTING = "existing" +} diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts index d2c87e772..02be76565 100644 --- a/backend/src/services/certificate-authority/certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -5,13 +5,14 @@ import { NotFoundError } from "@app/lib/errors"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; import { CertKeyAlgorithm, CertStatus } from "../certificate/certificate-types"; +import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; import { TDNParts, TGetCaCertChainDTO, TGetCaCertChainsDTO, TGetCaCredentialsDTO, TRebuildCaCrlDTO -} from "./certificate-authority-types"; +} from "./internal/internal-certificate-authority-types"; /* eslint-disable no-bitwise */ export const createSerialNumber = () => { @@ -112,8 +113,8 @@ export const getCaCredentials = async ({ projectDAL, kmsService }: TGetCaCredentialsDTO) => { - const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca?.internalCa?.id) throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); const caSecret = await certificateAuthoritySecretDAL.findOne({ caId }); if (!caSecret) throw new NotFoundError({ message: `CA secret for CA with ID '${caId}' not found` }); @@ -131,7 +132,7 @@ export const getCaCredentials = async ({ cipherTextBlob: caSecret.encryptedPrivateKey }); - const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); const skObj = crypto.createPrivateKey({ key: decryptedPrivateKey, format: "der", type: "pkcs8" }); const caPrivateKey = await crypto.subtle.importKey( "pkcs8", @@ -255,12 +256,12 @@ export const rebuildCaCrl = async ({ certificateDAL, kmsService }: TRebuildCaCrlDTO) => { - const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca?.internalCa?.id) throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); const caSecret = await certificateAuthoritySecretDAL.findOne({ caId: ca.id }); - const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); const keyId = await getProjectKmsCertificateKeyId({ projectId: ca.projectId, @@ -287,7 +288,7 @@ export const rebuildCaCrl = async ({ }); const crl = await x509.X509CrlGenerator.create({ - issuer: ca.dn, + issuer: ca.internalCa.dn, thisUpdate: new Date(), nextUpdate: new Date("2025/12/12"), entries: revokedCerts.map((revokedCert) => { @@ -318,3 +319,16 @@ export const rebuildCaCrl = async ({ } ); }; + +export const expandInternalCa = ( + ca: Awaited> +) => { + if (!ca.internalCa) { + throw new Error("Internal CA must be defined"); + } + return { + ...ca.internalCa, + ...ca, + requireTemplateForIssuance: !ca.enableDirectIssuance + } as const; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-maps.ts b/backend/src/services/certificate-authority/certificate-authority-maps.ts new file mode 100644 index 000000000..d13f65138 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-maps.ts @@ -0,0 +1,6 @@ +import { CaType } from "./certificate-authority-enums"; + +export const CERTIFICATE_AUTHORITIES_TYPE_MAP: Record = { + [CaType.INTERNAL]: "Internal", + [CaType.ACME]: "ACME" +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-queue.ts b/backend/src/services/certificate-authority/certificate-authority-queue.ts index 8c6d3906d..74970bf0c 100644 --- a/backend/src/services/certificate-authority/certificate-authority-queue.ts +++ b/backend/src/services/certificate-authority/certificate-authority-queue.ts @@ -1,9 +1,10 @@ import * as x509 from "@peculiar/x509"; import crypto from "crypto"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { daysToMillisecond, secondsToMillis } from "@app/lib/dates"; -import { NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; @@ -13,21 +14,43 @@ import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal"; +import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service"; +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 { AcmeCertificateAuthorityFns } from "./acme/acme-certificate-authority-fns"; import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; +import { CaType } from "./certificate-authority-enums"; import { keyAlgorithmToAlgCfg } from "./certificate-authority-fns"; import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal"; -import { TRotateCaCrlTriggerDTO } from "./certificate-authority-types"; +import { TExternalCertificateAuthorityDALFactory } from "./external-certificate-authority-dal"; +import { + TOrderCertificateForSubscriberDTO, + TRotateCaCrlTriggerDTO +} from "./internal/internal-certificate-authority-types"; type TCertificateAuthorityQueueFactoryDep = { - // TODO: Pick certificateAuthorityDAL: TCertificateAuthorityDALFactory; + appConnectionDAL: Pick; + appConnectionService: Pick; + externalCertificateAuthorityDAL: Pick; + keyStore: Pick; certificateAuthorityCrlDAL: TCertificateAuthorityCrlDALFactory; certificateAuthoritySecretDAL: TCertificateAuthoritySecretDALFactory; certificateDAL: TCertificateDALFactory; projectDAL: Pick; - kmsService: Pick; + kmsService: Pick< + TKmsServiceFactory, + "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "createCipherPairWithDataKey" + >; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; queueService: TQueueServiceFactory; + pkiSubscriberDAL: Pick; }; + export type TCertificateAuthorityQueueFactory = ReturnType; export const certificateAuthorityQueueFactory = ({ @@ -37,8 +60,28 @@ export const certificateAuthorityQueueFactory = ({ certificateDAL, projectDAL, kmsService, - queueService + queueService, + keyStore, + appConnectionDAL, + appConnectionService, + externalCertificateAuthorityDAL, + certificateBodyDAL, + certificateSecretDAL, + pkiSubscriberDAL }: TCertificateAuthorityQueueFactoryDep) => { + const acmeFns = AcmeCertificateAuthorityFns({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + pkiSubscriberDAL, + projectDAL + }); + // TODO 1: auto-periodic rotation // TODO 2: manual rotation @@ -71,16 +114,76 @@ export const certificateAuthorityQueueFactory = ({ ); }; + const orderCertificateForSubscriber = async ({ subscriberId, caType }: TOrderCertificateForSubscriberDTO) => { + const entry = await keyStore.getItem(KeyStorePrefixes.CaOrderCertificateForSubscriberLock(subscriberId)); + if (entry) { + throw new BadRequestError({ message: `Certificate order already in progress for subscriber ${subscriberId}` }); + } + + await queueService.queue( + QueueName.CaLifecycle, + QueueJobs.CaOrderCertificateForSubscriber, + { + subscriberId, + caType + }, + { + attempts: 1, + removeOnComplete: true, + removeOnFail: true + } + ); + }; + + queueService.start(QueueName.CaLifecycle, async (job) => { + if (job.name === QueueJobs.CaOrderCertificateForSubscriber) { + const { subscriberId, caType } = job.data; + let lock: Awaited>; + + try { + lock = await keyStore.acquireLock( + [KeyStorePrefixes.CaOrderCertificateForSubscriberLock(subscriberId)], + 5 * 60 * 1000 + ); + } catch (e) { + logger.info(`CaOrderCertificate Failed to acquire lock [subscriberId=${subscriberId}] [job=${job.name}]`); + return; + } + + try { + if (caType === CaType.ACME) { + await acmeFns.orderSubscriberCertificate(subscriberId); + await pkiSubscriberDAL.updateById(subscriberId, { + lastOperationStatus: SubscriberOperationStatus.SUCCESS, + lastOperationMessage: "Certificate ordered successfully", + lastOperationAt: new Date() + }); + } + } catch (e: unknown) { + if (e instanceof Error) { + await pkiSubscriberDAL.updateById(subscriberId, { + lastOperationStatus: SubscriberOperationStatus.FAILED, + lastOperationMessage: e.message, + lastOperationAt: new Date() + }); + } + logger.error(e, `CaOrderCertificate Failed [subscriberId=${subscriberId}] [job=${job.name}]`); + } finally { + await lock.release(); + } + } + }); + queueService.start(QueueName.CaCrlRotation, async (job) => { const { caId } = job.data; logger.info(`secretReminderQueue.process: [secretDocument=${caId}]`); - const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); const caSecret = await certificateAuthoritySecretDAL.findOne({ caId: ca.id }); - const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); const keyId = await getProjectKmsCertificateKeyId({ projectId: ca.projectId, @@ -106,7 +209,7 @@ export const certificateAuthorityQueueFactory = ({ }); const crl = await x509.X509CrlGenerator.create({ - issuer: ca.dn, + issuer: ca.internalCa.dn, thisUpdate: new Date(), nextUpdate: new Date("2025/12/12"), // TODO: depends on configured rebuild interval entries: revokedCerts.map((revokedCert) => { @@ -115,7 +218,7 @@ export const certificateAuthorityQueueFactory = ({ revocationDate: new Date(revokedCert.revokedAt as Date), reason: revokedCert.revocationReason as number, invalidity: new Date("2022/01/01"), - issuer: ca.dn + issuer: ca.internalCa?.dn }; }), signingAlgorithm: alg, @@ -144,6 +247,7 @@ export const certificateAuthorityQueueFactory = ({ }); return { - setCaCrlRotationInterval + setCaCrlRotationInterval, + orderCertificateForSubscriber }; }; diff --git a/backend/src/services/certificate-authority/certificate-authority-schemas.ts b/backend/src/services/certificate-authority/certificate-authority-schemas.ts new file mode 100644 index 000000000..61d620156 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-schemas.ts @@ -0,0 +1,32 @@ +import z from "zod"; + +import { CertificateAuthoritiesSchema } from "@app/db/schemas"; +import { CertificateAuthorities } from "@app/lib/api-docs/constants"; +import { slugSchema } from "@app/server/lib/schemas"; + +import { CaStatus, CaType } from "./certificate-authority-enums"; + +export const BaseCertificateAuthoritySchema = CertificateAuthoritiesSchema.pick({ + projectId: true, + enableDirectIssuance: true, + name: true, + id: true +}).extend({ + status: z.nativeEnum(CaStatus) +}); + +export const GenericCreateCertificateAuthorityFieldsSchema = (type: CaType) => + z.object({ + name: slugSchema({ field: "name" }).describe(CertificateAuthorities.CREATE(type).name), + projectId: z.string().trim().min(1, "Project ID required").describe(CertificateAuthorities.CREATE(type).projectId), + enableDirectIssuance: z.boolean().describe(CertificateAuthorities.CREATE(type).enableDirectIssuance), + status: z.nativeEnum(CaStatus).describe(CertificateAuthorities.CREATE(type).status) + }); + +export const GenericUpdateCertificateAuthorityFieldsSchema = (type: CaType) => + z.object({ + name: slugSchema({ field: "name" }).optional().describe(CertificateAuthorities.UPDATE(type).name), + projectId: z.string().trim().min(1, "Project ID required").describe(CertificateAuthorities.UPDATE(type).projectId), + enableDirectIssuance: z.boolean().optional().describe(CertificateAuthorities.UPDATE(type).enableDirectIssuance), + status: z.nativeEnum(CaStatus).optional().describe(CertificateAuthorities.UPDATE(type).status) + }); diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index e1d7ce5cb..a57c085ba 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -1,155 +1,119 @@ -/* eslint-disable no-bitwise */ import { ForbiddenError } from "@casl/ability"; -import * as x509 from "@peculiar/x509"; -import crypto, { KeyObject } from "crypto"; -import { z } from "zod"; -import { ActionProjectType, ProjectType, TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas"; +import { ActionProjectType, ProjectType, TableName } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { - ProjectPermissionActions, - ProjectPermissionCertificateActions, - ProjectPermissionSub -} from "@app/ee/services/permission/project-permission"; -import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; -import { getConfig } from "@app/lib/config/env"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; -import { ms } from "@app/lib/ms"; -import { isFQDN } from "@app/lib/validator/validate-url"; -import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; -import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; -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 { TProjectDALFactory } from "@app/services/project/project-dal"; -import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; +import { OrgServiceActor } from "@app/lib/types"; -import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal"; +import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service"; +import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; +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 { TProjectDALFactory } from "../project/project-dal"; import { - CertExtendedKeyUsage, - CertExtendedKeyUsageOIDToName, - CertKeyAlgorithm, - CertKeyUsage, - CertStatus -} from "../certificate/certificate-types"; -import { TCertificateTemplateDALFactory } from "../certificate-template/certificate-template-dal"; -import { validateCertificateDetailsAgainstTemplate } from "../certificate-template/certificate-template-fns"; -import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal"; + AcmeCertificateAuthorityFns, + castDbEntryToAcmeCertificateAuthority +} from "./acme/acme-certificate-authority-fns"; +import { + TCreateAcmeCertificateAuthorityDTO, + TUpdateAcmeCertificateAuthorityDTO +} from "./acme/acme-certificate-authority-types"; import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; +import { CaType } from "./certificate-authority-enums"; import { - createDistinguishedName, - createSerialNumber, - getCaCertChain, // TODO: consider rename - getCaCertChains, - getCaCredentials, - keyAlgorithmToAlgCfg, - parseDistinguishedName -} from "./certificate-authority-fns"; -import { TCertificateAuthorityQueueFactory } from "./certificate-authority-queue"; -import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal"; -import { - CaStatus, - CaType, - TCreateCaDTO, - TDeleteCaDTO, - TGetCaCertDTO, - TGetCaCertificateTemplatesDTO, - TGetCaCertsDTO, - TGetCaCsrDTO, - TGetCaDTO, - TImportCertToCaDTO, - TIssueCertFromCaDTO, - TRenewCaCertDTO, - TSignCertFromCaDTO, - TSignIntermediateDTO, - TUpdateCaDTO + TCertificateAuthority, + TCreateCertificateAuthorityDTO, + TUpdateCertificateAuthorityDTO } from "./certificate-authority-types"; +import { TExternalCertificateAuthorityDALFactory } from "./external-certificate-authority-dal"; +import { TInternalCertificateAuthorityServiceFactory } from "./internal/internal-certificate-authority-service"; +import { TCreateInternalCertificateAuthorityDTO } from "./internal/internal-certificate-authority-types"; type TCertificateAuthorityServiceFactoryDep = { + appConnectionDAL: Pick; + appConnectionService: Pick; certificateAuthorityDAL: Pick< TCertificateAuthorityDALFactory, - "transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" + | "transaction" + | "create" + | "findById" + | "updateById" + | "deleteById" + | "findOne" + | "findByIdWithAssociatedCa" + | "findWithAssociatedCa" + | "findByNameAndProjectIdWithAssociatedCa" >; - certificateAuthorityCertDAL: Pick< - TCertificateAuthorityCertDALFactory, - "create" | "findOne" | "transaction" | "find" | "findById" - >; - certificateAuthoritySecretDAL: Pick; - certificateAuthorityCrlDAL: Pick; - certificateTemplateDAL: Pick; - certificateAuthorityQueue: TCertificateAuthorityQueueFactory; // TODO: Pick - certificateDAL: Pick; - certificateSecretDAL: Pick; - certificateBodyDAL: Pick; - pkiCollectionDAL: Pick; - pkiCollectionItemDAL: Pick; + externalCertificateAuthorityDAL: Pick; + internalCertificateAuthorityService: TInternalCertificateAuthorityServiceFactory; projectDAL: Pick< TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction" | "getProjectFromSplitId" >; - kmsService: Pick; permissionService: Pick; + certificateDAL: Pick; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + kmsService: Pick< + TKmsServiceFactory, + "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey" + >; + pkiSubscriberDAL: Pick; }; export type TCertificateAuthorityServiceFactory = ReturnType; export const certificateAuthorityServiceFactory = ({ certificateAuthorityDAL, - certificateAuthorityCertDAL, - certificateAuthoritySecretDAL, - certificateAuthorityCrlDAL, - certificateTemplateDAL, + projectDAL, + permissionService, + internalCertificateAuthorityService, + appConnectionDAL, + appConnectionService, + externalCertificateAuthorityDAL, certificateDAL, certificateBodyDAL, certificateSecretDAL, - pkiCollectionDAL, - pkiCollectionItemDAL, - projectDAL, kmsService, - permissionService + pkiSubscriberDAL }: TCertificateAuthorityServiceFactoryDep) => { - /** - * Generates new root or intermediate CA - */ - const createCa = async ({ - projectSlug, - type, - friendlyName, - commonName, - organization, - ou, - country, - province, - locality, - notBefore, - notAfter, - maxPathLength, - keyAlgorithm, - requireTemplateForIssuance, - actorId, - actorAuthMethod, - actor, - actorOrgId - }: TCreateCaDTO) => { - const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); - if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); - let projectId = project.id; + const acmeFns = AcmeCertificateAuthorityFns({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + pkiSubscriberDAL, + projectDAL + }); + const createCertificateAuthority = async ( + { type, projectId, name, enableDirectIssuance, configuration, status }: TCreateCertificateAuthorityDTO, + actor: OrgServiceActor + ) => { + let finalProjectId: string = projectId; const certManagerProjectFromSplit = await projectDAL.getProjectFromSplitId( projectId, ProjectType.CertificateManager ); + if (certManagerProjectFromSplit) { - projectId = certManagerProjectFromSplit.id; + finalProjectId = certManagerProjectFromSplit.id; } const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId, - actorAuthMethod, - actorOrgId, + actor: actor.type, + actorId: actor.id, + projectId: finalProjectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, actionProjectType: ActionProjectType.CertificateManager }); @@ -158,199 +122,179 @@ export const certificateAuthorityServiceFactory = ({ ProjectPermissionSub.CertificateAuthorities ); - const dn = createDistinguishedName({ - commonName, - organization, - ou, - country, - province, - locality - }); - - const alg = keyAlgorithmToAlgCfg(keyAlgorithm); - const keys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); - - const newCa = await certificateAuthorityDAL.transaction(async (tx) => { - const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); - - // if undefined, set [notAfterDate] to 10 years from now - const notAfterDate = notAfter - ? new Date(notAfter) - : new Date(new Date().setFullYear(new Date().getFullYear() + 10)); - - const serialNumber = createSerialNumber(); - - const ca = await certificateAuthorityDAL.create( - { - projectId, - type, - organization, - ou, - country, - province, - locality, - friendlyName: friendlyName || dn, - commonName, - status: type === CaType.ROOT ? CaStatus.ACTIVE : CaStatus.PENDING_CERTIFICATE, - dn, - keyAlgorithm, - ...(type === CaType.ROOT && { - maxPathLength, - notBefore: notBeforeDate, - notAfter: notAfterDate, - serialNumber - }), - requireTemplateForIssuance - }, - tx - ); - - const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ - projectId, - projectDAL, - kmsService - }); - const kmsEncryptor = await kmsService.encryptWithKmsKey({ - kmsId: certificateManagerKmsId + if (type === CaType.INTERNAL) { + const ca = await internalCertificateAuthorityService.createCa({ + ...(configuration as TCreateInternalCertificateAuthorityDTO["configuration"]), + isInternal: true, + projectId: finalProjectId, + enableDirectIssuance, + name }); - // https://nodejs.org/api/crypto.html#static-method-keyobjectfromkey - const skObj = KeyObject.from(keys.privateKey); - - const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ - plainText: skObj.export({ - type: "pkcs8", - format: "der" - }) - }); - - const caSecret = await certificateAuthoritySecretDAL.create( - { - caId: ca.id, - encryptedPrivateKey - }, - tx - ); - - if (type === CaType.ROOT) { - // note: create self-signed cert only applicable for root CA - const cert = await x509.X509CertificateGenerator.createSelfSigned({ - name: dn, - serialNumber, - notBefore: notBeforeDate, - notAfter: notAfterDate, - signingAlgorithm: alg, - keys, - extensions: [ - new x509.BasicConstraintsExtension(true, maxPathLength === -1 ? undefined : maxPathLength, true), - // eslint-disable-next-line no-bitwise - new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), - await x509.SubjectKeyIdentifierExtension.create(keys.publicKey) - ] + if (!ca.internalCa) { + throw new BadRequestError({ + message: "Failed to create internal certificate authority" }); - - const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ - plainText: Buffer.from(new Uint8Array(cert.rawData)) - }); - - const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ - plainText: Buffer.alloc(0) - }); - - const caCert = await certificateAuthorityCertDAL.create( - { - caId: ca.id, - encryptedCertificate, - encryptedCertificateChain, - version: 1, - caSecretId: caSecret.id - }, - tx - ); - - await certificateAuthorityDAL.updateById( - ca.id, - { - activeCaCertId: caCert.id - }, - tx - ); } - // create empty CRL - const crl = await x509.X509CrlGenerator.create({ - issuer: ca.dn, - thisUpdate: new Date(), - nextUpdate: new Date("2025/12/12"), // TODO: change - entries: [], - signingAlgorithm: alg, - signingKey: keys.privateKey + return { + id: ca.id, + type, + enableDirectIssuance: ca.enableDirectIssuance, + name: ca.name, + projectId: finalProjectId, + status, + configuration: ca.internalCa + } as TCertificateAuthority; + } + + if (type === CaType.ACME) { + return acmeFns.createCertificateAuthority({ + name, + projectId: finalProjectId, + configuration: configuration as TCreateAcmeCertificateAuthorityDTO["configuration"], + enableDirectIssuance, + status, + actor }); + } - const { cipherTextBlob: encryptedCrl } = await kmsEncryptor({ - plainText: Buffer.from(new Uint8Array(crl.rawData)) - }); - - await certificateAuthorityCrlDAL.create( - { - caId: ca.id, - encryptedCrl, - caSecretId: caSecret.id - }, - tx - ); - - return ca; - }); - - return newCa; + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; - /** - * Return CA with id [caId] - */ - const getCaById = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaDTO) => { - const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + const findCertificateAuthorityByNameAndProjectId = async ( + { caName, type, projectId }: { caName: string; type: CaType; projectId: string }, + actor: OrgServiceActor + ) => { + const certificateAuthority = await certificateAuthorityDAL.findByNameAndProjectIdWithAssociatedCa( + caName, + projectId + ); + + if (!certificateAuthority) + throw new NotFoundError({ + message: `Could not find certificate authority with name "${caName}" in project "${projectId}"` + }); const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: ca.projectId, - actorAuthMethod, - actorOrgId, + actor: actor.type, + actorId: actor.id, + projectId: certificateAuthority.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, actionProjectType: ActionProjectType.CertificateManager }); + ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities ); - return ca; + if (type === CaType.INTERNAL) { + if (!certificateAuthority.internalCa?.id) { + throw new NotFoundError({ + message: `Internal certificate authority with name "${caName}" in project "${projectId}" not found` + }); + } + + return { + id: certificateAuthority.id, + type, + enableDirectIssuance: certificateAuthority.enableDirectIssuance, + name: certificateAuthority.name, + projectId: certificateAuthority.projectId, + configuration: certificateAuthority.internalCa, + status: certificateAuthority.status + } as TCertificateAuthority; + } + + if (certificateAuthority.externalCa?.type !== type) { + throw new NotFoundError({ + message: `Could not find external certificate authority with name "${caName}" in project "${projectId}" and type "${type}"` + }); + } + + if (type === CaType.ACME) { + return castDbEntryToAcmeCertificateAuthority(certificateAuthority); + } + + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; - /** - * Update CA with id [caId]. - * Note: Used to enable/disable CA - */ - const updateCaById = async ({ - caId, - status, - requireTemplateForIssuance, - actorId, - actorAuthMethod, - actor, - actorOrgId - }: TUpdateCaDTO) => { - const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + const listCertificateAuthoritiesByProjectId = async ( + { projectId, type }: { projectId: string; type: CaType }, + actor: OrgServiceActor + ) => { + let finalProjectId: string = projectId; + const certManagerProjectFromSplit = await projectDAL.getProjectFromSplitId( + projectId, + ProjectType.CertificateManager + ); + + if (certManagerProjectFromSplit) { + finalProjectId = certManagerProjectFromSplit.id; + } const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: ca.projectId, - actorAuthMethod, - actorOrgId, + actor: actor.type, + actorId: actor.id, + projectId: finalProjectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + if (type === CaType.INTERNAL) { + const cas = await certificateAuthorityDAL.findWithAssociatedCa({ + [`${TableName.CertificateAuthority}.projectId` as "projectId"]: finalProjectId, + $notNull: [`${TableName.InternalCertificateAuthority}.id` as "id"] + }); + + return cas + .filter((ca): ca is typeof ca & { internalCa: NonNullable } => Boolean(ca.internalCa)) + .map((ca) => ({ + id: ca.id, + type, + enableDirectIssuance: ca.enableDirectIssuance, + name: ca.name, + projectId: ca.projectId, + configuration: ca.internalCa, + status: ca.status + })) as TCertificateAuthority[]; + } + + if (type === CaType.ACME) { + return acmeFns.listCertificateAuthorities({ projectId: finalProjectId }); + } + + throw new BadRequestError({ message: "Invalid certificate authority type" }); + }; + + const updateCertificateAuthority = async ( + { caName, type, configuration, enableDirectIssuance, status, name, projectId }: TUpdateCertificateAuthorityDTO, + actor: OrgServiceActor + ) => { + const certificateAuthority = await certificateAuthorityDAL.findByNameAndProjectIdWithAssociatedCa( + caName, + projectId + ); + + if (!certificateAuthority) + throw new NotFoundError({ + message: `Could not find certificate authority with name "${caName}" in project "${projectId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: certificateAuthority.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, actionProjectType: ActionProjectType.CertificateManager }); @@ -359,24 +303,72 @@ export const certificateAuthorityServiceFactory = ({ ProjectPermissionSub.CertificateAuthorities ); - const updatedCa = await certificateAuthorityDAL.updateById(caId, { status, requireTemplateForIssuance }); + if (type === CaType.INTERNAL) { + if (!certificateAuthority.internalCa?.id) { + throw new NotFoundError({ + message: `Internal certificate authority with name "${caName}" in project "${projectId}" not found` + }); + } - return updatedCa; + const updatedCa = await internalCertificateAuthorityService.updateCaById({ + isInternal: true, + enableDirectIssuance, + caId: certificateAuthority.id, + status, + name + }); + + if (!updatedCa.internalCa) { + throw new BadRequestError({ + message: "Failed to update internal certificate authority" + }); + } + + return { + id: updatedCa.id, + type, + enableDirectIssuance: updatedCa.enableDirectIssuance, + name: updatedCa.name, + projectId: updatedCa.projectId, + configuration: updatedCa.internalCa, + status: updatedCa.status + } as TCertificateAuthority; + } + + if (type === CaType.ACME) { + return acmeFns.updateCertificateAuthority({ + id: certificateAuthority.id, + configuration: configuration as TUpdateAcmeCertificateAuthorityDTO["configuration"], + enableDirectIssuance, + actor, + status, + name + }); + } + + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; - /** - * Delete CA with id [caId] - */ - const deleteCaById = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteCaDTO) => { - const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + const deleteCertificateAuthority = async ( + { caName, type, projectId }: { caName: string; type: CaType; projectId: string }, + actor: OrgServiceActor + ) => { + const certificateAuthority = await certificateAuthorityDAL.findByNameAndProjectIdWithAssociatedCa( + caName, + projectId + ); + + if (!certificateAuthority) + throw new NotFoundError({ + message: `Could not find certificate authority with name "${caName}" in project "${projectId}"` + }); const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: ca.projectId, - actorAuthMethod, - actorOrgId, + actor: actor.type, + actorId: actor.id, + projectId: certificateAuthority.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, actionProjectType: ActionProjectType.CertificateManager }); @@ -385,1521 +377,44 @@ export const certificateAuthorityServiceFactory = ({ ProjectPermissionSub.CertificateAuthorities ); - const deletedCa = await certificateAuthorityDAL.deleteById(caId); - - return deletedCa; - }; - - /** - * Return certificate signing request (CSR) made with CA with id [caId] - */ - const getCaCsr = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCsrDTO) => { - const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: ca.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.CertificateManager - }); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - ProjectPermissionSub.CertificateAuthorities - ); - - if (ca.type === CaType.ROOT) throw new BadRequestError({ message: "Root CA cannot generate CSR" }); - - const { caPrivateKey, caPublicKey } = await getCaCredentials({ - caId, - certificateAuthorityDAL, - certificateAuthoritySecretDAL, - projectDAL, - kmsService - }); - - const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); - - const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ - name: ca.dn, - keys: { - privateKey: caPrivateKey, - publicKey: caPublicKey - }, - signingAlgorithm: alg, - extensions: [ - // eslint-disable-next-line no-bitwise - new x509.KeyUsagesExtension( - x509.KeyUsageFlags.keyCertSign | - x509.KeyUsageFlags.cRLSign | - x509.KeyUsageFlags.digitalSignature | - x509.KeyUsageFlags.keyEncipherment - ) - ], - attributes: [new x509.ChallengePasswordAttribute("password")] - }); - - return { - csr: csrObj.toString("pem"), - ca - }; - }; - - /** - * Renew certificate for CA with id [caId] - * Note 1: This CA renewal method is only applicable to CAs with internal parent CAs - * Note 2: Currently implements CA renewal with same key-pair only - */ - const renewCaCert = async ({ caId, notAfter, actorId, actorAuthMethod, actor, actorOrgId }: TRenewCaCertDTO) => { - const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); - - if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: ca.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.CertificateManager - }); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - ProjectPermissionSub.CertificateAuthorities - ); - - if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); - - // get latest CA certificate - const caCert = await certificateAuthorityCertDAL.findById(ca.activeCaCertId); - - const serialNumber = createSerialNumber(); - - const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ - projectId: ca.projectId, - projectDAL, - kmsService - }); - - const kmsEncryptor = await kmsService.encryptWithKmsKey({ - kmsId: certificateManagerKmsId - }); - - const { caPrivateKey, caPublicKey, caSecret } = await getCaCredentials({ - caId: ca.id, - certificateAuthorityDAL, - certificateAuthoritySecretDAL, - projectDAL, - kmsService - }); - - const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); - - const kmsDecryptor = await kmsService.decryptWithKmsKey({ - kmsId: certificateManagerKmsId - }); - const decryptedCaCert = await kmsDecryptor({ - cipherTextBlob: caCert.encryptedCertificate - }); - - const caCertObj = new x509.X509Certificate(decryptedCaCert); - - let certificate = ""; - let certificateChain = ""; - - switch (ca.type) { - case CaType.ROOT: { - if (new Date(notAfter) <= new Date(caCertObj.notAfter)) { - throw new BadRequestError({ - message: - "New Root CA certificate must have notAfter date that is greater than the current certificate notAfter date" - }); - } - - const notBeforeDate = new Date(); - const cert = await x509.X509CertificateGenerator.createSelfSigned({ - name: ca.dn, - serialNumber, - notBefore: notBeforeDate, - notAfter: new Date(notAfter), - signingAlgorithm: alg, - keys: { - privateKey: caPrivateKey, - publicKey: caPublicKey - }, - extensions: [ - new x509.BasicConstraintsExtension( - true, - ca.maxPathLength === -1 || !ca.maxPathLength ? undefined : ca.maxPathLength, - true - ), - // eslint-disable-next-line no-bitwise - new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), - await x509.SubjectKeyIdentifierExtension.create(caPublicKey) - ] - }); - - const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ - plainText: Buffer.from(new Uint8Array(cert.rawData)) - }); - - const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ - plainText: Buffer.alloc(0) - }); - - await certificateAuthorityDAL.transaction(async (tx) => { - const newCaCert = await certificateAuthorityCertDAL.create( - { - caId: ca.id, - encryptedCertificate, - encryptedCertificateChain, - version: caCert.version + 1, - caSecretId: caSecret.id - }, - tx - ); - - await certificateAuthorityDAL.updateById( - ca.id, - { - activeCaCertId: newCaCert.id, - notBefore: notBeforeDate, - notAfter: new Date(notAfter) - }, - tx - ); - }); - - certificate = cert.toString("pem"); - break; - } - case CaType.INTERMEDIATE: { - if (!ca.parentCaId) { - // TODO: look into optimal way to support renewal of intermediate CA with external parent CA - throw new BadRequestError({ - message: "Failed to renew intermediate CA certificate with external parent CA" - }); - } - - const parentCa = await certificateAuthorityDAL.findById(ca.parentCaId); - const { caPrivateKey: parentCaPrivateKey } = await getCaCredentials({ - caId: parentCa.id, - certificateAuthorityDAL, - certificateAuthoritySecretDAL, - projectDAL, - kmsService - }); - - // get latest parent CA certificate - if (!parentCa.activeCaCertId) - throw new BadRequestError({ message: "Parent CA does not have a certificate installed" }); - const parentCaCert = await certificateAuthorityCertDAL.findById(parentCa.activeCaCertId); - - const decryptedParentCaCert = await kmsDecryptor({ - cipherTextBlob: parentCaCert.encryptedCertificate - }); - - const parentCaCertObj = new x509.X509Certificate(decryptedParentCaCert); - - if (new Date(notAfter) <= new Date(caCertObj.notAfter)) { - throw new BadRequestError({ - message: - "New Intermediate CA certificate must have notAfter date that is greater than the current certificate notAfter date" - }); - } - - if (new Date(notAfter) > new Date(parentCaCertObj.notAfter)) { - throw new BadRequestError({ - message: - "New Intermediate CA certificate must have notAfter date that is equal to or smaller than the notAfter date of the parent CA certificate current certificate notAfter date" - }); - } - - const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ - name: ca.dn, - keys: { - privateKey: caPrivateKey, - publicKey: caPublicKey - }, - signingAlgorithm: alg, - extensions: [ - // eslint-disable-next-line no-bitwise - new x509.KeyUsagesExtension( - x509.KeyUsageFlags.keyCertSign | - x509.KeyUsageFlags.cRLSign | - x509.KeyUsageFlags.digitalSignature | - x509.KeyUsageFlags.keyEncipherment - ) - ], - attributes: [new x509.ChallengePasswordAttribute("password")] - }); - - const notBeforeDate = new Date(); - const intermediateCert = await x509.X509CertificateGenerator.create({ - serialNumber, - subject: csrObj.subject, - issuer: parentCaCertObj.subject, - notBefore: notBeforeDate, - notAfter: new Date(notAfter), - signingKey: parentCaPrivateKey, - publicKey: csrObj.publicKey, - signingAlgorithm: alg, - extensions: [ - new x509.KeyUsagesExtension( - x509.KeyUsageFlags.keyCertSign | - x509.KeyUsageFlags.cRLSign | - x509.KeyUsageFlags.digitalSignature | - x509.KeyUsageFlags.keyEncipherment, - true - ), - new x509.BasicConstraintsExtension( - true, - ca.maxPathLength === -1 || !ca.maxPathLength ? undefined : ca.maxPathLength, - true - ), - await x509.AuthorityKeyIdentifierExtension.create(parentCaCertObj, false), - await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) - ] - }); - - const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ - plainText: Buffer.from(new Uint8Array(intermediateCert.rawData)) - }); - - const { caCert: parentCaCertificate, caCertChain: parentCaCertChain } = await getCaCertChain({ - caCertId: parentCa.activeCaCertId, - certificateAuthorityDAL, - certificateAuthorityCertDAL, - projectDAL, - kmsService - }); - - certificateChain = `${parentCaCertificate}\n${parentCaCertChain}`.trim(); - - const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ - plainText: Buffer.from(certificateChain) - }); - - await certificateAuthorityDAL.transaction(async (tx) => { - const newCaCert = await certificateAuthorityCertDAL.create( - { - caId: ca.id, - encryptedCertificate, - encryptedCertificateChain, - version: caCert.version + 1, - caSecretId: caSecret.id - }, - tx - ); - - await certificateAuthorityDAL.updateById( - ca.id, - { - activeCaCertId: newCaCert.id, - notBefore: notBeforeDate, - notAfter: new Date(notAfter) - }, - tx - ); - }); - - certificate = intermediateCert.toString("pem"); - break; - } - default: { - throw new BadRequestError({ - message: "Unrecognized CA type" - }); - } - } - - return { - certificate, - certificateChain, - serialNumber, - ca - }; - }; - - const getCaCerts = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCertsDTO) => { - const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: ca.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.CertificateManager - }); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - ProjectPermissionSub.CertificateAuthorities - ); - - const caCertChains = await getCaCertChains({ - caId, - certificateAuthorityDAL, - certificateAuthorityCertDAL, - projectDAL, - kmsService - }); - - return { - ca, - caCerts: caCertChains - }; - }; - - /** - * Return current certificate and certificate chain for CA - */ - const getCaCert = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCertDTO) => { - const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); - if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: ca.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.CertificateManager - }); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - ProjectPermissionSub.CertificateAuthorities - ); - - const { caCert, caCertChain, serialNumber } = await getCaCertChain({ - caCertId: ca.activeCaCertId, - certificateAuthorityDAL, - certificateAuthorityCertDAL, - projectDAL, - kmsService - }); - - return { - certificate: caCert, - certificateChain: caCertChain, - serialNumber, - ca - }; - }; - - /** - * Return CA certificate object by ID - */ - const getCaCertById = async ({ caId, caCertId }: { caId: string; caCertId: string }) => { - const caCert = await certificateAuthorityCertDAL.findOne({ - caId, - id: caCertId - }); - - if (!caCert) { - throw new NotFoundError({ message: `Ca certificate with ID '${caCertId}' not found for CA with ID '${caId}'` }); - } - - const ca = await certificateAuthorityDAL.findById(caId); - const keyId = await getProjectKmsCertificateKeyId({ - projectId: ca.projectId, - projectDAL, - kmsService - }); - - const kmsDecryptor = await kmsService.decryptWithKmsKey({ - kmsId: keyId - }); - - const decryptedCaCert = await kmsDecryptor({ - cipherTextBlob: caCert.encryptedCertificate - }); - - const caCertObj = new x509.X509Certificate(decryptedCaCert); - - return caCertObj; - }; - - /** - * Issue certificate to be imported back in for intermediate CA - */ - const signIntermediate = async ({ - caId, - actorId, - actorAuthMethod, - actor, - actorOrgId, - csr, - notBefore, - notAfter, - maxPathLength - }: TSignIntermediateDTO) => { - const appCfg = getConfig(); - const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new NotFoundError({ message: "CA not found" }); - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: ca.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.CertificateManager - }); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - ProjectPermissionSub.CertificateAuthorities - ); - - if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); - if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); - - const caCert = await certificateAuthorityCertDAL.findById(ca.activeCaCertId); - - if (ca.notAfter && new Date() > new Date(ca.notAfter)) { - throw new BadRequestError({ message: "CA is expired" }); - } - - const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); - - const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ - projectId: ca.projectId, - projectDAL, - kmsService - }); - const kmsDecryptor = await kmsService.decryptWithKmsKey({ - kmsId: certificateManagerKmsId - }); - - const decryptedCaCert = await kmsDecryptor({ - cipherTextBlob: caCert.encryptedCertificate - }); - - const caCertObj = new x509.X509Certificate(decryptedCaCert); - const csrObj = new x509.Pkcs10CertificateRequest(csr); - - // check path length constraint - const caPathLength = caCertObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; - if (caPathLength !== undefined) { - if (caPathLength === 0) - throw new BadRequestError({ - message: "Failed to issue intermediate certificate due to CA path length constraint" - }); - if (maxPathLength >= caPathLength || (maxPathLength === -1 && caPathLength !== -1)) - throw new BadRequestError({ - message: "The requested path length constraint exceeds the CA's allowed path length" - }); - } - - const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); - const notAfterDate = new Date(notAfter); - - const caCertNotBeforeDate = new Date(caCertObj.notBefore); - const caCertNotAfterDate = new Date(caCertObj.notAfter); - - // check not before constraint - if (notBeforeDate < caCertNotBeforeDate) { - throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); - } - - if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" }); - - // check not after constraint - if (notAfterDate > caCertNotAfterDate) { - throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); - } - - const { caPrivateKey, caSecret } = await getCaCredentials({ - caId: ca.id, - certificateAuthorityDAL, - certificateAuthoritySecretDAL, - projectDAL, - kmsService - }); - - const serialNumber = createSerialNumber(); - - const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); - const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; - - const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; - const intermediateCert = await x509.X509CertificateGenerator.create({ - serialNumber, - subject: csrObj.subject, - issuer: caCertObj.subject, - notBefore: notBeforeDate, - notAfter: notAfterDate, - signingKey: caPrivateKey, - publicKey: csrObj.publicKey, - signingAlgorithm: alg, - extensions: [ - new x509.KeyUsagesExtension( - x509.KeyUsageFlags.keyCertSign | - x509.KeyUsageFlags.cRLSign | - x509.KeyUsageFlags.digitalSignature | - x509.KeyUsageFlags.keyEncipherment, - true - ), - new x509.BasicConstraintsExtension(true, maxPathLength === -1 ? undefined : maxPathLength, true), - await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), - await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey), - new x509.CRLDistributionPointsExtension([distributionPointUrl]), - new x509.AuthorityInfoAccessExtension({ - caIssuers: new x509.GeneralName("url", caIssuerUrl) - }) - ] - }); - - const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ - caCertId: ca.activeCaCertId, - certificateAuthorityDAL, - certificateAuthorityCertDAL, - projectDAL, - kmsService - }); - - return { - certificate: intermediateCert.toString("pem"), - issuingCaCertificate, - certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), - serialNumber: intermediateCert.serialNumber, - ca - }; - }; - - /** - * Import certificate for CA with id [caId]. - * Note: Can be used to import an external certificate and certificate chain - * to be into an installed or uninstalled CA. - */ - const importCertToCa = async ({ - caId, - actorId, - actorAuthMethod, - actor, - actorOrgId, - certificate, - certificateChain - }: TImportCertToCaDTO) => { - const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: ca.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.CertificateManager - }); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - ProjectPermissionSub.CertificateAuthorities - ); - - if (ca.parentCaId) { - /** - * re-evaluate in the future if we should allow users to import a new CA certificate for an intermediate - * CA chained to an internal parent CA. Doing so would allow users to re-chain the CA to a different - * internal CA. - */ + if (!certificateAuthority.internalCa?.id && type === CaType.INTERNAL) { throw new BadRequestError({ - message: "Cannot import certificate to intermediate CA chained to internal parent CA" + message: "Internal certificate authority cannot be deleted" }); } - const caCert = ca.activeCaCertId ? await certificateAuthorityCertDAL.findById(ca.activeCaCertId) : undefined; - - const certObj = new x509.X509Certificate(certificate); - const maxPathLength = certObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; - - // validate imported certificate and certificate chain - const certificates = extractX509CertFromChain(certificateChain)?.map((cert) => new x509.X509Certificate(cert)); - - if (!certificates) throw new BadRequestError({ message: "Failed to parse certificate chain" }); - - const chain = new x509.X509ChainBuilder({ - certificates - }); - - const chainItems = await chain.build(certObj); - - // chain.build() implicitly verifies the chain - if (chainItems.length !== certificates.length + 1) - throw new BadRequestError({ message: "Invalid certificate chain" }); - - const parentCertObj = chainItems[1]; - const parentCertSubject = parentCertObj.subject; - - const parentCa = await certificateAuthorityDAL.findOne({ - projectId: ca.projectId, - dn: parentCertSubject - }); - - const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ - projectId: ca.projectId, - projectDAL, - kmsService - }); - const kmsEncryptor = await kmsService.encryptWithKmsKey({ - kmsId: certificateManagerKmsId - }); - - const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ - plainText: Buffer.from(new Uint8Array(certObj.rawData)) - }); - - const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ - plainText: Buffer.from(certificateChain) - }); - - // TODO: validate that latest key-pair of CA is used to sign the certificate - // once renewal with new key pair is supported - const { caSecret, caPublicKey } = await getCaCredentials({ - caId: ca.id, - certificateAuthorityDAL, - certificateAuthoritySecretDAL, - projectDAL, - kmsService - }); - - const isCaAndCertPublicKeySame = Buffer.from(await crypto.subtle.exportKey("spki", caPublicKey)).equals( - Buffer.from(certObj.publicKey.rawData) - ); - - if (!isCaAndCertPublicKeySame) { - throw new BadRequestError({ message: "CA and certificate public key do not match" }); - } - - await certificateAuthorityCertDAL.transaction(async (tx) => { - const newCaCert = await certificateAuthorityCertDAL.create( - { - caId: ca.id, - encryptedCertificate, - encryptedCertificateChain, - version: caCert ? caCert.version + 1 : 1, - caSecretId: caSecret.id - }, - tx - ); - - await certificateAuthorityDAL.updateById( - ca.id, - { - status: CaStatus.ACTIVE, - maxPathLength: maxPathLength === undefined ? -1 : maxPathLength, - notBefore: new Date(certObj.notBefore), - notAfter: new Date(certObj.notAfter), - serialNumber: certObj.serialNumber, - parentCaId: parentCa?.id, - activeCaCertId: newCaCert.id - }, - tx - ); - }); - - return { ca }; - }; - - /** - * Return new leaf certificate issued by CA with id [caId] and private key. - * Note: private key and CSR are generated within Infisical. - */ - const issueCertFromCa = async ({ - caId, - certificateTemplateId, - pkiCollectionId, - friendlyName, - commonName, - altNames, - ttl, - notBefore, - notAfter, - actorId, - actorAuthMethod, - actor, - actorOrgId, - keyUsages, - extendedKeyUsages - }: TIssueCertFromCaDTO) => { - let ca: TCertificateAuthorities | undefined; - let certificateTemplate: TCertificateTemplates | undefined; - let collectionId = pkiCollectionId; - - if (caId) { - ca = await certificateAuthorityDAL.findById(caId); - } else if (certificateTemplateId) { - certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId); - if (!certificateTemplate) { - throw new NotFoundError({ - message: `Certificate template with ID '${certificateTemplateId}' not found` - }); - } - - collectionId = certificateTemplate.pkiCollectionId as string; - ca = await certificateAuthorityDAL.findById(certificateTemplate.caId); - } - - if (!ca) { - throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); - } - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: ca.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.CertificateManager - }); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionCertificateActions.Create, - ProjectPermissionSub.Certificates - ); - - if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); - if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); - if (ca.requireTemplateForIssuance && !certificateTemplate) { - throw new BadRequestError({ message: "Certificate template is required for issuance" }); - } - const caCert = await certificateAuthorityCertDAL.findById(ca.activeCaCertId); - - if (ca.notAfter && new Date() > new Date(ca.notAfter)) { - throw new BadRequestError({ message: "CA is expired" }); - } - - // check PKI collection - if (collectionId) { - const pkiCollection = await pkiCollectionDAL.findById(collectionId); - if (!pkiCollection) throw new NotFoundError({ message: "PKI collection not found" }); - if (pkiCollection.projectId !== ca.projectId) throw new BadRequestError({ message: "Invalid PKI collection" }); - } - - const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ - projectId: ca.projectId, - projectDAL, - kmsService - }); - const kmsDecryptor = await kmsService.decryptWithKmsKey({ - kmsId: certificateManagerKmsId - }); - - const decryptedCaCert = await kmsDecryptor({ - cipherTextBlob: caCert.encryptedCertificate - }); - - const caCertObj = new x509.X509Certificate(decryptedCaCert); - - const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); - - let notAfterDate = new Date(new Date().setFullYear(new Date().getFullYear() + 1)); - if (notAfter) { - notAfterDate = new Date(notAfter); - } else if (ttl) { - notAfterDate = new Date(new Date().getTime() + ms(ttl)); - } - - const caCertNotBeforeDate = new Date(caCertObj.notBefore); - const caCertNotAfterDate = new Date(caCertObj.notAfter); - - // check not before constraint - if (notBeforeDate < caCertNotBeforeDate) { - throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); - } - - if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" }); - - // check not after constraint - if (notAfterDate > caCertNotAfterDate) { - throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); - } - - const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); - const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); - - const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ - name: `CN=${commonName}`, - keys: leafKeys, - signingAlgorithm: alg, - extensions: [ - // eslint-disable-next-line no-bitwise - new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment) - ], - attributes: [new x509.ChallengePasswordAttribute("password")] - }); - - const { caPrivateKey, caSecret } = await getCaCredentials({ - caId: ca.id, - certificateAuthorityDAL, - certificateAuthoritySecretDAL, - projectDAL, - kmsService - }); - - const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); - const appCfg = getConfig(); - - const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; - const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; - - const extensions: x509.Extension[] = [ - new x509.BasicConstraintsExtension(false), - new x509.CRLDistributionPointsExtension([distributionPointUrl]), - await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), - await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey), - new x509.AuthorityInfoAccessExtension({ - caIssuers: new x509.GeneralName("url", caIssuerUrl) - }), - new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy - ]; - - // handle key usages - let selectedKeyUsages: CertKeyUsage[] = keyUsages ?? []; - if (keyUsages === undefined && !certificateTemplate) { - selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; - } - - if (keyUsages === undefined && certificateTemplate) { - selectedKeyUsages = (certificateTemplate.keyUsages ?? []) as CertKeyUsage[]; - } - - if (keyUsages?.length && certificateTemplate) { - const validKeyUsages = certificateTemplate.keyUsages || []; - if (keyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) { - throw new BadRequestError({ - message: "Invalid key usage value based on template policy" - }); - } - selectedKeyUsages = keyUsages; - } - - const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0); - if (keyUsagesBitValue) { - extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true)); - } - - // handle extended key usages - let selectedExtendedKeyUsages: CertExtendedKeyUsage[] = extendedKeyUsages ?? []; - if (extendedKeyUsages === undefined && certificateTemplate) { - selectedExtendedKeyUsages = (certificateTemplate.extendedKeyUsages ?? []) as CertExtendedKeyUsage[]; - } - - if (extendedKeyUsages?.length && certificateTemplate) { - const validExtendedKeyUsages = certificateTemplate.extendedKeyUsages || []; - if (extendedKeyUsages.some((eku) => !validExtendedKeyUsages.includes(eku))) { - throw new BadRequestError({ - message: "Invalid extended key usage value based on template policy" - }); - } - selectedExtendedKeyUsages = extendedKeyUsages; - } - - if (selectedExtendedKeyUsages.length) { - extensions.push( - new x509.ExtendedKeyUsageExtension( - selectedExtendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku]), - true - ) - ); - } - - let altNamesArray: { - type: "email" | "dns"; - value: string; - }[] = []; - - if (altNames) { - altNamesArray = altNames - .split(",") - .map((name) => name.trim()) - .map((altName) => { - // check if the altName is a valid email - if (z.string().email().safeParse(altName).success) { - return { - type: "email", - value: altName - }; - } - - // check if the altName is a valid hostname - if (isFQDN(altName, { allow_wildcard: true })) { - return { - type: "dns", - value: altName - }; - } - - // If altName is neither a valid email nor a valid hostname, throw an error or handle it accordingly - throw new Error(`Invalid altName: ${altName}`); - }); - - const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); - extensions.push(altNamesExtension); - } - - if (certificateTemplate) { - validateCertificateDetailsAgainstTemplate( - { - commonName, - notBeforeDate, - notAfterDate, - altNames: altNamesArray.map((entry) => entry.value) - }, - certificateTemplate - ); - } - - const serialNumber = createSerialNumber(); - const leafCert = await x509.X509CertificateGenerator.create({ - serialNumber, - subject: csrObj.subject, - issuer: caCertObj.subject, - notBefore: notBeforeDate, - notAfter: notAfterDate, - signingKey: caPrivateKey, - publicKey: csrObj.publicKey, - signingAlgorithm: alg, - extensions - }); - - const skLeafObj = KeyObject.from(leafKeys.privateKey); - const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; - - const kmsEncryptor = await kmsService.encryptWithKmsKey({ - kmsId: certificateManagerKmsId - }); - const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ - plainText: Buffer.from(new Uint8Array(leafCert.rawData)) - }); - const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ - plainText: Buffer.from(skLeaf) - }); - - const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ - caCertId: caCert.id, - certificateAuthorityDAL, - certificateAuthorityCertDAL, - projectDAL, - kmsService - }); - - const certificateChainPem = `${issuingCaCertificate}\n${caCertChain}`.trim(); - - const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ - plainText: Buffer.from(certificateChainPem) - }); - - await certificateDAL.transaction(async (tx) => { - const cert = await certificateDAL.create( - { - caId: (ca as TCertificateAuthorities).id, - caCertId: caCert.id, - certificateTemplateId: certificateTemplate?.id, - status: CertStatus.ACTIVE, - friendlyName: friendlyName || commonName, - commonName, - altNames, - serialNumber, - notBefore: notBeforeDate, - notAfter: notAfterDate, - keyUsages: selectedKeyUsages, - extendedKeyUsages: selectedExtendedKeyUsages - }, - tx - ); - - await certificateBodyDAL.create( - { - certId: cert.id, - encryptedCertificate, - encryptedCertificateChain - }, - tx - ); - - await certificateSecretDAL.create( - { - certId: cert.id, - encryptedPrivateKey - }, - tx - ); - - if (collectionId) { - await pkiCollectionItemDAL.create( - { - pkiCollectionId: collectionId, - certId: cert.id - }, - tx - ); - } - - return cert; - }); - - return { - certificate: leafCert.toString("pem"), - certificateChain: certificateChainPem, - issuingCaCertificate, - privateKey: skLeaf, - serialNumber, - ca - }; - }; - - /** - * Return new leaf certificate issued by CA with id [caId]. - * Note: CSR is generated externally and submitted to Infisical. - */ - const signCertFromCa = async (dto: TSignCertFromCaDTO) => { - const appCfg = getConfig(); - let ca: TCertificateAuthorities | undefined; - let certificateTemplate: TCertificateTemplates | undefined; - - const { - caId, - certificateTemplateId, - csr, - pkiCollectionId, - friendlyName, - commonName, - altNames, - ttl, - notBefore, - notAfter, - keyUsages, - extendedKeyUsages - } = dto; - - let collectionId = pkiCollectionId; - - if (caId) { - ca = await certificateAuthorityDAL.findById(caId); - } else if (certificateTemplateId) { - certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId); - if (!certificateTemplate) { - throw new NotFoundError({ - message: `Certificate template with ID '${certificateTemplateId}' not found` - }); - } - - collectionId = certificateTemplate.pkiCollectionId as string; - ca = await certificateAuthorityDAL.findById(certificateTemplate.caId); - } - - if (!ca) { - throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); - } - - if (!dto.isInternal) { - const { permission } = await permissionService.getProjectPermission({ - actor: dto.actor, - actorId: dto.actorId, - projectId: ca.projectId, - actorAuthMethod: dto.actorAuthMethod, - actorOrgId: dto.actorOrgId, - actionProjectType: ActionProjectType.CertificateManager - }); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionCertificateActions.Create, - ProjectPermissionSub.Certificates - ); - } - - if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); - if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); - if (ca.requireTemplateForIssuance && !certificateTemplate) { - throw new BadRequestError({ message: "Certificate template is required for issuance" }); - } - - const caCert = await certificateAuthorityCertDAL.findById(ca.activeCaCertId); - - if (ca.notAfter && new Date() > new Date(ca.notAfter)) { - throw new BadRequestError({ message: "CA is expired" }); - } - - // check PKI collection - if (pkiCollectionId) { - const pkiCollection = await pkiCollectionDAL.findById(pkiCollectionId); - if (!pkiCollection) throw new NotFoundError({ message: `PKI collection with ID '${pkiCollectionId}' not found` }); - if (pkiCollection.projectId !== ca.projectId) throw new BadRequestError({ message: "Invalid PKI collection" }); - } - - const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ - projectId: ca.projectId, - projectDAL, - kmsService - }); - - const kmsDecryptor = await kmsService.decryptWithKmsKey({ - kmsId: certificateManagerKmsId - }); - - const decryptedCaCert = await kmsDecryptor({ - cipherTextBlob: caCert.encryptedCertificate - }); - - const caCertObj = new x509.X509Certificate(decryptedCaCert); - - const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); - - let notAfterDate = new Date(new Date().setFullYear(new Date().getFullYear() + 1)); - if (notAfter) { - notAfterDate = new Date(notAfter); - } else if (ttl) { - notAfterDate = new Date(new Date().getTime() + ms(ttl)); - } else if (certificateTemplate?.ttl) { - notAfterDate = new Date(new Date().getTime() + ms(certificateTemplate.ttl)); - } - - const caCertNotBeforeDate = new Date(caCertObj.notBefore); - const caCertNotAfterDate = new Date(caCertObj.notAfter); - - // check not before constraint - if (notBeforeDate < caCertNotBeforeDate) { - throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); - } - - if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" }); - - // check not after constraint - if (notAfterDate > caCertNotAfterDate) { - throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); - } - - const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); - - const csrObj = new x509.Pkcs10CertificateRequest(csr); - - const dn = parseDistinguishedName(csrObj.subject); - const cn = commonName || dn.commonName; - - if (!cn) + if (certificateAuthority.externalCa?.id && certificateAuthority.externalCa.type !== type) { throw new BadRequestError({ - message: "A common name (CN) is required in the CSR or as a parameter to this endpoint" + message: "External certificate authority cannot be deleted" }); - - const { caPrivateKey, caSecret } = await getCaCredentials({ - caId: ca.id, - certificateAuthorityDAL, - certificateAuthoritySecretDAL, - projectDAL, - kmsService - }); - - const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); - const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; - - const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; - const extensions: x509.Extension[] = [ - new x509.BasicConstraintsExtension(false), - await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), - await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey), - new x509.CRLDistributionPointsExtension([distributionPointUrl]), - new x509.AuthorityInfoAccessExtension({ - caIssuers: new x509.GeneralName("url", caIssuerUrl) - }), - new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy - ]; - - // handle key usages - const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension; - let csrKeyUsages: CertKeyUsage[] = []; - if (csrKeyUsageExtension) { - csrKeyUsages = Object.values(CertKeyUsage).filter( - (keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0 - ); } - let selectedKeyUsages: CertKeyUsage[] = keyUsages ?? []; - if (keyUsages === undefined && !certificateTemplate) { - if (csrKeyUsageExtension) { - selectedKeyUsages = csrKeyUsages; - } else { - selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; - } + await certificateAuthorityDAL.deleteById(certificateAuthority.id); + + if (type === CaType.INTERNAL) { + return { + id: certificateAuthority.id, + type, + enableDirectIssuance: certificateAuthority.enableDirectIssuance, + name: certificateAuthority.name, + projectId: certificateAuthority.projectId, + configuration: certificateAuthority.internalCa, + status: certificateAuthority.status + } as TCertificateAuthority; } - if (keyUsages === undefined && certificateTemplate) { - if (csrKeyUsageExtension) { - const validKeyUsages = certificateTemplate.keyUsages || []; - if (csrKeyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) { - throw new BadRequestError({ - message: "Invalid key usage value based on template policy" - }); - } - selectedKeyUsages = csrKeyUsages; - } else { - selectedKeyUsages = (certificateTemplate.keyUsages ?? []) as CertKeyUsage[]; - } + if (type === CaType.ACME) { + return castDbEntryToAcmeCertificateAuthority(certificateAuthority); } - if (keyUsages?.length && certificateTemplate) { - const validKeyUsages = certificateTemplate.keyUsages || []; - if (keyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) { - throw new BadRequestError({ - message: "Invalid key usage value based on template policy" - }); - } - selectedKeyUsages = keyUsages; - } - - const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0); - if (keyUsagesBitValue) { - extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true)); - } - - // handle extended key usages - const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension; - let csrExtendedKeyUsages: CertExtendedKeyUsage[] = []; - if (csrExtendedKeyUsageExtension) { - csrExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map( - (ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string] - ); - } - - let selectedExtendedKeyUsages: CertExtendedKeyUsage[] = extendedKeyUsages ?? []; - if (extendedKeyUsages === undefined && !certificateTemplate && csrExtendedKeyUsageExtension) { - selectedExtendedKeyUsages = csrExtendedKeyUsages; - } - - if (extendedKeyUsages === undefined && certificateTemplate) { - if (csrExtendedKeyUsageExtension) { - const validExtendedKeyUsages = certificateTemplate.extendedKeyUsages || []; - if (csrExtendedKeyUsages.some((eku) => !validExtendedKeyUsages.includes(eku))) { - throw new BadRequestError({ - message: "Invalid extended key usage value based on template policy" - }); - } - selectedExtendedKeyUsages = csrExtendedKeyUsages; - } else { - selectedExtendedKeyUsages = (certificateTemplate.extendedKeyUsages ?? []) as CertExtendedKeyUsage[]; - } - } - - if (extendedKeyUsages?.length && certificateTemplate) { - const validExtendedKeyUsages = certificateTemplate.extendedKeyUsages || []; - if (extendedKeyUsages.some((keyUsage) => !validExtendedKeyUsages.includes(keyUsage))) { - throw new BadRequestError({ - message: "Invalid extended key usage value based on template policy" - }); - } - selectedExtendedKeyUsages = extendedKeyUsages; - } - - if (selectedExtendedKeyUsages.length) { - extensions.push( - new x509.ExtendedKeyUsageExtension( - selectedExtendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku]), - true - ) - ); - } - - let altNamesFromCsr: string = ""; - let altNamesArray: { - type: "email" | "dns"; - value: string; - }[] = []; - if (altNames) { - altNamesArray = altNames - .split(",") - .map((name) => name.trim()) - .map((altName) => { - // check if the altName is a valid email - if (z.string().email().safeParse(altName).success) { - return { - type: "email", - value: altName - }; - } - - // check if the altName is a valid hostname - if (isFQDN(altName, { allow_wildcard: true })) { - return { - type: "dns", - value: altName - }; - } - - // If altName is neither a valid email nor a valid hostname, throw an error or handle it accordingly - throw new Error(`Invalid altName: ${altName}`); - }); - } else { - // attempt to read from CSR if altNames is not explicitly provided - const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); - if (sanExtension) { - const sanNames = new x509.GeneralNames(sanExtension.value); - - altNamesArray = sanNames.items - .filter((value) => value.type === "email" || value.type === "dns") - .map((name) => ({ - type: name.type as "email" | "dns", - value: name.value - })); - - altNamesFromCsr = sanNames.items.map((item) => item.value).join(","); - } - } - - if (altNamesArray.length) { - const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); - extensions.push(altNamesExtension); - } - - if (certificateTemplate) { - validateCertificateDetailsAgainstTemplate( - { - commonName: cn, - notBeforeDate, - notAfterDate, - altNames: altNamesArray.map((entry) => entry.value) - }, - certificateTemplate - ); - } - - const serialNumber = createSerialNumber(); - const leafCert = await x509.X509CertificateGenerator.create({ - serialNumber, - subject: csrObj.subject, - issuer: caCertObj.subject, - notBefore: notBeforeDate, - notAfter: notAfterDate, - signingKey: caPrivateKey, - publicKey: csrObj.publicKey, - signingAlgorithm: alg, - extensions - }); - - const kmsEncryptor = await kmsService.encryptWithKmsKey({ - kmsId: certificateManagerKmsId - }); - const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ - plainText: Buffer.from(new Uint8Array(leafCert.rawData)) - }); - - await certificateDAL.transaction(async (tx) => { - const cert = await certificateDAL.create( - { - caId: (ca as TCertificateAuthorities).id, - caCertId: caCert.id, - certificateTemplateId: certificateTemplate?.id, - status: CertStatus.ACTIVE, - friendlyName: friendlyName || csrObj.subject, - commonName: cn, - altNames: altNamesFromCsr || altNames, - serialNumber, - notBefore: notBeforeDate, - notAfter: notAfterDate, - keyUsages: selectedKeyUsages, - extendedKeyUsages: selectedExtendedKeyUsages - }, - tx - ); - - await certificateBodyDAL.create( - { - certId: cert.id, - encryptedCertificate - }, - tx - ); - - if (collectionId) { - await pkiCollectionItemDAL.create( - { - pkiCollectionId: collectionId, - certId: cert.id - }, - tx - ); - } - - return cert; - }); - - const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ - caCertId: ca.activeCaCertId, - certificateAuthorityDAL, - certificateAuthorityCertDAL, - projectDAL, - kmsService - }); - - return { - certificate: leafCert, - certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), - issuingCaCertificate, - serialNumber, - ca, - commonName: cn - }; - }; - - /** - * Return list of certificate templates for CA with id [caId]. - */ - const getCaCertificateTemplates = async ({ - caId, - actorId, - actorAuthMethod, - actor, - actorOrgId - }: TGetCaCertificateTemplatesDTO) => { - const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: ca.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.CertificateManager - }); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - ProjectPermissionSub.CertificateTemplates - ); - - const certificateTemplates = await certificateTemplateDAL.find({ caId }); - - return { - certificateTemplates, - ca - }; + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; return { - createCa, - getCaById, - updateCaById, - deleteCaById, - getCaCsr, - renewCaCert, - getCaCerts, - getCaCert, - getCaCertById, - signIntermediate, - importCertToCa, - issueCertFromCa, - signCertFromCa, - getCaCertificateTemplates + createCertificateAuthority, + findCertificateAuthorityByNameAndProjectId, + listCertificateAuthoritiesByProjectId, + updateCertificateAuthority, + deleteCertificateAuthority }; }; diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index e2f523348..d76330bd8 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -1,186 +1,18 @@ -import { TProjectPermission } from "@app/lib/types"; -import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; -import { TKmsServiceFactory } from "@app/services/kms/kms-service"; -import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TAcmeCertificateAuthority, TAcmeCertificateAuthorityInput } from "./acme/acme-certificate-authority-types"; +import { CaType } from "./certificate-authority-enums"; +import { + TInternalCertificateAuthority, + TInternalCertificateAuthorityInput +} from "./internal/internal-certificate-authority-types"; -import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal"; -import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "../certificate/certificate-types"; -import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal"; -import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; -import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal"; +export type TCertificateAuthority = TInternalCertificateAuthority | TAcmeCertificateAuthority; -export enum CaType { - ROOT = "root", - INTERMEDIATE = "intermediate" -} +export type TCertificateAuthorityInput = TInternalCertificateAuthorityInput | TAcmeCertificateAuthorityInput; -export enum CaStatus { - ACTIVE = "active", - DISABLED = "disabled", - PENDING_CERTIFICATE = "pending-certificate" -} +export type TCreateCertificateAuthorityDTO = Omit; -export enum CaRenewalType { - EXISTING = "existing" -} - -export type TCreateCaDTO = { - projectSlug: string; +export type TUpdateCertificateAuthorityDTO = Partial> & { type: CaType; - friendlyName?: string; - commonName: string; - organization: string; - ou: string; - country: string; - province: string; - locality: string; - notBefore?: string; - notAfter?: string; - maxPathLength: number; - keyAlgorithm: CertKeyAlgorithm; - requireTemplateForIssuance: boolean; -} & Omit; - -export type TGetCaDTO = { - caId: string; -} & Omit; - -export type TUpdateCaDTO = { - caId: string; - status?: CaStatus; - requireTemplateForIssuance?: boolean; -} & Omit; - -export type TDeleteCaDTO = { - caId: string; -} & Omit; - -export type TGetCaCsrDTO = { - caId: string; -} & Omit; - -export type TRenewCaCertDTO = { - caId: string; - notAfter: string; - type: CaRenewalType; -} & Omit; - -export type TGetCaCertsDTO = { - caId: string; -} & Omit; - -export type TGetCaCertDTO = { - caId: string; -} & Omit; - -export type TSignIntermediateDTO = { - caId: string; - csr: string; - notBefore?: string; - notAfter: string; - maxPathLength: number; -} & Omit; - -export type TImportCertToCaDTO = { - caId: string; - certificate: string; - certificateChain: string; -} & Omit; - -export type TIssueCertFromCaDTO = { - caId?: string; - certificateTemplateId?: string; - pkiCollectionId?: string; - friendlyName?: string; - commonName: string; - altNames: string; - ttl: string; - notBefore?: string; - notAfter?: string; - keyUsages?: CertKeyUsage[]; - extendedKeyUsages?: CertExtendedKeyUsage[]; -} & Omit; - -export type TSignCertFromCaDTO = - | { - isInternal: true; - caId?: string; - csr: string; - certificateTemplateId?: string; - pkiCollectionId?: string; - friendlyName?: string; - commonName?: string; - altNames?: string; - ttl?: string; - notBefore?: string; - notAfter?: string; - keyUsages?: CertKeyUsage[]; - extendedKeyUsages?: CertExtendedKeyUsage[]; - } - | ({ - isInternal: false; - caId?: string; - csr: string; - certificateTemplateId?: string; - pkiCollectionId?: string; - friendlyName?: string; - commonName?: string; - altNames: string; - ttl: string; - notBefore?: string; - notAfter?: string; - keyUsages?: CertKeyUsage[]; - extendedKeyUsages?: CertExtendedKeyUsage[]; - } & Omit); - -export type TGetCaCertificateTemplatesDTO = { - caId: string; -} & Omit; - -export type TDNParts = { - commonName?: string; - organization?: string; - ou?: string; - country?: string; - province?: string; - locality?: string; -}; - -export type TGetCaCredentialsDTO = { - caId: string; - certificateAuthorityDAL: Pick; - certificateAuthoritySecretDAL: Pick; - projectDAL: Pick; - kmsService: Pick; -}; - -export type TGetCaCertChainsDTO = { - caId: string; - certificateAuthorityDAL: Pick; - certificateAuthorityCertDAL: Pick; - projectDAL: Pick; - kmsService: Pick; -}; - -export type TGetCaCertChainDTO = { - caCertId: string; - certificateAuthorityDAL: Pick; - certificateAuthorityCertDAL: Pick; - projectDAL: Pick; - kmsService: Pick; -}; - -export type TRebuildCaCrlDTO = { - caId: string; - certificateAuthorityDAL: Pick; - certificateAuthorityCrlDAL: Pick; - certificateAuthoritySecretDAL: Pick; - projectDAL: Pick; - certificateDAL: Pick; - kmsService: Pick; -}; - -export type TRotateCaCrlTriggerDTO = { - caId: string; - rotationIntervalDays: number; + caName: string; + projectId: string; }; diff --git a/backend/src/services/certificate-authority/certificate-authority-validators.ts b/backend/src/services/certificate-authority/certificate-authority-validators.ts index 979a3b9c5..fab62ddbf 100644 --- a/backend/src/services/certificate-authority/certificate-authority-validators.ts +++ b/backend/src/services/certificate-authority/certificate-authority-validators.ts @@ -10,6 +10,18 @@ const isValidDate = (dateString: string) => { export const validateCaDateField = z.string().trim().refine(isValidDate, { message: "Invalid date format" }); +export const validateAltNameField = z + .string() + .trim() + .refine( + (name) => { + return isFQDN(name, { allow_wildcard: true }) || z.string().email().safeParse(name).success || isValidIp(name); + }, + { + message: "SAN must be a valid hostname, email address, or IP address" + } + ); + export const validateAltNamesField = z .string() .trim() diff --git a/backend/src/services/certificate-authority/external-certificate-authority-dal.ts b/backend/src/services/certificate-authority/external-certificate-authority-dal.ts new file mode 100644 index 000000000..a27fcbc98 --- /dev/null +++ b/backend/src/services/certificate-authority/external-certificate-authority-dal.ts @@ -0,0 +1,13 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TExternalCertificateAuthorityDALFactory = ReturnType; + +export const externalCertificateAuthorityDALFactory = (db: TDbClient) => { + const caOrm = ormify(db, TableName.ExternalCertificateAuthority); + + return { + ...caOrm + }; +}; diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-dal.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-dal.ts new file mode 100644 index 000000000..c3ea228fe --- /dev/null +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-dal.ts @@ -0,0 +1,13 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TInternalCertificateAuthorityDALFactory = ReturnType; + +export const internalCertificateAuthorityDALFactory = (db: TDbClient) => { + const caOrm = ormify(db, TableName.InternalCertificateAuthority); + + return { + ...caOrm + }; +}; 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 new file mode 100644 index 000000000..def2e2bed --- /dev/null +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts @@ -0,0 +1,533 @@ +/* eslint-disable no-bitwise */ +import * as x509 from "@peculiar/x509"; +import { KeyObject } from "crypto"; +import RE2 from "re2"; +import { z } from "zod"; + +import { TCertificateTemplates, TPkiSubscribers } from "@app/db/schemas"; +import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; +import { isFQDN } from "@app/lib/validator/validate-url"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; +import { + CertExtendedKeyUsage, + CertKeyAlgorithm, + CertKeyUsage, + CertStatus +} from "@app/services/certificate/certificate-types"; +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 { TCertificateAuthorityCertDALFactory } from "../certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "../certificate-authority-dal"; +import { CaStatus } from "../certificate-authority-enums"; +import { + createSerialNumber, + getCaCertChain, + getCaCredentials, + keyAlgorithmToAlgCfg +} from "../certificate-authority-fns"; +import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority-secret-dal"; +import { TIssueCertWithTemplateDTO } from "./internal-certificate-authority-types"; + +type TInternalCertificateAuthorityFnsDeps = { + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; + certificateAuthoritySecretDAL: Pick; + certificateAuthorityCrlDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + certificateDAL: Pick; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; +}; + +export const InternalCertificateAuthorityFns = ({ + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL +}: TInternalCertificateAuthorityFnsDeps) => { + const issueCertificate = async ( + subscriber: TPkiSubscribers, + ca: Awaited> + ) => { + if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); + if (!ca.internalCa?.activeCaCertId) + throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const caCert = await certificateAuthorityCertDAL.findById(ca.internalCa.activeCaCertId); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + const notBeforeDate = new Date(); + const notAfterDate = new Date(new Date().getTime() + ms(subscriber.ttl ?? "0")); + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: `CN=${subscriber.commonName}`, + keys: leafKeys, + signingAlgorithm: alg, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment) + ], + attributes: [new x509.ChallengePasswordAttribute("password")] + }); + + const { caPrivateKey, caSecret } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); + const appCfg = getConfig(); + + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + + const extensions: x509.Extension[] = [ + new x509.BasicConstraintsExtension(false), + new x509.CRLDistributionPointsExtension([distributionPointUrl]), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey), + new x509.AuthorityInfoAccessExtension({ + caIssuers: new x509.GeneralName("url", caIssuerUrl) + }), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy + ]; + + const selectedKeyUsages = subscriber.keyUsages as CertKeyUsage[]; + // eslint-disable-next-line no-bitwise + const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0); + if (keyUsagesBitValue) { + extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true)); + } + + if (subscriber.extendedKeyUsages.length) { + const extendedKeyUsagesExtension = new x509.ExtendedKeyUsageExtension( + subscriber.extendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku as CertExtendedKeyUsage]), + true + ); + extensions.push(extendedKeyUsagesExtension); + } + + let altNamesArray: { type: "email" | "dns"; value: string }[] = []; + + if (subscriber.subjectAlternativeNames?.length) { + altNamesArray = subscriber.subjectAlternativeNames.map((altName) => { + if (z.string().email().safeParse(altName).success) { + return { type: "email", value: altName }; + } + + if (isFQDN(altName, { allow_wildcard: true })) { + return { type: "dns", value: altName }; + } + + throw new BadRequestError({ message: `Invalid SAN entry: ${altName}` }); + }); + + const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); + extensions.push(altNamesExtension); + } + + const serialNumber = createSerialNumber(); + const leafCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions + }); + + const skLeafObj = KeyObject.from(leafKeys.privateKey); + const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(leafCert.rawData)) + }); + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: Buffer.from(skLeaf) + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caCertId: caCert.id, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + const certificateChainPem = `${issuingCaCertificate}\n${caCertChain}`.trim(); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + caCertId: caCert.id, + pkiSubscriberId: subscriber.id, + status: CertStatus.ACTIVE, + friendlyName: subscriber.commonName, + commonName: subscriber.commonName, + altNames: subscriber.subjectAlternativeNames.join(","), + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + keyUsages: selectedKeyUsages, + extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[], + projectId: ca.projectId + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateSecretDAL.create( + { + certId: cert.id, + encryptedPrivateKey + }, + tx + ); + }); + + return { + certificate: leafCert.toString("pem"), + certificateChain: certificateChainPem, + issuingCaCertificate, + privateKey: skLeaf, + serialNumber, + ca, + subscriber + }; + }; + + const issueCertificateWithTemplate = async ( + ca: Awaited>, + certificateTemplate: TCertificateTemplates, + { altNames, commonName, ttl, extendedKeyUsages, keyUsages, notAfter, notBefore }: TIssueCertWithTemplateDTO + ) => { + if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); + if (!ca.internalCa?.activeCaCertId) + throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const caCert = await certificateAuthorityCertDAL.findById(ca.internalCa.activeCaCertId); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + + let notAfterDate = new Date(new Date().setFullYear(new Date().getFullYear() + 1)); + if (notAfter) { + notAfterDate = new Date(notAfter); + } else if (ttl) { + notAfterDate = new Date(new Date().getTime() + ms(ttl)); + } + + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const commonNameRegex = new RE2(certificateTemplate.commonName); + if (!commonNameRegex.test(commonName)) { + throw new BadRequestError({ + message: "Invalid common name based on template policy" + }); + } + + if (notAfterDate.getTime() - notBeforeDate.getTime() > ms(certificateTemplate.ttl)) { + throw new BadRequestError({ + message: "Invalid validity date based on template policy" + }); + } + + const subjectAlternativeNameRegex = new RE2(certificateTemplate.subjectAlternativeName); + altNames.split(",").forEach((altName) => { + if (!subjectAlternativeNameRegex.test(altName)) { + throw new BadRequestError({ + message: "Invalid subject alternative name based on template policy" + }); + } + }); + + const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: `CN=${commonName}`, + keys: leafKeys, + signingAlgorithm: alg, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment) + ], + attributes: [new x509.ChallengePasswordAttribute("password")] + }); + + const { caPrivateKey, caSecret } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); + const appCfg = getConfig(); + + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + + const extensions: x509.Extension[] = [ + new x509.BasicConstraintsExtension(false), + new x509.CRLDistributionPointsExtension([distributionPointUrl]), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey), + new x509.AuthorityInfoAccessExtension({ + caIssuers: new x509.GeneralName("url", caIssuerUrl) + }), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy + ]; + + let selectedKeyUsages: CertKeyUsage[] = keyUsages ?? []; + if (keyUsages === undefined && !certificateTemplate) { + selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; + } + + if (keyUsages === undefined && certificateTemplate) { + selectedKeyUsages = (certificateTemplate.keyUsages ?? []) as CertKeyUsage[]; + } + + if (keyUsages?.length && certificateTemplate) { + const validKeyUsages = certificateTemplate.keyUsages || []; + if (keyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) { + throw new BadRequestError({ + message: "Invalid key usage value based on template policy" + }); + } + selectedKeyUsages = keyUsages; + } + + const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0); + if (keyUsagesBitValue) { + extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true)); + } + + // handle extended key usages + let selectedExtendedKeyUsages: CertExtendedKeyUsage[] = extendedKeyUsages ?? []; + if (extendedKeyUsages === undefined && certificateTemplate) { + selectedExtendedKeyUsages = (certificateTemplate.extendedKeyUsages ?? []) as CertExtendedKeyUsage[]; + } + + if (extendedKeyUsages?.length && certificateTemplate) { + const validExtendedKeyUsages = certificateTemplate.extendedKeyUsages || []; + if (extendedKeyUsages.some((eku) => !validExtendedKeyUsages.includes(eku))) { + throw new BadRequestError({ + message: "Invalid extended key usage value based on template policy" + }); + } + selectedExtendedKeyUsages = extendedKeyUsages; + } + + if (selectedExtendedKeyUsages.length) { + extensions.push( + new x509.ExtendedKeyUsageExtension( + selectedExtendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku]), + true + ) + ); + } + + let altNamesArray: { type: "email" | "dns"; value: string }[] = []; + + if (altNames) { + altNamesArray = altNames.split(",").map((altName) => { + if (z.string().email().safeParse(altName).success) { + return { type: "email", value: altName }; + } + + if (isFQDN(altName, { allow_wildcard: true })) { + return { type: "dns", value: altName }; + } + + throw new BadRequestError({ message: `Invalid SAN entry: ${altName}` }); + }); + + const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); + extensions.push(altNamesExtension); + } + + const serialNumber = createSerialNumber(); + const leafCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions + }); + + const skLeafObj = KeyObject.from(leafKeys.privateKey); + const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(leafCert.rawData)) + }); + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: Buffer.from(skLeaf) + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caCertId: caCert.id, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + const certificateChainPem = `${issuingCaCertificate}\n${caCertChain}`.trim(); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + caCertId: caCert.id, + status: CertStatus.ACTIVE, + friendlyName: commonName, + commonName, + altNames, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + keyUsages: selectedKeyUsages, + extendedKeyUsages: selectedExtendedKeyUsages, + projectId: ca.projectId, + certificateTemplateId: certificateTemplate.id + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateSecretDAL.create( + { + certId: cert.id, + encryptedPrivateKey + }, + tx + ); + }); + + return { + certificate: leafCert.toString("pem"), + certificateChain: certificateChainPem, + issuingCaCertificate, + privateKey: skLeaf, + serialNumber, + ca, + template: certificateTemplate + }; + }; + + return { + issueCertificate, + issueCertificateWithTemplate + }; +}; diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-schemas.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-schemas.ts new file mode 100644 index 000000000..1cf9a8597 --- /dev/null +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-schemas.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +import { CertificateAuthorities } from "@app/lib/api-docs/constants"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +import { CaType, InternalCaType } from "../certificate-authority-enums"; +import { + BaseCertificateAuthoritySchema, + GenericCreateCertificateAuthorityFieldsSchema, + GenericUpdateCertificateAuthorityFieldsSchema +} from "../certificate-authority-schemas"; +import { validateCaDateField } from "../certificate-authority-validators"; + +const InternalCertificateAuthorityConfigurationSchema = z + .object({ + type: z.nativeEnum(InternalCaType).describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.type), + friendlyName: z.string().optional().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.friendlyName), + commonName: z.string().trim().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.commonName), + organization: z.string().trim().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.organization), + ou: z.string().trim().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.ou), + country: z.string().trim().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.country), + province: z.string().trim().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.province), + locality: z.string().trim().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.locality), + notBefore: validateCaDateField.optional().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.notBefore), + notAfter: validateCaDateField.optional().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.notAfter), + maxPathLength: z.number().min(-1).nullish().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.maxPathLength), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.keyAlgorithm), + dn: z.string().trim().nullish(), + parentCaId: z.string().uuid().nullish(), + serialNumber: z.string().trim().nullish(), + activeCaCertId: z.string().uuid().nullish() + }) + .refine( + (data) => { + // Check that at least one of the specified fields is non-empty + return [data.commonName, data.organization, data.ou, data.country, data.province, data.locality].some( + (field) => field !== "" + ); + }, + { + message: + "At least one of the fields commonName, organization, ou, country, province, or locality must be non-empty", + path: [] + } + ); + +export const InternalCertificateAuthoritySchema = BaseCertificateAuthoritySchema.extend({ + type: z.literal(CaType.INTERNAL), + configuration: InternalCertificateAuthorityConfigurationSchema +}); + +export const CreateInternalCertificateAuthoritySchema = GenericCreateCertificateAuthorityFieldsSchema( + CaType.INTERNAL +).extend({ + configuration: InternalCertificateAuthorityConfigurationSchema +}); + +export const UpdateInternalCertificateAuthoritySchema = GenericUpdateCertificateAuthorityFieldsSchema(CaType.INTERNAL); diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts new file mode 100644 index 000000000..083117241 --- /dev/null +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts @@ -0,0 +1,1985 @@ +/* eslint-disable no-bitwise */ +import { ForbiddenError, subject } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; +import slugify from "@sindresorhus/slugify"; +import crypto, { KeyObject } from "crypto"; +import { z } from "zod"; + +import { + ActionProjectType, + ProjectType, + TableName, + TCertificateAuthorities, + TCertificateTemplates +} from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionActions, + ProjectPermissionCertificateActions, + ProjectPermissionPkiTemplateActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { isFQDN } from "@app/lib/validator/validate-url"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +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 { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { TCertificateAuthorityCrlDALFactory } from "../../../ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { TCertificateSecretDALFactory } from "../../certificate/certificate-secret-dal"; +import { + CertExtendedKeyUsage, + CertExtendedKeyUsageOIDToName, + CertKeyAlgorithm, + CertKeyUsage, + CertStatus +} from "../../certificate/certificate-types"; +import { TCertificateTemplateDALFactory } from "../../certificate-template/certificate-template-dal"; +import { validateCertificateDetailsAgainstTemplate } from "../../certificate-template/certificate-template-fns"; +import { TCertificateAuthorityCertDALFactory } from "../certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory, TCertificateAuthorityWithAssociatedCa } from "../certificate-authority-dal"; +import { CaStatus, InternalCaType } from "../certificate-authority-enums"; +import { + createDistinguishedName, + createSerialNumber, + expandInternalCa, + getCaCertChain, // TODO: consider rename + getCaCertChains, + getCaCredentials, + keyAlgorithmToAlgCfg, + parseDistinguishedName +} from "../certificate-authority-fns"; +import { TCertificateAuthorityQueueFactory } from "../certificate-authority-queue"; +import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority-secret-dal"; +import { TInternalCertificateAuthorityDALFactory } from "./internal-certificate-authority-dal"; +import { + TCreateCaDTO, + TDeleteCaDTO, + TGetCaCertDTO, + TGetCaCertificateTemplatesDTO, + TGetCaCertsDTO, + TGetCaCsrDTO, + TGetCaDTO, + TImportCertToCaDTO, + TIssueCertFromCaDTO, + TRenewCaCertDTO, + TSignCertFromCaDTO, + TSignIntermediateDTO, + TUpdateCaDTO +} from "./internal-certificate-authority-types"; + +type TInternalCertificateAuthorityServiceFactoryDep = { + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + | "transaction" + | "create" + | "findById" + | "updateById" + | "deleteById" + | "findOne" + | "findByIdWithAssociatedCa" + | "findWithAssociatedCa" + >; + internalCertificateAuthorityDAL: Pick< + TInternalCertificateAuthorityDALFactory, + "transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" | "update" + >; + certificateAuthorityCertDAL: Pick< + TCertificateAuthorityCertDALFactory, + "create" | "findOne" | "transaction" | "find" | "findById" + >; + certificateAuthoritySecretDAL: Pick; + certificateAuthorityCrlDAL: Pick; + certificateTemplateDAL: Pick; + certificateAuthorityQueue: TCertificateAuthorityQueueFactory; // TODO: Pick + certificateDAL: Pick; + certificateSecretDAL: Pick; + certificateBodyDAL: Pick; + pkiCollectionDAL: Pick; + pkiCollectionItemDAL: Pick; + projectDAL: Pick< + TProjectDALFactory, + "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction" | "getProjectFromSplitId" + >; + kmsService: Pick; + permissionService: Pick; +}; + +export type TInternalCertificateAuthorityServiceFactory = ReturnType; + +export const internalCertificateAuthorityServiceFactory = ({ + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateTemplateDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + pkiCollectionDAL, + pkiCollectionItemDAL, + internalCertificateAuthorityDAL, + projectDAL, + kmsService, + permissionService +}: TInternalCertificateAuthorityServiceFactoryDep) => { + const createCa = async ({ + type, + friendlyName, + commonName, + organization, + ou, + country, + province, + locality, + notBefore, + notAfter, + maxPathLength, + keyAlgorithm, + enableDirectIssuance, + name, + ...dto + }: TCreateCaDTO) => { + let projectId: string; + if (!dto.isInternal) { + const project = await projectDAL.findProjectBySlug(dto.projectSlug, dto.actorOrgId); + if (!project) throw new NotFoundError({ message: `Project with slug '${dto.projectSlug}' not found` }); + projectId = project.id; + + const certManagerProjectFromSplit = await projectDAL.getProjectFromSplitId( + projectId, + ProjectType.CertificateManager + ); + if (certManagerProjectFromSplit) { + projectId = certManagerProjectFromSplit.id; + } + + const { permission } = await permissionService.getProjectPermission({ + actor: dto.actor, + actorId: dto.actorId, + projectId, + actorAuthMethod: dto.actorAuthMethod, + actorOrgId: dto.actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + } else { + projectId = dto.projectId; + } + + const dn = createDistinguishedName({ + commonName, + organization, + ou, + country, + province, + locality + }); + + const alg = keyAlgorithmToAlgCfg(keyAlgorithm); + const keys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); + + const newCa = await certificateAuthorityDAL.transaction(async (tx) => { + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + + // if undefined, set [notAfterDate] to 10 years from now + const notAfterDate = notAfter + ? new Date(notAfter) + : new Date(new Date().setFullYear(new Date().getFullYear() + 10)); + + const serialNumber = createSerialNumber(); + + const ca = await certificateAuthorityDAL.create( + { + projectId, + enableDirectIssuance, + name: name || slugify(`${(friendlyName || dn).slice(0, 16)}-${alphaNumericNanoId(8)}`), + status: type === InternalCaType.ROOT ? CaStatus.ACTIVE : CaStatus.PENDING_CERTIFICATE + }, + tx + ); + + const internalCa = await internalCertificateAuthorityDAL.create( + { + caId: ca.id, + type, + organization, + ou, + country, + province, + locality, + friendlyName: friendlyName || dn, + commonName, + dn, + keyAlgorithm, + ...(type === InternalCaType.ROOT && { + maxPathLength, + notBefore: notBeforeDate, + notAfter: notAfterDate, + serialNumber + }) + }, + tx + ); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + // // https://nodejs.org/api/crypto.html#static-method-keyobjectfromkey + const skObj = KeyObject.from(keys.privateKey); + + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: skObj.export({ + type: "pkcs8", + format: "der" + }) + }); + + const caSecret = await certificateAuthoritySecretDAL.create( + { + caId: ca.id, + encryptedPrivateKey + }, + tx + ); + + if (type === InternalCaType.ROOT) { + // note: create self-signed cert only applicable for root CA + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + name: dn, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingAlgorithm: alg, + keys, + extensions: [ + new x509.BasicConstraintsExtension( + true, + maxPathLength === -1 || maxPathLength === null ? undefined : maxPathLength, + true + ), + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), + await x509.SubjectKeyIdentifierExtension.create(keys.publicKey) + ] + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(cert.rawData)) + }); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.alloc(0) + }); + + const caCert = await certificateAuthorityCertDAL.create( + { + caId: ca.id, + encryptedCertificate, + encryptedCertificateChain, + version: 1, + caSecretId: caSecret.id + }, + tx + ); + + await internalCertificateAuthorityDAL.updateById( + internalCa.id, + { + activeCaCertId: caCert.id + }, + tx + ); + } + + // create empty CRL + const crl = await x509.X509CrlGenerator.create({ + issuer: internalCa.dn, + thisUpdate: new Date(), + nextUpdate: new Date("2025/12/12"), // TODO: change + entries: [], + signingAlgorithm: alg, + signingKey: keys.privateKey + }); + + const { cipherTextBlob: encryptedCrl } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(crl.rawData)) + }); + + await certificateAuthorityCrlDAL.create( + { + caId: ca.id, + encryptedCrl, + caSecretId: caSecret.id + }, + tx + ); + + return certificateAuthorityDAL.findByIdWithAssociatedCa(ca.id, tx); + }); + + return expandInternalCa(newCa); + }; + + /** + * Return CA with id [caId] + */ + const getCaById = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaDTO) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + return expandInternalCa(ca); + }; + + /** + * Update CA with id [caId]. + * Note: Used to enable/disable CA + */ + const updateCaById = async ({ caId, status, enableDirectIssuance, name, ...dto }: TUpdateCaDTO) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + + if (!dto.isInternal) { + const { permission } = await permissionService.getProjectPermission({ + actor: dto.actor, + actorId: dto.actorId, + projectId: ca.projectId, + actorAuthMethod: dto.actorAuthMethod, + actorOrgId: dto.actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.CertificateAuthorities + ); + } + + const updatedCa = await certificateAuthorityDAL.transaction(async (tx) => { + if (enableDirectIssuance !== undefined || status !== undefined || name !== undefined) { + await certificateAuthorityDAL.updateById(ca.id, { enableDirectIssuance, status, name }, tx); + } + + return certificateAuthorityDAL.findByIdWithAssociatedCa(caId, tx); + }); + + return expandInternalCa(updatedCa); + }; + + /** + * Delete CA with id [caId] + */ + const deleteCaById = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteCaDTO) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.CertificateAuthorities + ); + + await certificateAuthorityDAL.deleteById(ca.id); + + return expandInternalCa(ca); + }; + + /** + * Return certificate signing request (CSR) made with CA with id [caId] + */ + const getCaCsr = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCsrDTO) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + if (ca.internalCa.type === InternalCaType.ROOT) + throw new BadRequestError({ message: "Root CA cannot generate CSR" }); + + const { caPrivateKey, caPublicKey } = await getCaCredentials({ + caId, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: ca.internalCa.dn, + keys: { + privateKey: caPrivateKey, + publicKey: caPublicKey + }, + signingAlgorithm: alg, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment + ) + ], + attributes: [new x509.ChallengePasswordAttribute("password")] + }); + + return { + csr: csrObj.toString("pem"), + ca: expandInternalCa(ca) + }; + }; + + /** + * Renew certificate for CA with id [caId] + * Note 1: This CA renewal method is only applicable to CAs with internal parent CAs + * Note 2: Currently implements CA renewal with same key-pair only + */ + const renewCaCert = async ({ caId, notAfter, actorId, actorAuthMethod, actor, actorOrgId }: TRenewCaCertDTO) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + + if (!ca.internalCa.activeCaCertId) + throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); + + // get latest CA certificate + const caCert = await certificateAuthorityCertDAL.findById(ca.internalCa.activeCaCertId); + + const serialNumber = createSerialNumber(); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const { caPrivateKey, caPublicKey, caSecret } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + + let certificate = ""; + let certificateChain = ""; + + switch (ca.internalCa.type) { + case InternalCaType.ROOT: { + if (new Date(notAfter) <= new Date(caCertObj.notAfter)) { + throw new BadRequestError({ + message: + "New Root CA certificate must have notAfter date that is greater than the current certificate notAfter date" + }); + } + + const notBeforeDate = new Date(); + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + name: ca.internalCa.dn, + serialNumber, + notBefore: notBeforeDate, + notAfter: new Date(notAfter), + signingAlgorithm: alg, + keys: { + privateKey: caPrivateKey, + publicKey: caPublicKey + }, + extensions: [ + new x509.BasicConstraintsExtension( + true, + ca.internalCa.maxPathLength === -1 || !ca.internalCa.maxPathLength + ? undefined + : ca.internalCa.maxPathLength, + true + ), + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), + await x509.SubjectKeyIdentifierExtension.create(caPublicKey) + ] + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(cert.rawData)) + }); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.alloc(0) + }); + + await internalCertificateAuthorityDAL.transaction(async (tx) => { + const newCaCert = await certificateAuthorityCertDAL.create( + { + caId: ca.id, + encryptedCertificate, + encryptedCertificateChain, + version: caCert.version + 1, + caSecretId: caSecret.id + }, + tx + ); + + await internalCertificateAuthorityDAL.update( + { + caId: ca.id + }, + { + activeCaCertId: newCaCert.id, + notBefore: notBeforeDate, + notAfter: new Date(notAfter) + }, + tx + ); + }); + + certificate = cert.toString("pem"); + break; + } + case InternalCaType.INTERMEDIATE: { + if (!ca.internalCa.parentCaId) { + // TODO: look into optimal way to support renewal of intermediate CA with external parent CA + throw new BadRequestError({ + message: "Failed to renew intermediate CA certificate with external parent CA" + }); + } + + const parentCa = await certificateAuthorityDAL.findByIdWithAssociatedCa(ca.internalCa.parentCaId); + const { caPrivateKey: parentCaPrivateKey } = await getCaCredentials({ + caId: parentCa.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + if (!parentCa.internalCa) { + throw new BadRequestError({ message: "Parent CA not found" }); + } + + // get latest parent CA certificate + if (!parentCa.internalCa.activeCaCertId) + throw new BadRequestError({ message: "Parent CA does not have a certificate installed" }); + + const parentCaCert = await certificateAuthorityCertDAL.findById(parentCa.internalCa.activeCaCertId); + + const decryptedParentCaCert = await kmsDecryptor({ + cipherTextBlob: parentCaCert.encryptedCertificate + }); + + const parentCaCertObj = new x509.X509Certificate(decryptedParentCaCert); + + if (new Date(notAfter) <= new Date(caCertObj.notAfter)) { + throw new BadRequestError({ + message: + "New Intermediate CA certificate must have notAfter date that is greater than the current certificate notAfter date" + }); + } + + if (new Date(notAfter) > new Date(parentCaCertObj.notAfter)) { + throw new BadRequestError({ + message: + "New Intermediate CA certificate must have notAfter date that is equal to or smaller than the notAfter date of the parent CA certificate current certificate notAfter date" + }); + } + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: ca.internalCa.dn, + keys: { + privateKey: caPrivateKey, + publicKey: caPublicKey + }, + signingAlgorithm: alg, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment + ) + ], + attributes: [new x509.ChallengePasswordAttribute("password")] + }); + + const notBeforeDate = new Date(); + const intermediateCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: parentCaCertObj.subject, + notBefore: notBeforeDate, + notAfter: new Date(notAfter), + signingKey: parentCaPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension( + true, + ca.internalCa.maxPathLength === -1 || !ca.internalCa.maxPathLength + ? undefined + : ca.internalCa.maxPathLength, + true + ), + await x509.AuthorityKeyIdentifierExtension.create(parentCaCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) + ] + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(intermediateCert.rawData)) + }); + + const { caCert: parentCaCertificate, caCertChain: parentCaCertChain } = await getCaCertChain({ + caCertId: parentCa.internalCa.activeCaCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + certificateChain = `${parentCaCertificate}\n${parentCaCertChain}`.trim(); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChain) + }); + + await internalCertificateAuthorityDAL.transaction(async (tx) => { + const newCaCert = await certificateAuthorityCertDAL.create( + { + caId: ca.id, + encryptedCertificate, + encryptedCertificateChain, + version: caCert.version + 1, + caSecretId: caSecret.id + }, + tx + ); + + await internalCertificateAuthorityDAL.update( + { + caId: ca.id + }, + { + activeCaCertId: newCaCert.id, + notBefore: notBeforeDate, + notAfter: new Date(notAfter) + }, + tx + ); + }); + + certificate = intermediateCert.toString("pem"); + break; + } + default: { + throw new BadRequestError({ + message: "Unrecognized CA type" + }); + } + } + + return { + certificate, + certificateChain, + serialNumber, + ca: { + ...ca, + ...ca.internalCa + } + }; + }; + + const getCaCerts = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCertsDTO) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + const caCertChains = await getCaCertChains({ + caId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + ca: expandInternalCa(ca), + caCerts: caCertChains + }; + }; + + /** + * Return current certificate and certificate chain for CA + */ + const getCaCert = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCertDTO) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + if (!ca.internalCa.activeCaCertId) + throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + const { caCert, caCertChain, serialNumber } = await getCaCertChain({ + caCertId: ca.internalCa.activeCaCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + certificate: caCert, + certificateChain: caCertChain, + serialNumber, + ca: expandInternalCa(ca) + }; + }; + + /** + * Return CA certificate object by ID + */ + const getCaCertById = async ({ caId, caCertId }: { caId: string; caCertId: string }) => { + const caCert = await certificateAuthorityCertDAL.findOne({ + caId, + id: caCertId + }); + + if (!caCert) { + throw new NotFoundError({ message: `Ca certificate with ID '${caCertId}' not found for CA with ID '${caId}'` }); + } + + const ca = await certificateAuthorityDAL.findById(caId); + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: keyId + }); + + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + + return caCertObj; + }; + + /** + * Issue certificate to be imported back in for intermediate CA + */ + const signIntermediate = async ({ + caId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + csr, + notBefore, + notAfter, + maxPathLength + }: TSignIntermediateDTO) => { + const appCfg = getConfig(); + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.internalCa) throw new NotFoundError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); + if (!ca.internalCa.activeCaCertId) + throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const caCert = await certificateAuthorityCertDAL.findById(ca.internalCa.activeCaCertId); + + if (ca.internalCa.notAfter && new Date() > new Date(ca.internalCa.notAfter)) { + throw new BadRequestError({ message: "CA is expired" }); + } + + const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + const csrObj = new x509.Pkcs10CertificateRequest(csr); + + // check path length constraint + const caPathLength = caCertObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; + if (caPathLength !== undefined) { + if (caPathLength === 0) + throw new BadRequestError({ + message: "Failed to issue intermediate certificate due to CA path length constraint" + }); + if (maxPathLength >= caPathLength || (maxPathLength === -1 && caPathLength !== -1)) + throw new BadRequestError({ + message: "The requested path length constraint exceeds the CA's allowed path length" + }); + } + + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + const notAfterDate = new Date(notAfter); + + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" }); + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const { caPrivateKey, caSecret } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const serialNumber = createSerialNumber(); + + const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; + + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + const intermediateCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension(true, maxPathLength === -1 ? undefined : maxPathLength, true), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey), + new x509.CRLDistributionPointsExtension([distributionPointUrl]), + new x509.AuthorityInfoAccessExtension({ + caIssuers: new x509.GeneralName("url", caIssuerUrl) + }) + ] + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caCertId: ca.internalCa.activeCaCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + certificate: intermediateCert.toString("pem"), + issuingCaCertificate, + certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), + serialNumber: intermediateCert.serialNumber, + ca: expandInternalCa(ca) + }; + }; + + /** + * Import certificate for CA with id [caId]. + * Note: Can be used to import an external certificate and certificate chain + * to be into an installed or uninstalled CA. + */ + const importCertToCa = async ({ + caId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + certificate, + certificateChain + }: TImportCertToCaDTO) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + if (ca.internalCa.parentCaId) { + /** + * re-evaluate in the future if we should allow users to import a new CA certificate for an intermediate + * CA chained to an internal parent CA. Doing so would allow users to re-chain the CA to a different + * internal CA. + */ + throw new BadRequestError({ + message: "Cannot import certificate to intermediate CA chained to internal parent CA" + }); + } + + const caCert = ca.internalCa.activeCaCertId + ? await certificateAuthorityCertDAL.findById(ca.internalCa.activeCaCertId) + : undefined; + + const certObj = new x509.X509Certificate(certificate); + const maxPathLength = certObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; + + // validate imported certificate and certificate chain + const certificates = extractX509CertFromChain(certificateChain)?.map((cert) => new x509.X509Certificate(cert)); + + if (!certificates) throw new BadRequestError({ message: "Failed to parse certificate chain" }); + + const chain = new x509.X509ChainBuilder({ + certificates + }); + + const chainItems = await chain.build(certObj); + + // chain.build() implicitly verifies the chain + if (chainItems.length !== certificates.length + 1) + throw new BadRequestError({ message: "Invalid certificate chain" }); + + const parentCertObj = chainItems[1]; + const parentCertSubject = parentCertObj.subject; + + const [parentCa] = await certificateAuthorityDAL.findWithAssociatedCa({ + [`${TableName.CertificateAuthority}.projectId` as "projectId"]: ca.projectId, + [`${TableName.InternalCertificateAuthority}.dn` as "dn"]: parentCertSubject + }); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(certObj.rawData)) + }); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChain) + }); + + // TODO: validate that latest key-pair of CA is used to sign the certificate + // once renewal with new key pair is supported + const { caSecret, caPublicKey } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const isCaAndCertPublicKeySame = Buffer.from(await crypto.subtle.exportKey("spki", caPublicKey)).equals( + Buffer.from(certObj.publicKey.rawData) + ); + + if (!isCaAndCertPublicKeySame) { + throw new BadRequestError({ message: "CA and certificate public key do not match" }); + } + + await certificateAuthorityCertDAL.transaction(async (tx) => { + const newCaCert = await certificateAuthorityCertDAL.create( + { + caId: ca.id, + encryptedCertificate, + encryptedCertificateChain, + version: caCert ? caCert.version + 1 : 1, + caSecretId: caSecret.id + }, + tx + ); + + await certificateAuthorityDAL.updateById(ca.id, { + status: CaStatus.ACTIVE + }); + + await internalCertificateAuthorityDAL.update( + { + caId: ca.id + }, + { + maxPathLength: maxPathLength === undefined ? -1 : maxPathLength, + notBefore: new Date(certObj.notBefore), + notAfter: new Date(certObj.notAfter), + serialNumber: certObj.serialNumber, + parentCaId: parentCa?.id, + activeCaCertId: newCaCert.id + }, + tx + ); + }); + + return { ca: expandInternalCa(ca) }; + }; + + /** + * Return new leaf certificate issued by CA with id [caId] and private key. + * Note: private key and CSR are generated within Infisical. + */ + const issueCertFromCa = async ({ + caId, + certificateTemplateId, + pkiCollectionId, + friendlyName, + commonName, + altNames, + ttl, + notBefore, + notAfter, + actorId, + actorAuthMethod, + actor, + actorOrgId, + keyUsages, + extendedKeyUsages + }: TIssueCertFromCaDTO) => { + let ca: TCertificateAuthorityWithAssociatedCa | undefined; + let certificateTemplate: TCertificateTemplates | undefined; + let collectionId = pkiCollectionId; + + if (caId) { + ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + } else if (certificateTemplateId) { + certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId); + if (!certificateTemplate) { + throw new NotFoundError({ + message: `Certificate template with ID '${certificateTemplateId}' not found` + }); + } + + collectionId = certificateTemplate.pkiCollectionId as string; + ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certificateTemplate.caId); + } + + if (!ca) { + throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); + } + + if (!ca?.internalCa?.id) { + throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Create, + ProjectPermissionSub.Certificates + ); + + if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); + if (!ca.internalCa.activeCaCertId) + throw new BadRequestError({ message: "CA does not have a certificate installed" }); + if (!ca.enableDirectIssuance && !certificateTemplate) { + throw new BadRequestError({ message: "Certificate template or subscriber is required for issuance" }); + } + + const caCert = await certificateAuthorityCertDAL.findById(ca.internalCa.activeCaCertId); + + if (ca.internalCa.notAfter && new Date() > new Date(ca.internalCa.notAfter)) { + throw new BadRequestError({ message: "CA is expired" }); + } + + // check PKI collection + if (collectionId) { + const pkiCollection = await pkiCollectionDAL.findById(collectionId); + if (!pkiCollection) throw new NotFoundError({ message: "PKI collection not found" }); + if (pkiCollection.projectId !== ca.projectId) throw new BadRequestError({ message: "Invalid PKI collection" }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + + let notAfterDate = new Date(new Date().setFullYear(new Date().getFullYear() + 1)); + if (notAfter) { + notAfterDate = new Date(notAfter); + } else if (ttl) { + notAfterDate = new Date(new Date().getTime() + ms(ttl)); + } + + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" }); + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: `CN=${commonName}`, + keys: leafKeys, + signingAlgorithm: alg, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment) + ], + attributes: [new x509.ChallengePasswordAttribute("password")] + }); + + const { caPrivateKey, caSecret } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); + const appCfg = getConfig(); + + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + + const extensions: x509.Extension[] = [ + new x509.BasicConstraintsExtension(false), + new x509.CRLDistributionPointsExtension([distributionPointUrl]), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey), + new x509.AuthorityInfoAccessExtension({ + caIssuers: new x509.GeneralName("url", caIssuerUrl) + }), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy + ]; + + // handle key usages + let selectedKeyUsages: CertKeyUsage[] = keyUsages ?? []; + if (keyUsages === undefined && !certificateTemplate) { + selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; + } + + if (keyUsages === undefined && certificateTemplate) { + selectedKeyUsages = (certificateTemplate.keyUsages ?? []) as CertKeyUsage[]; + } + + if (keyUsages?.length && certificateTemplate) { + const validKeyUsages = certificateTemplate.keyUsages || []; + if (keyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) { + throw new BadRequestError({ + message: "Invalid key usage value based on template policy" + }); + } + selectedKeyUsages = keyUsages; + } + + const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0); + if (keyUsagesBitValue) { + extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true)); + } + + // handle extended key usages + let selectedExtendedKeyUsages: CertExtendedKeyUsage[] = extendedKeyUsages ?? []; + if (extendedKeyUsages === undefined && certificateTemplate) { + selectedExtendedKeyUsages = (certificateTemplate.extendedKeyUsages ?? []) as CertExtendedKeyUsage[]; + } + + if (extendedKeyUsages?.length && certificateTemplate) { + const validExtendedKeyUsages = certificateTemplate.extendedKeyUsages || []; + if (extendedKeyUsages.some((eku) => !validExtendedKeyUsages.includes(eku))) { + throw new BadRequestError({ + message: "Invalid extended key usage value based on template policy" + }); + } + selectedExtendedKeyUsages = extendedKeyUsages; + } + + if (selectedExtendedKeyUsages.length) { + extensions.push( + new x509.ExtendedKeyUsageExtension( + selectedExtendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku]), + true + ) + ); + } + + let altNamesArray: { + type: "email" | "dns"; + value: string; + }[] = []; + + if (altNames) { + altNamesArray = altNames + .split(",") + .map((name) => name.trim()) + .map((altName) => { + // check if the altName is a valid email + if (z.string().email().safeParse(altName).success) { + return { + type: "email", + value: altName + }; + } + + // check if the altName is a valid hostname + if (isFQDN(altName, { allow_wildcard: true })) { + return { + type: "dns", + value: altName + }; + } + + // If altName is neither a valid email nor a valid hostname, throw an error or handle it accordingly + throw new Error(`Invalid altName: ${altName}`); + }); + + const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); + extensions.push(altNamesExtension); + } + + if (certificateTemplate) { + validateCertificateDetailsAgainstTemplate( + { + commonName, + notBeforeDate, + notAfterDate, + altNames: altNamesArray.map((entry) => entry.value) + }, + certificateTemplate + ); + } + + const serialNumber = createSerialNumber(); + const leafCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions + }); + + const skLeafObj = KeyObject.from(leafKeys.privateKey); + const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(leafCert.rawData)) + }); + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: Buffer.from(skLeaf) + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caCertId: caCert.id, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + const certificateChainPem = `${issuingCaCertificate}\n${caCertChain}`.trim(); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: (ca as TCertificateAuthorities).id, + caCertId: caCert.id, + certificateTemplateId: certificateTemplate?.id, + status: CertStatus.ACTIVE, + friendlyName: friendlyName || commonName, + commonName, + altNames, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + keyUsages: selectedKeyUsages, + extendedKeyUsages: selectedExtendedKeyUsages, + projectId: ca!.projectId + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateSecretDAL.create( + { + certId: cert.id, + encryptedPrivateKey + }, + tx + ); + + if (collectionId) { + await pkiCollectionItemDAL.create( + { + pkiCollectionId: collectionId, + certId: cert.id + }, + tx + ); + } + + return cert; + }); + + return { + certificate: leafCert.toString("pem"), + certificateChain: certificateChainPem, + issuingCaCertificate, + privateKey: skLeaf, + serialNumber, + ca: expandInternalCa(ca) + }; + }; + + /** + * Return new leaf certificate issued by CA with id [caId]. + * Note: CSR is generated externally and submitted to Infisical. + */ + const signCertFromCa = async (dto: TSignCertFromCaDTO) => { + const appCfg = getConfig(); + let ca: TCertificateAuthorityWithAssociatedCa | undefined; + let certificateTemplate: TCertificateTemplates | undefined; + + const { + caId, + certificateTemplateId, + csr, + pkiCollectionId, + friendlyName, + commonName, + altNames, + ttl, + notBefore, + notAfter, + keyUsages, + extendedKeyUsages + } = dto; + + let collectionId = pkiCollectionId; + + if (caId) { + ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + } else if (certificateTemplateId) { + certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId); + if (!certificateTemplate) { + throw new NotFoundError({ + message: `Certificate template with ID '${certificateTemplateId}' not found` + }); + } + + collectionId = certificateTemplate.pkiCollectionId as string; + ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certificateTemplate.caId); + } + + if (!ca) { + throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); + } + + if (!ca?.internalCa?.id) { + throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); + } + + if (!dto.isInternal) { + const { permission } = await permissionService.getProjectPermission({ + actor: dto.actor, + actorId: dto.actorId, + projectId: ca.projectId, + actorAuthMethod: dto.actorAuthMethod, + actorOrgId: dto.actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Create, + ProjectPermissionSub.Certificates + ); + } + + if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); + if (!ca.internalCa.activeCaCertId) + throw new BadRequestError({ message: "CA does not have a certificate installed" }); + if (!ca.enableDirectIssuance && !certificateTemplate) { + throw new BadRequestError({ message: "Certificate template or subscriber is required for issuance" }); + } + + const caCert = await certificateAuthorityCertDAL.findById(ca.internalCa.activeCaCertId); + + if (ca.internalCa.notAfter && new Date() > new Date(ca.internalCa.notAfter)) { + throw new BadRequestError({ message: "CA is expired" }); + } + + // check PKI collection + if (pkiCollectionId) { + const pkiCollection = await pkiCollectionDAL.findById(pkiCollectionId); + if (!pkiCollection) throw new NotFoundError({ message: `PKI collection with ID '${pkiCollectionId}' not found` }); + if (pkiCollection.projectId !== ca.projectId) throw new BadRequestError({ message: "Invalid PKI collection" }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + + let notAfterDate = new Date(new Date().setFullYear(new Date().getFullYear() + 1)); + if (notAfter) { + notAfterDate = new Date(notAfter); + } else if (ttl) { + notAfterDate = new Date(new Date().getTime() + ms(ttl)); + } else if (certificateTemplate?.ttl) { + notAfterDate = new Date(new Date().getTime() + ms(certificateTemplate.ttl)); + } + + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" }); + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + + const csrObj = new x509.Pkcs10CertificateRequest(csr); + + const dn = parseDistinguishedName(csrObj.subject); + const cn = commonName || dn.commonName; + + if (!cn) + throw new BadRequestError({ + message: "A common name (CN) is required in the CSR or as a parameter to this endpoint" + }); + + const { caPrivateKey, caSecret } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; + + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + const extensions: x509.Extension[] = [ + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey), + new x509.CRLDistributionPointsExtension([distributionPointUrl]), + new x509.AuthorityInfoAccessExtension({ + caIssuers: new x509.GeneralName("url", caIssuerUrl) + }), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy + ]; + + // handle key usages + const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension; + let csrKeyUsages: CertKeyUsage[] = []; + if (csrKeyUsageExtension) { + csrKeyUsages = Object.values(CertKeyUsage).filter( + (keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0 + ); + } + + let selectedKeyUsages: CertKeyUsage[] = keyUsages ?? []; + if (keyUsages === undefined && !certificateTemplate) { + if (csrKeyUsageExtension) { + selectedKeyUsages = csrKeyUsages; + } else { + selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; + } + } + + if (keyUsages === undefined && certificateTemplate) { + if (csrKeyUsageExtension) { + const validKeyUsages = certificateTemplate.keyUsages || []; + if (csrKeyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) { + throw new BadRequestError({ + message: "Invalid key usage value based on template policy" + }); + } + selectedKeyUsages = csrKeyUsages; + } else { + selectedKeyUsages = (certificateTemplate.keyUsages ?? []) as CertKeyUsage[]; + } + } + + if (keyUsages?.length && certificateTemplate) { + const validKeyUsages = certificateTemplate.keyUsages || []; + if (keyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) { + throw new BadRequestError({ + message: "Invalid key usage value based on template policy" + }); + } + selectedKeyUsages = keyUsages; + } + + const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0); + if (keyUsagesBitValue) { + extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true)); + } + + // handle extended key usages + const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension; + let csrExtendedKeyUsages: CertExtendedKeyUsage[] = []; + if (csrExtendedKeyUsageExtension) { + csrExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map( + (ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string] + ); + } + + let selectedExtendedKeyUsages: CertExtendedKeyUsage[] = extendedKeyUsages ?? []; + if (extendedKeyUsages === undefined && !certificateTemplate && csrExtendedKeyUsageExtension) { + selectedExtendedKeyUsages = csrExtendedKeyUsages; + } + + if (extendedKeyUsages === undefined && certificateTemplate) { + if (csrExtendedKeyUsageExtension) { + const validExtendedKeyUsages = certificateTemplate.extendedKeyUsages || []; + if (csrExtendedKeyUsages.some((eku) => !validExtendedKeyUsages.includes(eku))) { + throw new BadRequestError({ + message: "Invalid extended key usage value based on template policy" + }); + } + selectedExtendedKeyUsages = csrExtendedKeyUsages; + } else { + selectedExtendedKeyUsages = (certificateTemplate.extendedKeyUsages ?? []) as CertExtendedKeyUsage[]; + } + } + + if (extendedKeyUsages?.length && certificateTemplate) { + const validExtendedKeyUsages = certificateTemplate.extendedKeyUsages || []; + if (extendedKeyUsages.some((keyUsage) => !validExtendedKeyUsages.includes(keyUsage))) { + throw new BadRequestError({ + message: "Invalid extended key usage value based on template policy" + }); + } + selectedExtendedKeyUsages = extendedKeyUsages; + } + + if (selectedExtendedKeyUsages.length) { + extensions.push( + new x509.ExtendedKeyUsageExtension( + selectedExtendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku]), + true + ) + ); + } + + let altNamesFromCsr: string = ""; + let altNamesArray: { + type: "email" | "dns"; + value: string; + }[] = []; + if (altNames) { + altNamesArray = altNames + .split(",") + .map((name) => name.trim()) + .map((altName) => { + // check if the altName is a valid email + if (z.string().email().safeParse(altName).success) { + return { + type: "email", + value: altName + }; + } + + // check if the altName is a valid hostname + if (isFQDN(altName, { allow_wildcard: true })) { + return { + type: "dns", + value: altName + }; + } + + // If altName is neither a valid email nor a valid hostname, throw an error or handle it accordingly + throw new Error(`Invalid altName: ${altName}`); + }); + } else { + // attempt to read from CSR if altNames is not explicitly provided + const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (sanExtension) { + const sanNames = new x509.GeneralNames(sanExtension.value); + + altNamesArray = sanNames.items + .filter((value) => value.type === "email" || value.type === "dns") + .map((name) => ({ + type: name.type as "email" | "dns", + value: name.value + })); + + altNamesFromCsr = sanNames.items.map((item) => item.value).join(","); + } + } + + if (altNamesArray.length) { + const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); + extensions.push(altNamesExtension); + } + + if (certificateTemplate) { + validateCertificateDetailsAgainstTemplate( + { + commonName: cn, + notBeforeDate, + notAfterDate, + altNames: altNamesArray.map((entry) => entry.value) + }, + certificateTemplate + ); + } + + const serialNumber = createSerialNumber(); + const leafCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(leafCert.rawData)) + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caCertId: ca.internalCa.activeCaCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + const certificateChainPem = `${issuingCaCertificate}\n${caCertChain}`.trim(); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: (ca as TCertificateAuthorities).id, + caCertId: caCert.id, + certificateTemplateId: certificateTemplate?.id, + status: CertStatus.ACTIVE, + friendlyName: friendlyName || csrObj.subject, + commonName: cn, + altNames: altNamesFromCsr || altNames, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + keyUsages: selectedKeyUsages, + extendedKeyUsages: selectedExtendedKeyUsages, + projectId: ca!.projectId + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + if (collectionId) { + await pkiCollectionItemDAL.create( + { + pkiCollectionId: collectionId, + certId: cert.id + }, + tx + ); + } + + return cert; + }); + + return { + certificate: leafCert, + certificateChain: certificateChainPem, + issuingCaCertificate, + serialNumber, + ca: expandInternalCa(ca), + commonName: cn + }; + }; + + /** + * Return list of certificate templates for CA with id [caId]. + */ + const getCaCertificateTemplates = async ({ + caId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TGetCaCertificateTemplatesDTO) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca?.internalCa?.id) throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + const certificateTemplates = await certificateTemplateDAL.find({ caId }); + + return { + certificateTemplates: certificateTemplates.filter((el) => + permission.can( + ProjectPermissionPkiTemplateActions.Read, + subject(ProjectPermissionSub.CertificateTemplates, { name: el.name }) + ) + ), + ca: expandInternalCa(ca) + }; + }; + + return { + createCa, + getCaById, + updateCaById, + deleteCaById, + getCaCsr, + renewCaCert, + getCaCerts, + getCaCert, + getCaCertById, + signIntermediate, + importCertToCa, + issueCertFromCa, + signCertFromCa, + getCaCertificateTemplates + }; +}; diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts new file mode 100644 index 000000000..fadd7b88d --- /dev/null +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts @@ -0,0 +1,233 @@ +import { z } from "zod"; + +import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { TProjectPermission } from "@app/lib/types"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; + +import { TCertificateAuthorityCertDALFactory } from "../certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "../certificate-authority-dal"; +import { CaRenewalType, CaStatus, CaType, InternalCaType } from "../certificate-authority-enums"; +import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority-secret-dal"; +import { + CreateInternalCertificateAuthoritySchema, + InternalCertificateAuthoritySchema, + UpdateInternalCertificateAuthoritySchema +} from "./internal-certificate-authority-schemas"; + +export type TInternalCertificateAuthority = z.infer; + +export type TInternalCertificateAuthorityInput = z.infer; + +export type TCreateInternalCertificateAuthorityDTO = z.infer; + +export type TUpdateInternalCertificateAuthorityDTO = z.infer; + +export type TCreateCaDTO = + | { + isInternal: true; + projectId: string; + type: InternalCaType; + friendlyName?: string; + name?: string; + commonName: string; + organization: string; + ou: string; + country: string; + province: string; + locality: string; + notBefore?: string; + notAfter?: string; + maxPathLength?: number | null; + keyAlgorithm: CertKeyAlgorithm; + enableDirectIssuance: boolean; + } + | ({ + isInternal: false; + projectSlug: string; + type: InternalCaType; + friendlyName?: string; + name?: string; + commonName: string; + organization: string; + ou: string; + country: string; + province: string; + locality: string; + notBefore?: string; + notAfter?: string; + maxPathLength?: number | null; + keyAlgorithm: CertKeyAlgorithm; + enableDirectIssuance: boolean; + } & Omit); + +export type TGetCaDTO = { + caId: string; +} & Omit; + +export type TUpdateCaDTO = + | { + isInternal: true; + caId: string; + name?: string; + status?: CaStatus; + enableDirectIssuance?: boolean; + } + | ({ + isInternal: false; + caId: string; + name?: string; + status?: CaStatus; + enableDirectIssuance?: boolean; + } & Omit); + +export type TDeleteCaDTO = { + caId: string; +} & Omit; + +export type TGetCaCsrDTO = { + caId: string; +} & Omit; + +export type TRenewCaCertDTO = { + caId: string; + notAfter: string; + type: CaRenewalType; +} & Omit; + +export type TGetCaCertsDTO = { + caId: string; +} & Omit; + +export type TGetCaCertDTO = { + caId: string; +} & Omit; + +export type TSignIntermediateDTO = { + caId: string; + csr: string; + notBefore?: string; + notAfter: string; + maxPathLength: number; +} & Omit; + +export type TImportCertToCaDTO = { + caId: string; + certificate: string; + certificateChain: string; +} & Omit; + +export type TIssueCertFromCaDTO = { + caId?: string; + certificateTemplateId?: string; + pkiCollectionId?: string; + friendlyName?: string; + commonName: string; + altNames: string; + ttl: string; + notBefore?: string; + notAfter?: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; +} & Omit; + +export type TSignCertFromCaDTO = + | { + isInternal: true; + caId?: string; + csr: string; + certificateTemplateId?: string; + pkiCollectionId?: string; + friendlyName?: string; + commonName?: string; + altNames?: string; + ttl?: string; + notBefore?: string; + notAfter?: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; + } + | ({ + isInternal: false; + caId?: string; + csr: string; + certificateTemplateId?: string; + pkiCollectionId?: string; + friendlyName?: string; + commonName?: string; + altNames: string; + ttl: string; + notBefore?: string; + notAfter?: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; + } & Omit); + +export type TGetCaCertificateTemplatesDTO = { + caId: string; +} & Omit; + +export type TDNParts = { + commonName?: string; + organization?: string; + ou?: string; + country?: string; + province?: string; + locality?: string; +}; + +export type TGetCaCredentialsDTO = { + caId: string; + certificateAuthorityDAL: Pick; + certificateAuthoritySecretDAL: Pick; + projectDAL: Pick; + kmsService: Pick; +}; + +export type TGetCaCertChainsDTO = { + caId: string; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; + projectDAL: Pick; + kmsService: Pick; +}; + +export type TGetCaCertChainDTO = { + caCertId: string; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; + projectDAL: Pick; + kmsService: Pick; +}; + +export type TRebuildCaCrlDTO = { + caId: string; + certificateAuthorityDAL: Pick; + certificateAuthorityCrlDAL: Pick; + certificateAuthoritySecretDAL: Pick; + projectDAL: Pick; + certificateDAL: Pick; + kmsService: Pick; +}; + +export type TRotateCaCrlTriggerDTO = { + caId: string; + rotationIntervalDays: number; +}; + +export type TOrderCertificateForSubscriberDTO = { + subscriberId: string; + caType: CaType; +}; + +export type TIssueCertWithTemplateDTO = { + commonName: string; + altNames: string; + ttl: string; + notBefore?: string; + notAfter?: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; +}; diff --git a/backend/src/services/certificate-template/certificate-template-dal.ts b/backend/src/services/certificate-template/certificate-template-dal.ts index c500833d1..092215c45 100644 --- a/backend/src/services/certificate-template/certificate-template-dal.ts +++ b/backend/src/services/certificate-template/certificate-template-dal.ts @@ -19,10 +19,15 @@ export const certificateTemplateDALFactory = (db: TDbClient) => { `${TableName.CertificateAuthority}.id`, `${TableName.CertificateTemplate}.caId` ) + .join( + TableName.InternalCertificateAuthority, + `${TableName.InternalCertificateAuthority}.caId`, + `${TableName.CertificateAuthority}.id` + ) .where(`${TableName.CertificateAuthority}.projectId`, "=", projectId) .select(selectAllTableCols(TableName.CertificateTemplate)) .select( - db.ref("friendlyName").as("caName").withSchema(TableName.CertificateAuthority), + db.ref("friendlyName").as("caName").withSchema(TableName.InternalCertificateAuthority), db.ref("projectId").withSchema(TableName.CertificateAuthority) ); @@ -41,11 +46,16 @@ export const certificateTemplateDALFactory = (db: TDbClient) => { `${TableName.CertificateTemplate}.caId` ) .join(TableName.Project, `${TableName.Project}.id`, `${TableName.CertificateAuthority}.projectId`) + .join( + TableName.InternalCertificateAuthority, + `${TableName.InternalCertificateAuthority}.caId`, + `${TableName.CertificateAuthority}.id` + ) .where(`${TableName.CertificateTemplate}.id`, "=", id) .select(selectAllTableCols(TableName.CertificateTemplate)) .select( db.ref("projectId").withSchema(TableName.CertificateAuthority), - db.ref("friendlyName").as("caName").withSchema(TableName.CertificateAuthority), + db.ref("friendlyName").as("caName").withSchema(TableName.InternalCertificateAuthority), db.ref("orgId").withSchema(TableName.Project) ) .first(); diff --git a/backend/src/services/certificate-template/certificate-template-schema.ts b/backend/src/services/certificate-template/certificate-template-schema.ts index 7a87daddf..6ab39af7d 100644 --- a/backend/src/services/certificate-template/certificate-template-schema.ts +++ b/backend/src/services/certificate-template/certificate-template-schema.ts @@ -18,3 +18,20 @@ export const sanitizedCertificateTemplate = CertificateTemplatesSchema.pick({ caName: z.string() }) ); + +export const sanitizedCertificateTemplateV2 = CertificateTemplatesSchema.pick({ + id: true, + caId: true, + name: true, + commonName: true, + subjectAlternativeName: true, + pkiCollectionId: true, + ttl: true, + keyUsages: true, + extendedKeyUsages: true +}).merge( + z.object({ + projectId: z.string(), + caName: z.string() + }) +); diff --git a/backend/src/services/certificate-template/certificate-template-service.ts b/backend/src/services/certificate-template/certificate-template-service.ts index 04bf76f5c..be1200503 100644 --- a/backend/src/services/certificate-template/certificate-template-service.ts +++ b/backend/src/services/certificate-template/certificate-template-service.ts @@ -1,11 +1,14 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import bcrypt from "bcrypt"; import { ActionProjectType, TCertificateTemplateEstConfigsUpdate } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { + ProjectPermissionPkiTemplateActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -78,8 +81,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Create, + subject(ProjectPermissionSub.CertificateTemplates, { name }) ); return certificateTemplateDAL.transaction(async (tx) => { @@ -140,8 +143,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Edit, + subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); if (caId) { @@ -153,6 +156,13 @@ export const certificateTemplateServiceFactory = ({ } } + if (name) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Create, + subject(ProjectPermissionSub.CertificateTemplates, { name }) + ); + } + return certificateTemplateDAL.transaction(async (tx) => { await certificateTemplateDAL.updateById( certTemplate.id, @@ -198,8 +208,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Delete, + subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); await certificateTemplateDAL.deleteById(certTemplate.id); @@ -225,8 +235,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Read, + subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); return certTemplate; @@ -267,8 +277,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Edit, + subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); const appCfg = getConfig(); @@ -350,8 +360,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Edit, + subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); const originalCaEstConfig = await certificateTemplateEstConfigDAL.findOne({ @@ -430,8 +440,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Edit, + subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); } diff --git a/backend/src/services/certificate/certificate-dal.ts b/backend/src/services/certificate/certificate-dal.ts index 71c70838c..9db473236 100644 --- a/backend/src/services/certificate/certificate-dal.ts +++ b/backend/src/services/certificate/certificate-dal.ts @@ -3,11 +3,28 @@ import { TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; +import { CertStatus } from "./certificate-types"; + export type TCertificateDALFactory = ReturnType; export const certificateDALFactory = (db: TDbClient) => { const certificateOrm = ormify(db, TableName.Certificate); + const findLatestActiveCertForSubscriber = async ({ subscriberId }: { subscriberId: string }) => { + try { + const cert = await db + .replicaNode()(TableName.Certificate) + .where({ pkiSubscriberId: subscriberId, status: CertStatus.ACTIVE }) + .where("notAfter", ">", new Date()) + .orderBy("notBefore", "desc") + .first(); + + return cert; + } catch (error) { + throw new DatabaseError({ error, name: "Find latest active certificate for subscriber" }); + } + }; + const countCertificatesInProject = async ({ projectId, friendlyName, @@ -44,8 +61,28 @@ export const certificateDALFactory = (db: TDbClient) => { } }; + const countCertificatesForPkiSubscriber = async (subscriberId: string) => { + try { + interface CountResult { + count: string; + } + + const query = db + .replicaNode()(TableName.Certificate) + .where(`${TableName.Certificate}.pkiSubscriberId`, subscriberId); + + const count = await query.count("*").first(); + + return parseInt((count as unknown as CountResult).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count all subscriber certificates" }); + } + }; + return { ...certificateOrm, - countCertificatesInProject + countCertificatesInProject, + countCertificatesForPkiSubscriber, + findLatestActiveCertForSubscriber }; }; diff --git a/backend/src/services/certificate/certificate-fns.ts b/backend/src/services/certificate/certificate-fns.ts index 961fb27ff..ffdaec3b4 100644 --- a/backend/src/services/certificate/certificate-fns.ts +++ b/backend/src/services/certificate/certificate-fns.ts @@ -1,11 +1,12 @@ import crypto from "node:crypto"; import * as x509 from "@peculiar/x509"; +import RE2 from "re2"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { getProjectKmsCertificateKeyId } from "../project/project-fns"; -import { CrlReason, TBuildCertificateChainDTO, TGetCertificateCredentialsDTO } from "./certificate-types"; +import { CrlReason, TGetCertificateCredentialsDTO } from "./certificate-types"; export const revocationReasonToCrlCode = (crlReason: CrlReason) => { switch (crlReason) { @@ -52,6 +53,12 @@ export const constructPemChainFromCerts = (certificates: x509.X509Certificate[]) .join("\n") .trim(); +export const splitPemChain = (pemText: string) => { + const re2Pattern = new RE2("-----BEGIN CERTIFICATE-----[^-]+-----END CERTIFICATE-----", "g"); + + return re2Pattern.match(pemText) || []; +}; + /** * Return the public and private key of certificate * Note: credentials are returned as PEM strings @@ -95,29 +102,3 @@ export const getCertificateCredentials = async ({ throw new BadRequestError({ message: `Failed to process private key for certificate with ID '${certId}'` }); } }; - -// If the certificate was generated after ~05/01/25 it will have a encryptedCertificateChain attached to it's body -// Otherwise we'll fallback to manually building the chain -export const buildCertificateChain = async ({ - caCert, - caCertChain, - encryptedCertificateChain, - kmsService, - kmsId -}: TBuildCertificateChainDTO) => { - if (!encryptedCertificateChain && (!caCert || !caCertChain)) { - return null; - } - - let certificateChain = `${caCert}\n${caCertChain}`.trim(); - - if (encryptedCertificateChain) { - const kmsDecryptor = await kmsService.decryptWithKmsKey({ kmsId }); - const decryptedCertChain = await kmsDecryptor({ - cipherTextBlob: encryptedCertificateChain - }); - certificateChain = decryptedCertChain.toString(); - } - - return certificateChain; -}; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index 73a8caed7..3921774fd 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -1,44 +1,57 @@ import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; +import { createPrivateKey, createPublicKey, sign, verify } from "crypto"; -import { ActionProjectType } from "@app/db/schemas"; +import { ActionProjectType, ProjectType } from "@app/db/schemas"; import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionCertificateActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; 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 { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; -import { getCaCertChain, rebuildCaCrl } from "../certificate-authority/certificate-authority-fns"; -import { buildCertificateChain, getCertificateCredentials, revocationReasonToCrlCode } from "./certificate-fns"; +import { expandInternalCa, getCaCertChain, rebuildCaCrl } from "../certificate-authority/certificate-authority-fns"; +import { getCertificateCredentials, revocationReasonToCrlCode, splitPemChain } from "./certificate-fns"; import { TCertificateSecretDALFactory } from "./certificate-secret-dal"; import { + CertExtendedKeyUsage, + CertExtendedKeyUsageOIDToName, + CertKeyUsage, CertStatus, TDeleteCertDTO, TGetCertBodyDTO, TGetCertBundleDTO, TGetCertDTO, TGetCertPrivateKeyDTO, + TImportCertDTO, TRevokeCertDTO } from "./certificate-types"; type TCertificateServiceFactoryDep = { - certificateDAL: Pick; - certificateSecretDAL: Pick; - certificateBodyDAL: Pick; - certificateAuthorityDAL: Pick; + certificateDAL: Pick; + certificateSecretDAL: Pick; + certificateBodyDAL: Pick; + certificateAuthorityDAL: Pick; certificateAuthorityCertDAL: Pick; certificateAuthorityCrlDAL: Pick; certificateAuthoritySecretDAL: Pick; - projectDAL: Pick; + pkiCollectionDAL: Pick; + pkiCollectionItemDAL: Pick; + projectDAL: Pick< + TProjectDALFactory, + "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction" | "getProjectFromSplitId" + >; kmsService: Pick; permissionService: Pick; }; @@ -53,6 +66,8 @@ export const certificateServiceFactory = ({ certificateAuthorityCertDAL, certificateAuthorityCrlDAL, certificateAuthoritySecretDAL, + pkiCollectionDAL, + pkiCollectionItemDAL, projectDAL, kmsService, permissionService @@ -62,12 +77,11 @@ export const certificateServiceFactory = ({ */ const getCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertDTO) => { const cert = await certificateDAL.findOne({ serialNumber }); - const ca = await certificateAuthorityDAL.findById(cert.caId); const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId: ca.projectId, + projectId: cert.projectId, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.CertificateManager @@ -79,8 +93,7 @@ export const certificateServiceFactory = ({ ); return { - cert, - ca + cert }; }; @@ -95,12 +108,11 @@ export const certificateServiceFactory = ({ actorOrgId }: TGetCertPrivateKeyDTO) => { const cert = await certificateDAL.findOne({ serialNumber }); - const ca = await certificateAuthorityDAL.findById(cert.caId); const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId: ca.projectId, + projectId: cert.projectId, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.CertificateManager @@ -113,14 +125,13 @@ export const certificateServiceFactory = ({ const { certPrivateKey } = await getCertificateCredentials({ certId: cert.id, - projectId: ca.projectId, + projectId: cert.projectId, certificateSecretDAL, projectDAL, kmsService }); return { - ca, cert, certPrivateKey }; @@ -131,12 +142,11 @@ export const certificateServiceFactory = ({ */ const deleteCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteCertDTO) => { const cert = await certificateDAL.findOne({ serialNumber }); - const ca = await certificateAuthorityDAL.findById(cert.caId); const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId: ca.projectId, + projectId: cert.projectId, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.CertificateManager @@ -150,8 +160,7 @@ export const certificateServiceFactory = ({ const deletedCert = await certificateDAL.deleteById(cert.id); return { - deletedCert, - ca + deletedCert }; }; @@ -169,7 +178,20 @@ export const certificateServiceFactory = ({ actorOrgId }: TRevokeCertDTO) => { const cert = await certificateDAL.findOne({ serialNumber }); - const ca = await certificateAuthorityDAL.findById(cert.caId); + + if (!cert.caId) { + throw new BadRequestError({ + message: "Cannot revoke imported certificates" + }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(cert.caId); + + if (ca.externalCa?.id) { + throw new BadRequestError({ + message: "Cannot revoke external certificates" + }); + } const { permission } = await permissionService.getProjectPermission({ actor, @@ -210,7 +232,7 @@ export const certificateServiceFactory = ({ kmsService }); - return { revokedAt, cert, ca }; + return { revokedAt, cert, ca: expandInternalCa(ca) }; }; /** @@ -219,12 +241,11 @@ export const certificateServiceFactory = ({ */ const getCertBody = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertBodyDTO) => { const cert = await certificateDAL.findOne({ serialNumber }); - const ca = await certificateAuthorityDAL.findById(cert.caId); const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId: ca.projectId, + projectId: cert.projectId, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.CertificateManager @@ -238,7 +259,7 @@ export const certificateServiceFactory = ({ const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ - projectId: ca.projectId, + projectId: cert.projectId, projectDAL, kmsService }); @@ -252,28 +273,259 @@ export const certificateServiceFactory = ({ const certObj = new x509.X509Certificate(decryptedCert); - const { caCert, caCertChain } = await getCaCertChain({ - caCertId: cert.caCertId, - certificateAuthorityDAL, - certificateAuthorityCertDAL, - projectDAL, - kmsService - }); + let certificateChain = null; - const certificateChain = await buildCertificateChain({ - caCert, - caCertChain, - kmsId: certificateManagerKeyId, - kmsService, - encryptedCertificateChain: certBody.encryptedCertificateChain || undefined - }); + // On newer certs the certBody.encryptedCertificateChain column will always exist. + // Older certs will have a caCertId which will be used as a fallback mechanism for structuring the chain. + if (certBody.encryptedCertificateChain) { + const decryptedCertChain = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificateChain + }); + certificateChain = decryptedCertChain.toString(); + } else if (cert.caCertId) { + const { caCert, caCertChain } = await getCaCertChain({ + caCertId: cert.caCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + certificateChain = `${caCert}\n${caCertChain}`.trim(); + } return { certificate: certObj.toString("pem"), certificateChain, serialNumber: certObj.serialNumber, - cert, - ca + cert + }; + }; + + /** + * Import certificate + */ + const importCert = async ({ + projectSlug, + pkiCollectionId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + friendlyName, + certificatePem, + chainPem, + privateKeyPem + }: TImportCertDTO) => { + const collectionId = pkiCollectionId; + + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); + let projectId = project.id; + + const certManagerProjectFromSplit = await projectDAL.getProjectFromSplitId( + projectId, + ProjectType.CertificateManager + ); + if (certManagerProjectFromSplit) { + projectId = certManagerProjectFromSplit.id; + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Create, + ProjectPermissionSub.Certificates + ); + + // Check PKI collection + if (collectionId) { + const pkiCollection = await pkiCollectionDAL.findById(collectionId); + if (!pkiCollection) throw new NotFoundError({ message: "PKI collection not found" }); + if (pkiCollection.projectId !== projectId) throw new BadRequestError({ message: "Invalid PKI collection" }); + } + + const leafCert = new x509.X509Certificate(certificatePem); + + // Verify the certificate chain + const chainCerts = splitPemChain(chainPem).map((pem) => new x509.X509Certificate(pem)); + + // Remove leaf cert from the chain if it's present + if (chainCerts[0].equal(leafCert)) { + chainCerts.splice(0, 1); + } + + if (chainCerts.length === 0) { + throw new BadRequestError({ + message: "Certificate chain must contain at least one issuer certificate" + }); + } + + // Verify leaf certificate is signed by the first certificate in the chain + const isLeafVerified = await leafCert.verify({ publicKey: chainCerts[0].publicKey }).catch(() => false); + if (!isLeafVerified) { + throw new BadRequestError({ message: "Leaf certificate verification against chain failed" }); + } + + // Verify the entire chain of trust + const verificationPromises = chainCerts.slice(0, -1).map(async (currentCert, index) => { + const issuerCert = chainCerts[index + 1]; + return currentCert.verify({ publicKey: issuerCert.publicKey }).catch(() => false); + }); + + const verificationResults = await Promise.all(verificationPromises); + + if (verificationResults.some((result) => !result)) { + throw new BadRequestError({ + message: "Certificate chain verification failed: broken trust chain" + }); + } + + // Verify private key matches the certificate + let privateKey; + try { + privateKey = createPrivateKey(privateKeyPem); + } catch (err) { + throw new BadRequestError({ message: "Invalid private key format" }); + } + + try { + const message = Buffer.from(Buffer.alloc(32)); + const publicKey = createPublicKey(certificatePem); + const signature = sign(null, message, privateKey); + const isValid = verify(null, message, publicKey, signature); + + if (!isValid) { + throw new BadRequestError({ message: "Private key does not match certificate" }); + } + } catch (err) { + if (err instanceof BadRequestError) { + throw err; + } + throw new BadRequestError({ message: "Error verifying private key against certificate" }); + } + + // Get certificate attributes + const commonName = Array.from(leafCert.subjectName.getField("CN")?.values() || [])[0] || ""; + + let altNames: undefined | string; + const sanExtension = leafCert.extensions.find((ext) => ext.type === "2.5.29.17"); + if (sanExtension) { + const sanNames = new x509.GeneralNames(sanExtension.value); + altNames = sanNames.items.map((name) => name.value).join(", "); + } + + const { serialNumber, notBefore, notAfter } = leafCert; + + // Encrypt certificate for storage + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKeyId + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(certificatePem) + }); + + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: Buffer.from(privateKeyPem) + }); + + // Extract Key Usage + const keyUsagesExt = leafCert.getExtension("2.5.29.15") as x509.KeyUsagesExtension; + + let keyUsages: CertKeyUsage[] = []; + if (keyUsagesExt) { + keyUsages = Object.values(CertKeyUsage).filter( + // eslint-disable-next-line no-bitwise + (keyUsage) => (x509.KeyUsageFlags[keyUsage] & keyUsagesExt.usages) !== 0 + ); + } + + // Extract Extended Key Usage + const extKeyUsageExt = leafCert.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension; + let extendedKeyUsages: CertExtendedKeyUsage[] = []; + if (extKeyUsageExt) { + extendedKeyUsages = extKeyUsageExt.usages.map((ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string]); + } + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(chainPem) + }); + + const cert = await certificateDAL.transaction(async (tx) => { + try { + const txCert = await certificateDAL.create( + { + status: CertStatus.ACTIVE, + friendlyName: friendlyName || commonName, + commonName, + altNames, + serialNumber, + notBefore, + notAfter, + projectId, + keyUsages, + extendedKeyUsages + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: txCert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateSecretDAL.create( + { + certId: txCert.id, + encryptedPrivateKey + }, + tx + ); + + if (collectionId) { + await pkiCollectionItemDAL.create( + { + pkiCollectionId: collectionId, + certId: txCert.id + }, + tx + ); + } + + return txCert; + } catch (error) { + // @ts-expect-error We're expecting a database error + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (error?.error?.code === "23505") { + throw new BadRequestError({ message: "Certificate serial already exists in your project" }); + } + throw error; + } + }); + + return { + certificate: certificatePem, + certificateChain: chainPem, + privateKey: privateKeyPem, + serialNumber, + cert }; }; @@ -283,12 +535,11 @@ export const certificateServiceFactory = ({ */ const getCertBundle = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertBundleDTO) => { const cert = await certificateDAL.findOne({ serialNumber }); - const ca = await certificateAuthorityDAL.findById(cert.caId); const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId: ca.projectId, + projectId: cert.projectId, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.CertificateManager @@ -306,7 +557,7 @@ export const certificateServiceFactory = ({ const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ - projectId: ca.projectId, + projectId: cert.projectId, projectDAL, kmsService }); @@ -321,37 +572,50 @@ export const certificateServiceFactory = ({ const certObj = new x509.X509Certificate(decryptedCert); const certificate = certObj.toString("pem"); - const { caCert, caCertChain } = await getCaCertChain({ - caCertId: cert.caCertId, - certificateAuthorityDAL, - certificateAuthorityCertDAL, - projectDAL, - kmsService - }); + let certificateChain = null; - const certificateChain = await buildCertificateChain({ - caCert, - caCertChain, - kmsId: certificateManagerKeyId, - kmsService, - encryptedCertificateChain: certBody.encryptedCertificateChain || undefined - }); + // On newer certs the certBody.encryptedCertificateChain column will always exist. + // Older certs will have a caCertId which will be used as a fallback mechanism for structuring the chain. + if (certBody.encryptedCertificateChain) { + const decryptedCertChain = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificateChain + }); + certificateChain = decryptedCertChain.toString(); + } else if (cert.caCertId) { + const { caCert, caCertChain } = await getCaCertChain({ + caCertId: cert.caCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); - const { certPrivateKey } = await getCertificateCredentials({ - certId: cert.id, - projectId: ca.projectId, - certificateSecretDAL, - projectDAL, - kmsService - }); + certificateChain = `${caCert}\n${caCertChain}`.trim(); + } + + let privateKey: string | null = null; + try { + const { certPrivateKey } = await getCertificateCredentials({ + certId: cert.id, + projectId: cert.projectId, + certificateSecretDAL, + projectDAL, + kmsService + }); + privateKey = certPrivateKey; + } catch (e) { + // Skip NotFound errors but throw all others + if (!(e instanceof NotFoundError)) { + throw e; + } + } return { certificate, certificateChain, - privateKey: certPrivateKey, + privateKey, serialNumber, - cert, - ca + cert }; }; @@ -361,6 +625,7 @@ export const certificateServiceFactory = ({ deleteCert, revokeCert, getCertBody, + importCert, getCertBundle }; }; diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts index ae04eae6b..f1c79a36f 100644 --- a/backend/src/services/certificate/certificate-types.ts +++ b/backend/src/services/certificate/certificate-types.ts @@ -78,6 +78,17 @@ export type TGetCertBodyDTO = { serialNumber: string; } & Omit; +export type TImportCertDTO = { + projectSlug: string; + + friendlyName?: string; + pkiCollectionId?: string; + + certificatePem: string; + privateKeyPem: string; + chainPem: string; +} & Omit; + export type TGetCertPrivateKeyDTO = { serialNumber: string; } & Omit; @@ -93,11 +104,3 @@ export type TGetCertificateCredentialsDTO = { projectDAL: Pick; kmsService: Pick; }; - -export type TBuildCertificateChainDTO = { - caCert?: string; - caCertChain?: string; - encryptedCertificateChain?: Buffer; - kmsService: Pick; - kmsId: string; -}; diff --git a/backend/src/services/group-project/group-project-service.ts b/backend/src/services/group-project/group-project-service.ts index 1ff2c78d2..a793ecfab 100644 --- a/backend/src/services/group-project/group-project-service.ts +++ b/backend/src/services/group-project/group-project-service.ts @@ -1,6 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import { ActionProjectType, ProjectMembershipRole, SecretKeyEncoding, TGroups } from "@app/db/schemas"; +import { TListProjectGroupUsersDTO } from "@app/ee/services/group/group-types"; import { constructPermissionErrorMessage, validatePrivilegeChangeOperation @@ -42,7 +43,7 @@ type TGroupProjectServiceFactoryDep = { projectKeyDAL: Pick; projectRoleDAL: Pick; projectBotDAL: TProjectBotDALFactory; - groupDAL: Pick; + groupDAL: Pick; permissionService: Pick; }; @@ -471,11 +472,54 @@ export const groupProjectServiceFactory = ({ return groupMembership; }; + const listProjectGroupUsers = async ({ + id, + projectId, + offset, + limit, + username, + actor, + actorId, + actorAuthMethod, + actorOrgId, + search, + filter + }: TListProjectGroupUsersDTO) => { + const project = await projectDAL.findById(projectId); + + if (!project) { + throw new NotFoundError({ message: `Failed to find project with ID ${projectId}` }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.Any + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups); + + const { members, totalCount } = await groupDAL.findAllGroupPossibleMembers({ + orgId: project.orgId, + groupId: id, + offset, + limit, + username, + search, + filter + }); + + return { users: members, totalCount }; + }; + return { addGroupToProject, updateGroupInProject, removeGroupFromProject, listGroupsInProject, - getGroupInProject + getGroupInProject, + listProjectGroupUsers }; }; diff --git a/backend/src/services/identity-access-token/identity-access-token-dal.ts b/backend/src/services/identity-access-token/identity-access-token-dal.ts index 57517c706..fea12d3ee 100644 --- a/backend/src/services/identity-access-token/identity-access-token-dal.ts +++ b/backend/src/services/identity-access-token/identity-access-token-dal.ts @@ -30,11 +30,13 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { .leftJoin(TableName.IdentityGcpAuth, `${TableName.Identity}.id`, `${TableName.IdentityGcpAuth}.identityId`) .leftJoin(TableName.IdentityAwsAuth, `${TableName.Identity}.id`, `${TableName.IdentityAwsAuth}.identityId`) .leftJoin(TableName.IdentityAzureAuth, `${TableName.Identity}.id`, `${TableName.IdentityAzureAuth}.identityId`) + .leftJoin(TableName.IdentityLdapAuth, `${TableName.Identity}.id`, `${TableName.IdentityLdapAuth}.identityId`) .leftJoin( TableName.IdentityKubernetesAuth, `${TableName.Identity}.id`, `${TableName.IdentityKubernetesAuth}.identityId` ) + .leftJoin(TableName.IdentityOciAuth, `${TableName.Identity}.id`, `${TableName.IdentityOciAuth}.identityId`) .leftJoin(TableName.IdentityOidcAuth, `${TableName.Identity}.id`, `${TableName.IdentityOidcAuth}.identityId`) .leftJoin(TableName.IdentityTokenAuth, `${TableName.Identity}.id`, `${TableName.IdentityTokenAuth}.identityId`) .leftJoin(TableName.IdentityJwtAuth, `${TableName.Identity}.id`, `${TableName.IdentityJwtAuth}.identityId`) @@ -45,9 +47,11 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAwsAuth).as("accessTokenTrustedIpsAws"), db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAzureAuth).as("accessTokenTrustedIpsAzure"), db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityKubernetesAuth).as("accessTokenTrustedIpsK8s"), + db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityOciAuth).as("accessTokenTrustedIpsOci"), db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityOidcAuth).as("accessTokenTrustedIpsOidc"), db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityTokenAuth).as("accessTokenTrustedIpsToken"), db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityJwtAuth).as("accessTokenTrustedIpsJwt"), + db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityLdapAuth).as("accessTokenTrustedIpsLdap"), db.ref("name").withSchema(TableName.Identity) ) .first(); @@ -61,9 +65,11 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { trustedIpsAwsAuth: doc.accessTokenTrustedIpsAws, trustedIpsAzureAuth: doc.accessTokenTrustedIpsAzure, trustedIpsKubernetesAuth: doc.accessTokenTrustedIpsK8s, + trustedIpsOciAuth: doc.accessTokenTrustedIpsOci, trustedIpsOidcAuth: doc.accessTokenTrustedIpsOidc, trustedIpsAccessTokenAuth: doc.accessTokenTrustedIpsToken, - trustedIpsAccessJwtAuth: doc.accessTokenTrustedIpsJwt + trustedIpsAccessJwtAuth: doc.accessTokenTrustedIpsJwt, + trustedIpsAccessLdapAuth: doc.accessTokenTrustedIpsLdap }; } catch (error) { throw new DatabaseError({ error, name: "IdAccessTokenFindOne" }); diff --git a/backend/src/services/identity-access-token/identity-access-token-service.ts b/backend/src/services/identity-access-token/identity-access-token-service.ts index a51d80e41..c5b57373d 100644 --- a/backend/src/services/identity-access-token/identity-access-token-service.ts +++ b/backend/src/services/identity-access-token/identity-access-token-service.ts @@ -96,10 +96,15 @@ export const identityAccessTokenServiceFactory = ({ } await validateAccessTokenExp({ ...identityAccessToken, accessTokenNumUses }); - const { accessTokenMaxTTL, createdAt: accessTokenCreatedAt, accessTokenTTL } = identityAccessToken; + const { + accessTokenMaxTTL, + createdAt: accessTokenCreatedAt, + accessTokenTTL, + accessTokenPeriod + } = identityAccessToken; - // max ttl checks - will it go above max ttl - if (Number(accessTokenMaxTTL) > 0) { + // Only enforce Max TTL for non-periodic tokens + if (Number(accessTokenMaxTTL) > 0 && Number(accessTokenPeriod) === 0) { const accessTokenCreated = new Date(accessTokenCreatedAt); const ttlInMilliseconds = Number(accessTokenMaxTTL) * 1000; const currentDate = new Date(); @@ -125,6 +130,18 @@ export const identityAccessTokenServiceFactory = ({ accessTokenLastRenewedAt: new Date() }); + const ttl = Number(accessTokenTTL); + const period = Number(accessTokenPeriod); + + let expiresIn: number | undefined; + if (period > 0) { + expiresIn = period; + } else if (ttl > 0) { + expiresIn = ttl; + } else { + expiresIn = undefined; + } + const renewedToken = jwt.sign( { identityId: decodedToken.identityId, @@ -133,12 +150,7 @@ export const identityAccessTokenServiceFactory = ({ 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) - } + expiresIn !== undefined ? { expiresIn } : undefined ); return { accessToken: renewedToken, identityAccessToken: updatedIdentityAccessToken }; @@ -182,11 +194,13 @@ export const identityAccessTokenServiceFactory = ({ [IdentityAuthMethod.UNIVERSAL_AUTH]: identityAccessToken.trustedIpsUniversalAuth, [IdentityAuthMethod.GCP_AUTH]: identityAccessToken.trustedIpsGcpAuth, [IdentityAuthMethod.AWS_AUTH]: identityAccessToken.trustedIpsAwsAuth, + [IdentityAuthMethod.OCI_AUTH]: identityAccessToken.trustedIpsOciAuth, [IdentityAuthMethod.AZURE_AUTH]: identityAccessToken.trustedIpsAzureAuth, [IdentityAuthMethod.KUBERNETES_AUTH]: identityAccessToken.trustedIpsKubernetesAuth, [IdentityAuthMethod.OIDC_AUTH]: identityAccessToken.trustedIpsOidcAuth, [IdentityAuthMethod.TOKEN_AUTH]: identityAccessToken.trustedIpsAccessTokenAuth, - [IdentityAuthMethod.JWT_AUTH]: identityAccessToken.trustedIpsAccessJwtAuth + [IdentityAuthMethod.JWT_AUTH]: identityAccessToken.trustedIpsAccessJwtAuth, + [IdentityAuthMethod.LDAP_AUTH]: identityAccessToken.trustedIpsAccessLdapAuth }; const trustedIps = trustedIpsMap[identityAccessToken.authMethod as IdentityAuthMethod]; diff --git a/backend/src/services/identity-access-token/identity-access-token-types.ts b/backend/src/services/identity-access-token/identity-access-token-types.ts index c97d2f40a..87adfa5dc 100644 --- a/backend/src/services/identity-access-token/identity-access-token-types.ts +++ b/backend/src/services/identity-access-token/identity-access-token-types.ts @@ -11,5 +11,9 @@ export type TIdentityAccessTokenJwtPayload = { oidc?: { claims: Record; }; + kubernetes?: { + namespace: string; + name: string; + }; }; }; diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index 9c0e8d2dd..a1231c353 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -2,10 +2,17 @@ import { ForbiddenError } from "@casl/ability"; import axios, { AxiosError } from "axios"; import https from "https"; import jwt from "jsonwebtoken"; +import RE2 from "re2"; import { IdentityAuthMethod, TIdentityKubernetesAuthsUpdate } from "@app/db/schemas"; +import { TGatewayDALFactory } from "@app/ee/services/gateway/gateway-dal"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; -import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + OrgPermissionGatewayActions, + OrgPermissionIdentityActions, + OrgPermissionSubjects +} from "@app/ee/services/permission/org-permission"; import { constructPermissionErrorMessage, validatePrivilegeChangeOperation @@ -13,7 +20,9 @@ import { import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { logger } from "@app/lib/logger"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; @@ -25,6 +34,7 @@ import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/su import { TIdentityKubernetesAuthDALFactory } from "./identity-kubernetes-auth-dal"; import { extractK8sUsername } from "./identity-kubernetes-auth-fns"; import { + IdentityKubernetesAuthTokenReviewMode, TAttachKubernetesAuthDTO, TCreateTokenReviewResponse, TGetKubernetesAuthDTO, @@ -43,6 +53,8 @@ type TIdentityKubernetesAuthServiceFactoryDep = { permissionService: Pick; licenseService: Pick; kmsService: Pick; + gatewayService: TGatewayServiceFactory; + gatewayDAL: Pick; }; export type TIdentityKubernetesAuthServiceFactory = ReturnType; @@ -53,8 +65,56 @@ export const identityKubernetesAuthServiceFactory = ({ identityAccessTokenDAL, permissionService, licenseService, + gatewayService, + gatewayDAL, kmsService }: TIdentityKubernetesAuthServiceFactoryDep) => { + const $gatewayProxyWrapper = async ( + inputs: { + gatewayId: string; + targetHost: string; + targetPort: number; + caCert?: string; + reviewTokenThroughGateway: boolean; + }, + gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise + ): Promise => { + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + + const callbackResult = await withGatewayProxy( + async (port, httpsAgent) => { + const res = await gatewayCallback( + inputs.reviewTokenThroughGateway ? "http://localhost" : "https://localhost", + port, + httpsAgent + ); + return res; + }, + { + protocol: inputs.reviewTokenThroughGateway ? GatewayProxyProtocol.Http : GatewayProxyProtocol.Tcp, + targetHost: inputs.targetHost, + targetPort: inputs.targetPort, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + }, + // we always pass this, because its needed for both tcp and http protocol + httpsAgent: new https.Agent({ + ca: inputs.caCert, + rejectUnauthorized: Boolean(inputs.caCert) + }) + } + ); + + return callbackResult; + }; + const login = async ({ identityId, jwt: serviceAccountJwt }: TLoginKubernetesAuthDTO) => { const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); if (!identityKubernetesAuth) { @@ -82,56 +142,206 @@ export const identityKubernetesAuthServiceFactory = ({ caCert = decryptor({ cipherTextBlob: identityKubernetesAuth.encryptedKubernetesCaCertificate }).toString(); } - let tokenReviewerJwt = ""; - if (identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt) { - tokenReviewerJwt = decryptor({ - cipherTextBlob: identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt - }).toString(); - } else { - // if no token reviewer is provided means the incoming token has to act as reviewer - tokenReviewerJwt = serviceAccountJwt; - } + const tokenReviewCallbackRaw = async (host: string = identityKubernetesAuth.kubernetesHost, port?: number) => { + logger.info({ host, port }, "tokenReviewCallbackRaw: Processing kubernetes token review using raw API"); + let tokenReviewerJwt = ""; + if (identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt) { + tokenReviewerJwt = decryptor({ + cipherTextBlob: identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt + }).toString(); + } else { + // if no token reviewer is provided means the incoming token has to act as reviewer + tokenReviewerJwt = serviceAccountJwt; + } - const { data } = await axios - .post( - `${identityKubernetesAuth.kubernetesHost}/apis/authentication.k8s.io/v1/tokenreviews`, - { - apiVersion: "authentication.k8s.io/v1", - kind: "TokenReview", - spec: { - token: serviceAccountJwt, - ...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {}) - } - }, - { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${tokenReviewerJwt}` + let servername = identityKubernetesAuth.kubernetesHost; + if (servername.startsWith("https://") || servername.startsWith("http://")) { + servername = new RE2("^https?:\\/\\/").replace(servername, ""); + } + + // get the last colon index, if it has a port, remove it, including the colon + const lastColonIndex = servername.lastIndexOf(":"); + if (lastColonIndex !== -1) { + servername = servername.substring(0, lastColonIndex); + } + + const baseUrl = port ? `${host}:${port}` : host; + + const res = await axios + .post( + `${baseUrl}/apis/authentication.k8s.io/v1/tokenreviews`, + { + apiVersion: "authentication.k8s.io/v1", + kind: "TokenReview", + spec: { + token: serviceAccountJwt, + ...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {}) + } }, - signal: AbortSignal.timeout(10000), - timeout: 10000, - // if ca cert, rejectUnauthorized: true - httpsAgent: new https.Agent({ - ca: caCert, - rejectUnauthorized: !!caCert - }) - } - ) - .catch((err) => { - if (err instanceof AxiosError) { - if (err.response) { - const { message } = err?.response?.data as unknown as { message?: string }; + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokenReviewerJwt}` + }, + signal: AbortSignal.timeout(10000), + timeout: 10000, + httpsAgent: new https.Agent({ + ca: caCert, + rejectUnauthorized: Boolean(caCert), + servername + }) + } + ) + .catch((err) => { + if (err instanceof AxiosError) { + if (err.response) { + const { message } = err?.response?.data as unknown as { message?: string }; - if (message) { - throw new UnauthorizedError({ - message, - name: "KubernetesTokenReviewRequestError" - }); + if (message) { + throw new UnauthorizedError({ + message, + name: "KubernetesTokenReviewRequestError" + }); + } } } - } - throw err; + throw err; + }); + + return res.data; + }; + + const tokenReviewCallbackThroughGateway = async ( + host: string = identityKubernetesAuth.kubernetesHost, + port?: number, + httpsAgent?: https.Agent + ) => { + logger.info( + { + host, + port + }, + "tokenReviewCallbackThroughGateway: Processing kubernetes token review using gateway" + ); + + const baseUrl = port ? `${host}:${port}` : host; + + const res = await axios + .post( + `${baseUrl}/apis/authentication.k8s.io/v1/tokenreviews`, + { + apiVersion: "authentication.k8s.io/v1", + kind: "TokenReview", + spec: { + token: serviceAccountJwt, + ...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {}) + } + }, + { + headers: { + "Content-Type": "application/json", + "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken + }, + signal: AbortSignal.timeout(10000), + timeout: 10000, + ...(httpsAgent ? { httpsAgent } : {}) + } + ) + .catch((err) => { + if (err instanceof AxiosError) { + if (err.response) { + let { message } = err?.response?.data as unknown as { message?: string }; + + if (!message && typeof err.response.data === "string") { + message = err.response.data; + } + + if (message) { + throw new UnauthorizedError({ + message, + name: "KubernetesTokenReviewRequestError" + }); + } + } + } + throw err; + }); + + return res.data; + }; + + let data: TCreateTokenReviewResponse | undefined; + + if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) { + const { kubernetesHost } = identityKubernetesAuth; + + let urlString = kubernetesHost; + if (!kubernetesHost.startsWith("http://") && !kubernetesHost.startsWith("https://")) { + urlString = `https://${kubernetesHost}`; + } + + const url = new URL(urlString); + let { port: k8sPort } = url; + const { protocol, hostname: k8sHost } = url; + + const cleanedProtocol = new RE2(/[^a-zA-Z0-9]/g).replace(protocol, "").toLowerCase(); + + if (!["https", "http"].includes(cleanedProtocol)) { + throw new BadRequestError({ + message: "Invalid Kubernetes host URL, must start with http:// or https://" + }); + } + + if (!k8sPort) { + k8sPort = cleanedProtocol === "https" ? "443" : "80"; + } + + if (!identityKubernetesAuth.gatewayId) { + throw new BadRequestError({ + message: "Gateway ID is required when token review mode is set to Gateway" + }); + } + + data = await $gatewayProxyWrapper( + { + gatewayId: identityKubernetesAuth.gatewayId, + targetHost: `${cleanedProtocol}://${k8sHost}`, // note(daniel): must include the protocol (https|http) + targetPort: k8sPort ? Number(k8sPort) : 443, + caCert, + reviewTokenThroughGateway: true + }, + tokenReviewCallbackThroughGateway + ); + } else if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api) { + let { kubernetesHost } = identityKubernetesAuth; + if (kubernetesHost.startsWith("https://") || kubernetesHost.startsWith("http://")) { + kubernetesHost = new RE2("^https?:\\/\\/").replace(kubernetesHost, ""); + } + + const [k8sHost, k8sPort] = kubernetesHost.split(":"); + + data = identityKubernetesAuth.gatewayId + ? await $gatewayProxyWrapper( + { + gatewayId: identityKubernetesAuth.gatewayId, + targetHost: k8sHost, + targetPort: k8sPort ? Number(k8sPort) : 443, + reviewTokenThroughGateway: false + }, + tokenReviewCallbackRaw + ) + : await tokenReviewCallbackRaw(); + } else { + throw new BadRequestError({ + message: `Invalid token review mode: ${identityKubernetesAuth.tokenReviewMode}` }); + } + + if (!data) { + throw new BadRequestError({ + message: "Failed to review token" + }); + } if ("error" in data.status) throw new UnauthorizedError({ message: data.status.error, name: "KubernetesTokenReviewError" }); @@ -206,7 +416,13 @@ export const identityKubernetesAuthServiceFactory = ({ { identityId: identityKubernetesAuth.identityId, identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN, + identityAuth: { + kubernetes: { + namespace: targetNamespace, + name: targetName + } + } } 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 @@ -222,9 +438,11 @@ export const identityKubernetesAuthServiceFactory = ({ const attachKubernetesAuth = async ({ identityId, + gatewayId, kubernetesHost, caCert, tokenReviewerJwt, + tokenReviewMode, allowedNamespaces, allowedNames, allowedAudience, @@ -280,6 +498,27 @@ export const identityKubernetesAuthServiceFactory = ({ return extractIPDetails(accessTokenTrustedIp.ipAddress); }); + if (gatewayId) { + const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: identityMembershipOrg.orgId }); + if (!gateway) { + throw new NotFoundError({ + message: `Gateway with ID ${gatewayId} not found` + }); + } + + const { permission: orgPermission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(orgPermission).throwUnlessCan( + OrgPermissionGatewayActions.AttachGateways, + OrgPermissionSubjects.Gateway + ); + } + const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId: identityMembershipOrg.orgId @@ -290,12 +529,14 @@ export const identityKubernetesAuthServiceFactory = ({ { identityId: identityMembershipOrg.identityId, kubernetesHost, + tokenReviewMode, allowedNamespaces, allowedNames, allowedAudience, accessTokenMaxTTL, accessTokenTTL, accessTokenNumUsesLimit, + gatewayId, accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), encryptedKubernetesTokenReviewerJwt: tokenReviewerJwt ? encryptor({ plainText: Buffer.from(tokenReviewerJwt) }).cipherTextBlob @@ -315,9 +556,11 @@ export const identityKubernetesAuthServiceFactory = ({ kubernetesHost, caCert, tokenReviewerJwt, + tokenReviewMode, allowedNamespaces, allowedNames, allowedAudience, + gatewayId, accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, @@ -373,11 +616,34 @@ export const identityKubernetesAuthServiceFactory = ({ return extractIPDetails(accessTokenTrustedIp.ipAddress); }); + if (gatewayId) { + const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: identityMembershipOrg.orgId }); + if (!gateway) { + throw new NotFoundError({ + message: `Gateway with ID ${gatewayId} not found` + }); + } + + const { permission: orgPermission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(orgPermission).throwUnlessCan( + OrgPermissionGatewayActions.AttachGateways, + OrgPermissionSubjects.Gateway + ); + } + const updateQuery: TIdentityKubernetesAuthsUpdate = { kubernetesHost, + tokenReviewMode, allowedNamespaces, allowedNames, allowedAudience, + gatewayId, accessTokenMaxTTL, accessTokenTTL, accessTokenNumUsesLimit, diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts index b3bbcb49e..03dd7fd77 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts @@ -5,14 +5,21 @@ export type TLoginKubernetesAuthDTO = { jwt: string; }; +export enum IdentityKubernetesAuthTokenReviewMode { + Api = "api", + Gateway = "gateway" +} + export type TAttachKubernetesAuthDTO = { identityId: string; kubernetesHost: string; caCert: string; tokenReviewerJwt?: string; + tokenReviewMode: IdentityKubernetesAuthTokenReviewMode; allowedNamespaces: string; allowedNames: string; allowedAudience: string; + gatewayId?: string | null; accessTokenTTL: number; accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; @@ -25,9 +32,11 @@ export type TUpdateKubernetesAuthDTO = { kubernetesHost?: string; caCert?: string; tokenReviewerJwt?: string | null; + tokenReviewMode?: IdentityKubernetesAuthTokenReviewMode; allowedNamespaces?: string; allowedNames?: string; allowedAudience?: string; + gatewayId?: string | null; accessTokenTTL?: number; accessTokenMaxTTL?: number; accessTokenNumUsesLimit?: number; @@ -61,6 +70,18 @@ export type TCreateTokenReviewResponse = { status: TCreateTokenReviewSuccessResponse | TCreateTokenReviewErrorResponse; }; +export type TKubernetesTokenRequest = { + apiVersion: "authentication.k8s.io/v1"; + kind: "TokenRequest"; + spec: { + audiences: string[]; + expirationSeconds: number; + }; + status: { + token: string; + }; +}; + export type TRevokeKubernetesAuthDTO = { identityId: string; } & Omit; diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-dal.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-dal.ts new file mode 100644 index 000000000..0d998dbe9 --- /dev/null +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityLdapAuthDALFactory = ReturnType; + +export const identityLdapAuthDALFactory = (db: TDbClient) => { + const ldapAuthOrm = ormify(db, TableName.IdentityLdapAuth); + + return ldapAuthOrm; +}; 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 new file mode 100644 index 000000000..7462c9228 --- /dev/null +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts @@ -0,0 +1,543 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { ForbiddenError } from "@casl/ability"; +import jwt from "jsonwebtoken"; + +import { IdentityAuthMethod } from "@app/db/schemas"; +import { testLDAPConfig } from "@app/ee/services/ldap-config/ldap-fns"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; + +import { ActorType, AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { KmsDataKey } from "../kms/kms-types"; +import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; +import { TIdentityLdapAuthDALFactory } from "./identity-ldap-auth-dal"; +import { + AllowedFieldsSchema, + TAttachLdapAuthDTO, + TGetLdapAuthDTO, + TLoginLdapAuthDTO, + TRevokeLdapAuthDTO, + TUpdateLdapAuthDTO +} from "./identity-ldap-auth-types"; + +type TIdentityLdapAuthServiceFactoryDep = { + identityAccessTokenDAL: Pick; + identityLdapAuthDAL: Pick< + TIdentityLdapAuthDALFactory, + "findOne" | "transaction" | "create" | "updateById" | "delete" + >; + identityOrgMembershipDAL: Pick; + licenseService: Pick; + permissionService: Pick; + kmsService: TKmsServiceFactory; + identityDAL: TIdentityDALFactory; +}; + +export type TIdentityLdapAuthServiceFactory = ReturnType; + +export const identityLdapAuthServiceFactory = ({ + identityAccessTokenDAL, + identityDAL, + identityLdapAuthDAL, + identityOrgMembershipDAL, + licenseService, + permissionService, + kmsService +}: TIdentityLdapAuthServiceFactoryDep) => { + const getLdapConfig = async (identityId: string) => { + const identity = await identityDAL.findOne({ id: identityId }); + if (!identity) throw new NotFoundError({ message: `Identity with ID '${identityId}' not found` }); + + const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId: identity.id }); + if (!identityOrgMembership) throw new NotFoundError({ message: `Identity with ID '${identityId}' not found` }); + + const ldapAuth = await identityLdapAuthDAL.findOne({ identityId: identity.id }); + if (!ldapAuth) throw new NotFoundError({ message: `LDAP auth with ID '${identityId}' not found` }); + + const parsedAllowedFields = ldapAuth.allowedFields + ? AllowedFieldsSchema.array().parse(ldapAuth.allowedFields) + : undefined; + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identityOrgMembership.orgId + }); + + const bindDN = decryptor({ cipherTextBlob: ldapAuth.encryptedBindDN }).toString(); + const bindPass = decryptor({ cipherTextBlob: ldapAuth.encryptedBindPass }).toString(); + const ldapCaCertificate = ldapAuth.encryptedLdapCaCertificate + ? decryptor({ cipherTextBlob: ldapAuth.encryptedLdapCaCertificate }).toString() + : undefined; + + const ldapConfig = { + id: ldapAuth.id, + organization: identityOrgMembership.orgId, + url: ldapAuth.url, + bindDN, + bindPass, + searchBase: ldapAuth.searchBase, + searchFilter: ldapAuth.searchFilter, + caCert: ldapCaCertificate || "", + allowedFields: parsedAllowedFields + }; + + const opts = { + server: { + url: ldapAuth.url, + bindDN, + bindCredentials: bindPass, + searchBase: ldapAuth.searchBase, + searchFilter: ldapAuth.searchFilter, + ...(ldapCaCertificate + ? { + tlsOptions: { + ca: [ldapCaCertificate] + } + } + : {}) + }, + passReqToCallback: true + }; + + return { opts, ldapConfig }; + }; + + const login = async ({ identityId }: TLoginLdapAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + + if (!identityMembershipOrg) { + throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + } + + const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId }); + + if (!identityLdapAuth) { + throw new NotFoundError({ message: `Failed to find LDAP auth for identity with ID ${identityId}` }); + } + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + if (!plan.ldap) { + throw new BadRequestError({ + message: + "Failed to login to identity due to plan restriction. Upgrade plan to login to use LDAP authentication." + }); + } + + const identityAccessToken = await identityLdapAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityLdapAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityLdapAuth.accessTokenTTL, + accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.LDAP_AUTH + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityLdapAuth.identityId, + 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, identityLdapAuth, identityAccessToken, identityMembershipOrg }; + }; + + const attachLdapAuth = async ({ + identityId, + url, + searchBase, + searchFilter, + bindDN, + bindPass, + ldapCaCertificate, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId, + isActorSuperAdmin, + allowedFields + }: TAttachLdapAuthDTO) => { + await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); + + 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: "Failed to add LDAP Auth to already configured identity" + }); + } + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + + if (!plan.ldap) { + throw new BadRequestError({ + message: "Failed to add LDAP Auth to identity due to plan restriction. Upgrade plan to add LDAP Auth." + }); + } + + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + if (allowedFields) AllowedFieldsSchema.array().parse(allowedFields); + + const identityLdapAuth = await identityLdapAuthDAL.transaction(async (tx) => { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identityMembershipOrg.orgId + }); + + const { cipherTextBlob: encryptedBindPass } = encryptor({ + plainText: Buffer.from(bindPass) + }); + + let encryptedLdapCaCertificate: Buffer | undefined; + if (ldapCaCertificate) { + const { cipherTextBlob: encryptedCertificate } = encryptor({ + plainText: Buffer.from(ldapCaCertificate) + }); + + encryptedLdapCaCertificate = encryptedCertificate; + } + + const { cipherTextBlob: encryptedBindDN } = encryptor({ + plainText: Buffer.from(bindDN) + }); + + const isConnected = await testLDAPConfig({ + bindDN, + bindPass, + caCert: ldapCaCertificate || "", + url + }); + + if (!isConnected) { + throw new BadRequestError({ + message: + "Failed to connect to LDAP server. Please ensure that the LDAP server is running and your credentials are correct." + }); + } + + const doc = await identityLdapAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + encryptedBindDN, + encryptedBindPass, + searchBase, + searchFilter, + url, + encryptedLdapCaCertificate, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), + allowedFields: allowedFields ? JSON.stringify(allowedFields) : undefined + }, + tx + ); + return doc; + }); + return { ...identityLdapAuth, orgId: identityMembershipOrg.orgId }; + }; + + const updateLdapAuth = async ({ + identityId, + url, + searchBase, + searchFilter, + bindDN, + bindPass, + ldapCaCertificate, + allowedFields, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateLdapAuthDTO) => { + 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 NotFoundError({ + message: "The identity does not have LDAP Auth attached" + }); + } + + const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityLdapAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityLdapAuth.accessTokenTTL) > (accessTokenMaxTTL || identityLdapAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + + if (!plan.ldap) { + throw new BadRequestError({ + message: "Failed to update LDAP Auth due to plan restriction. Upgrade plan to update LDAP Auth." + }); + } + + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + if (allowedFields) AllowedFieldsSchema.array().parse(allowedFields); + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identityMembershipOrg.orgId + }); + + let encryptedBindPass: Buffer | undefined; + if (bindPass) { + const { cipherTextBlob: bindPassCiphertext } = encryptor({ + plainText: Buffer.from(bindPass) + }); + + encryptedBindPass = bindPassCiphertext; + } + + let encryptedLdapCaCertificate: Buffer | undefined; + if (ldapCaCertificate) { + const { cipherTextBlob: ldapCaCertificateCiphertext } = encryptor({ + plainText: Buffer.from(ldapCaCertificate) + }); + + encryptedLdapCaCertificate = ldapCaCertificateCiphertext; + } + + let encryptedBindDN: Buffer | undefined; + if (bindDN) { + const { cipherTextBlob: bindDNCiphertext } = encryptor({ + plainText: Buffer.from(bindDN) + }); + + encryptedBindDN = bindDNCiphertext; + } + + const { ldapConfig } = await getLdapConfig(identityId); + + const isConnected = await testLDAPConfig({ + bindDN: bindDN || ldapConfig.bindDN, + bindPass: bindPass || ldapConfig.bindPass, + caCert: ldapCaCertificate || ldapConfig.caCert, + url: url || ldapConfig.url + }); + + if (!isConnected) { + throw new BadRequestError({ + message: + "Failed to connect to LDAP server. Please ensure that the LDAP server is running and your credentials are correct." + }); + } + + const updatedLdapAuth = await identityLdapAuthDAL.updateById(identityLdapAuth.id, { + url, + searchBase, + searchFilter, + encryptedBindDN, + encryptedBindPass, + encryptedLdapCaCertificate, + allowedFields: allowedFields ? JSON.stringify(allowedFields) : undefined, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }); + + return { ...updatedLdapAuth, orgId: identityMembershipOrg.orgId }; + }; + + const getLdapAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetLdapAuthDTO) => { + 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 attached" + }); + } + + const ldapIdentityAuth = await identityLdapAuthDAL.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identityMembershipOrg.orgId + }); + + const bindDN = decryptor({ cipherTextBlob: ldapIdentityAuth.encryptedBindDN }).toString(); + const bindPass = decryptor({ cipherTextBlob: ldapIdentityAuth.encryptedBindPass }).toString(); + const ldapCaCertificate = ldapIdentityAuth.encryptedLdapCaCertificate + ? decryptor({ cipherTextBlob: ldapIdentityAuth.encryptedLdapCaCertificate }).toString() + : undefined; + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + return { ...ldapIdentityAuth, orgId: identityMembershipOrg.orgId, bindDN, bindPass, ldapCaCertificate }; + }; + + const revokeIdentityLdapAuth = async ({ + identityId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TRevokeLdapAuthDTO) => { + 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 attached" + }); + } + const { permission, membership } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityMembershipOrg.identityId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke LDAP auth of identity with more privileged role", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + + const revokedIdentityLdapAuth = await identityLdapAuthDAL.transaction(async (tx) => { + const [deletedLdapAuth] = await identityLdapAuthDAL.delete({ identityId }, tx); + await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.LDAP_AUTH }, tx); + + return { ...deletedLdapAuth, orgId: identityMembershipOrg.orgId }; + }); + return revokedIdentityLdapAuth; + }; + + return { + attachLdapAuth, + getLdapConfig, + updateLdapAuth, + login, + revokeIdentityLdapAuth, + getLdapAuth + }; +}; 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 new file mode 100644 index 000000000..0e6feb5fb --- /dev/null +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +import { TProjectPermission } from "@app/lib/types"; + +export const AllowedFieldsSchema = z.object({ + key: z.string().trim(), + value: z + .string() + .trim() + .transform((val) => val.replace(/\s/g, "")) +}); + +export type TAllowedFields = z.infer; + +export type TAttachLdapAuthDTO = { + identityId: string; + url: string; + searchBase: string; + searchFilter: string; + bindDN: string; + bindPass: string; + ldapCaCertificate?: string; + allowedFields?: TAllowedFields[]; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; + isActorSuperAdmin?: boolean; +} & Omit; + +export type TUpdateLdapAuthDTO = { + identityId: string; + url?: string; + searchBase?: string; + searchFilter?: string; + bindDN?: string; + bindPass?: string; + allowedFields?: TAllowedFields[]; + ldapCaCertificate?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetLdapAuthDTO = { + identityId: string; +} & Omit; + +export type TLoginLdapAuthDTO = { + identityId: string; +}; + +export type TRevokeLdapAuthDTO = { + identityId: string; +} & Omit; diff --git a/backend/src/services/identity-oci-auth/identity-oci-auth-dal.ts b/backend/src/services/identity-oci-auth/identity-oci-auth-dal.ts new file mode 100644 index 000000000..95278c75a --- /dev/null +++ b/backend/src/services/identity-oci-auth/identity-oci-auth-dal.ts @@ -0,0 +1,9 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityOciAuthDALFactory = ReturnType; + +export const identityOciAuthDALFactory = (db: TDbClient) => { + return ormify(db, TableName.IdentityOciAuth); +}; diff --git a/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts b/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts new file mode 100644 index 000000000..00e3884bd --- /dev/null +++ b/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts @@ -0,0 +1,368 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { ForbiddenError } from "@casl/ability"; +import { AxiosError } from "axios"; +import jwt from "jsonwebtoken"; +import RE2 from "re2"; + +import { IdentityAuthMethod } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; +import { BadRequestError, NotFoundError, PermissionBoundaryError, 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 { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; +import { TIdentityOciAuthDALFactory } from "./identity-oci-auth-dal"; +import { + TAttachOciAuthDTO, + TGetOciAuthDTO, + TLoginOciAuthDTO, + TOciGetUserResponse, + TRevokeOciAuthDTO, + TUpdateOciAuthDTO +} from "./identity-oci-auth-types"; + +type TIdentityOciAuthServiceFactoryDep = { + identityAccessTokenDAL: Pick; + identityOciAuthDAL: Pick; + identityOrgMembershipDAL: Pick; + licenseService: Pick; + permissionService: Pick; +}; + +export type TIdentityOciAuthServiceFactory = ReturnType; + +export const identityOciAuthServiceFactory = ({ + identityAccessTokenDAL, + identityOciAuthDAL, + identityOrgMembershipDAL, + licenseService, + permissionService +}: TIdentityOciAuthServiceFactoryDep) => { + const login = async ({ identityId, headers, userOcid }: TLoginOciAuthDTO) => { + const identityOciAuth = await identityOciAuthDAL.findOne({ identityId }); + if (!identityOciAuth) { + throw new NotFoundError({ message: "OCI auth method not found for identity, did you configure OCI auth?" }); + } + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityOciAuth.identityId }); + + // Validate OCI host format. Ensures that the host is in "identity..oraclecloud.com" format. + if (!headers.host || !new RE2("^identity\\.([a-z]{2}-[a-z]+-[1-9])\\.oraclecloud\\.com$").test(headers.host)) { + throw new BadRequestError({ + message: "Invalid OCI host format. Expected format: identity..oraclecloud.com" + }); + } + + const { data } = await request + .get(`https://${headers.host}/20160918/users/${userOcid}`, { + headers + }) + .catch((err: AxiosError) => { + logger.error(err.response, "OciIdentityLogin: Failed to authenticate with Oracle Cloud"); + throw err; + }); + + if (data.compartmentId !== identityOciAuth.tenancyOcid) { + throw new UnauthorizedError({ + message: "Access denied: OCI account isn't part of tenancy." + }); + } + + if (identityOciAuth.allowedUsernames) { + const isAccountAllowed = identityOciAuth.allowedUsernames.split(",").some((name) => name.trim() === data.name); + + if (!isAccountAllowed) + throw new UnauthorizedError({ + message: "Access denied: OCI account username not allowed." + }); + } + + // Generate the token + const identityAccessToken = await identityOciAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityOciAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityOciAuth.accessTokenTTL, + accessTokenMaxTTL: identityOciAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityOciAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.OCI_AUTH + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityOciAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } + ); + + return { + identityOciAuth, + accessToken, + identityAccessToken, + identityMembershipOrg + }; + }; + + const attachOciAuth = async ({ + identityId, + tenancyOcid, + allowedUsernames, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId, + isActorSuperAdmin + }: TAttachOciAuthDTO) => { + await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); + + 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.OCI_AUTH)) { + throw new BadRequestError({ + message: "Failed to add OCI Auth to already configured identity" + }); + } + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const identityOciAuth = await identityOciAuthDAL.transaction(async (tx) => { + const doc = await identityOciAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + type: "iam", + tenancyOcid, + allowedUsernames, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + return doc; + }); + return { ...identityOciAuth, orgId: identityMembershipOrg.orgId }; + }; + + const updateOciAuth = async ({ + identityId, + tenancyOcid, + allowedUsernames, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateOciAuthDTO) => { + 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.OCI_AUTH)) { + throw new NotFoundError({ + message: "The identity does not have OCI Auth attached" + }); + } + + const identityOciAuth = await identityOciAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityOciAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityOciAuth.accessTokenTTL) > (accessTokenMaxTTL || identityOciAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const updatedOciAuth = await identityOciAuthDAL.updateById(identityOciAuth.id, { + tenancyOcid, + allowedUsernames, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }); + + return { ...updatedOciAuth, orgId: identityMembershipOrg.orgId }; + }; + + const getOciAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetOciAuthDTO) => { + 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.OCI_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have OCI Auth attached" + }); + } + + const ociIdentityAuth = await identityOciAuthDAL.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + return { ...ociIdentityAuth, orgId: identityMembershipOrg.orgId }; + }; + + const revokeIdentityOciAuth = async ({ + identityId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TRevokeOciAuthDTO) => { + 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.OCI_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have OCI auth" + }); + } + const { permission, membership } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityMembershipOrg.identityId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke OCI auth of identity with more privileged role", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + + const revokedIdentityOciAuth = await identityOciAuthDAL.transaction(async (tx) => { + const deletedOciAuth = await identityOciAuthDAL.delete({ identityId }, tx); + await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.OCI_AUTH }, tx); + + return { ...deletedOciAuth?.[0], orgId: identityMembershipOrg.orgId }; + }); + return revokedIdentityOciAuth; + }; + + return { + login, + attachOciAuth, + updateOciAuth, + getOciAuth, + revokeIdentityOciAuth + }; +}; diff --git a/backend/src/services/identity-oci-auth/identity-oci-auth-types.ts b/backend/src/services/identity-oci-auth/identity-oci-auth-types.ts new file mode 100644 index 000000000..c7a131bde --- /dev/null +++ b/backend/src/services/identity-oci-auth/identity-oci-auth-types.ts @@ -0,0 +1,53 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TLoginOciAuthDTO = { + identityId: string; + userOcid: string; + headers: { + authorization: string; + host: string; + "x-date": string; + }; +}; + +export type TAttachOciAuthDTO = { + identityId: string; + tenancyOcid: string; + allowedUsernames: string | null; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; + isActorSuperAdmin?: boolean; +} & Omit; + +export type TUpdateOciAuthDTO = { + identityId: string; + tenancyOcid: string; + allowedUsernames: string | null; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetOciAuthDTO = { + identityId: string; +} & Omit; + +export type TRevokeOciAuthDTO = { + identityId: string; +} & Omit; + +export type TOciGetUserResponse = { + email: string; + emailVerified: boolean; + timeModified: string; + isMfaActivated: boolean; + id: string; + compartmentId: string; + name: string; + timeCreated: string; + freeformTags: { [key: string]: string }; + lifecycleState: string; +}; diff --git a/backend/src/services/identity-oci-auth/identity-oci-auth-validators.ts b/backend/src/services/identity-oci-auth/identity-oci-auth-validators.ts new file mode 100644 index 000000000..49100b46c --- /dev/null +++ b/backend/src/services/identity-oci-auth/identity-oci-auth-validators.ts @@ -0,0 +1,32 @@ +import RE2 from "re2"; +import { z } from "zod"; + +const usernameSchema = z + .string() + .min(1, "Username cannot be empty") + .refine((val) => new RE2("^[a-zA-Z0-9._@-]+$").test(val), "Invalid OCI username format"); +export const validateUsernames = z + .string() + .trim() + .max(500, "Input exceeds the maximum limit of 500 characters") + .nullish() + .transform((val) => { + if (!val) return []; + return val + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + }) + .refine((arr) => arr.every((name) => usernameSchema.safeParse(name).success), { + message: "One or more usernames are invalid" + }) + .transform((arr) => (arr.length > 0 ? arr.join(", ") : null)); + +export const validateTenancy = z + .string() + .trim() + .min(1, "Tenancy OCID cannot be empty.") + .refine( + (val) => new RE2("^ocid1\\.tenancy\\.oc1\\..+$").test(val), + "Invalid Tenancy OCID format. Must start with ocid1.tenancy.oc1." + ); diff --git a/backend/src/services/identity-project/identity-project-dal.ts b/backend/src/services/identity-project/identity-project-dal.ts index bc4f4a303..4928fd178 100644 --- a/backend/src/services/identity-project/identity-project-dal.ts +++ b/backend/src/services/identity-project/identity-project-dal.ts @@ -8,6 +8,7 @@ import { TIdentityAzureAuths, TIdentityGcpAuths, TIdentityKubernetesAuths, + TIdentityOciAuths, TIdentityOidcAuths, TIdentityTokenAuths, TIdentityUniversalAuths @@ -66,6 +67,11 @@ export const identityProjectDALFactory = (db: TDbClient) => { `${TableName.IdentityProjectMembership}.identityId`, `${TableName.IdentityKubernetesAuth}.identityId` ) + .leftJoin( + TableName.IdentityOciAuth, + `${TableName.IdentityProjectMembership}.identityId`, + `${TableName.IdentityOciAuth}.identityId` + ) .leftJoin( TableName.IdentityOidcAuth, `${TableName.IdentityProjectMembership}.identityId`, @@ -107,6 +113,7 @@ export const identityProjectDALFactory = (db: TDbClient) => { db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), db.ref("id").as("awsId").withSchema(TableName.IdentityAwsAuth), db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), + db.ref("id").as("ociId").withSchema(TableName.IdentityOciAuth), db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth) @@ -270,6 +277,11 @@ export const identityProjectDALFactory = (db: TDbClient) => { `${TableName.Identity}.id`, `${TableName.IdentityKubernetesAuth}.identityId` ) + .leftJoin( + TableName.IdentityOciAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityOciAuth}.identityId` + ) .leftJoin( TableName.IdentityOidcAuth, `${TableName.Identity}.id`, @@ -309,6 +321,7 @@ export const identityProjectDALFactory = (db: TDbClient) => { db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), db.ref("id").as("awsId").withSchema(TableName.IdentityAwsAuth), db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), + db.ref("id").as("ociId").withSchema(TableName.IdentityOciAuth), db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth) @@ -336,6 +349,7 @@ export const identityProjectDALFactory = (db: TDbClient) => { awsId, gcpId, kubernetesId, + ociId, oidcId, azureId, tokenId, @@ -356,6 +370,7 @@ export const identityProjectDALFactory = (db: TDbClient) => { awsId, gcpId, kubernetesId, + ociId, oidcId, azureId, tokenId @@ -397,7 +412,15 @@ export const identityProjectDALFactory = (db: TDbClient) => { } ] }); - return members; + + return members.map((el) => ({ + ...el, + roles: el.roles.sort((a, b) => { + const roleA = (a.customRoleName || a.role).toLowerCase(); + const roleB = (b.customRoleName || b.role).toLowerCase(); + return roleA.localeCompare(roleB); + }) + })); } catch (error) { throw new DatabaseError({ error, name: "FindByProjectId" }); } diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 8ab499e65..53404788e 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -114,21 +114,36 @@ export const identityUaServiceFactory = ({ }); } + const accessTokenTTLParams = + Number(identityUa.accessTokenPeriod) === 0 + ? { + accessTokenTTL: identityUa.accessTokenTTL, + accessTokenMaxTTL: identityUa.accessTokenMaxTTL + } + : { + accessTokenTTL: identityUa.accessTokenPeriod, + // Setting Max TTL to 2 × period ensures that clients can always renew their token + // at least once, and matches client logic that checks if renewing would exceed Max TTL. + accessTokenMaxTTL: 2 * identityUa.accessTokenPeriod + }; + const identityAccessToken = await identityUaDAL.transaction(async (tx) => { const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx); + const newToken = await identityAccessTokenDAL.create( { identityId: identityUa.identityId, isAccessTokenRevoked: false, identityUAClientSecretId: uaClientSecretDoc.id, - accessTokenTTL: identityUa.accessTokenTTL, - accessTokenMaxTTL: identityUa.accessTokenMaxTTL, accessTokenNumUses: 0, accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.UNIVERSAL_AUTH + accessTokenPeriod: identityUa.accessTokenPeriod, + authMethod: IdentityAuthMethod.UNIVERSAL_AUTH, + ...accessTokenTTLParams }, tx ); + return newToken; }); @@ -149,7 +164,14 @@ export const identityUaServiceFactory = ({ } ); - return { accessToken, identityUa, validClientSecretInfo, identityAccessToken, identityMembershipOrg }; + return { + accessToken, + identityUa, + validClientSecretInfo, + identityAccessToken, + identityMembershipOrg, + ...accessTokenTTLParams + }; }; const attachUniversalAuth = async ({ @@ -163,7 +185,8 @@ export const identityUaServiceFactory = ({ actorAuthMethod, actor, actorOrgId, - isActorSuperAdmin + isActorSuperAdmin, + accessTokenPeriod }: TAttachUaDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); @@ -232,7 +255,8 @@ export const identityUaServiceFactory = ({ accessTokenMaxTTL, accessTokenTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), + accessTokenPeriod }, tx ); @@ -248,6 +272,7 @@ export const identityUaServiceFactory = ({ accessTokenTTL, accessTokenTrustedIps, clientSecretTrustedIps, + accessTokenPeriod, actorId, actorAuthMethod, actor, @@ -324,6 +349,7 @@ export const identityUaServiceFactory = ({ accessTokenMaxTTL, accessTokenTTL, accessTokenNumUsesLimit, + accessTokenPeriod, accessTokenTrustedIps: reformattedAccessTokenTrustedIps ? JSON.stringify(reformattedAccessTokenTrustedIps) : undefined diff --git a/backend/src/services/identity-ua/identity-ua-types.ts b/backend/src/services/identity-ua/identity-ua-types.ts index 07b6a4810..f7938e0f7 100644 --- a/backend/src/services/identity-ua/identity-ua-types.ts +++ b/backend/src/services/identity-ua/identity-ua-types.ts @@ -5,6 +5,7 @@ export type TAttachUaDTO = { accessTokenTTL: number; accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; + accessTokenPeriod: number; clientSecretTrustedIps: { ipAddress: string }[]; accessTokenTrustedIps: { ipAddress: string }[]; isActorSuperAdmin?: boolean; @@ -15,6 +16,7 @@ export type TUpdateUaDTO = { accessTokenTTL?: number; accessTokenMaxTTL?: number; accessTokenNumUsesLimit?: number; + accessTokenPeriod?: number; clientSecretTrustedIps?: { ipAddress: string }[]; accessTokenTrustedIps?: { ipAddress: string }[]; } & Omit; diff --git a/backend/src/services/identity/identity-fns.ts b/backend/src/services/identity/identity-fns.ts index 2d77e6544..3fa2482aa 100644 --- a/backend/src/services/identity/identity-fns.ts +++ b/backend/src/services/identity/identity-fns.ts @@ -5,28 +5,34 @@ export const buildAuthMethods = ({ gcpId, awsId, kubernetesId, + ociId, oidcId, azureId, tokenId, - jwtId + jwtId, + ldapId }: { uaId?: string; gcpId?: string; awsId?: string; kubernetesId?: string; + ociId?: string; oidcId?: string; azureId?: string; tokenId?: string; jwtId?: string; + ldapId?: string; }) => { return [ ...[uaId ? IdentityAuthMethod.UNIVERSAL_AUTH : null], ...[gcpId ? IdentityAuthMethod.GCP_AUTH : null], ...[awsId ? IdentityAuthMethod.AWS_AUTH : null], ...[kubernetesId ? IdentityAuthMethod.KUBERNETES_AUTH : null], + ...[ociId ? IdentityAuthMethod.OCI_AUTH : null], ...[oidcId ? IdentityAuthMethod.OIDC_AUTH : null], ...[azureId ? IdentityAuthMethod.AZURE_AUTH : null], ...[tokenId ? IdentityAuthMethod.TOKEN_AUTH : null], - ...[jwtId ? IdentityAuthMethod.JWT_AUTH : null] + ...[jwtId ? IdentityAuthMethod.JWT_AUTH : null], + ...[ldapId ? IdentityAuthMethod.LDAP_AUTH : null] ].filter((authMethod) => authMethod) as IdentityAuthMethod[]; }; diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index dbae59bbe..af5537249 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -8,12 +8,14 @@ import { TIdentityGcpAuths, TIdentityJwtAuths, TIdentityKubernetesAuths, + TIdentityOciAuths, TIdentityOidcAuths, TIdentityOrgMemberships, TIdentityTokenAuths, TIdentityUniversalAuths, TOrgRoles } from "@app/db/schemas"; +import { TIdentityLdapAuths } from "@app/db/schemas/identity-ldap-auths"; import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; import { buildKnexFilterForSearchResource } from "@app/lib/search-resource/db"; @@ -61,6 +63,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { `${TableName.IdentityOrgMembership}.identityId`, `${TableName.IdentityKubernetesAuth}.identityId` ) + .leftJoin( + TableName.IdentityOciAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityOciAuth}.identityId` + ) .leftJoin( TableName.IdentityOidcAuth, `${TableName.IdentityOrgMembership}.identityId`, @@ -81,6 +88,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { `${TableName.IdentityOrgMembership}.identityId`, `${TableName.IdentityJwtAuth}.identityId` ) + .leftJoin( + TableName.IdentityLdapAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityLdapAuth}.identityId` + ) .select( selectAllTableCols(TableName.IdentityOrgMembership), @@ -89,11 +101,12 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), db.ref("id").as("awsId").withSchema(TableName.IdentityAwsAuth), db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), + db.ref("id").as("ociId").withSchema(TableName.IdentityOciAuth), db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), - + db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth), db.ref("name").withSchema(TableName.Identity) ); @@ -180,6 +193,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { "paginatedIdentity.identityId", `${TableName.IdentityKubernetesAuth}.identityId` ) + .leftJoin( + TableName.IdentityOciAuth, + "paginatedIdentity.identityId", + `${TableName.IdentityOciAuth}.identityId` + ) .leftJoin( TableName.IdentityOidcAuth, "paginatedIdentity.identityId", @@ -200,6 +218,12 @@ export const identityOrgDALFactory = (db: TDbClient) => { "paginatedIdentity.identityId", `${TableName.IdentityJwtAuth}.identityId` ) + .leftJoin( + TableName.IdentityLdapAuth, + "paginatedIdentity.identityId", + `${TableName.IdentityLdapAuth}.identityId` + ) + .select( db.ref("id").withSchema("paginatedIdentity"), db.ref("role").withSchema("paginatedIdentity"), @@ -214,10 +238,12 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), db.ref("id").as("awsId").withSchema(TableName.IdentityAwsAuth), db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), + db.ref("id").as("ociId").withSchema(TableName.IdentityOciAuth), db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), - db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth) + db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), + db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth) ) // cr stands for custom role .select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) @@ -256,9 +282,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { gcpId, jwtId, kubernetesId, + ociId, oidcId, azureId, tokenId, + ldapId, createdAt, updatedAt }) => ({ @@ -287,10 +315,12 @@ export const identityOrgDALFactory = (db: TDbClient) => { awsId, gcpId, kubernetesId, + ociId, oidcId, azureId, tokenId, - jwtId + jwtId, + ldapId }) } }), @@ -386,6 +416,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { `${TableName.IdentityOrgMembership}.identityId`, `${TableName.IdentityKubernetesAuth}.identityId` ) + .leftJoin( + TableName.IdentityOciAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityOciAuth}.identityId` + ) .leftJoin( TableName.IdentityOidcAuth, `${TableName.IdentityOrgMembership}.identityId`, @@ -406,6 +441,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { `${TableName.IdentityOrgMembership}.identityId`, `${TableName.IdentityJwtAuth}.identityId` ) + .leftJoin( + TableName.IdentityLdapAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityLdapAuth}.identityId` + ) .select( db.ref("id").withSchema(TableName.IdentityOrgMembership), db.ref("total_count").withSchema("searchedIdentities"), @@ -421,10 +461,12 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), db.ref("id").as("awsId").withSchema(TableName.IdentityAwsAuth), db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), + db.ref("id").as("ociId").withSchema(TableName.IdentityOciAuth), db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), - db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth) + db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), + db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth) ) // cr stands for custom role .select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) @@ -464,9 +506,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { gcpId, jwtId, kubernetesId, + ociId, oidcId, azureId, tokenId, + ldapId, createdAt, updatedAt }) => ({ @@ -495,10 +539,12 @@ export const identityOrgDALFactory = (db: TDbClient) => { awsId, gcpId, kubernetesId, + ociId, oidcId, azureId, tokenId, - jwtId + jwtId, + ldapId }) } }), diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index 6f72b3c6e..fd893713e 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -106,18 +106,29 @@ export const identityServiceFactory = ({ }, tx ); + + let insertedMetadata: Array<{ + id: string; + key: string; + value: string; + }> = []; + if (metadata && metadata.length) { - await identityMetadataDAL.insertMany( - metadata.map(({ key, value }) => ({ - identityId: newIdentity.id, - orgId, - key, - value - })), - tx - ); + const rowsToInsert = metadata.map(({ key, value }) => ({ + identityId: newIdentity.id, + orgId, + key, + value + })); + + insertedMetadata = await identityMetadataDAL.insertMany(rowsToInsert, tx); } - return { ...newIdentity, authMethods: [] }; + + return { + ...newIdentity, + authMethods: [], + metadata: insertedMetadata + }; }); await licenseService.updateSubscriptionOrgMemberCount(orgId); @@ -189,21 +200,31 @@ export const identityServiceFactory = ({ tx ); } + let insertedMetadata: Array<{ + id: string; + key: string; + value: string; + }> = []; + if (metadata) { await identityMetadataDAL.delete({ orgId: identityOrgMembership.orgId, identityId: id }, tx); + if (metadata.length) { - await identityMetadataDAL.insertMany( - metadata.map(({ key, value }) => ({ - identityId: newIdentity.id, - orgId: identityOrgMembership.orgId, - key, - value - })), - tx - ); + const rowsToInsert = metadata.map(({ key, value }) => ({ + identityId: newIdentity.id, + orgId: identityOrgMembership.orgId, + key, + value + })); + + insertedMetadata = await identityMetadataDAL.insertMany(rowsToInsert, tx); } } - return newIdentity; + + return { + ...newIdentity, + metadata: insertedMetadata + }; }); return { ...identity, orgId: identityOrgMembership.orgId }; @@ -224,6 +245,7 @@ export const identityServiceFactory = ({ actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + return identity; }; diff --git a/backend/src/services/integration-auth/integration-delete-secret.ts b/backend/src/services/integration-auth/integration-delete-secret.ts index 46c5ed2bd..f77becb02 100644 --- a/backend/src/services/integration-auth/integration-delete-secret.ts +++ b/backend/src/services/integration-auth/integration-delete-secret.ts @@ -177,7 +177,6 @@ export const deleteGithubSecrets = async ({ selected_repositories_url?: string | undefined; } - // @ts-expect-error just octokit ts compatiability issue const OctokitWithRetry = Octokit.plugin(retry); let octokit: Octokit; const appCfg = getConfig(); diff --git a/backend/src/services/microsoft-teams/microsoft-teams-service.ts b/backend/src/services/microsoft-teams/microsoft-teams-service.ts index 3712a0793..1413a3199 100644 --- a/backend/src/services/microsoft-teams/microsoft-teams-service.ts +++ b/backend/src/services/microsoft-teams/microsoft-teams-service.ts @@ -6,6 +6,7 @@ import { Request, Response } from "botbuilder"; +import { CronJob } from "cron"; import { FastifyReply, FastifyRequest } from "fastify"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; @@ -86,8 +87,17 @@ export const microsoftTeamsServiceFactory = ({ }: TMicrosoftTeamsServiceFactoryDep) => { let teamsBot: TeamsBot | null = null; let adapter: CloudAdapter | null = null; + let lastKnownUpdatedAt = new Date(); - const initializeTeamsBot = async ({ botAppId, botAppPassword }: { botAppId: string; botAppPassword: string }) => { + const initializeTeamsBot = async ({ + botAppId, + botAppPassword, + lastUpdatedAt + }: { + botAppId: string; + botAppPassword: string; + lastUpdatedAt?: Date; + }) => { logger.info("Initializing Microsoft Teams bot"); teamsBot = new TeamsBot({ botAppId, @@ -106,6 +116,57 @@ export const microsoftTeamsServiceFactory = ({ }) ) ); + + if (lastUpdatedAt) { + lastKnownUpdatedAt = lastUpdatedAt; + } + }; + + const $syncMicrosoftTeamsIntegrationConfiguration = async () => { + try { + const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); + if (!serverCfg) { + throw new BadRequestError({ + message: "Failed to get server configuration." + }); + } + + if (lastKnownUpdatedAt.getTime() === serverCfg.updatedAt.getTime()) { + logger.info("No changes to Microsoft Teams integration configuration, skipping sync"); + return; + } + + lastKnownUpdatedAt = serverCfg.updatedAt; + + if ( + serverCfg.encryptedMicrosoftTeamsAppId && + serverCfg.encryptedMicrosoftTeamsClientSecret && + serverCfg.encryptedMicrosoftTeamsBotId + ) { + const decryptWithRoot = kmsService.decryptWithRootKey(); + const decryptedAppId = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsAppId); + const decryptedAppPassword = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsClientSecret); + + await initializeTeamsBot({ + botAppId: decryptedAppId.toString(), + botAppPassword: decryptedAppPassword.toString() + }); + } + } catch (err) { + logger.error(err, "Error syncing Microsoft Teams integration configuration"); + } + }; + + const initializeBackgroundSync = async () => { + logger.info("Setting up background sync process for Microsoft Teams workflow integration configuration"); + // initial sync upon startup + await $syncMicrosoftTeamsIntegrationConfiguration(); + + // sync rate limits configuration every 5 minutes + const job = new CronJob("*/5 * * * *", $syncMicrosoftTeamsIntegrationConfiguration); + job.start(); + + return job; }; const start = async () => { @@ -703,6 +764,7 @@ export const microsoftTeamsServiceFactory = ({ getTeams, handleMessageEndpoint, start, + initializeBackgroundSync, sendNotification, checkInstallationStatus, getClientId diff --git a/backend/src/services/org-admin/org-admin-service.ts b/backend/src/services/org-admin/org-admin-service.ts index 62767200c..5f9e25f29 100644 --- a/backend/src/services/org-admin/org-admin-service.ts +++ b/backend/src/services/org-admin/org-admin-service.ts @@ -196,17 +196,20 @@ export const orgAdminServiceFactory = ({ .filter( (member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin) && member.userId !== actorId ) - .map((el) => el.user.email!); + .map((el) => el.user.email!) + .filter(Boolean); - 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 (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 + } + }); + } return { isExistingMember: false, membership: updatedMembership }; }; diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 54b0e1b0f..5730c0d92 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -2,6 +2,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { + OrganizationsSchema, OrgMembershipRole, TableName, TOrganizations, @@ -12,7 +13,15 @@ import { TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt, withTransaction } from "@app/lib/knex"; +import { + buildFindFilter, + ormify, + selectAllTableCols, + sqlNestRelationships, + TFindFilter, + TFindOpt, + withTransaction +} from "@app/lib/knex"; import { generateKnexQueryFromScim } from "@app/lib/knex/scim"; import { OrgAuthMethod } from "./org-types"; @@ -22,6 +31,110 @@ export type TOrgDALFactory = ReturnType; export const orgDALFactory = (db: TDbClient) => { const orgOrm = ormify(db, TableName.Organization); + const findOrganizationsByFilter = async ({ + limit, + offset, + searchTerm, + sortBy + }: { + limit: number; + offset: number; + searchTerm: string; + sortBy?: keyof TOrganizations; + }) => { + try { + const query = db.replicaNode()(TableName.Organization); + + // Build the subquery for limited organization IDs + const orgSubquery = db.replicaNode().select("id").from(TableName.Organization); + + if (searchTerm) { + void orgSubquery.where((qb) => { + void qb.whereILike(`${TableName.Organization}.name`, `%${searchTerm}%`); + }); + } + + if (sortBy) { + void orgSubquery.orderBy(sortBy); + } + + void orgSubquery.limit(limit).offset(offset); + + // Main query with joins, limited to the subquery results + const docs = await query + .whereIn(`${TableName.Organization}.id`, orgSubquery) + .leftJoin(TableName.Project, `${TableName.Organization}.id`, `${TableName.Project}.orgId`) + .leftJoin(TableName.OrgMembership, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) + .leftJoin(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.OrgRoles, `${TableName.OrgMembership}.roleId`, `${TableName.OrgRoles}.id`) + .where((qb) => { + void qb.where(`${TableName.Users}.isGhost`, false).orWhereNull(`${TableName.Users}.id`); + }) + .select(selectAllTableCols(TableName.Organization)) + .select(db.ref("name").withSchema(TableName.Project).as("projectName")) + .select(db.ref("id").withSchema(TableName.Project).as("projectId")) + .select(db.ref("slug").withSchema(TableName.Project).as("projectSlug")) + .select(db.ref("createdAt").withSchema(TableName.Project).as("projectCreatedAt")) + .select(db.ref("email").withSchema(TableName.Users).as("userEmail")) + .select(db.ref("username").withSchema(TableName.Users).as("username")) + .select(db.ref("firstName").withSchema(TableName.Users).as("firstName")) + .select(db.ref("lastName").withSchema(TableName.Users).as("lastName")) + .select(db.ref("id").withSchema(TableName.Users).as("userId")) + .select(db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId")) + .select(db.ref("role").withSchema(TableName.OrgMembership).as("orgMembershipRole")) + .select(db.ref("roleId").withSchema(TableName.OrgMembership).as("orgMembershipRoleId")) + .select(db.ref("name").withSchema(TableName.OrgRoles).as("orgMembershipRoleName")); + + const formattedDocs = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (data) => OrganizationsSchema.parse(data), + childrenMapper: [ + { + key: "projectId", + label: "projects" as const, + mapper: ({ projectId, projectName, projectSlug, projectCreatedAt }) => ({ + id: projectId, + name: projectName, + slug: projectSlug, + createdAt: projectCreatedAt + }) + }, + { + key: "userId", + label: "members" as const, + mapper: ({ + userId, + userEmail, + username, + firstName, + lastName, + orgMembershipId, + orgMembershipRole, + orgMembershipRoleName, + orgMembershipRoleId + }) => ({ + user: { + id: userId, + email: userEmail, + username, + firstName, + lastName + }, + membershipId: orgMembershipId, + role: orgMembershipRoleName || orgMembershipRole, // custom role name or pre-defined role name + roleId: orgMembershipRoleId + }) + } + ] + }); + + return formattedDocs; + } catch (error) { + throw new DatabaseError({ error, name: "Find organizations by filter" }); + } + }; + const findOrgById = async (orgId: string) => { try { const org = (await db @@ -99,7 +212,7 @@ export const orgDALFactory = (db: TDbClient) => { // special query const findAllOrgsByUserId = async ( userId: string - ): Promise<(TOrganizations & { orgAuthMethod: string; userRole: string })[]> => { + ): Promise<(TOrganizations & { orgAuthMethod: string; userRole: string; userStatus: string })[]> => { try { const org = (await db .replicaNode()(TableName.OrgMembership) @@ -121,6 +234,7 @@ export const orgDALFactory = (db: TDbClient) => { }) .select(selectAllTableCols(TableName.Organization)) .select(db.ref("role").withSchema(TableName.OrgMembership).as("userRole")) + .select(db.ref("status").withSchema(TableName.OrgMembership).as("userStatus")) .select( db.raw(` CASE @@ -129,7 +243,7 @@ export const orgDALFactory = (db: TDbClient) => { ELSE '' END as "orgAuthMethod" `) - )) as (TOrganizations & { orgAuthMethod: string; userRole: string })[]; + )) as (TOrganizations & { orgAuthMethod: string; userRole: string; userStatus: string })[]; return org; } catch (error) { @@ -206,7 +320,7 @@ export const orgDALFactory = (db: TDbClient) => { .where(`${TableName.OrgMembership}.orgId`, orgId) .count("*") .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) - .where({ isGhost: false }) + .where({ isGhost: false, [`${TableName.OrgMembership}.isActive` as "isActive"]: true }) .first(); return parseInt((count as unknown as CountResult).count || "0", 10); @@ -507,6 +621,7 @@ export const orgDALFactory = (db: TDbClient) => { findOrgById, findOrgBySlug, findAllOrgsByUserId, + findOrganizationsByFilter, ghostUserExists, findOrgMembersByUsername, findOrgMembersByRole, diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index 5a1a4c333..ae82cd1bc 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -18,5 +18,13 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ privilegeUpgradeInitiatedByUsername: true, privilegeUpgradeInitiatedAt: true, bypassOrgAuthEnabled: true, - userTokenExpiration: true + userTokenExpiration: true, + secretsProductEnabled: true, + pkiProductEnabled: true, + kmsProductEnabled: true, + sshProductEnabled: true, + scannerProductEnabled: true, + shareSecretsProductEnabled: true, + maxSharedSecretLifetime: true, + maxSharedSecretViewLimit: true }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index d794391c1..63cb8935d 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -183,7 +183,9 @@ export const orgServiceFactory = ({ * */ const findAllOrganizationOfUser = async (userId: string) => { const orgs = await orgDAL.findAllOrgsByUserId(userId); - return orgs; + + // Filter out orgs where the membership object is an invitation + return orgs.filter((org) => org.userStatus !== "invited"); }; /* * Get all workspace members @@ -355,7 +357,15 @@ export const orgServiceFactory = ({ selectedMfaMethod, allowSecretSharingOutsideOrganization, bypassOrgAuthEnabled, - userTokenExpiration + userTokenExpiration, + secretsProductEnabled, + pkiProductEnabled, + kmsProductEnabled, + sshProductEnabled, + scannerProductEnabled, + shareSecretsProductEnabled, + maxSharedSecretLifetime, + maxSharedSecretViewLimit } }: TUpdateOrgDTO) => { const appCfg = getConfig(); @@ -457,7 +467,15 @@ export const orgServiceFactory = ({ selectedMfaMethod, allowSecretSharingOutsideOrganization, bypassOrgAuthEnabled, - userTokenExpiration + userTokenExpiration, + secretsProductEnabled, + pkiProductEnabled, + kmsProductEnabled, + sshProductEnabled, + scannerProductEnabled, + shareSecretsProductEnabled, + maxSharedSecretLifetime, + maxSharedSecretViewLimit }); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); return org; @@ -811,20 +829,30 @@ export const orgServiceFactory = ({ const users: Pick[] = []; for await (const inviteeEmail of inviteeEmails) { - let inviteeUser = await userDAL.findUserByUsername(inviteeEmail, tx); + const usersByUsername = await userDAL.findUserByUsername(inviteeEmail, tx); + let inviteeUser = + usersByUsername?.length > 1 + ? usersByUsername.find((el) => el.username === inviteeEmail) + : usersByUsername?.[0]; // if the user doesn't exist we create the user with the email if (!inviteeUser) { - inviteeUser = await userDAL.create( - { - isAccepted: false, - email: inviteeEmail, - username: inviteeEmail, - authMethods: [AuthMethod.EMAIL], - isGhost: false - }, - tx - ); + // TODO(carlos): will be removed once the function receives usernames instead of emails + const usersByEmail = await userDAL.findUserByEmail(inviteeEmail, tx); + if (usersByEmail?.length === 1) { + [inviteeUser] = usersByEmail; + } else { + inviteeUser = await userDAL.create( + { + isAccepted: false, + email: inviteeEmail, + username: inviteeEmail, + authMethods: [AuthMethod.EMAIL], + isGhost: false + }, + tx + ); + } } const inviteeUserId = inviteeUser?.id; @@ -1223,10 +1251,13 @@ export const orgServiceFactory = ({ * magic link and issue a temporary signup token for user to complete setting up their account */ const verifyUserToOrg = async ({ orgId, email, code }: TVerifyUserToOrgDTO) => { - const user = await userDAL.findUserByUsername(email); + const usersByUsername = await userDAL.findUserByUsername(email); + const user = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; if (!user) { throw new NotFoundError({ message: "User not found" }); } + const [orgMembership] = await orgDAL.findMembership({ [`${TableName.OrgMembership}.userId` as "userId"]: user.id, status: OrgMembershipStatus.Invited, diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 702cd25bf..8b2485ac4 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -75,6 +75,14 @@ export type TUpdateOrgDTO = { allowSecretSharingOutsideOrganization: boolean; bypassOrgAuthEnabled: boolean; userTokenExpiration: string; + secretsProductEnabled: boolean; + pkiProductEnabled: boolean; + kmsProductEnabled: boolean; + sshProductEnabled: boolean; + scannerProductEnabled: boolean; + shareSecretsProductEnabled: boolean; + maxSharedSecretLifetime: number; + maxSharedSecretViewLimit: number | null; }>; } & TOrgPermission; diff --git a/backend/src/services/pki-alert/pki-alert-dal.ts b/backend/src/services/pki-alert/pki-alert-dal.ts index d4d4fa987..2183f6a41 100644 --- a/backend/src/services/pki-alert/pki-alert-dal.ts +++ b/backend/src/services/pki-alert/pki-alert-dal.ts @@ -31,12 +31,13 @@ export const pkiAlertDALFactory = (db: TDbClient) => { .select( db.raw("? as type", [PkiItemType.CA]), `${PkiItemType.CA}.id`, - `${PkiItemType.CA}.notAfter as expiryDate`, - `${PkiItemType.CA}.serialNumber`, - `${PkiItemType.CA}.friendlyName`, + "ic.notAfter as expiryDate", + "ic.serialNumber", + "ic.friendlyName", "pci.pkiCollectionId" ) .from(`${TableName.CertificateAuthority} as ${PkiItemType.CA}`) + .join(`${TableName.InternalCertificateAuthority} as ic`, `${PkiItemType.CA}.id`, "ic.caId") .join(`${TableName.PkiCollectionItem} as pci`, `${PkiItemType.CA}.id`, "pci.caId") .unionAll((qb) => { void qb diff --git a/backend/src/services/pki-collection/pki-collection-item-dal.ts b/backend/src/services/pki-collection/pki-collection-item-dal.ts index de896e15c..d2b056e6d 100644 --- a/backend/src/services/pki-collection/pki-collection-item-dal.ts +++ b/backend/src/services/pki-collection/pki-collection-item-dal.ts @@ -27,13 +27,13 @@ export const pkiCollectionItemDALFactory = (db: TDbClient) => { .select( "pki_collection_items.*", db.raw( - `COALESCE("${TableName.CertificateAuthority}"."notBefore", "${TableName.Certificate}"."notBefore") as "notBefore"` + `COALESCE("${TableName.InternalCertificateAuthority}"."notBefore", "${TableName.Certificate}"."notBefore") as "notBefore"` ), db.raw( - `COALESCE("${TableName.CertificateAuthority}"."notAfter", "${TableName.Certificate}"."notAfter") as "notAfter"` + `COALESCE("${TableName.InternalCertificateAuthority}"."notAfter", "${TableName.Certificate}"."notAfter") as "notAfter"` ), db.raw( - `COALESCE("${TableName.CertificateAuthority}"."friendlyName", "${TableName.Certificate}"."friendlyName") as "friendlyName"` + `COALESCE("${TableName.InternalCertificateAuthority}"."friendlyName", "${TableName.Certificate}"."friendlyName") as "friendlyName"` ) ) .leftJoin( @@ -41,6 +41,11 @@ export const pkiCollectionItemDALFactory = (db: TDbClient) => { `${TableName.PkiCollectionItem}.caId`, `${TableName.CertificateAuthority}.id` ) + .leftJoin( + TableName.InternalCertificateAuthority, + `${TableName.PkiCollectionItem}.caId`, + `${TableName.InternalCertificateAuthority}.caId` + ) .leftJoin(TableName.Certificate, `${TableName.PkiCollectionItem}.certId`, `${TableName.Certificate}.id`) .where((builder) => { void builder.where(`${TableName.PkiCollectionItem}.pkiCollectionId`, collectionId); diff --git a/backend/src/services/pki-collection/pki-collection-service.ts b/backend/src/services/pki-collection/pki-collection-service.ts index bee3ee621..577441bfb 100644 --- a/backend/src/services/pki-collection/pki-collection-service.ts +++ b/backend/src/services/pki-collection/pki-collection-service.ts @@ -269,14 +269,8 @@ export const pkiCollectionServiceFactory = ({ }); if (isCertAdded) throw new BadRequestError({ message: "Certificate already part of the PKI collection" }); - // validate that there exists a certificate in same project as PKI collection - const cas = await certificateAuthorityDAL.find({ projectId: pkiCollection.projectId }); - - // TODO: consider making this more efficient const [certificate] = await certificateDAL.find({ - $in: { - caId: cas.map((ca) => ca.id) - }, + projectId: pkiCollection.projectId, id: itemId }); if (!certificate) throw new NotFoundError({ message: `Certificate with ID '${itemId}' not found` }); diff --git a/backend/src/services/pki-subscriber/pki-subscriber-dal.ts b/backend/src/services/pki-subscriber/pki-subscriber-dal.ts new file mode 100644 index 000000000..1899c63a6 --- /dev/null +++ b/backend/src/services/pki-subscriber/pki-subscriber-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TPkiSubscriberDALFactory = ReturnType; + +export const pkiSubscriberDALFactory = (db: TDbClient) => { + const pkiSubscriberOrm = ormify(db, TableName.PkiSubscriber); + return pkiSubscriberOrm; +}; diff --git a/backend/src/services/pki-subscriber/pki-subscriber-queue.ts b/backend/src/services/pki-subscriber/pki-subscriber-queue.ts new file mode 100644 index 000000000..28b9353b7 --- /dev/null +++ b/backend/src/services/pki-subscriber/pki-subscriber-queue.ts @@ -0,0 +1,187 @@ +import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +import { ActorType } from "../auth/auth-type"; +import { TCertificateDALFactory } from "../certificate/certificate-dal"; +import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; +import { CaStatus, CaType } from "../certificate-authority/certificate-authority-enums"; +import { TCertificateAuthorityQueueFactory } from "../certificate-authority/certificate-authority-queue"; +import { InternalCertificateAuthorityFns } from "../certificate-authority/internal/internal-certificate-authority-fns"; +import { TPkiSubscriberDALFactory } from "./pki-subscriber-dal"; +import { PkiSubscriberStatus, SubscriberOperationStatus } from "./pki-subscriber-types"; + +type TPkiSubscriberQueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + pkiSubscriberDAL: TPkiSubscriberDALFactory; + certificateAuthorityDAL: TCertificateAuthorityDALFactory; + certificateAuthorityQueue: TCertificateAuthorityQueueFactory; + internalCaFns: ReturnType; + certificateDAL: TCertificateDALFactory; + auditLogService: Pick; +}; + +export const pkiSubscriberQueueServiceFactory = ({ + queueService, + pkiSubscriberDAL, + certificateAuthorityDAL, + certificateAuthorityQueue, + internalCaFns, + certificateDAL, + auditLogService +}: TPkiSubscriberQueueServiceFactoryDep) => { + queueService.start(QueueName.PkiSubscriber, async (job) => { + if (job.name === QueueJobs.PkiSubscriberDailyAutoRenewal) { + logger.info(`${QueueJobs.PkiSubscriberDailyAutoRenewal}: queue task started`); + + const BATCH_SIZE = 100; + let offset = 0; + let hasMore = true; + + while (hasMore) { + // fetch PKI subscribers with auto renewal enabled in batches + // eslint-disable-next-line no-await-in-loop + const pkiSubscribers = await pkiSubscriberDAL.find( + { + enableAutoRenewal: true, + $notNull: ["autoRenewalPeriodInDays"], + status: PkiSubscriberStatus.ACTIVE + }, + { + limit: BATCH_SIZE, + offset + } + ); + + if (pkiSubscribers.length === 0) { + hasMore = false; + break; + } + + // Process each subscriber in the batch concurrently + // eslint-disable-next-line no-await-in-loop + await Promise.all( + pkiSubscribers.map(async (subscriber) => { + try { + const cert = await certificateDAL.findLatestActiveCertForSubscriber({ subscriberId: subscriber.id }); + let shouldRenew = false; + if (!cert || !cert.notAfter) { + shouldRenew = true; + } else { + const now = new Date(); + const expiry = new Date(cert.notAfter); + const daysUntilExpiry = (expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24); + shouldRenew = daysUntilExpiry <= subscriber.autoRenewalPeriodInDays!; + } + + if (shouldRenew) { + // Get the CA for the subscriber + if (!subscriber.caId) { + await pkiSubscriberDAL.updateById(subscriber.id, { + lastOperationStatus: SubscriberOperationStatus.FAILED, + lastOperationMessage: "No CA assigned to subscriber", + lastOperationAt: new Date() + }); + return; + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId); + if (!ca) { + await pkiSubscriberDAL.updateById(subscriber.id, { + lastOperationStatus: SubscriberOperationStatus.FAILED, + lastOperationMessage: "CA not found", + lastOperationAt: new Date() + }); + return; + } + + // Check if CA is active + if (ca.status !== CaStatus.ACTIVE) { + await pkiSubscriberDAL.updateById(subscriber.id, { + lastOperationStatus: SubscriberOperationStatus.FAILED, + lastOperationMessage: "CA is not active", + lastOperationAt: new Date() + }); + return; + } + // Order new certificate based on CA type + if (ca.externalCa?.id && ca.externalCa.type === CaType.ACME) { + await certificateAuthorityQueue.orderCertificateForSubscriber({ + subscriberId: subscriber.id, + caType: ca.externalCa.type + }); + } else if (ca.internalCa?.id) { + // For internal CAs, we can issue certificates directly + await internalCaFns.issueCertificate(subscriber, ca); + } + + // Update last auto-renew timestamp + await pkiSubscriberDAL.updateById(subscriber.id, { + lastAutoRenewAt: new Date(), + lastOperationStatus: SubscriberOperationStatus.SUCCESS, + lastOperationMessage: "Triggered certificate auto-renewal", + lastOperationAt: new Date() + }); + + await auditLogService.createAuditLog({ + projectId: subscriber.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.AUTOMATED_RENEW_SUBSCRIBER_CERT, + metadata: { + subscriberId: subscriber.id, + name: subscriber.name + } + } + }); + } + } catch (error) { + // Log error and update subscriber status + logger.error(error, `Failed to auto-renew certificate for subscriber ${subscriber.id}`); + await pkiSubscriberDAL.updateById(subscriber.id, { + lastOperationStatus: SubscriberOperationStatus.FAILED, + lastOperationMessage: error instanceof Error ? error.message : "Unknown error", + lastOperationAt: new Date() + }); + } + }) + ); + + offset += BATCH_SIZE; + } + + logger.info(`${QueueJobs.PkiSubscriberDailyAutoRenewal}: queue task completed`); + } + }); + + // we do a repeat cron job in utc timezone at 12 Midnight each day + const startDailyAutoRenewalJob = async () => { + // clear previous job + await queueService.stopRepeatableJob( + QueueName.PkiSubscriber, + QueueJobs.PkiSubscriberDailyAutoRenewal, + { pattern: "0 0 * * *", utc: true }, + // { pattern: "*/30 * * * * *", utc: true } // for testing + QueueName.PkiSubscriber // just a job id + ); + + await queueService.queue(QueueName.PkiSubscriber, QueueJobs.PkiSubscriberDailyAutoRenewal, undefined, { + delay: 5000, + jobId: QueueName.PkiSubscriber, + // { pattern: "*/30 * * * * *", utc: true } // for testing + repeat: { pattern: "0 0 * * *", utc: true } + }); + }; + + queueService.listen(QueueName.PkiSubscriber, "failed", (_, err) => { + logger.error(err, `${QueueName.PkiSubscriber}: failed`); + }); + + return { + startDailyAutoRenewalJob + }; +}; diff --git a/backend/src/services/pki-subscriber/pki-subscriber-schema.ts b/backend/src/services/pki-subscriber/pki-subscriber-schema.ts new file mode 100644 index 000000000..337f81d8c --- /dev/null +++ b/backend/src/services/pki-subscriber/pki-subscriber-schema.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +import { PkiSubscribersSchema } from "@app/db/schemas"; + +export const sanitizedPkiSubscriber = PkiSubscribersSchema.pick({ + id: true, + projectId: true, + caId: true, + name: true, + commonName: true, + status: true, + subjectAlternativeNames: true, + ttl: true, + keyUsages: true, + extendedKeyUsages: true, + lastOperationStatus: true, + lastOperationMessage: true, + lastOperationAt: true, + enableAutoRenewal: true, + autoRenewalPeriodInDays: true, + lastAutoRenewAt: true +}).extend({ + supportsImmediateCertIssuance: z.boolean().optional() +}); diff --git a/backend/src/services/pki-subscriber/pki-subscriber-service.ts b/backend/src/services/pki-subscriber/pki-subscriber-service.ts new file mode 100644 index 000000000..5bedbd60c --- /dev/null +++ b/backend/src/services/pki-subscriber/pki-subscriber-service.ts @@ -0,0 +1,847 @@ +/* eslint-disable no-bitwise */ +import { ForbiddenError, subject } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionCertificateActions, + ProjectPermissionPkiSubscriberActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { + CertExtendedKeyUsage, + CertExtendedKeyUsageOIDToName, + CertKeyAlgorithm, + CertKeyUsage, + CertStatus +} from "@app/services/certificate/certificate-types"; +import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { + createSerialNumber, + expandInternalCa, + getCaCertChain, + getCaCredentials, + keyAlgorithmToAlgCfg, + parseDistinguishedName +} from "@app/services/certificate-authority/certificate-authority-fns"; +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 { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { getCertificateCredentials } from "../certificate/certificate-fns"; +import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; +import { TCertificateAuthorityQueueFactory } from "../certificate-authority/certificate-authority-queue"; +import { InternalCertificateAuthorityFns } from "../certificate-authority/internal/internal-certificate-authority-fns"; +import { + PkiSubscriberStatus, + TCreatePkiSubscriberDTO, + TDeletePkiSubscriberDTO, + TGetPkiSubscriberDTO, + TGetSubscriberActiveCertBundleDTO, + TIssuePkiSubscriberCertDTO, + TListPkiSubscriberCertsDTO, + TOrderPkiSubscriberCertDTO, + TSignPkiSubscriberCertDTO, + TUpdatePkiSubscriberDTO +} from "./pki-subscriber-types"; + +type TPkiSubscriberServiceFactoryDep = { + pkiSubscriberDAL: Pick< + TPkiSubscriberDALFactory, + "create" | "findById" | "updateById" | "deleteById" | "transaction" | "find" | "findOne" + >; + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + "findByIdWithAssociatedCa" | "findById" | "transaction" | "create" | "updateById" | "findWithAssociatedCa" + >; + certificateAuthorityCertDAL: Pick; + certificateAuthoritySecretDAL: Pick; + certificateAuthorityQueue: Pick; + certificateAuthorityCrlDAL: Pick; + certificateDAL: Pick< + TCertificateDALFactory, + "create" | "transaction" | "countCertificatesForPkiSubscriber" | "findLatestActiveCertForSubscriber" | "find" + >; + certificateSecretDAL: Pick; + certificateBodyDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + permissionService: Pick; + internalCaFns: ReturnType; +}; + +export type TPkiSubscriberServiceFactory = ReturnType; + +export const pkiSubscriberServiceFactory = ({ + pkiSubscriberDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateDAL, + certificateSecretDAL, + certificateBodyDAL, + projectDAL, + kmsService, + permissionService, + certificateAuthorityQueue, + internalCaFns +}: TPkiSubscriberServiceFactoryDep) => { + const createSubscriber = async ({ + name, + commonName, + status, + caId, + ttl, + subjectAlternativeNames, + keyUsages, + extendedKeyUsages, + enableAutoRenewal, + autoRenewalPeriodInDays, + projectId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreatePkiSubscriberDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSubscriberActions.Create, + subject(ProjectPermissionSub.PkiSubscribers, { + name + }) + ); + + if (enableAutoRenewal) { + if (!autoRenewalPeriodInDays) { + throw new BadRequestError({ message: "autoRenewalPeriodInDays is required when enableAutoRenewal is true" }); + } + } + + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) { + throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + } + + if (ca.projectId !== projectId) { + throw new BadRequestError({ message: "CA does not belong to the project" }); + } + + const newSubscriber = await pkiSubscriberDAL.create({ + caId, + projectId, + name, + commonName, + status, + ttl, + subjectAlternativeNames, + keyUsages, + extendedKeyUsages, + enableAutoRenewal, + autoRenewalPeriodInDays + }); + + return newSubscriber; + }; + + const getSubscriber = async ({ + subscriberName, + projectId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TGetPkiSubscriberDTO) => { + const subscriber = await pkiSubscriberDAL.findOne({ + name: subscriberName, + projectId + }); + + if (!subscriber) throw new NotFoundError({ message: `PKI subscriber named '${subscriberName}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: subscriber.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSubscriberActions.Read, + subject(ProjectPermissionSub.PkiSubscribers, { + name: subscriber.name + }) + ); + + let supportsImmediateCertIssuance = false; + if (subscriber.caId) { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId); + if (ca.internalCa?.id) { + supportsImmediateCertIssuance = true; + } + } + + return { + ...subscriber, + supportsImmediateCertIssuance + }; + }; + + const updateSubscriber = async ({ + subscriberName, + projectId, + name, + commonName, + status, + caId, + ttl, + subjectAlternativeNames, + keyUsages, + extendedKeyUsages, + enableAutoRenewal, + autoRenewalPeriodInDays, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdatePkiSubscriberDTO) => { + const subscriber = await pkiSubscriberDAL.findOne({ + name: subscriberName, + projectId + }); + if (!subscriber) throw new NotFoundError({ message: `PKI subscriber named '${subscriberName}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: subscriber.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSubscriberActions.Edit, + subject(ProjectPermissionSub.PkiSubscribers, { + name: subscriber.name + }) + ); + + if (enableAutoRenewal) { + if (!autoRenewalPeriodInDays && !subscriber.autoRenewalPeriodInDays) { + throw new BadRequestError({ message: "autoRenewalPeriodInDays is required when enableAutoRenewal is true" }); + } + } + + if (caId) { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) { + throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + } + + if (ca.projectId !== projectId) { + throw new BadRequestError({ message: "CA does not belong to the project" }); + } + } + + const updatedSubscriber = await pkiSubscriberDAL.updateById(subscriber.id, { + caId, + name, + commonName, + status, + ttl, + subjectAlternativeNames, + keyUsages, + extendedKeyUsages, + enableAutoRenewal, + autoRenewalPeriodInDays + }); + + return updatedSubscriber; + }; + + const deleteSubscriber = async ({ + subscriberName, + projectId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TDeletePkiSubscriberDTO) => { + const subscriber = await pkiSubscriberDAL.findOne({ + name: subscriberName, + projectId + }); + if (!subscriber) throw new NotFoundError({ message: `PKI subscriber named '${subscriberName}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: subscriber.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSubscriberActions.Delete, + subject(ProjectPermissionSub.PkiSubscribers, { + name: subscriber.name + }) + ); + + await pkiSubscriberDAL.deleteById(subscriber.id); + + return subscriber; + }; + + const orderSubscriberCert = async ({ + subscriberName, + projectId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TOrderPkiSubscriberCertDTO) => { + const subscriber = await pkiSubscriberDAL.findOne({ + name: subscriberName, + projectId + }); + + if (!subscriber) throw new NotFoundError({ message: `PKI subscriber named '${subscriberName}' not found` }); + if (!subscriber.caId) throw new BadRequestError({ message: "Subscriber does not have an assigned issuing CA" }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: subscriber.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSubscriberActions.IssueCert, + subject(ProjectPermissionSub.PkiSubscribers, { + name: subscriber.name + }) + ); + + if (subscriber.status !== PkiSubscriberStatus.ACTIVE) + throw new BadRequestError({ message: "Subscriber is not active" }); + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId); + if (ca.internalCa?.id) { + throw new BadRequestError({ message: "CA does not support ordering of certificates" }); + } + + if (ca.status !== CaStatus.ACTIVE) { + throw new BadRequestError({ message: "CA is disabled" }); + } + + if (ca.externalCa?.id && ca.externalCa.type === CaType.ACME) { + await certificateAuthorityQueue.orderCertificateForSubscriber({ + subscriberId: subscriber.id, + caType: ca.externalCa.type + }); + + return subscriber; + } + + throw new BadRequestError({ message: "Unsupported CA type" }); + }; + + const issueSubscriberCert = async ({ + subscriberName, + projectId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TIssuePkiSubscriberCertDTO) => { + const subscriber = await pkiSubscriberDAL.findOne({ + name: subscriberName, + projectId + }); + + if (!subscriber) throw new NotFoundError({ message: `PKI subscriber named '${subscriberName}' not found` }); + if (!subscriber.caId) throw new BadRequestError({ message: "Subscriber does not have an assigned issuing CA" }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: subscriber.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSubscriberActions.IssueCert, + subject(ProjectPermissionSub.PkiSubscribers, { + name: subscriber.name + }) + ); + + if (subscriber.status !== PkiSubscriberStatus.ACTIVE) + throw new BadRequestError({ message: "Subscriber is not active" }); + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId); + if (ca.internalCa?.id) { + return internalCaFns.issueCertificate(subscriber, ca); + } + + throw new BadRequestError({ message: "CA does not support immediate issuance of certificates" }); + }; + + const signSubscriberCert = async ({ + subscriberName, + projectId, + csr, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TSignPkiSubscriberCertDTO) => { + const appCfg = getConfig(); + const subscriber = await pkiSubscriberDAL.findOne({ + name: subscriberName, + projectId + }); + if (!subscriber) throw new NotFoundError({ message: `PKI subscriber named '${subscriberName}' not found` }); + if (!subscriber.caId) throw new BadRequestError({ message: "Subscriber does not have an assigned issuing CA" }); + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId); + if (!ca?.internalCa) throw new NotFoundError({ message: `CA with ID '${subscriber.caId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSubscriberActions.IssueCert, + subject(ProjectPermissionSub.PkiSubscribers, { + name: subscriber.name + }) + ); + + if (subscriber.status !== PkiSubscriberStatus.ACTIVE) + throw new BadRequestError({ message: "Subscriber is not active" }); + if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); + if (!ca.internalCa?.activeCaCertId) + throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const caCert = await certificateAuthorityCertDAL.findById(ca.internalCa.activeCaCertId); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + const notBeforeDate = new Date(); + const notAfterDate = new Date(new Date().getTime() + ms(subscriber.ttl ?? "0")); + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + + const csrObj = new x509.Pkcs10CertificateRequest(csr); + + const dn = parseDistinguishedName(csrObj.subject); + const cn = dn.commonName; + if (cn !== subscriber.commonName) { + throw new BadRequestError({ message: "Common name (CN) in the CSR does not match the subscriber's common name" }); + } + + const { caPrivateKey, caSecret } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + + const extensions: x509.Extension[] = [ + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey), + new x509.CRLDistributionPointsExtension([distributionPointUrl]), + new x509.AuthorityInfoAccessExtension({ + caIssuers: new x509.GeneralName("url", caIssuerUrl) + }), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy + ]; + + // handle key usages + const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension; + let csrKeyUsages: CertKeyUsage[] = []; + if (csrKeyUsageExtension) { + csrKeyUsages = Object.values(CertKeyUsage).filter( + (keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0 + ); + } + + const selectedKeyUsages = subscriber.keyUsages as CertKeyUsage[]; + + if (csrKeyUsages.some((keyUsage) => !selectedKeyUsages.includes(keyUsage))) { + throw new BadRequestError({ + message: "Invalid key usage value based on subscriber's specified key usages" + }); + } + + const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0); + if (keyUsagesBitValue) { + extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true)); + } + + // handle extended key usages + const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension; + let csrExtendedKeyUsages: CertExtendedKeyUsage[] = []; + if (csrExtendedKeyUsageExtension) { + csrExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map( + (ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string] + ); + } + + const selectedExtendedKeyUsages = subscriber.extendedKeyUsages as CertExtendedKeyUsage[]; + if (csrExtendedKeyUsages.some((eku) => !selectedExtendedKeyUsages.includes(eku))) { + throw new BadRequestError({ + message: "Invalid extended key usage value based on subscriber's specified extended key usages" + }); + } + + if (selectedExtendedKeyUsages.length) { + extensions.push( + new x509.ExtendedKeyUsageExtension( + selectedExtendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku]), + true + ) + ); + } + + // attempt to read from CSR if altNames is not explicitly provided + let altNamesArray: { + type: "email" | "dns"; + value: string; + }[] = []; + + const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (sanExtension) { + const sanNames = new x509.GeneralNames(sanExtension.value); + + altNamesArray = sanNames.items + .filter((value) => value.type === "email" || value.type === "dns") + .map((name) => ({ + type: name.type as "email" | "dns", + value: name.value + })); + } + + if ( + altNamesArray + .map((altName) => altName.value) + .some((altName) => !subscriber.subjectAlternativeNames.includes(altName)) + ) { + throw new BadRequestError({ + message: "Invalid subject alternative name based on subscriber's specified subject alternative names" + }); + } + + if (altNamesArray.length) { + const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); + extensions.push(altNamesExtension); + } + + const serialNumber = createSerialNumber(); + const leafCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(leafCert.rawData)) + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caCertId: ca.internalCa.activeCaCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + const certificateChainPem = `${issuingCaCertificate}\n${caCertChain}`.trim(); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + caCertId: caCert.id, + pkiSubscriberId: subscriber.id, + status: CertStatus.ACTIVE, + friendlyName: subscriber.commonName, + commonName: subscriber.commonName, + altNames: subscriber.subjectAlternativeNames.join(","), + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + keyUsages: selectedKeyUsages, + extendedKeyUsages: selectedExtendedKeyUsages, + projectId + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + return cert; + }); + + return { + certificate: leafCert.toString("pem"), + certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), + issuingCaCertificate, + serialNumber, + ca: expandInternalCa(ca), + commonName: subscriber.commonName, + subscriber + }; + }; + + const listSubscriberCerts = async ({ + subscriberName, + projectId, + offset, + limit, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TListPkiSubscriberCertsDTO) => { + const subscriber = await pkiSubscriberDAL.findOne({ + name: subscriberName, + projectId + }); + if (!subscriber) throw new NotFoundError({ message: `PKI subscriber named '${subscriberName}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: subscriber.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSubscriberActions.ListCerts, + subject(ProjectPermissionSub.PkiSubscribers, { + name: subscriber.name + }) + ); + + const certificates = await certificateDAL.find( + { + pkiSubscriberId: subscriber.id + }, + { offset, limit, sort: [["updatedAt", "desc"]] } + ); + + const count = await certificateDAL.countCertificatesForPkiSubscriber(subscriber.id); + + return { + certificates, + totalCount: count + }; + }; + + const getSubscriberActiveCertBundle = async ({ + subscriberName, + projectId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TGetSubscriberActiveCertBundleDTO) => { + const subscriber = await pkiSubscriberDAL.findOne({ + name: subscriberName, + projectId + }); + + if (!subscriber) { + throw new NotFoundError({ message: `PKI subscriber named '${subscriberName}' not found` }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: subscriber.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSubscriberActions.ListCerts, + subject(ProjectPermissionSub.PkiSubscribers, { + name: subscriber.name + }) + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Read, + ProjectPermissionSub.Certificates + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.ReadPrivateKey, + ProjectPermissionSub.Certificates + ); + + const cert = await certificateDAL.findLatestActiveCertForSubscriber({ + subscriberId: subscriber.id + }); + + if (!cert) { + throw new NotFoundError({ message: "No active certificate found for subscriber" }); + } + + const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); + + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ + projectId: cert.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKeyId + }); + const decryptedCert = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificate + }); + + const certObj = new x509.X509Certificate(decryptedCert); + const certificate = certObj.toString("pem"); + + let certificateChain = null; + + // On newer certs the certBody.encryptedCertificateChain column will always exist. + // Older certs will have a caCertId which will be used as a fallback mechanism for structuring the chain. + if (certBody.encryptedCertificateChain) { + const decryptedCertChain = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificateChain + }); + certificateChain = decryptedCertChain.toString(); + } else if (cert.caCertId) { + const { caCert, caCertChain } = await getCaCertChain({ + caCertId: cert.caCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + certificateChain = `${caCert}\n${caCertChain}`.trim(); + } + + const { certPrivateKey } = await getCertificateCredentials({ + certId: cert.id, + projectId: cert.projectId, + certificateSecretDAL, + projectDAL, + kmsService + }); + + return { + certificate, + certificateChain, + privateKey: certPrivateKey, + serialNumber: cert.serialNumber, + cert, + subscriber + }; + }; + + return { + createSubscriber, + getSubscriber, + updateSubscriber, + deleteSubscriber, + issueSubscriberCert, + signSubscriberCert, + listSubscriberCerts, + orderSubscriberCert, + getSubscriberActiveCertBundle + }; +}; diff --git a/backend/src/services/pki-subscriber/pki-subscriber-types.ts b/backend/src/services/pki-subscriber/pki-subscriber-types.ts new file mode 100644 index 000000000..6881eea74 --- /dev/null +++ b/backend/src/services/pki-subscriber/pki-subscriber-types.ts @@ -0,0 +1,71 @@ +import { TProjectPermission } from "@app/lib/types"; + +import { CertExtendedKeyUsage, CertKeyUsage } from "../certificate/certificate-types"; + +export enum PkiSubscriberStatus { + ACTIVE = "active", + DISABLED = "disabled" +} + +export type TCreatePkiSubscriberDTO = { + caId: string; + name: string; + commonName: string; + status: PkiSubscriberStatus; + ttl?: string; + subjectAlternativeNames: string[]; + keyUsages: CertKeyUsage[]; + extendedKeyUsages: CertExtendedKeyUsage[]; + enableAutoRenewal?: boolean; + autoRenewalPeriodInDays?: number; +} & TProjectPermission; + +export type TGetPkiSubscriberDTO = { + subscriberName: string; +} & TProjectPermission; + +export type TUpdatePkiSubscriberDTO = { + subscriberName: string; + caId?: string; + name?: string; + commonName?: string; + status?: PkiSubscriberStatus; + ttl?: string; + subjectAlternativeNames?: string[]; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; + enableAutoRenewal?: boolean; + autoRenewalPeriodInDays?: number; +} & TProjectPermission; + +export type TDeletePkiSubscriberDTO = { + subscriberName: string; +} & TProjectPermission; + +export type TIssuePkiSubscriberCertDTO = { + subscriberName: string; +} & TProjectPermission; + +export type TOrderPkiSubscriberCertDTO = { + subscriberName: string; +} & TProjectPermission; + +export type TSignPkiSubscriberCertDTO = { + subscriberName: string; + csr: string; +} & TProjectPermission; + +export type TListPkiSubscriberCertsDTO = { + subscriberName: string; + offset: number; + limit: number; +} & TProjectPermission; + +export type TGetSubscriberActiveCertBundleDTO = { + subscriberName: string; +} & TProjectPermission; + +export enum SubscriberOperationStatus { + SUCCESS = "success", + FAILED = "failed" +} diff --git a/backend/src/services/pki-templates/pki-templates-dal.ts b/backend/src/services/pki-templates/pki-templates-dal.ts new file mode 100644 index 000000000..45c632d70 --- /dev/null +++ b/backend/src/services/pki-templates/pki-templates-dal.ts @@ -0,0 +1,102 @@ +import { Knex } from "knex"; +import { Tables } from "knex/types/tables"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt, TFindReturn } from "@app/lib/knex"; + +export type TPkiTemplatesDALFactory = ReturnType; + +export const pkiTemplatesDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.CertificateTemplate); + + const findOne = async ( + filter: Partial, + tx?: Knex + ) => { + try { + const { projectId, ...templateFilters } = filter; + const res = await (tx || db.replicaNode())(TableName.CertificateTemplate) + .join( + TableName.CertificateAuthority, + `${TableName.CertificateAuthority}.id`, + `${TableName.CertificateTemplate}.caId` + ) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter(templateFilters, TableName.CertificateTemplate)) + .where((qb) => { + if (projectId) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + void qb.where(buildFindFilter({ projectId }, TableName.CertificateAuthority)); + } + }) + .select(selectAllTableCols(TableName.CertificateTemplate)) + .select(db.ref("name").withSchema(TableName.CertificateAuthority).as("caName")) + .select(db.ref("projectId").withSchema(TableName.CertificateAuthority)) + .first(); + + if (!res) return undefined; + + return { ...res, ca: { id: res.caId, name: res.caName } }; + } catch (error) { + throw new DatabaseError({ error, name: "Find one" }); + } + }; + + const find = async < + TCount extends boolean = false, + TCountDistinct extends keyof Tables[TableName.CertificateTemplate]["base"] | undefined = undefined + >( + filter: TFindFilter & { projectId: string }, + { + offset, + limit, + sort, + count, + tx, + countDistinct + }: TFindOpt = {} + ) => { + try { + const { projectId, ...templateFilters } = filter; + + const query = (tx || db.replicaNode())(TableName.CertificateTemplate) + .join( + TableName.CertificateAuthority, + `${TableName.CertificateAuthority}.id`, + `${TableName.CertificateTemplate}.caId` + ) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter(templateFilters, TableName.CertificateTemplate)) + .where((qb) => { + if (projectId) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + void qb.where(buildFindFilter({ projectId }, TableName.CertificateAuthority)); + } + }) + .select(selectAllTableCols(TableName.CertificateTemplate)) + .select(db.ref("projectId").withSchema(TableName.CertificateAuthority)) + .select(db.ref("name").withSchema(TableName.CertificateAuthority).as("caName")); + + if (countDistinct) { + void query.countDistinct(countDistinct); + } else if (count) { + void query.select(db.raw("COUNT(*) OVER() AS count")); + } + + if (limit) void query.limit(limit); + if (offset) void query.offset(offset); + if (sort) { + void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))); + } + + const res = (await query) as TFindReturn; + return res.map((el) => ({ ...el, ca: { id: el.caId, name: el.caName } })); + } catch (error) { + throw new DatabaseError({ error, name: "Find one" }); + } + }; + + return { ...orm, find, findOne }; +}; diff --git a/backend/src/services/pki-templates/pki-templates-service.ts b/backend/src/services/pki-templates/pki-templates-service.ts new file mode 100644 index 000000000..97f910d6e --- /dev/null +++ b/backend/src/services/pki-templates/pki-templates-service.ts @@ -0,0 +1,644 @@ +/* eslint-disable no-bitwise */ +import { ForbiddenError, subject } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; +import RE2 from "re2"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionPkiTemplateActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; + +import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "../certificate/certificate-dal"; +import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; +import { + CertExtendedKeyUsage, + CertExtendedKeyUsageOIDToName, + CertKeyAlgorithm, + CertKeyUsage, + CertStatus +} from "../certificate/certificate-types"; +import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; +import { CaStatus } from "../certificate-authority/certificate-authority-enums"; +import { + createSerialNumber, + expandInternalCa, + getCaCertChain, + getCaCredentials, + keyAlgorithmToAlgCfg, + parseDistinguishedName +} from "../certificate-authority/certificate-authority-fns"; +import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority/certificate-authority-secret-dal"; +import { InternalCertificateAuthorityFns } from "../certificate-authority/internal/internal-certificate-authority-fns"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { TProjectDALFactory } from "../project/project-dal"; +import { getProjectKmsCertificateKeyId } from "../project/project-fns"; +import { TPkiTemplatesDALFactory } from "./pki-templates-dal"; +import { + TCreatePkiTemplateDTO, + TDeletePkiTemplateDTO, + TGetPkiTemplateDTO, + TIssueCertPkiTemplateDTO, + TListPkiTemplateDTO, + TSignCertPkiTemplateDTO, + TUpdatePkiTemplateDTO +} from "./pki-templates-types"; + +type TPkiTemplatesServiceFactoryDep = { + pkiTemplatesDAL: TPkiTemplatesDALFactory; + permissionService: Pick; + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + | "findByIdWithAssociatedCa" + | "findById" + | "transaction" + | "create" + | "updateById" + | "findWithAssociatedCa" + | "findOne" + >; + internalCaFns: ReturnType; + kmsService: Pick; + certificateAuthorityCertDAL: Pick; + certificateAuthoritySecretDAL: Pick; + certificateAuthorityCrlDAL: Pick; + certificateDAL: Pick< + TCertificateDALFactory, + "create" | "transaction" | "countCertificatesForPkiSubscriber" | "findLatestActiveCertForSubscriber" | "find" + >; + certificateSecretDAL: Pick; + certificateBodyDAL: Pick; + projectDAL: Pick; +}; + +export type TPkiTemplatesServiceFactory = ReturnType; + +export const pkiTemplatesServiceFactory = ({ + pkiTemplatesDAL, + permissionService, + internalCaFns, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateDAL, + certificateBodyDAL, + kmsService, + projectDAL +}: TPkiTemplatesServiceFactoryDep) => { + const createTemplate = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + caName, + commonName, + extendedKeyUsages, + keyUsages, + name, + subjectAlternativeName, + ttl, + projectId + }: TCreatePkiTemplateDTO) => { + const ca = await certificateAuthorityDAL.findOne({ name: caName, projectId }); + if (!ca) { + throw new NotFoundError({ + message: `CA with name ${caName} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Create, + subject(ProjectPermissionSub.CertificateTemplates, { name }) + ); + + const existingTemplate = await pkiTemplatesDAL.findOne({ name, projectId: ca.projectId }); + if (existingTemplate) { + throw new BadRequestError({ message: `Template with name ${name} already exists.` }); + } + + const newTemplate = await pkiTemplatesDAL.create({ + caId: ca.id, + name, + commonName, + subjectAlternativeName, + ttl, + keyUsages, + extendedKeyUsages + }); + return newTemplate; + }; + + const updateTemplate = async ({ + templateName, + actor, + actorId, + actorAuthMethod, + actorOrgId, + caName, + commonName, + extendedKeyUsages, + keyUsages, + name, + subjectAlternativeName, + ttl, + projectId + }: TUpdatePkiTemplateDTO) => { + const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId }); + if (!certTemplate) { + throw new NotFoundError({ + message: `Certificate template with name ${templateName} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certTemplate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Edit, + subject(ProjectPermissionSub.CertificateTemplates, { name: templateName }) + ); + + let caId; + if (caName) { + const ca = await certificateAuthorityDAL.findOne({ name: caName, projectId }); + if (!ca || ca.projectId !== certTemplate.projectId) { + throw new NotFoundError({ + message: `CA with name ${caName} not found` + }); + } + caId = ca.id; + } + + if (name) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Edit, + subject(ProjectPermissionSub.CertificateTemplates, { name }) + ); + + const existingTemplate = await pkiTemplatesDAL.findOne({ name, projectId }); + if (existingTemplate && existingTemplate.id !== certTemplate.id) { + throw new BadRequestError({ message: `Template with name ${name} already exists.` }); + } + } + + const updatedTemplate = await pkiTemplatesDAL.updateById(certTemplate.id, { + caId, + name, + commonName, + subjectAlternativeName, + ttl, + keyUsages, + extendedKeyUsages + }); + return updatedTemplate; + }; + + const deleteTemplate = async ({ + templateName, + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId + }: TDeletePkiTemplateDTO) => { + const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId }); + if (!certTemplate) { + throw new NotFoundError({ + message: `Certificate template with name ${templateName} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certTemplate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Delete, + subject(ProjectPermissionSub.CertificateTemplates, { name: templateName }) + ); + + const deletedTemplate = await pkiTemplatesDAL.deleteById(certTemplate.id); + return deletedTemplate; + }; + + const getTemplateByName = async ({ + templateName, + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId + }: TGetPkiTemplateDTO) => { + const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId }); + if (!certTemplate) { + throw new NotFoundError({ + message: `Certificate template with name ${templateName} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certTemplate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Read, + subject(ProjectPermissionSub.CertificateTemplates, { name: templateName }) + ); + + return certTemplate; + }; + + const listTemplate = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + limit, + offset + }: TListPkiTemplateDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + const certTemplate = await pkiTemplatesDAL.find({ projectId }, { limit, offset, count: true }); + return { + certificateTemplates: certTemplate.filter((el) => + permission.can( + ProjectPermissionPkiTemplateActions.Read, + subject(ProjectPermissionSub.CertificateTemplates, { name: el.name }) + ) + ), + totalCount: Number(certTemplate?.[0]?.count ?? 0) + }; + }; + + const issueCertificate = async ({ + templateName, + projectId, + commonName, + altNames, + ttl, + notBefore, + notAfter, + actorId, + actorAuthMethod, + actor, + actorOrgId, + keyUsages, + extendedKeyUsages + }: TIssueCertPkiTemplateDTO) => { + const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId }); + if (!certTemplate) { + throw new NotFoundError({ + message: `Certificate template with name ${templateName} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certTemplate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.IssueCert, + subject(ProjectPermissionSub.CertificateTemplates, { name: templateName }) + ); + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certTemplate.caId); + if (ca.internalCa?.id) { + return internalCaFns.issueCertificateWithTemplate(ca, certTemplate, { + altNames, + commonName, + ttl, + extendedKeyUsages, + keyUsages, + notAfter, + notBefore + }); + } + + throw new BadRequestError({ message: "CA does not support immediate issuance of certificates" }); + }; + + const signCertificate = async ({ + templateName, + csr, + projectId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + ttl + }: TSignCertPkiTemplateDTO) => { + const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId }); + if (!certTemplate) { + throw new NotFoundError({ + message: `Certificate template with name ${templateName} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certTemplate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.IssueCert, + subject(ProjectPermissionSub.CertificateTemplates, { name: templateName }) + ); + + const appCfg = getConfig(); + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certTemplate.caId); + if (!ca?.internalCa) throw new NotFoundError({ message: `CA with ID '${certTemplate.caId}' not found` }); + + if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); + if (!ca.internalCa?.activeCaCertId) + throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const caCert = await certificateAuthorityCertDAL.findById(ca.internalCa.activeCaCertId); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + const notBeforeDate = new Date(); + const notAfterDate = new Date(new Date().getTime() + ms(ttl ?? "0")); + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + + const csrObj = new x509.Pkcs10CertificateRequest(csr); + const dn = parseDistinguishedName(csrObj.subject); + const cn = dn.commonName; + if (!cn) + throw new BadRequestError({ + message: "Missing common name on CSR" + }); + + const commonNameRegex = new RE2(certTemplate.commonName); + if (!commonNameRegex.test(cn)) { + throw new BadRequestError({ + message: "Invalid common name based on template policy" + }); + } + + if (ms(ttl) > ms(certTemplate.ttl)) { + throw new BadRequestError({ + message: "Invalid validity date based on template policy" + }); + } + + const { caPrivateKey, caSecret } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + + const extensions: x509.Extension[] = [ + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey), + new x509.CRLDistributionPointsExtension([distributionPointUrl]), + new x509.AuthorityInfoAccessExtension({ + caIssuers: new x509.GeneralName("url", caIssuerUrl) + }), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy + ]; + + // handle key usages + const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension | undefined; // Better to type as optional + let selectedKeyUsages: CertKeyUsage[] = []; + if (csrKeyUsageExtension && csrKeyUsageExtension.usages) { + selectedKeyUsages = Object.values(CertKeyUsage).filter( + (keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0 + ); + const validKeyUsages = certTemplate.keyUsages || []; + if (selectedKeyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) { + throw new BadRequestError({ + message: "Invalid key usage value based on template policy" + }); + } + + const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0); + if (keyUsagesBitValue) { + extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true)); + } + } + + // handle extended key usage + const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension | undefined; + let selectedExtendedKeyUsages: CertExtendedKeyUsage[] = []; + if (csrExtendedKeyUsageExtension && csrExtendedKeyUsageExtension.usages.length > 0) { + selectedExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map( + (ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string] + ); + + if (selectedExtendedKeyUsages.some((eku) => !certTemplate?.extendedKeyUsages?.includes(eku))) { + throw new BadRequestError({ + message: "Invalid extended key usage value based on subscriber's specified extended key usages" + }); + } + + if (selectedExtendedKeyUsages.length) { + extensions.push( + new x509.ExtendedKeyUsageExtension( + selectedExtendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku]), + true + ) + ); + } + } + + // attempt to read from CSR if altNames is not explicitly provided + let altNamesArray: { + type: "email" | "dns"; + value: string; + }[] = []; + + const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (sanExtension) { + const sanNames = new x509.GeneralNames(sanExtension.value); + + altNamesArray = sanNames.items + .filter((value) => value.type === "email" || value.type === "dns") + .map((name) => ({ + type: name.type as "email" | "dns", + value: name.value + })); + } + + if (altNamesArray.length) { + const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); + extensions.push(altNamesExtension); + } + + const subjectAlternativeNameRegex = new RE2(certTemplate.subjectAlternativeName); + altNamesArray.forEach((altName) => { + if (!subjectAlternativeNameRegex.test(altName.value)) { + throw new BadRequestError({ + message: "Invalid subject alternative name based on template policy" + }); + } + }); + + const serialNumber = createSerialNumber(); + const leafCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(leafCert.rawData)) + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caCertId: ca.internalCa.activeCaCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + const certificateChainPem = `${issuingCaCertificate}\n${caCertChain}`.trim(); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + caCertId: caCert.id, + status: CertStatus.ACTIVE, + friendlyName: cn, + commonName: cn, + altNames: altNamesArray.map((el) => el.value).join(","), + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + keyUsages: selectedKeyUsages, + extendedKeyUsages: selectedExtendedKeyUsages, + projectId + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + return cert; + }); + + return { + certificate: leafCert.toString("pem"), + certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), + issuingCaCertificate, + serialNumber, + ca: expandInternalCa(ca), + commonName: cn, + template: certTemplate + }; + }; + + return { + createTemplate, + updateTemplate, + getTemplateByName, + listTemplate, + deleteTemplate, + signCertificate, + issueCertificate + }; +}; diff --git a/backend/src/services/pki-templates/pki-templates-types.ts b/backend/src/services/pki-templates/pki-templates-types.ts new file mode 100644 index 000000000..8dd18c8a9 --- /dev/null +++ b/backend/src/services/pki-templates/pki-templates-types.ts @@ -0,0 +1,53 @@ +import { TProjectPermission } from "@app/lib/types"; +import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types"; + +export type TCreatePkiTemplateDTO = { + caName: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + keyUsages: CertKeyUsage[]; + extendedKeyUsages: CertExtendedKeyUsage[]; +} & TProjectPermission; + +export type TUpdatePkiTemplateDTO = { + templateName: string; + caName?: string; + name?: string; + commonName?: string; + subjectAlternativeName?: string; + ttl?: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; +} & TProjectPermission; + +export type TListPkiTemplateDTO = { + limit?: number; + offset?: number; +} & TProjectPermission; + +export type TGetPkiTemplateDTO = { + templateName: string; +} & TProjectPermission; + +export type TDeletePkiTemplateDTO = { + templateName: string; +} & TProjectPermission; + +export type TIssueCertPkiTemplateDTO = { + templateName: string; + commonName: string; + altNames: string; + ttl: string; + notBefore?: string; + notAfter?: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; +} & TProjectPermission; + +export type TSignCertPkiTemplateDTO = { + templateName: string; + csr: string; + ttl: string; +} & TProjectPermission; diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 1e71f4605..8315ed429 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -92,7 +92,8 @@ export const projectMembershipDALFactory = (db: TDbClient) => { db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole), db.ref("name").as("projectName").withSchema(TableName.Project) ) - .where({ isGhost: false }); + .where({ isGhost: false }) + .orderBy(`${TableName.Users}.username` as "username"); const members = sqlNestRelationships({ data: docs, @@ -149,7 +150,14 @@ export const projectMembershipDALFactory = (db: TDbClient) => { } ] }); - return members; + return members.map((el) => ({ + ...el, + roles: el.roles.sort((a, b) => { + const roleA = (a.customRoleName || a.role).toLowerCase(); + const roleB = (b.customRoleName || b.role).toLowerCase(); + return roleA.localeCompare(roleB); + }) + })); } catch (error) { throw new DatabaseError({ error, name: "Find all project members" }); } diff --git a/backend/src/services/project-role/project-role-fns.ts b/backend/src/services/project-role/project-role-fns.ts index c465715a7..4dfcf960b 100644 --- a/backend/src/services/project-role/project-role-fns.ts +++ b/backend/src/services/project-role/project-role-fns.ts @@ -1,15 +1,20 @@ -import { ProjectMembershipRole } from "@app/db/schemas"; +import { v4 as uuidv4 } from "uuid"; + +import { ProjectMembershipRole, ProjectType } from "@app/db/schemas"; import { + cryptographicOperatorPermissions, projectAdminPermissions, projectMemberPermissions, projectNoAccessPermissions, - projectViewerPermission -} from "@app/ee/services/permission/project-permission"; + projectViewerPermission, + sshHostBootstrapPermissions +} from "@app/ee/services/permission/default-roles"; +import { TGetPredefinedRolesDTO } from "@app/services/project-role/project-role-types"; -export const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMembershipRole) => { +export const getPredefinedRoles = ({ projectId, projectType, roleFilter }: TGetPredefinedRolesDTO) => { return [ { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid + id: uuidv4(), projectId, name: "Admin", slug: ProjectMembershipRole.Admin, @@ -19,7 +24,7 @@ export const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMember updatedAt: new Date() }, { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response + id: uuidv4(), projectId, name: "Developer", slug: ProjectMembershipRole.Member, @@ -29,7 +34,29 @@ export const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMember updatedAt: new Date() }, { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response + id: uuidv4(), + projectId, + name: "SSH Host Bootstrapper", + slug: ProjectMembershipRole.SshHostBootstrapper, + permissions: sshHostBootstrapPermissions, + description: "Create and issue SSH Hosts in a project", + createdAt: new Date(), + updatedAt: new Date(), + type: ProjectType.SSH + }, + { + id: uuidv4(), + projectId, + name: "Cryptographic Operator", + slug: ProjectMembershipRole.KmsCryptographicOperator, + permissions: cryptographicOperatorPermissions, + description: "Perform cryptographic operations, such as encryption and signing, in a project", + createdAt: new Date(), + updatedAt: new Date(), + type: ProjectType.KMS + }, + { + id: uuidv4(), projectId, name: "Viewer", slug: ProjectMembershipRole.Viewer, @@ -39,7 +66,7 @@ export const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMember updatedAt: new Date() }, { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response + id: uuidv4(), projectId, name: "No Access", slug: ProjectMembershipRole.NoAccess, @@ -48,5 +75,5 @@ export const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMember createdAt: new Date(), updatedAt: new Date() } - ].filter(({ slug }) => !roleFilter || roleFilter.includes(slug)); + ].filter(({ slug, type }) => (type ? type === projectType : true) && (!roleFilter || roleFilter === slug)); }; diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts index 211dcff4f..babcf7d9c 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability"; import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; import { requestContext } from "@fastify/request-context"; -import { ActionProjectType, ProjectMembershipRole, TableName } from "@app/db/schemas"; +import { ActionProjectType, ProjectMembershipRole, ProjectType, TableName, TProjects } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, @@ -34,7 +34,7 @@ type TProjectRoleServiceFactoryDep = { projectRoleDAL: TProjectRoleDALFactory; identityDAL: Pick; userDAL: Pick; - projectDAL: Pick; + projectDAL: Pick; permissionService: Pick; identityProjectMembershipRoleDAL: TIdentityProjectMembershipRoleDALFactory; projectUserMembershipRoleDAL: TProjectUserMembershipRoleDALFactory; @@ -98,30 +98,37 @@ export const projectRoleServiceFactory = ({ roleSlug, filter }: TGetRoleDetailsDTO) => { - let projectId = ""; + let project: TProjects; if (filter.type === ProjectRoleServiceIdentifierType.SLUG) { - const project = await projectDAL.findProjectBySlug(filter.projectSlug, actorOrgId); - if (!project) throw new NotFoundError({ message: "Project not found" }); - projectId = project.id; + project = await projectDAL.findProjectBySlug(filter.projectSlug, actorOrgId); } else { - projectId = filter.projectId; + project = await projectDAL.findProjectById(filter.projectId); } + if (!project) throw new NotFoundError({ message: "Project not found" }); + const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId, + projectId: project.id, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.Any }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role); if (roleSlug !== "custom" && Object.values(ProjectMembershipRole).includes(roleSlug as ProjectMembershipRole)) { - const predefinedRole = getPredefinedRoles(projectId, roleSlug as ProjectMembershipRole)[0]; + const [predefinedRole] = getPredefinedRoles({ + projectId: project.id, + projectType: project.type as ProjectType, + roleFilter: roleSlug as ProjectMembershipRole + }); + + if (!predefinedRole) throw new NotFoundError({ message: `Default role with slug '${roleSlug}' not found` }); + return { ...predefinedRole, permissions: UnpackedPermissionSchema.array().parse(predefinedRole.permissions) }; } - const customRole = await projectRoleDAL.findOne({ slug: roleSlug, projectId }); + const customRole = await projectRoleDAL.findOne({ slug: roleSlug, projectId: project.id }); if (!customRole) throw new NotFoundError({ message: `Project role with slug '${roleSlug}' not found` }); return { ...customRole, permissions: unpackPermissions(customRole.permissions) }; }; @@ -194,29 +201,32 @@ export const projectRoleServiceFactory = ({ }; const listRoles = async ({ actorOrgId, actorAuthMethod, actorId, actor, filter }: TListRolesDTO) => { - let projectId = ""; + let project: TProjects; if (filter.type === ProjectRoleServiceIdentifierType.SLUG) { - const project = await projectDAL.findProjectBySlug(filter.projectSlug, actorOrgId); - if (!project) throw new BadRequestError({ message: "Project not found" }); - projectId = project.id; + project = await projectDAL.findProjectBySlug(filter.projectSlug, actorOrgId); } else { - projectId = filter.projectId; + project = await projectDAL.findProjectById(filter.projectId); } + if (!project) throw new BadRequestError({ message: "Project not found" }); + const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId, + projectId: project.id, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.Any }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role); const customRoles = await projectRoleDAL.find( - { projectId }, + { projectId: project.id }, { sort: [[`${TableName.ProjectRoles}.slug` as "slug", "asc"]] } ); - const roles = [...getPredefinedRoles(projectId), ...(customRoles || [])]; + const roles = [ + ...getPredefinedRoles({ projectId: project.id, projectType: project.type as ProjectType }), + ...(customRoles || []) + ]; return roles; }; diff --git a/backend/src/services/project-role/project-role-types.ts b/backend/src/services/project-role/project-role-types.ts index a71c73113..508623a0c 100644 --- a/backend/src/services/project-role/project-role-types.ts +++ b/backend/src/services/project-role/project-role-types.ts @@ -1,4 +1,4 @@ -import { TOrgRolesUpdate, TProjectRolesInsert } from "@app/db/schemas"; +import { ProjectMembershipRole, ProjectType, TOrgRolesUpdate, TProjectRolesInsert } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; export enum ProjectRoleServiceIdentifierType { @@ -34,3 +34,9 @@ export type TListRolesDTO = { | { type: ProjectRoleServiceIdentifierType.SLUG; projectSlug: string } | { type: ProjectRoleServiceIdentifierType.ID; projectId: string }; } & Omit; + +export type TGetPredefinedRolesDTO = { + projectId: string; + projectType: ProjectType; + roleFilter?: ProjectMembershipRole; +}; diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index 43f1d57e4..54bef02d1 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -22,6 +22,56 @@ export type TProjectDALFactory = ReturnType; export const projectDALFactory = (db: TDbClient) => { const projectOrm = ormify(db, TableName.Project); + const findIdentityProjects = async (identityId: string, orgId: string, projectType: ProjectType | "all") => { + try { + const workspaces = await db(TableName.IdentityProjectMembership) + .where({ identityId }) + .join(TableName.Project, `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id`) + .where(`${TableName.Project}.orgId`, orgId) + .andWhere((qb) => { + if (projectType !== "all") { + void qb.where(`${TableName.Project}.type`, projectType); + } + }) + .leftJoin(TableName.Environment, `${TableName.Environment}.projectId`, `${TableName.Project}.id`) + .select( + selectAllTableCols(TableName.Project), + db.ref("id").withSchema(TableName.Project).as("_id"), + db.ref("id").withSchema(TableName.Environment).as("envId"), + db.ref("slug").withSchema(TableName.Environment).as("envSlug"), + db.ref("name").withSchema(TableName.Environment).as("envName") + ) + .orderBy([ + { column: `${TableName.Project}.name`, order: "asc" }, + { column: `${TableName.Environment}.position`, order: "asc" } + ]); + + const nestedWorkspaces = sqlNestRelationships({ + data: workspaces, + key: "id", + parentMapper: ({ _id, ...el }) => ({ _id, ...ProjectsSchema.parse(el) }), + childrenMapper: [ + { + key: "envId", + label: "environments" as const, + mapper: ({ envId: id, envSlug: slug, envName: name }) => ({ + id, + slug, + name + }) + } + ] + }); + + return nestedWorkspaces.map((workspace) => ({ + ...workspace, + organization: workspace.orgId + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find identity projects" }); + } + }; + const findUserProjects = async (userId: string, orgId: string, projectType: ProjectType | "all") => { try { const workspaces = await db @@ -425,9 +475,25 @@ export const projectDALFactory = (db: TDbClient) => { return { docs, totalCount: Number(docs?.[0]?.count ?? 0) }; }; + const countOfOrgProjects = async (orgId: string | null, tx?: Knex) => { + try { + const doc = await (tx || db.replicaNode())(TableName.Project) + .andWhere((bd) => { + if (orgId) { + void bd.where({ orgId }); + } + }) + .count(); + return Number(doc?.[0]?.count ?? 0); + } catch (error) { + throw new DatabaseError({ error, name: "Count of Org Projects" }); + } + }; + return { ...projectOrm, findUserProjects, + findIdentityProjects, setProjectUpgradeStatus, findAllProjectsByIdentity, findProjectGhostUser, @@ -437,6 +503,7 @@ export const projectDALFactory = (db: TDbClient) => { findProjectWithOrg, checkProjectUpgradeStatus, getProjectFromSplitId, - searchProjects + searchProjects, + countOfOrgProjects }; }; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index ecc8c5a36..d8eee188a 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -6,6 +6,7 @@ import { ProjectMembershipRole, ProjectType, ProjectVersion, + TableName, TProjectEnvironments } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -15,6 +16,8 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionCertificateActions, + ProjectPermissionPkiSubscriberActions, + ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, ProjectPermissionSshHostActions, ProjectPermissionSub @@ -27,7 +30,7 @@ import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh- import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal"; import { TSshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal"; -import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; @@ -35,10 +38,12 @@ import { groupBy } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TProjectPermission } from "@app/lib/types"; import { TQueueServiceFactory } from "@app/queue"; +import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { ActorType } from "../auth/auth-type"; import { TCertificateDALFactory } from "../certificate/certificate-dal"; import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; +import { expandInternalCa } from "../certificate-authority/certificate-authority-fns"; import { TCertificateTemplateDALFactory } from "../certificate-template/certificate-template-dal"; import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; @@ -86,6 +91,7 @@ import { TListProjectCasDTO, TListProjectCertificateTemplatesDTO, TListProjectCertsDTO, + TListProjectPkiSubscribersDTO, TListProjectsDTO, TListProjectSshCasDTO, TListProjectSshCertificatesDTO, @@ -145,7 +151,8 @@ type TProjectServiceFactoryDep = { "findById" | "findByIdWithWorkflowIntegrationDetails" >; projectUserMembershipRoleDAL: Pick; - certificateAuthorityDAL: Pick; + pkiSubscriberDAL: Pick; + certificateAuthorityDAL: Pick; certificateDAL: Pick; certificateTemplateDAL: Pick; pkiAlertDAL: Pick; @@ -158,7 +165,7 @@ type TProjectServiceFactoryDep = { sshHostGroupDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; - licenseService: Pick; + licenseService: Pick; queueService: Pick; smtpService: Pick; orgDAL: Pick; @@ -207,6 +214,7 @@ export const projectServiceFactory = ({ certificateTemplateDAL, pkiCollectionDAL, pkiAlertDAL, + pkiSubscriberDAL, sshCertificateAuthorityDAL, sshCertificateAuthoritySecretDAL, sshCertificateDAL, @@ -251,16 +259,17 @@ export const projectServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); - const plan = await licenseService.getPlan(organization.id); - if (plan.workspaceLimit !== null && plan.workspacesUsed >= plan.workspaceLimit) { - // case: limit imposed on number of workspaces allowed - // case: number of workspaces used exceeds the number of workspaces allowed - throw new BadRequestError({ - message: "Failed to create workspace due to plan limit reached. Upgrade plan to add more workspaces." - }); - } - const results = await (trx || projectDAL).transaction(async (tx) => { + await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.CreateProject(organization.id)]); + + const plan = await licenseService.getPlan(organization.id); + if (plan.workspaceLimit !== null && plan.workspacesUsed >= plan.workspaceLimit) { + // case: limit imposed on number of workspaces allowed + // case: number of workspaces used exceeds the number of workspaces allowed + throw new BadRequestError({ + message: "Failed to create workspace due to plan limit reached. Upgrade plan to add more workspaces." + }); + } const ghostUser = await orgService.addGhostUser(organization.id, tx); if (kmsKeyId) { @@ -329,14 +338,16 @@ export const projectServiceFactory = ({ // set default environments and root folder for provided environments let envs: TProjectEnvironments[] = []; if (projectTemplate) { - envs = await projectEnvDAL.insertMany( - projectTemplate.environments.map((env) => ({ ...env, projectId: project.id })), - tx - ); - await folderDAL.insertMany( - envs.map(({ id }) => ({ name: ROOT_FOLDER_NAME, envId: id, version: 1 })), - tx - ); + if (projectTemplate.environments) { + envs = await projectEnvDAL.insertMany( + projectTemplate.environments.map((env) => ({ ...env, projectId: project.id })), + tx + ); + await folderDAL.insertMany( + envs.map(({ id }) => ({ name: ROOT_FOLDER_NAME, envId: id, version: 1 })), + tx + ); + } await projectRoleDAL.insertMany( projectTemplate.packedRoles.map((role) => ({ ...role, @@ -483,6 +494,10 @@ export const projectServiceFactory = ({ ); } + // no need to invalidate if there was no limit + if (plan.workspaceLimit) { + await licenseService.invalidateGetPlan(organization.id); + } return { ...project, environments: envs, @@ -563,12 +578,16 @@ export const projectServiceFactory = ({ const getProjects = async ({ actorId, + actor, includeRoles, actorAuthMethod, actorOrgId, type = ProjectType.SecretManager }: TListProjectsDTO) => { - const workspaces = await projectDAL.findUserProjects(actorId, actorOrgId, type); + const workspaces = + actor === ActorType.IDENTITY + ? await projectDAL.findIdentityProjects(actorId, actorOrgId, type) + : await projectDAL.findUserProjects(actorId, actorOrgId, type); if (includeRoles) { const { permission } = await permissionService.getUserOrgPermission( @@ -592,7 +611,10 @@ export const projectServiceFactory = ({ workspaces.map(async (workspace) => { return { ...workspace, - roles: [...(workspaceMappedToRoles[workspace.id] || []), ...getPredefinedRoles(workspace.id)] + roles: [ + ...(workspaceMappedToRoles[workspace.id] || []), + ...getPredefinedRoles({ projectId: workspace.id, projectType: workspace.type as ProjectType }) + ] }; }) ); @@ -648,7 +670,8 @@ export const projectServiceFactory = ({ autoCapitalization: update.autoCapitalization, enforceCapitalization: update.autoCapitalization, hasDeleteProtection: update.hasDeleteProtection, - slug: update.slug + slug: update.slug, + secretSharing: update.secretSharing }); return updatedProject; @@ -903,17 +926,20 @@ export const projectServiceFactory = ({ ProjectPermissionSub.CertificateAuthorities ); - const cas = await certificateAuthorityDAL.find( + const cas = await certificateAuthorityDAL.findWithAssociatedCa( { - projectId, - ...(status && { status }), - ...(friendlyName && { friendlyName }), - ...(commonName && { commonName }) + [`${TableName.CertificateAuthority}.projectId` as "projectId"]: projectId, + $notNull: [`${TableName.InternalCertificateAuthority}.id` as "id"], + ...(status && { [`${TableName.CertificateAuthority}.status` as "status"]: status }), + ...(friendlyName && { + [`${TableName.InternalCertificateAuthority}.friendlyName` as "friendlyName"]: friendlyName + }), + ...(commonName && { [`${TableName.InternalCertificateAuthority}.commonName` as "commonName"]: commonName }) }, { offset, limit, sort: [["updatedAt", "desc"]] } ); - return cas; + return cas.map((ca) => expandInternalCa(ca)); }; /** @@ -954,13 +980,9 @@ export const projectServiceFactory = ({ ProjectPermissionSub.Certificates ); - const cas = await certificateAuthorityDAL.find({ projectId }); - const certificates = await certificateDAL.find( { - $in: { - caId: cas.map((ca) => ca.id) - }, + projectId, ...(friendlyName && { friendlyName }), ...(commonName && { commonName }) }, @@ -1052,6 +1074,45 @@ export const projectServiceFactory = ({ }; }; + /** + * Return list of PKI subscribers for project + */ + const listProjectPkiSubscribers = async ({ + actorId, + actorOrgId, + actorAuthMethod, + actor, + projectId + }: TListProjectPkiSubscribersDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + const allowedSubscribers = []; + + // (dangtony98): room to optimize + const subscribers = await pkiSubscriberDAL.find({ projectId }); + + for (const subscriber of subscribers) { + const canRead = permission.can( + ProjectPermissionPkiSubscriberActions.Read, + subject(ProjectPermissionSub.PkiSubscribers, { + name: subscriber.name + }) + ); + if (canRead) { + allowedSubscribers.push(subscriber); + } + } + + return allowedSubscribers; + }; + /** * Return list of certificate templates for project */ @@ -1080,15 +1141,15 @@ export const projectServiceFactory = ({ actionProjectType: ActionProjectType.CertificateManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - ProjectPermissionSub.CertificateTemplates - ); - const certificateTemplates = await certificateTemplateDAL.getCertTemplatesByProjectId(projectId); return { - certificateTemplates + certificateTemplates: certificateTemplates.filter((el) => + permission.can( + ProjectPermissionPkiTemplateActions.Read, + subject(ProjectPermissionSub.CertificateTemplates, { name: el.name }) + ) + ) }; }; @@ -1151,17 +1212,15 @@ export const projectServiceFactory = ({ const hosts = await sshHostDAL.findSshHostsWithLoginMappings(projectId); for (const host of hosts) { - try { - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSshHostActions.Read, - subject(ProjectPermissionSub.SshHosts, { - hostname: host.hostname - }) - ); + const canRead = permission.can( + ProjectPermissionSshHostActions.Read, + subject(ProjectPermissionSub.SshHosts, { + hostname: host.hostname + }) + ); + if (canRead) { allowedHosts.push(host); - } catch { - // intentionally ignore projects where user lacks access } } @@ -1925,6 +1984,7 @@ export const projectServiceFactory = ({ listProjectSshCas, listProjectSshHosts, listProjectSshHostGroups, + listProjectPkiSubscribers, listProjectSshCertificates, listProjectSshCertificateTemplates, updateVersionLimit, diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index dc26d2357..be052f1cb 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -93,6 +93,7 @@ export type TUpdateProjectDTO = { autoCapitalization?: boolean; hasDeleteProtection?: boolean; slug?: string; + secretSharing?: boolean; }; } & Omit; @@ -155,6 +156,7 @@ export type TListProjectCertificateTemplatesDTO = TProjectPermission; export type TListProjectSshCasDTO = TProjectPermission; export type TListProjectSshHostsDTO = TProjectPermission; export type TListProjectSshCertificateTemplatesDTO = TProjectPermission; +export type TListProjectPkiSubscribersDTO = TProjectPermission; export type TListProjectSshCertificatesDTO = { offset: number; limit: number; diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts index 9649be722..24739b01b 100644 --- a/backend/src/services/secret-sharing/secret-sharing-service.ts +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -6,6 +6,7 @@ import { TSecretSharing } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { SecretSharingAccessType } from "@app/lib/types"; import { isUuidV4 } from "@app/lib/validator"; @@ -60,7 +61,9 @@ export const secretSharingServiceFactory = ({ } const fiveMins = 5 * 60 * 1000; - if (expiryTime - currentTime < fiveMins) { + + // 1 second buffer + if (expiryTime - currentTime + 1000 < fiveMins) { throw new BadRequestError({ message: "Expiration time cannot be less than 5 mins" }); } }; @@ -76,8 +79,11 @@ export const secretSharingServiceFactory = ({ password, accessType, expiresAt, - expiresAfterViews + expiresAfterViews, + emails }: TCreateSharedSecretDTO) => { + const appCfg = getConfig(); + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" }); $validateSharedSecretExpiry(expiresAt); @@ -93,7 +99,40 @@ export const secretSharingServiceFactory = ({ throw new BadRequestError({ message: "Shared secret value too long" }); } + // Check lifetime is within org allowance + const expiresAtTimestamp = new Date(expiresAt).getTime(); + const lifetime = expiresAtTimestamp - new Date().getTime(); + + // org.maxSharedSecretLifetime is in seconds + if (org.maxSharedSecretLifetime && lifetime / 1000 > org.maxSharedSecretLifetime) { + throw new BadRequestError({ message: "Secret lifetime exceeds organization limit" }); + } + + // Check max view count is within org allowance + if (org.maxSharedSecretViewLimit && (!expiresAfterViews || expiresAfterViews > org.maxSharedSecretViewLimit)) { + throw new BadRequestError({ message: "Secret max views parameter exceeds organization limit" }); + } + const encryptWithRoot = kmsService.encryptWithRootKey(); + + const orgEmails = []; + + if (emails && emails.length > 0) { + const allOrgMembers = await orgDAL.findAllOrgMembers(orgId); + + // Check to see that all emails are a part of the organization (if enforced) while also collecting a list of emails which are in the org + for (const email of emails) { + if (allOrgMembers.some((v) => v.user.email === email)) { + orgEmails.push(email); + // If the email is not part of the org, but access type / org settings require it + } else if (!org.allowSecretSharingOutsideOrganization || accessType === SecretSharingAccessType.Organization) { + throw new BadRequestError({ + message: "Organization does not allow sharing secrets to members outside of this organization" + }); + } + } + } + const encryptedSecret = encryptWithRoot(Buffer.from(secretValue)); const id = crypto.randomBytes(32).toString("hex"); @@ -112,11 +151,41 @@ export const secretSharingServiceFactory = ({ expiresAfterViews, userId: actorId, orgId, - accessType + accessType, + authorizedEmails: emails && emails.length > 0 ? JSON.stringify(emails) : undefined }); const idToReturn = `${Buffer.from(newSharedSecret.identifier!, "hex").toString("base64url")}`; + // Loop through recipients and send out emails with unique access links + if (emails) { + const user = await userDAL.findById(actorId); + + if (!user) { + throw new NotFoundError({ message: `User with ID '${actorId}' not found` }); + } + + for await (const email of emails) { + try { + // Only show the username to emails which are part of the organization + const respondentUsername = orgEmails.includes(email) ? user.username : undefined; + + await smtpService.sendMail({ + recipients: [email], + subjectLine: "A secret has been shared with you", + substitutions: { + name, + respondentUsername, + secretRequestUrl: `${appCfg.SITE_URL}/shared/secret/${idToReturn}` + }, + template: SmtpTemplates.SecretRequestCompleted + }); + } catch (e) { + logger.error(e, "Failed to send shared secret URL to a recipient's email."); + } + } + } + return { id: idToReturn }; }; @@ -390,8 +459,14 @@ export const secretSharingServiceFactory = ({ }); }; - /** Get's password-less secret. validates all secret's requested (must be fresh). */ - const getSharedSecretById = async ({ sharedSecretId, hashedHex, orgId, password }: TGetActiveSharedSecretByIdDTO) => { + /** Gets password-less secret. validates all secret's requested (must be fresh). */ + const getSharedSecretById = async ({ + sharedSecretId, + hashedHex, + orgId, + actorId, + password + }: TGetActiveSharedSecretByIdDTO) => { const sharedSecret = isUuidV4(sharedSecretId) ? await secretSharingDAL.findOne({ id: sharedSecretId, @@ -420,6 +495,17 @@ export const secretSharingServiceFactory = ({ throw new ForbiddenRequestError(); } + // If the secret was shared with specific emails, verify that the current user's session email is authorized + if (sharedSecret.authorizedEmails && (sharedSecret.authorizedEmails as string[]).length > 0) { + if (!actorId) throw new UnauthorizedError(); + + const user = await userDAL.findById(actorId); + if (!user || !user.email) throw new UnauthorizedError(); + + if (!(sharedSecret.authorizedEmails as string[]).includes(user.email)) + throw new UnauthorizedError({ message: "Email not authorized to view secret" }); + } + // all secrets pass through here, meaning we check if its expired first and then check if it needs verification // or can be safely sent to the client. if (expiresAt !== null && expiresAt < new Date()) { @@ -438,6 +524,7 @@ export const secretSharingServiceFactory = ({ }); } + // Password checks const isPasswordProtected = Boolean(sharedSecret.password); const hasProvidedPassword = Boolean(password); if (isPasswordProtected) { @@ -449,10 +536,11 @@ export const secretSharingServiceFactory = ({ } } + const decryptWithRoot = kmsService.decryptWithRootKey(); + // If encryptedSecret is set, we know that this secret has been encrypted using KMS, and we can therefore do server-side decryption. let decryptedSecretValue: Buffer | undefined; if (sharedSecret.encryptedSecret) { - const decryptWithRoot = kmsService.decryptWithRootKey(); decryptedSecretValue = decryptWithRoot(sharedSecret.encryptedSecret); } diff --git a/backend/src/services/secret-sharing/secret-sharing-types.ts b/backend/src/services/secret-sharing/secret-sharing-types.ts index 835d70eff..3d968edf3 100644 --- a/backend/src/services/secret-sharing/secret-sharing-types.ts +++ b/backend/src/services/secret-sharing/secret-sharing-types.ts @@ -22,6 +22,7 @@ export type TSharedSecretPermission = { accessType?: SecretSharingAccessType; name?: string; password?: string; + emails?: string[]; }; export type TCreatePublicSharedSecretDTO = { @@ -36,6 +37,7 @@ export type TGetActiveSharedSecretByIdDTO = { sharedSecretId: string; hashedHex?: string; orgId?: string; + actorId?: string; password?: string; }; diff --git a/backend/src/services/secret-sync/1password/1password-sync-constants.ts b/backend/src/services/secret-sync/1password/1password-sync-constants.ts new file mode 100644 index 000000000..01226a026 --- /dev/null +++ b/backend/src/services/secret-sync/1password/1password-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const ONEPASS_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "1Password", + destination: SecretSync.OnePass, + connection: AppConnection.OnePass, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/1password/1password-sync-fns.ts b/backend/src/services/secret-sync/1password/1password-sync-fns.ts new file mode 100644 index 000000000..9305f2e3c --- /dev/null +++ b/backend/src/services/secret-sync/1password/1password-sync-fns.ts @@ -0,0 +1,227 @@ +import { request } from "@app/lib/config/request"; +import { getOnePassInstanceUrl } from "@app/services/app-connection/1password"; +import { + TDeleteOnePassVariable, + TOnePassListVariables, + TOnePassListVariablesResponse, + TOnePassSyncWithCredentials, + TOnePassVariable, + TOnePassVariableDetails, + TPostOnePassVariable, + TPutOnePassVariable +} from "@app/services/secret-sync/1password/1password-sync-types"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +const listOnePassItems = async ({ instanceUrl, apiToken, vaultId }: TOnePassListVariables) => { + const { data } = await request.get(`${instanceUrl}/v1/vaults/${vaultId}/items`, { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + }); + + const result: Record = {}; + + for await (const s of data) { + const { data: secret } = await request.get( + `${instanceUrl}/v1/vaults/${vaultId}/items/${s.id}`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + } + ); + + const value = secret.fields.find((f) => f.label === "value")?.value; + const fieldId = secret.fields.find((f) => f.label === "value")?.id; + + // eslint-disable-next-line no-continue + if (!value || !fieldId) continue; + + result[s.title] = { + ...secret, + value, + fieldId + }; + } + + return result; +}; + +const createOnePassItem = async ({ instanceUrl, apiToken, vaultId, itemTitle, itemValue }: TPostOnePassVariable) => { + return request.post( + `${instanceUrl}/v1/vaults/${vaultId}/items`, + { + title: itemTitle, + category: "API_CREDENTIAL", + vault: { + id: vaultId + }, + tags: ["synced-from-infisical"], + fields: [ + { + label: "value", + value: itemValue, + type: "CONCEALED" + } + ] + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json" + } + } + ); +}; + +const updateOnePassItem = async ({ + instanceUrl, + apiToken, + vaultId, + itemId, + fieldId, + itemTitle, + itemValue +}: TPutOnePassVariable) => { + return request.put( + `${instanceUrl}/v1/vaults/${vaultId}/items/${itemId}`, + { + id: itemId, + title: itemTitle, + category: "API_CREDENTIAL", + vault: { + id: vaultId + }, + tags: ["synced-from-infisical"], + fields: [ + { + id: fieldId, + label: "value", + value: itemValue, + type: "CONCEALED" + } + ] + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json" + } + } + ); +}; + +const deleteOnePassItem = async ({ instanceUrl, apiToken, vaultId, itemId }: TDeleteOnePassVariable) => { + return request.delete(`${instanceUrl}/v1/vaults/${vaultId}/items/${itemId}`, { + headers: { + Authorization: `Bearer ${apiToken}` + } + }); +}; + +export const OnePassSyncFns = { + syncSecrets: async (secretSync: TOnePassSyncWithCredentials, secretMap: TSecretMap) => { + const { + connection, + environment, + destinationConfig: { vaultId } + } = secretSync; + + const instanceUrl = await getOnePassInstanceUrl(connection); + const { apiToken } = connection.credentials; + + const items = await listOnePassItems({ instanceUrl, apiToken, vaultId }); + + for await (const entry of Object.entries(secretMap)) { + const [key, { value }] = entry; + + try { + if (key in items) { + await updateOnePassItem({ + instanceUrl, + apiToken, + vaultId, + itemTitle: key, + itemValue: value, + itemId: items[key].id, + fieldId: items[key].fieldId + }); + } else { + await createOnePassItem({ instanceUrl, apiToken, vaultId, itemTitle: key, itemValue: value }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + + if (secretSync.syncOptions.disableSecretDeletion) return; + + for await (const [key, variable] of Object.entries(items)) { + // eslint-disable-next-line no-continue + if (!matchesSchema(key, environment?.slug || "", secretSync.syncOptions.keySchema)) continue; + + if (!(key in secretMap)) { + try { + await deleteOnePassItem({ + instanceUrl, + apiToken, + vaultId, + itemId: variable.id + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + } + }, + removeSecrets: async (secretSync: TOnePassSyncWithCredentials, secretMap: TSecretMap) => { + const { + connection, + destinationConfig: { vaultId } + } = secretSync; + + const instanceUrl = await getOnePassInstanceUrl(connection); + const { apiToken } = connection.credentials; + + const items = await listOnePassItems({ instanceUrl, apiToken, vaultId }); + + for await (const [key, item] of Object.entries(items)) { + if (key in secretMap) { + try { + await deleteOnePassItem({ + apiToken, + vaultId, + instanceUrl, + itemId: item.id + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + } + }, + getSecrets: async (secretSync: TOnePassSyncWithCredentials) => { + const { + connection, + destinationConfig: { vaultId } + } = secretSync; + + const instanceUrl = await getOnePassInstanceUrl(connection); + const { apiToken } = connection.credentials; + + return listOnePassItems({ instanceUrl, apiToken, vaultId }); + } +}; diff --git a/backend/src/services/secret-sync/1password/1password-sync-schemas.ts b/backend/src/services/secret-sync/1password/1password-sync-schemas.ts new file mode 100644 index 000000000..2f77a1dad --- /dev/null +++ b/backend/src/services/secret-sync/1password/1password-sync-schemas.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const OnePassSyncDestinationConfigSchema = z.object({ + vaultId: z.string().trim().min(1, "Vault required").describe(SecretSyncs.DESTINATION_CONFIG.ONEPASS.vaultId) +}); + +const OnePassSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const OnePassSyncSchema = BaseSecretSyncSchema(SecretSync.OnePass, OnePassSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.OnePass), + destinationConfig: OnePassSyncDestinationConfigSchema +}); + +export const CreateOnePassSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.OnePass, + OnePassSyncOptionsConfig +).extend({ + destinationConfig: OnePassSyncDestinationConfigSchema +}); + +export const UpdateOnePassSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.OnePass, + OnePassSyncOptionsConfig +).extend({ + destinationConfig: OnePassSyncDestinationConfigSchema.optional() +}); + +export const OnePassSyncListItemSchema = z.object({ + name: z.literal("1Password"), + connection: z.literal(AppConnection.OnePass), + destination: z.literal(SecretSync.OnePass), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/1password/1password-sync-types.ts b/backend/src/services/secret-sync/1password/1password-sync-types.ts new file mode 100644 index 000000000..af4db7369 --- /dev/null +++ b/backend/src/services/secret-sync/1password/1password-sync-types.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +import { TOnePassConnection } from "@app/services/app-connection/1password"; + +import { CreateOnePassSyncSchema, OnePassSyncListItemSchema, OnePassSyncSchema } from "./1password-sync-schemas"; + +export type TOnePassSync = z.infer; + +export type TOnePassSyncInput = z.infer; + +export type TOnePassSyncListItem = z.infer; + +export type TOnePassSyncWithCredentials = TOnePassSync & { + connection: TOnePassConnection; +}; + +export type TOnePassVariable = { + id: string; + title: string; + category: string; // API_CREDENTIAL, SECURE_NOTE, LOGIN, etc +}; + +export type TOnePassVariableDetails = TOnePassVariable & { + fields: { + id: string; + type: string; // CONCEALED, STRING + label: string; + value: string; + }[]; +}; + +export type TOnePassListVariablesResponse = TOnePassVariable[]; + +export type TOnePassListVariables = { + apiToken: string; + instanceUrl: string; + vaultId: string; +}; + +export type TPostOnePassVariable = TOnePassListVariables & { + itemTitle: string; + itemValue: string; +}; + +export type TPutOnePassVariable = TOnePassListVariables & { + itemId: string; + fieldId: string; + itemTitle: string; + itemValue: string; +}; + +export type TDeleteOnePassVariable = TOnePassListVariables & { + itemId: string; +}; diff --git a/backend/src/services/secret-sync/1password/index.ts b/backend/src/services/secret-sync/1password/index.ts new file mode 100644 index 000000000..db098b299 --- /dev/null +++ b/backend/src/services/secret-sync/1password/index.ts @@ -0,0 +1,4 @@ +export * from "./1password-sync-constants"; +export * from "./1password-sync-fns"; +export * from "./1password-sync-schemas"; +export * from "./1password-sync-types"; diff --git a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts index 7e77bd256..b687d81dd 100644 --- a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts +++ b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts @@ -2,6 +2,7 @@ import AWS, { AWSError } from "aws-sdk"; import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; import { TAwsParameterStoreSyncWithCredentials } from "./aws-parameter-store-sync-types"; @@ -169,7 +170,7 @@ const getParameterStoreTagsRecord = async ( throw new SecretSyncError({ message: - "IAM role has inadequate permissions to manage resource tags. Ensure the following polices are present: ssm:ListTagsForResource, ssm:AddTagsToResource, and ssm:RemoveTagsFromResource", + "IAM role has inadequate permissions to manage resource tags. Ensure the following policies are present: ssm:ListTagsForResource, ssm:AddTagsToResource, and ssm:RemoveTagsFromResource", shouldRetry: false }); } @@ -293,7 +294,7 @@ const deleteParametersBatch = async ( export const AwsParameterStoreSyncFns = { syncSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => { - const { destinationConfig, syncOptions } = secretSync; + const { destinationConfig, syncOptions, environment } = secretSync; const ssm = await getSSM(secretSync); @@ -389,6 +390,9 @@ export const AwsParameterStoreSyncFns = { for (const entry of Object.entries(awsParameterStoreSecretsRecord)) { const [key, parameter] = entry; + // eslint-disable-next-line no-continue + if (!matchesSchema(key, environment?.slug || "", syncOptions.keySchema)) continue; + if (!(key in secretMap) || !secretMap[key].value) { parametersToDelete.push(parameter); } diff --git a/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts index 7cea12d1b..df73512e5 100644 --- a/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts +++ b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts @@ -27,6 +27,7 @@ import { import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; import { AwsSecretsManagerSyncMappingBehavior } from "@app/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-enums"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; import { TAwsSecretsManagerSyncWithCredentials } from "./aws-secrets-manager-sync-types"; @@ -56,7 +57,11 @@ const sleep = async () => setTimeout(resolve, 1000); }); -const getSecretsRecord = async (client: SecretsManagerClient): Promise => { +const getSecretsRecord = async ( + client: SecretsManagerClient, + environment: string, + keySchema?: string +): Promise => { const awsSecretsRecord: TAwsSecretsRecord = {}; let hasNext = true; let nextToken: string | undefined; @@ -71,7 +76,7 @@ const getSecretsRecord = async (client: SecretsManagerClient): Promise { - if (secretEntry.Name) { + if (secretEntry.Name && matchesSchema(secretEntry.Name, environment, keySchema)) { awsSecretsRecord[secretEntry.Name] = secretEntry; } }); @@ -306,11 +311,11 @@ const processTags = ({ export const AwsSecretsManagerSyncFns = { syncSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials, secretMap: TSecretMap) => { - const { destinationConfig, syncOptions } = secretSync; + const { destinationConfig, syncOptions, environment } = secretSync; const client = await getSecretsManagerClient(secretSync); - const awsSecretsRecord = await getSecretsRecord(client); + const awsSecretsRecord = await getSecretsRecord(client, environment?.slug || "", syncOptions.keySchema); const awsValuesRecord = await getSecretValuesRecord(client, awsSecretsRecord); @@ -399,6 +404,9 @@ export const AwsSecretsManagerSyncFns = { if (syncOptions.disableSecretDeletion) return; for await (const secretKey of Object.keys(awsSecretsRecord)) { + // eslint-disable-next-line no-continue + if (!matchesSchema(secretKey, environment?.slug || "", syncOptions.keySchema)) continue; + if (!(secretKey in secretMap) || !secretMap[secretKey].value) { try { await deleteSecret(client, secretKey); @@ -464,14 +472,20 @@ export const AwsSecretsManagerSyncFns = { getSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials): Promise => { const client = await getSecretsManagerClient(secretSync); - const awsSecretsRecord = await getSecretsRecord(client); + const awsSecretsRecord = await getSecretsRecord( + client, + secretSync.environment?.slug || "", + secretSync.syncOptions.keySchema + ); const awsValuesRecord = await getSecretValuesRecord(client, awsSecretsRecord); const { destinationConfig } = secretSync; if (destinationConfig.mappingBehavior === AwsSecretsManagerSyncMappingBehavior.OneToOne) { return Object.fromEntries( - Object.keys(awsSecretsRecord).map((key) => [key, { value: awsValuesRecord[key].SecretString ?? "" }]) + Object.keys(awsSecretsRecord) + .filter((key) => Object.hasOwn(awsValuesRecord, key)) + .map((key) => [key, { value: awsValuesRecord[key]?.SecretString ?? "" }]) ); } @@ -497,11 +511,11 @@ export const AwsSecretsManagerSyncFns = { } }, removeSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials, secretMap: TSecretMap) => { - const { destinationConfig } = secretSync; + const { destinationConfig, syncOptions, environment } = secretSync; const client = await getSecretsManagerClient(secretSync); - const awsSecretsRecord = await getSecretsRecord(client); + const awsSecretsRecord = await getSecretsRecord(client, environment?.slug || "", syncOptions.keySchema); if (destinationConfig.mappingBehavior === AwsSecretsManagerSyncMappingBehavior.OneToOne) { for await (const secretKey of Object.keys(awsSecretsRecord)) { diff --git a/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts index 64d82c125..7aa1c16ce 100644 --- a/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts +++ b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts @@ -7,6 +7,7 @@ import { TAppConnectionDALFactory } from "@app/services/app-connection/app-conne import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure-key-vault"; import { isAzureKeyVaultReference } from "@app/services/integration-auth/integration-sync-secret-fns"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; import { TAzureAppConfigurationSyncWithCredentials } from "./azure-app-configuration-sync-types"; @@ -139,6 +140,9 @@ export const azureAppConfigurationSyncFactory = ({ if (secretSync.syncOptions.disableSecretDeletion) return; for await (const key of Object.keys(azureAppConfigSecrets)) { + // eslint-disable-next-line no-continue + if (!matchesSchema(key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue; + const azureSecret = azureAppConfigSecrets[key]; if ( !(key in secretMap) || diff --git a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts index 12f1f2aff..edc8af709 100644 --- a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts +++ b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts @@ -5,6 +5,7 @@ import { request } from "@app/lib/config/request"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure-key-vault"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; import { SecretSyncError } from "../secret-sync-errors"; @@ -192,7 +193,9 @@ export const azureKeyVaultSyncFactory = ({ kmsService, appConnectionDAL }: TAzur if (secretSync.syncOptions.disableSecretDeletion) return; for await (const deleteSecretKey of deleteSecrets.filter( - (secret) => !setSecrets.find((setSecret) => setSecret.key === secret) + (secret) => + matchesSchema(secret, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema) && + !setSecrets.find((setSecret) => setSecret.key === secret) )) { await request.delete(`${secretSync.destinationConfig.vaultBaseUrl}/secrets/${deleteSecretKey}?api-version=7.3`, { headers: { diff --git a/backend/src/services/secret-sync/camunda/camunda-sync-fns.ts b/backend/src/services/secret-sync/camunda/camunda-sync-fns.ts index 3a52a4939..516efae10 100644 --- a/backend/src/services/secret-sync/camunda/camunda-sync-fns.ts +++ b/backend/src/services/secret-sync/camunda/camunda-sync-fns.ts @@ -12,6 +12,7 @@ import { TCamundaSyncWithCredentials } from "@app/services/secret-sync/camunda/camunda-sync-types"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { TSecretMap } from "../secret-sync-types"; @@ -116,6 +117,9 @@ export const camundaSyncFactory = ({ kmsService, appConnectionDAL }: TCamundaSec if (secretSync.syncOptions.disableSecretDeletion) return; for await (const secret of Object.keys(camundaSecrets)) { + // eslint-disable-next-line no-continue + if (!matchesSchema(secret, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue; + if (!(secret in secretMap) || !secretMap[secret].value) { try { await deleteCamundaSecret({ diff --git a/backend/src/services/secret-sync/databricks/databricks-sync-fns.ts b/backend/src/services/secret-sync/databricks/databricks-sync-fns.ts index 2ee7977a4..175901323 100644 --- a/backend/src/services/secret-sync/databricks/databricks-sync-fns.ts +++ b/backend/src/services/secret-sync/databricks/databricks-sync-fns.ts @@ -11,6 +11,7 @@ import { TDatabricksSyncWithCredentials } from "@app/services/secret-sync/databricks/databricks-sync-types"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps"; import { TSecretMap } from "../secret-sync-types"; @@ -115,6 +116,9 @@ export const databricksSyncFactory = ({ kmsService, appConnectionDAL }: TDatabri if (secretSync.syncOptions.disableSecretDeletion) return; for await (const secret of databricksSecretKeys) { + // eslint-disable-next-line no-continue + if (!matchesSchema(secret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue; + if (!(secret.key in secretMap)) { await deleteDatabricksSecrets({ key: secret.key, diff --git a/backend/src/services/secret-sync/gcp/gcp-sync-enums.ts b/backend/src/services/secret-sync/gcp/gcp-sync-enums.ts index 348d2bfa5..389070a9a 100644 --- a/backend/src/services/secret-sync/gcp/gcp-sync-enums.ts +++ b/backend/src/services/secret-sync/gcp/gcp-sync-enums.ts @@ -1,3 +1,63 @@ export enum GcpSyncScope { - Global = "global" + Global = "global", + Region = "region" +} + +export enum GCPSecretManagerLocation { + // Asia Pacific + ASIA_SOUTHEAST3 = "asia-southeast3", // Bangkok + ASIA_SOUTH2 = "asia-south2", // Delhi + ASIA_EAST2 = "asia-east2", // Hong Kong + ASIA_SOUTHEAST2 = "asia-southeast2", // Jakarta + AUSTRALIA_SOUTHEAST2 = "australia-southeast2", // Melbourne + ASIA_SOUTH1 = "asia-south1", // Mumbai + ASIA_NORTHEAST2 = "asia-northeast2", // Osaka + ASIA_NORTHEAST3 = "asia-northeast3", // Seoul + ASIA_SOUTHEAST1 = "asia-southeast1", // Singapore + AUSTRALIA_SOUTHEAST1 = "australia-southeast1", // Sydney + ASIA_EAST1 = "asia-east1", // Taiwan + ASIA_NORTHEAST1 = "asia-northeast1", // Tokyo + + // Europe + EUROPE_WEST1 = "europe-west1", // Belgium + EUROPE_WEST10 = "europe-west10", // Berlin + EUROPE_NORTH1 = "europe-north1", // Finland + EUROPE_NORTH2 = "europe-north2", // Stockholm + EUROPE_WEST3 = "europe-west3", // Frankfurt + EUROPE_WEST2 = "europe-west2", // London + EUROPE_SOUTHWEST1 = "europe-southwest1", // Madrid + EUROPE_WEST8 = "europe-west8", // Milan + EUROPE_WEST4 = "europe-west4", // Netherlands + EUROPE_WEST12 = "europe-west12", // Turin + EUROPE_WEST9 = "europe-west9", // Paris + EUROPE_CENTRAL2 = "europe-central2", // Warsaw + EUROPE_WEST6 = "europe-west6", // Zurich + + // North America + US_CENTRAL1 = "us-central1", // Iowa + US_WEST4 = "us-west4", // Las Vegas + US_WEST2 = "us-west2", // Los Angeles + NORTHAMERICA_SOUTH1 = "northamerica-south1", // Mexico + NORTHAMERICA_NORTHEAST1 = "northamerica-northeast1", // Montréal + US_EAST4 = "us-east4", // Northern Virginia + US_CENTRAL2 = "us-central2", // Oklahoma + US_WEST1 = "us-west1", // Oregon + US_WEST3 = "us-west3", // Salt Lake City + US_EAST1 = "us-east1", // South Carolina + NORTHAMERICA_NORTHEAST2 = "northamerica-northeast2", // Toronto + US_EAST5 = "us-east5", // Columbus + US_SOUTH1 = "us-south1", // Dallas + US_WEST8 = "us-west8", // Phoenix + + // South America + SOUTHAMERICA_EAST1 = "southamerica-east1", // São Paulo + SOUTHAMERICA_WEST1 = "southamerica-west1", // Santiago + + // Middle East + ME_CENTRAL2 = "me-central2", // Dammam + ME_CENTRAL1 = "me-central1", // Doha + ME_WEST1 = "me-west1", // Tel Aviv + + // Africa + AFRICA_SOUTH1 = "africa-south1" // Johannesburg } diff --git a/backend/src/services/secret-sync/gcp/gcp-sync-fns.ts b/backend/src/services/secret-sync/gcp/gcp-sync-fns.ts index a71e29ae4..d51383fef 100644 --- a/backend/src/services/secret-sync/gcp/gcp-sync-fns.ts +++ b/backend/src/services/secret-sync/gcp/gcp-sync-fns.ts @@ -4,6 +4,8 @@ import { request } from "@app/lib/config/request"; import { logger } from "@app/lib/logger"; import { getGcpConnectionAuthToken } from "@app/services/app-connection/gcp"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { GcpSyncScope } from "@app/services/secret-sync/gcp/gcp-sync-enums"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { SecretSyncError } from "../secret-sync-errors"; import { TSecretMap } from "../secret-sync-types"; @@ -14,9 +16,17 @@ import { TGcpSyncWithCredentials } from "./gcp-sync-types"; -const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCredentials) => { +const getProjectUrl = (secretSync: TGcpSyncWithCredentials) => { const { destinationConfig } = secretSync; + if (destinationConfig.scope === GcpSyncScope.Global) { + return `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}`; + } + + return `https://secretmanager.${destinationConfig.locationId}.rep.googleapis.com/v1/projects/${destinationConfig.projectId}/locations/${destinationConfig.locationId}`; +}; + +const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCredentials) => { let gcpSecrets: GCPSecret[] = []; const pageSize = 100; @@ -30,16 +40,13 @@ const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCreden }); // eslint-disable-next-line no-await-in-loop - const { data: secretsRes } = await request.get( - `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${secretSync.destinationConfig.projectId}/secrets`, - { - params, - headers: { - Authorization: `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" - } + const { data: secretsRes } = await request.get(`${getProjectUrl(secretSync)}/secrets`, { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" } - ); + }); if (secretsRes.secrets) { gcpSecrets = gcpSecrets.concat(secretsRes.secrets); @@ -60,7 +67,7 @@ const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCreden try { const { data: secretLatest } = await request.get( - `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}/versions/latest:access`, + `${getProjectUrl(secretSync)}/secrets/${key}/versions/latest:access`, { headers: { Authorization: `Bearer ${accessToken}`, @@ -112,11 +119,14 @@ export const GcpSyncFns = { if (!(key in gcpSecrets)) { // case: create secret await request.post( - `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets`, + `${getProjectUrl(secretSync)}/secrets`, { - replication: { - automatic: {} - } + replication: + destinationConfig.scope === GcpSyncScope.Global + ? { + automatic: {} + } + : undefined }, { params: { @@ -130,7 +140,7 @@ export const GcpSyncFns = { ); await request.post( - `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}:addVersion`, + `${getProjectUrl(secretSync)}/secrets/${key}:addVersion`, { payload: { data: Buffer.from(secretMap[key].value).toString("base64") @@ -153,21 +163,21 @@ export const GcpSyncFns = { } for await (const key of Object.keys(gcpSecrets)) { + // eslint-disable-next-line no-continue + if (!matchesSchema(key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue; + try { if (!(key in secretMap) || !secretMap[key].value) { // eslint-disable-next-line no-continue if (secretSync.syncOptions.disableSecretDeletion) continue; // case: delete secret - await request.delete( - `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" - } + await request.delete(`${getProjectUrl(secretSync)}/secrets/${key}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" } - ); + }); } else if (secretMap[key].value !== gcpSecrets[key]) { if (!secretMap[key].value) { logger.warn( @@ -176,7 +186,7 @@ export const GcpSyncFns = { } await request.post( - `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}:addVersion`, + `${getProjectUrl(secretSync)}/secrets/${key}:addVersion`, { payload: { data: Buffer.from(secretMap[key].value).toString("base64") @@ -208,21 +218,18 @@ export const GcpSyncFns = { }, removeSecrets: async (secretSync: TGcpSyncWithCredentials, secretMap: TSecretMap) => { - const { destinationConfig, connection } = secretSync; + const { connection } = secretSync; const accessToken = await getGcpConnectionAuthToken(connection); const gcpSecrets = await getGcpSecrets(accessToken, secretSync); for await (const [key] of Object.entries(gcpSecrets)) { if (key in secretMap) { - await request.delete( - `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" - } + await request.delete(`${getProjectUrl(secretSync)}/secrets/${key}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" } - ); + }); } } } diff --git a/backend/src/services/secret-sync/gcp/gcp-sync-schemas.ts b/backend/src/services/secret-sync/gcp/gcp-sync-schemas.ts index 0643c431a..875ceaf70 100644 --- a/backend/src/services/secret-sync/gcp/gcp-sync-schemas.ts +++ b/backend/src/services/secret-sync/gcp/gcp-sync-schemas.ts @@ -10,14 +10,33 @@ import { import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; import { SecretSync } from "../secret-sync-enums"; -import { GcpSyncScope } from "./gcp-sync-enums"; +import { GCPSecretManagerLocation, GcpSyncScope } from "./gcp-sync-enums"; const GcpSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; -const GcpSyncDestinationConfigSchema = z.object({ - scope: z.literal(GcpSyncScope.Global).describe(SecretSyncs.DESTINATION_CONFIG.GCP.scope), - projectId: z.string().min(1, "Project ID is required").describe(SecretSyncs.DESTINATION_CONFIG.GCP.projectId) -}); +const GcpSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ + z + .object({ + scope: z.literal(GcpSyncScope.Global).describe(SecretSyncs.DESTINATION_CONFIG.GCP.scope), + projectId: z.string().min(1, "Project ID is required").describe(SecretSyncs.DESTINATION_CONFIG.GCP.projectId) + }) + .describe( + JSON.stringify({ + title: "Global" + }) + ), + z + .object({ + scope: z.literal(GcpSyncScope.Region).describe(SecretSyncs.DESTINATION_CONFIG.GCP.scope), + projectId: z.string().min(1, "Project ID is required").describe(SecretSyncs.DESTINATION_CONFIG.GCP.projectId), + locationId: z.nativeEnum(GCPSecretManagerLocation).describe(SecretSyncs.DESTINATION_CONFIG.GCP.locationId) + }) + .describe( + JSON.stringify({ + title: "Region" + }) + ) +]); export const GcpSyncSchema = BaseSecretSyncSchema(SecretSync.GCPSecretManager, GcpSyncOptionsConfig).extend({ destination: z.literal(SecretSync.GCPSecretManager), diff --git a/backend/src/services/secret-sync/github/github-sync-fns.ts b/backend/src/services/secret-sync/github/github-sync-fns.ts index 1fe922de5..f06f0cfc2 100644 --- a/backend/src/services/secret-sync/github/github-sync-fns.ts +++ b/backend/src/services/secret-sync/github/github-sync-fns.ts @@ -4,6 +4,7 @@ import sodium from "libsodium-wrappers"; import { getGitHubClient } from "@app/services/app-connection/github"; import { GitHubSyncScope, GitHubSyncVisibility } from "@app/services/secret-sync/github/github-sync-enums"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; @@ -222,6 +223,10 @@ export const GithubSyncFns = { if (secretSync.syncOptions.disableSecretDeletion) return; for await (const encryptedSecret of encryptedSecrets) { + if (!matchesSchema(encryptedSecret.name, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) + // eslint-disable-next-line no-continue + continue; + if (!(encryptedSecret.name in secretMap)) { await deleteSecret(client, secretSync, encryptedSecret); } diff --git a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts index db35df292..724eec7be 100644 --- a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts +++ b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts @@ -11,6 +11,7 @@ import { TPostHCVaultVariable } from "@app/services/secret-sync/hc-vault/hc-vault-sync-types"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; const listHCVaultVariables = async ({ instanceUrl, namespace, mount, accessToken, path }: THCVaultListVariables) => { @@ -67,8 +68,9 @@ export const HCVaultSyncFns = { syncSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => { const { connection, + environment, destinationConfig: { mount, path }, - syncOptions: { disableSecretDeletion } + syncOptions: { disableSecretDeletion, keySchema } } = secretSync; const { namespace } = connection.credentials; @@ -95,6 +97,9 @@ export const HCVaultSyncFns = { if (disableSecretDeletion) return; for await (const [key] of Object.entries(variables)) { + // eslint-disable-next-line no-continue + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + if (!(key in secretMap)) { delete variables[key]; tainted = true; diff --git a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-schemas.ts b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-schemas.ts index d0f2a9f65..f9096ac71 100644 --- a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-schemas.ts +++ b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-schemas.ts @@ -16,12 +16,14 @@ const HCVaultSyncDestinationConfigSchema = z.object({ .string() .trim() .min(1, "Secrets Engine Mount required") + .max(128) .describe(SecretSyncs.DESTINATION_CONFIG.HC_VAULT.mount), path: z .string() .trim() .min(1, "Path required") - .transform((val) => val.replace(/^\/+|\/+$/g, "")) // removes leading/trailing slashes + .max(128) + .transform((val) => new RE2("^/+|/+$", "g").replace(val, "")) // removes leading/trailing slashes .refine((val) => new RE2("^([a-zA-Z0-9._-]+/)*[a-zA-Z0-9._-]+$").test(val), { message: "Invalid Vault path format. Use alphanumerics, dots, dashes, underscores, and single slashes between segments." diff --git a/backend/src/services/secret-sync/humanitec/humanitec-sync-fns.ts b/backend/src/services/secret-sync/humanitec/humanitec-sync-fns.ts index 5fa0a3d63..ccb6ac2bc 100644 --- a/backend/src/services/secret-sync/humanitec/humanitec-sync-fns.ts +++ b/backend/src/services/secret-sync/humanitec/humanitec-sync-fns.ts @@ -2,6 +2,7 @@ import { request } from "@app/lib/config/request"; import { logger } from "@app/lib/logger"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; @@ -199,6 +200,10 @@ export const HumanitecSyncFns = { if (secretSync.syncOptions.disableSecretDeletion) return; for await (const humanitecSecret of humanitecSecrets) { + if (!matchesSchema(humanitecSecret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) + // eslint-disable-next-line no-continue + continue; + if (!secretMap[humanitecSecret.key]) { await deleteSecret(secretSync, humanitecSecret); } diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 9d59ebb76..24f7d05f8 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -12,7 +12,9 @@ export enum SecretSync { Vercel = "vercel", Windmill = "windmill", HCVault = "hashicorp-vault", - TeamCity = "teamcity" + TeamCity = "teamcity", + OCIVault = "oci-vault", + OnePass = "1password" } export enum SecretSyncInitialSyncBehavior { @@ -25,3 +27,8 @@ export enum SecretSyncImportBehavior { PrioritizeSource = "prioritize-source", PrioritizeDestination = "prioritize-destination" } + +export enum SecretSyncPlanType { + Enterprise = "enterprise", + Regular = "regular" +} diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 5749852d7..ba3a79c50 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -1,5 +1,9 @@ import { AxiosError } from "axios"; +import handlebars from "handlebars"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OCI_VAULT_SYNC_LIST_OPTION, OCIVaultSyncFns } from "@app/ee/services/secret-sync/oci-vault"; +import { BadRequestError } from "@app/lib/errors"; import { AWS_PARAMETER_STORE_SYNC_LIST_OPTION, AwsParameterStoreSyncFns @@ -10,7 +14,7 @@ import { } from "@app/services/secret-sync/aws-secrets-manager"; import { DATABRICKS_SYNC_LIST_OPTION, databricksSyncFactory } from "@app/services/secret-sync/databricks"; import { GITHUB_SYNC_LIST_OPTION, GithubSyncFns } from "@app/services/secret-sync/github"; -import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { SecretSync, SecretSyncPlanType } from "@app/services/secret-sync/secret-sync-enums"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; import { TSecretMap, @@ -20,6 +24,7 @@ import { import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; +import { ONEPASS_SYNC_LIST_OPTION, OnePassSyncFns } from "./1password"; import { AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION, azureAppConfigurationSyncFactory } from "./azure-app-configuration"; import { AZURE_KEY_VAULT_SYNC_LIST_OPTION, azureKeyVaultSyncFactory } from "./azure-key-vault"; import { CAMUNDA_SYNC_LIST_OPTION, camundaSyncFactory } from "./camunda"; @@ -28,6 +33,7 @@ import { GcpSyncFns } from "./gcp/gcp-sync-fns"; import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; +import { SECRET_SYNC_PLAN_MAP } from "./secret-sync-maps"; import { TEAMCITY_SYNC_LIST_OPTION, TeamCitySyncFns } from "./teamcity"; import { TERRAFORM_CLOUD_SYNC_LIST_OPTION, TerraformCloudSyncFns } from "./terraform-cloud"; import { VERCEL_SYNC_LIST_OPTION, VercelSyncFns } from "./vercel"; @@ -47,7 +53,9 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.Vercel]: VERCEL_SYNC_LIST_OPTION, [SecretSync.Windmill]: WINDMILL_SYNC_LIST_OPTION, [SecretSync.HCVault]: HC_VAULT_SYNC_LIST_OPTION, - [SecretSync.TeamCity]: TEAMCITY_SYNC_LIST_OPTION + [SecretSync.TeamCity]: TEAMCITY_SYNC_LIST_OPTION, + [SecretSync.OCIVault]: OCI_VAULT_SYNC_LIST_OPTION, + [SecretSync.OnePass]: ONEPASS_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -59,45 +67,93 @@ type TSyncSecretDeps = { kmsService: Pick; }; -// const addAffixes = (secretSync: TSecretSyncWithCredentials, unprocessedSecretMap: TSecretMap) => { -// let secretMap = { ...unprocessedSecretMap }; -// -// const { appendSuffix, prependPrefix } = secretSync.syncOptions; -// -// if (appendSuffix || prependPrefix) { -// secretMap = {}; -// Object.entries(unprocessedSecretMap).forEach(([key, value]) => { -// secretMap[`${prependPrefix || ""}${key}${appendSuffix || ""}`] = value; -// }); -// } -// -// return secretMap; -// }; -// -// const stripAffixes = (secretSync: TSecretSyncWithCredentials, unprocessedSecretMap: TSecretMap) => { -// let secretMap = { ...unprocessedSecretMap }; -// -// const { appendSuffix, prependPrefix } = secretSync.syncOptions; -// -// if (appendSuffix || prependPrefix) { -// secretMap = {}; -// Object.entries(unprocessedSecretMap).forEach(([key, value]) => { -// let processedKey = key; -// -// if (prependPrefix && processedKey.startsWith(prependPrefix)) { -// processedKey = processedKey.slice(prependPrefix.length); -// } -// -// if (appendSuffix && processedKey.endsWith(appendSuffix)) { -// processedKey = processedKey.slice(0, -appendSuffix.length); -// } -// -// secretMap[processedKey] = value; -// }); -// } -// -// return secretMap; -// }; +// Add schema to secret keys +const addSchema = (unprocessedSecretMap: TSecretMap, environment: string, schema?: string): TSecretMap => { + if (!schema) return unprocessedSecretMap; + + const processedSecretMap: TSecretMap = {}; + + for (const [key, value] of Object.entries(unprocessedSecretMap)) { + const newKey = handlebars.compile(schema)({ + secretKey: key, + environment + }); + + processedSecretMap[newKey] = value; + } + + return processedSecretMap; +}; + +// Strip schema from secret keys +const stripSchema = (unprocessedSecretMap: TSecretMap, environment: string, schema?: string): TSecretMap => { + if (!schema) return unprocessedSecretMap; + + const compiledSchemaPattern = handlebars.compile(schema)({ + secretKey: "{{secretKey}}", // Keep secretKey + environment + }); + + const parts = compiledSchemaPattern.split("{{secretKey}}"); + const prefix = parts[0]; + const suffix = parts[parts.length - 1]; + + const strippedMap: TSecretMap = {}; + + for (const [key, value] of Object.entries(unprocessedSecretMap)) { + if (!key.startsWith(prefix) || !key.endsWith(suffix)) { + // eslint-disable-next-line no-continue + continue; + } + + const strippedKey = key.slice(prefix.length, key.length - suffix.length); + strippedMap[strippedKey] = value; + } + + return strippedMap; +}; + +// Checks if a key matches a schema +export const matchesSchema = (key: string, environment: string, schema?: string): boolean => { + if (!schema) return true; + + const compiledSchemaPattern = handlebars.compile(schema)({ + secretKey: "{{secretKey}}", // Keep secretKey + environment + }); + + // This edge-case shouldn't be possible + if (!compiledSchemaPattern.includes("{{secretKey}}")) { + return key === compiledSchemaPattern; + } + + const parts = compiledSchemaPattern.split("{{secretKey}}"); + const prefix = parts[0]; + const suffix = parts[parts.length - 1]; + + if (prefix === "" && suffix === "") return true; + + // If prefix is empty, key must end with suffix + if (prefix === "") return key.endsWith(suffix); + + // If suffix is empty, key must start with prefix + if (suffix === "") return key.startsWith(prefix); + + return key.startsWith(prefix) && key.endsWith(suffix) && key.length >= prefix.length + suffix.length; +}; + +// Filter only for secrets with keys that match the schema +const filterForSchema = (secretMap: TSecretMap, environment: string, schema?: string): TSecretMap => { + const filteredMap: TSecretMap = {}; + + for (const [key, value] of Object.entries(secretMap)) { + if (matchesSchema(key, environment, schema)) { + filteredMap[key] = value; + } + } + + return filteredMap; +}; export const SecretSyncFns = { syncSecrets: ( @@ -105,49 +161,53 @@ export const SecretSyncFns = { secretMap: TSecretMap, { kmsService, appConnectionDAL }: TSyncSecretDeps ): Promise => { - // const affixedSecretMap = addAffixes(secretSync, secretMap); + const schemaSecretMap = addSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema); switch (secretSync.destination) { case SecretSync.AWSParameterStore: - return AwsParameterStoreSyncFns.syncSecrets(secretSync, secretMap); + return AwsParameterStoreSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.AWSSecretsManager: - return AwsSecretsManagerSyncFns.syncSecrets(secretSync, secretMap); + return AwsSecretsManagerSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.GitHub: - return GithubSyncFns.syncSecrets(secretSync, secretMap); + return GithubSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.GCPSecretManager: - return GcpSyncFns.syncSecrets(secretSync, secretMap); + return GcpSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.AzureKeyVault: return azureKeyVaultSyncFactory({ appConnectionDAL, kmsService - }).syncSecrets(secretSync, secretMap); + }).syncSecrets(secretSync, schemaSecretMap); case SecretSync.AzureAppConfiguration: return azureAppConfigurationSyncFactory({ appConnectionDAL, kmsService - }).syncSecrets(secretSync, secretMap); + }).syncSecrets(secretSync, schemaSecretMap); case SecretSync.Databricks: return databricksSyncFactory({ appConnectionDAL, kmsService - }).syncSecrets(secretSync, secretMap); + }).syncSecrets(secretSync, schemaSecretMap); case SecretSync.Humanitec: - return HumanitecSyncFns.syncSecrets(secretSync, secretMap); + return HumanitecSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.TerraformCloud: - return TerraformCloudSyncFns.syncSecrets(secretSync, secretMap); + return TerraformCloudSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Camunda: return camundaSyncFactory({ appConnectionDAL, kmsService - }).syncSecrets(secretSync, secretMap); + }).syncSecrets(secretSync, schemaSecretMap); case SecretSync.Vercel: - return VercelSyncFns.syncSecrets(secretSync, secretMap); + return VercelSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Windmill: - return WindmillSyncFns.syncSecrets(secretSync, secretMap); + return WindmillSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.HCVault: - return HCVaultSyncFns.syncSecrets(secretSync, secretMap); + return HCVaultSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.TeamCity: - return TeamCitySyncFns.syncSecrets(secretSync, secretMap); + return TeamCitySyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.OCIVault: + return OCIVaultSyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.OnePass: + return OnePassSyncFns.syncSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -213,63 +273,74 @@ export const SecretSyncFns = { case SecretSync.TeamCity: secretMap = await TeamCitySyncFns.getSecrets(secretSync); break; + case SecretSync.OCIVault: + secretMap = await OCIVaultSyncFns.getSecrets(secretSync); + break; + case SecretSync.OnePass: + secretMap = await OnePassSyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` ); } - return secretMap; - // return stripAffixes(secretSync, secretMap); + const filtered = filterForSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema); + const stripped = stripSchema(filtered, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema); + return stripped; }, removeSecrets: ( secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap, { kmsService, appConnectionDAL }: TSyncSecretDeps ): Promise => { - // const affixedSecretMap = addAffixes(secretSync, secretMap); + const schemaSecretMap = addSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema); switch (secretSync.destination) { case SecretSync.AWSParameterStore: - return AwsParameterStoreSyncFns.removeSecrets(secretSync, secretMap); + return AwsParameterStoreSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.AWSSecretsManager: - return AwsSecretsManagerSyncFns.removeSecrets(secretSync, secretMap); + return AwsSecretsManagerSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.GitHub: - return GithubSyncFns.removeSecrets(secretSync, secretMap); + return GithubSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.GCPSecretManager: - return GcpSyncFns.removeSecrets(secretSync, secretMap); + return GcpSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.AzureKeyVault: return azureKeyVaultSyncFactory({ appConnectionDAL, kmsService - }).removeSecrets(secretSync, secretMap); + }).removeSecrets(secretSync, schemaSecretMap); case SecretSync.AzureAppConfiguration: return azureAppConfigurationSyncFactory({ appConnectionDAL, kmsService - }).removeSecrets(secretSync, secretMap); + }).removeSecrets(secretSync, schemaSecretMap); case SecretSync.Databricks: return databricksSyncFactory({ appConnectionDAL, kmsService - }).removeSecrets(secretSync, secretMap); + }).removeSecrets(secretSync, schemaSecretMap); case SecretSync.Humanitec: - return HumanitecSyncFns.removeSecrets(secretSync, secretMap); + return HumanitecSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.TerraformCloud: - return TerraformCloudSyncFns.removeSecrets(secretSync, secretMap); + return TerraformCloudSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Camunda: return camundaSyncFactory({ appConnectionDAL, kmsService - }).removeSecrets(secretSync, secretMap); + }).removeSecrets(secretSync, schemaSecretMap); case SecretSync.Vercel: - return VercelSyncFns.removeSecrets(secretSync, secretMap); + return VercelSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Windmill: - return WindmillSyncFns.removeSecrets(secretSync, secretMap); + return WindmillSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.HCVault: - return HCVaultSyncFns.removeSecrets(secretSync, secretMap); + return HCVaultSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.TeamCity: - return TeamCitySyncFns.removeSecrets(secretSync, secretMap); + return TeamCitySyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.OCIVault: + return OCIVaultSyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.OnePass: + return OnePassSyncFns.removeSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -300,3 +371,18 @@ export const parseSyncErrorMessage = (err: unknown): string => { ? errorMessage : `${errorMessage.substring(0, MAX_MESSAGE_LENGTH - 3)}...`; }; + +export const enterpriseSyncCheck = async ( + licenseService: Pick, + secretSync: SecretSync, + orgId: string, + errorMessage: string +) => { + if (SECRET_SYNC_PLAN_MAP[secretSync] === SecretSyncPlanType.Enterprise) { + const plan = await licenseService.getPlan(orgId); + if (!plan.enterpriseSecretSyncs) + throw new BadRequestError({ + message: errorMessage + }); + } +}; diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index c6d7adc8c..832c15bf8 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -1,5 +1,5 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums"; -import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { SecretSync, SecretSyncPlanType } from "@app/services/secret-sync/secret-sync-enums"; export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.AWSParameterStore]: "AWS Parameter Store", @@ -15,7 +15,9 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Vercel]: "Vercel", [SecretSync.Windmill]: "Windmill", [SecretSync.HCVault]: "Hashicorp Vault", - [SecretSync.TeamCity]: "TeamCity" + [SecretSync.TeamCity]: "TeamCity", + [SecretSync.OCIVault]: "OCI Vault", + [SecretSync.OnePass]: "1Password" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -32,5 +34,26 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Vercel]: AppConnection.Vercel, [SecretSync.Windmill]: AppConnection.Windmill, [SecretSync.HCVault]: AppConnection.HCVault, - [SecretSync.TeamCity]: AppConnection.TeamCity + [SecretSync.TeamCity]: AppConnection.TeamCity, + [SecretSync.OCIVault]: AppConnection.OCI, + [SecretSync.OnePass]: AppConnection.OnePass +}; + +export const SECRET_SYNC_PLAN_MAP: Record = { + [SecretSync.AWSParameterStore]: SecretSyncPlanType.Regular, + [SecretSync.AWSSecretsManager]: SecretSyncPlanType.Regular, + [SecretSync.GitHub]: SecretSyncPlanType.Regular, + [SecretSync.GCPSecretManager]: SecretSyncPlanType.Regular, + [SecretSync.AzureKeyVault]: SecretSyncPlanType.Regular, + [SecretSync.AzureAppConfiguration]: SecretSyncPlanType.Regular, + [SecretSync.Databricks]: SecretSyncPlanType.Regular, + [SecretSync.Humanitec]: SecretSyncPlanType.Regular, + [SecretSync.TerraformCloud]: SecretSyncPlanType.Regular, + [SecretSync.Camunda]: SecretSyncPlanType.Regular, + [SecretSync.Vercel]: SecretSyncPlanType.Regular, + [SecretSync.Windmill]: SecretSyncPlanType.Regular, + [SecretSync.HCVault]: SecretSyncPlanType.Regular, + [SecretSync.TeamCity]: SecretSyncPlanType.Regular, + [SecretSync.OCIVault]: SecretSyncPlanType.Enterprise, + [SecretSync.OnePass]: SecretSyncPlanType.Regular }; diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index 62b4ba3cc..6f627c24e 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -5,6 +5,7 @@ import { Job } from "bullmq"; import { ProjectMembershipRole, SecretType } from "@app/db/schemas"; import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { EventType } 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"; @@ -32,7 +33,7 @@ import { SecretSyncInitialSyncBehavior } from "@app/services/secret-sync/secret-sync-enums"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; -import { parseSyncErrorMessage, SecretSyncFns } from "@app/services/secret-sync/secret-sync-fns"; +import { enterpriseSyncCheck, parseSyncErrorMessage, SecretSyncFns } from "@app/services/secret-sync/secret-sync-fns"; import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps"; import { SecretSyncAction, @@ -93,6 +94,7 @@ type TSecretSyncQueueFactoryDep = { secretVersionV2BridgeDAL: Pick; secretVersionTagV2BridgeDAL: Pick; resourceMetadataDAL: Pick; + licenseService: Pick; }; type SecretSyncActionJob = Job< @@ -133,7 +135,8 @@ export const secretSyncQueueFactory = ({ secretVersionTagDAL, secretVersionV2BridgeDAL, secretVersionTagV2BridgeDAL, - resourceMetadataDAL + resourceMetadataDAL, + licenseService }: TSecretSyncQueueFactoryDep) => { const appCfg = getConfig(); @@ -323,7 +326,20 @@ export const secretSyncQueueFactory = ({ secretSync: TSecretSyncWithCredentials, importBehavior: SecretSyncImportBehavior ): Promise => { - const { projectId, environment, folder } = secretSync; + const { + projectId, + environment, + folder, + destination, + connection: { orgId } + } = secretSync; + + await enterpriseSyncCheck( + licenseService, + destination, + orgId, + "Failed to import secrets due to plan restriction. Upgrade plan to access enterprise secret syncs." + ); if (!environment || !folder) throw new Error( @@ -400,6 +416,13 @@ export const secretSyncQueueFactory = ({ if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`); + await enterpriseSyncCheck( + licenseService, + secretSync.destination as SecretSync, + secretSync.connection.orgId, + "Failed to sync secrets due to plan restriction. Upgrade plan to access enterprise secret syncs." + ); + await secretSyncDAL.updateById(syncId, { syncStatus: SecretSyncStatus.Running }); @@ -659,6 +682,13 @@ export const secretSyncQueueFactory = ({ if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`); + await enterpriseSyncCheck( + licenseService, + secretSync.destination as SecretSync, + secretSync.connection.orgId, + "Failed to remove secrets due to plan restriction. Upgrade plan to access enterprise secret syncs." + ); + await secretSyncDAL.updateById(syncId, { removeStatus: SecretSyncStatus.Running }); diff --git a/backend/src/services/secret-sync/secret-sync-schemas.ts b/backend/src/services/secret-sync/secret-sync-schemas.ts index 50ff3f307..3622ef3d0 100644 --- a/backend/src/services/secret-sync/secret-sync-schemas.ts +++ b/backend/src/services/secret-sync/secret-sync-schemas.ts @@ -1,3 +1,4 @@ +import RE2 from "re2"; import { AnyZodObject, z } from "zod"; import { SecretSyncsSchema } from "@app/db/schemas/secret-syncs"; @@ -24,6 +25,34 @@ const BaseSyncOptionsSchema = ({ ? z.nativeEnum(SecretSyncInitialSyncBehavior) : z.literal(SecretSyncInitialSyncBehavior.OverwriteDestination) ).describe(SecretSyncs.SYNC_OPTIONS(destination).initialSyncBehavior), + keySchema: z + .string() + .optional() + .refine( + (val) => { + if (!val) return true; + + const allowedOptionalPlaceholders = ["{{environment}}"]; + + const allowedPlaceholdersRegexPart = ["{{secretKey}}", ...allowedOptionalPlaceholders] + .map((p) => p.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")) // Escape regex special characters + .join("|"); + + const allowedContentRegex = new RE2(`^([a-zA-Z0-9_\\-/]|${allowedPlaceholdersRegexPart})*$`); + const contentIsValid = allowedContentRegex.test(val); + + // Check if {{secretKey}} is present + const secretKeyRegex = new RE2(/\{\{secretKey\}\}/); + const secretKeyIsPresent = secretKeyRegex.test(val); + + return contentIsValid && secretKeyIsPresent; + }, + { + message: + "Key schema must include exactly one {{secretKey}} placeholder. It can also include {{environment}} placeholders. Only alphanumeric characters (a-z, A-Z, 0-9), dashes (-), underscores (_), and slashes (/) are allowed besides the placeholders." + } + ) + .describe(SecretSyncs.SYNC_OPTIONS(destination).keySchema), disableSecretDeletion: z.boolean().optional().describe(SecretSyncs.SYNC_OPTIONS(destination).disableSecretDeletion) }); diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index db350f785..e7751d3f9 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -1,6 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import { ActionProjectType } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { throwIfMissingSecretReadValueOrDescribePermission } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { @@ -16,7 +17,7 @@ import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-c import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; -import { listSecretSyncOptions } from "@app/services/secret-sync/secret-sync-fns"; +import { enterpriseSyncCheck, listSecretSyncOptions } from "@app/services/secret-sync/secret-sync-fns"; import { SecretSyncStatus, TCreateSecretSyncDTO, @@ -49,6 +50,7 @@ type TSecretSyncServiceFactoryDep = { TSecretSyncQueueFactory, "queueSecretSyncSyncSecretsById" | "queueSecretSyncImportSecretsById" | "queueSecretSyncRemoveSecretsById" >; + licenseService: Pick; }; export type TSecretSyncServiceFactory = ReturnType; @@ -61,7 +63,8 @@ export const secretSyncServiceFactory = ({ appConnectionService, projectBotService, secretSyncQueue, - keyStore + keyStore, + licenseService }: TSecretSyncServiceFactoryDep) => { const listSecretSyncsByProjectId = async ( { projectId, destination }: TListSecretSyncsByProjectId, @@ -191,6 +194,13 @@ export const secretSyncServiceFactory = ({ { projectId, secretPath, environment, ...params }: TCreateSecretSyncDTO, actor: OrgServiceActor ) => { + await enterpriseSyncCheck( + licenseService, + params.destination, + actor.orgId, + "Failed to create secret sync due to plan restriction. Upgrade plan to access enterprise secret syncs." + ); + const { permission: projectPermission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, @@ -260,6 +270,13 @@ export const secretSyncServiceFactory = ({ message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID ${syncId}` }); + await enterpriseSyncCheck( + licenseService, + secretSync.destination as SecretSync, + actor.orgId, + "Failed to update secret sync due to plan restriction. Upgrade plan to access enterprise secret syncs." + ); + const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, @@ -408,6 +425,13 @@ export const secretSyncServiceFactory = ({ message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"` }); + await enterpriseSyncCheck( + licenseService, + secretSync.destination as SecretSync, + actor.orgId, + "Failed to trigger secret sync due to plan restriction. Upgrade plan to access enterprise secret syncs." + ); + const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, @@ -463,6 +487,13 @@ export const secretSyncServiceFactory = ({ message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"` }); + await enterpriseSyncCheck( + licenseService, + secretSync.destination as SecretSync, + actor.orgId, + "Failed to trigger secret sync due to plan restriction. Upgrade plan to access enterprise secret syncs." + ); + const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, @@ -512,6 +543,13 @@ export const secretSyncServiceFactory = ({ message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"` }); + await enterpriseSyncCheck( + licenseService, + secretSync.destination as SecretSync, + actor.orgId, + "Failed to trigger secret sync due to plan restriction. Upgrade plan to access enterprise secret syncs." + ); + const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index e88174cc6..22f7848ad 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -1,6 +1,12 @@ import { Job } from "bullmq"; import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types"; +import { + TOCIVaultSync, + TOCIVaultSyncInput, + TOCIVaultSyncListItem, + TOCIVaultSyncWithCredentials +} from "@app/ee/services/secret-sync/oci-vault"; import { QueueJobs } from "@app/queue"; import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema"; import { @@ -36,6 +42,12 @@ import { TWindmillSyncWithCredentials } from "@app/services/secret-sync/windmill"; +import { + TOnePassSync, + TOnePassSyncInput, + TOnePassSyncListItem, + TOnePassSyncWithCredentials +} from "./1password/1password-sync-types"; import { TAwsParameterStoreSync, TAwsParameterStoreSyncInput, @@ -95,7 +107,9 @@ export type TSecretSync = | TVercelSync | TWindmillSync | THCVaultSync - | TTeamCitySync; + | TTeamCitySync + | TOCIVaultSync + | TOnePassSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -111,7 +125,9 @@ export type TSecretSyncWithCredentials = | TVercelSyncWithCredentials | TWindmillSyncWithCredentials | THCVaultSyncWithCredentials - | TTeamCitySyncWithCredentials; + | TTeamCitySyncWithCredentials + | TOCIVaultSyncWithCredentials + | TOnePassSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -127,7 +143,9 @@ export type TSecretSyncInput = | TVercelSyncInput | TWindmillSyncInput | THCVaultSyncInput - | TTeamCitySyncInput; + | TTeamCitySyncInput + | TOCIVaultSyncInput + | TOnePassSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -143,7 +161,9 @@ export type TSecretSyncListItem = | TVercelSyncListItem | TWindmillSyncListItem | THCVaultSyncListItem - | TTeamCitySyncListItem; + | TTeamCitySyncListItem + | TOCIVaultSyncListItem + | TOnePassSyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts b/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts index 6dbd9bdd7..28ef2d0d3 100644 --- a/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts +++ b/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts @@ -1,6 +1,7 @@ import { request } from "@app/lib/config/request"; import { getTeamCityInstanceUrl } from "@app/services/app-connection/teamcity"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; import { TDeleteTeamCityVariable, @@ -125,6 +126,9 @@ export const TeamCitySyncFns = { const variables = await listTeamCityVariables({ instanceUrl, accessToken, project, buildConfig }); for await (const [key, variable] of Object.entries(variables)) { + // eslint-disable-next-line no-continue + if (!matchesSchema(key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue; + if (!(key in secretMap)) { try { await deleteTeamCityVariable({ diff --git a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts index 4cfd7ec05..cb546ba63 100644 --- a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts +++ b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts @@ -4,6 +4,7 @@ import { AxiosResponse } from "axios"; import { request } from "@app/lib/config/request"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; @@ -231,6 +232,12 @@ export const TerraformCloudSyncFns = { if (secretSync.syncOptions.disableSecretDeletion) return; for (const terraformCloudVariable of terraformCloudVariables) { + if ( + !matchesSchema(terraformCloudVariable.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema) + ) + // eslint-disable-next-line no-continue + continue; + if (!Object.prototype.hasOwnProperty.call(secretMap, terraformCloudVariable.key)) { await deleteVariable(secretSync, terraformCloudVariable); } diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts b/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts index 713971283..b5ea98265 100644 --- a/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts +++ b/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts @@ -2,6 +2,7 @@ import { request } from "@app/lib/config/request"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; import { VercelEnvironmentType } from "./vercel-sync-enums"; @@ -290,6 +291,10 @@ export const VercelSyncFns = { if (secretSync.syncOptions.disableSecretDeletion) return; for await (const vercelSecret of vercelSecrets) { + if (!matchesSchema(vercelSecret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) + // eslint-disable-next-line no-continue + continue; + if (!secretMap[vercelSecret.key]) { await deleteSecret(secretSync, vercelSecret); } diff --git a/backend/src/services/secret-sync/windmill/windmill-sync-fns.ts b/backend/src/services/secret-sync/windmill/windmill-sync-fns.ts index 2e2c36740..b5e11c957 100644 --- a/backend/src/services/secret-sync/windmill/windmill-sync-fns.ts +++ b/backend/src/services/secret-sync/windmill/windmill-sync-fns.ts @@ -1,6 +1,7 @@ import { request } from "@app/lib/config/request"; import { getWindmillInstanceUrl } from "@app/services/app-connection/windmill"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { TDeleteWindmillVariable, TPostWindmillVariable, @@ -127,8 +128,9 @@ export const WindmillSyncFns = { syncSecrets: async (secretSync: TWindmillSyncWithCredentials, secretMap: TSecretMap) => { const { connection, + environment, destinationConfig: { path }, - syncOptions: { disableSecretDeletion } + syncOptions: { disableSecretDeletion, keySchema } } = secretSync; // url needs to be lowercase @@ -169,6 +171,9 @@ export const WindmillSyncFns = { if (disableSecretDeletion) return; for await (const [key, variable] of Object.entries(variables)) { + // eslint-disable-next-line no-continue + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + if (!(key in secretMap)) { try { await deleteWindmillVariable({ diff --git a/backend/src/services/smtp/emails/BaseEmailWrapper.tsx b/backend/src/services/smtp/emails/BaseEmailWrapper.tsx index 3d02fc793..01bf779c5 100644 --- a/backend/src/services/smtp/emails/BaseEmailWrapper.tsx +++ b/backend/src/services/smtp/emails/BaseEmailWrapper.tsx @@ -13,7 +13,7 @@ export const BaseEmailWrapper = ({ title, preview, children, siteUrl }: BaseEmai - + {preview}

diff --git a/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx b/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx index bad823bd3..a07110aa3 100644 --- a/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx +++ b/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx @@ -12,6 +12,7 @@ interface SecretApprovalRequestBypassedTemplateProps environment: string; bypassReason: string; approvalUrl: string; + requestType: "change" | "access"; } export const SecretApprovalRequestBypassedTemplate = ({ @@ -22,7 +23,8 @@ export const SecretApprovalRequestBypassedTemplate = ({ secretPath, environment, bypassReason, - approvalUrl + approvalUrl, + requestType = "change" }: SecretApprovalRequestBypassedTemplateProps) => { return ( {requesterEmail} - ) has merged a secret to {secretPath} in the {environment} environment - without obtaining the required approval. + ) has {requestType === "change" ? "merged" : "accessed"} a secret {requestType === "change" ? "to" : "in"}{" "} + {secretPath} in the {environment} environment without obtaining the required + approval. The following reason was provided for bypassing the policy: " diff --git a/backend/src/services/smtp/emails/SecretScanningScanFailedTemplate.tsx b/backend/src/services/smtp/emails/SecretScanningScanFailedTemplate.tsx new file mode 100644 index 000000000..2e212cb82 --- /dev/null +++ b/backend/src/services/smtp/emails/SecretScanningScanFailedTemplate.tsx @@ -0,0 +1,67 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface SecretScanningScanFailedTemplateProps extends Omit { + dataSourceName: string; + resourceName: string; + projectName: string; + timestamp: string; + url: string; + errorMessage: string; +} + +export const SecretScanningScanFailedTemplate = ({ + dataSourceName, + resourceName, + projectName, + siteUrl, + errorMessage, + url, + timestamp +}: SecretScanningScanFailedTemplateProps) => { + return ( + + + Infisical encountered an error while attempting to scan the resource {resourceName} + +
+ Resource + {resourceName} + Data Source + {dataSourceName} + Project + {projectName} + Timestamp + {timestamp} + Error + {errorMessage} +
+
+ +
+
+ ); +}; + +export default SecretScanningScanFailedTemplate; + +SecretScanningScanFailedTemplate.PreviewProps = { + dataSourceName: "my-data-source", + resourceName: "my-resource", + projectName: "my-project", + timestamp: "May 3rd 2025, 5:42 pm", + url: "https://infisical.com", + errorMessage: "401 Unauthorized", + siteUrl: "https://infisical.com" +} as SecretScanningScanFailedTemplateProps; diff --git a/backend/src/services/smtp/emails/SecretScanningSecretsDetectedTemplate.tsx b/backend/src/services/smtp/emails/SecretScanningSecretsDetectedTemplate.tsx new file mode 100644 index 000000000..b7c0d8a14 --- /dev/null +++ b/backend/src/services/smtp/emails/SecretScanningSecretsDetectedTemplate.tsx @@ -0,0 +1,101 @@ +import { Button, Heading, Link, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface SecretScanningSecretsDetectedTemplateProps + extends Omit { + numberOfSecrets: number; + isDiffScan: boolean; + authorName?: string; + authorEmail?: string; + resourceName: string; + url: string; +} + +export const SecretScanningSecretsDetectedTemplate = ({ + numberOfSecrets, + siteUrl, + authorName, + authorEmail, + isDiffScan, + resourceName, + url +}: SecretScanningSecretsDetectedTemplateProps) => { + return ( + + + Infisical has uncovered {numberOfSecrets} secret(s) + {isDiffScan ? " from a recent commit to" : " in"} {resourceName} + +
+ + You are receiving this notification because one or more leaked secrets have been detected + {isDiffScan && " in a recent commit"} + {isDiffScan ? ( + (authorName || authorEmail) && ( + <> + {" "} + pushed by {authorName ?? "Unknown Pusher"}{" "} + {authorEmail && ( + <> + ( + + {authorEmail} + + ) + + )} + + ) + ) : ( + <> + {" "} + in your resource {resourceName} + + )} + . + + + If these are test secrets, please add `infisical-scan:ignore` at the end of the line containing the secret as + a comment in the given programming language. This will prevent future notifications from being sent out for + these secrets. + + + If these are production secrets, please rotate them immediately. + + + Once you have taken action, be sure to update the finding status in the{" "} + + Infisical Dashboard + + . + +
+
+ +
+
+ ); +}; + +export default SecretScanningSecretsDetectedTemplate; + +SecretScanningSecretsDetectedTemplate.PreviewProps = { + authorName: "Jim", + authorEmail: "jim@infisical.com", + resourceName: "my-resource", + numberOfSecrets: 3, + url: "https://infisical.com", + isDiffScan: true, + siteUrl: "https://infisical.com" +} as SecretScanningSecretsDetectedTemplateProps; diff --git a/backend/src/services/smtp/emails/index.ts b/backend/src/services/smtp/emails/index.ts index 29738dbb2..840a98cad 100644 --- a/backend/src/services/smtp/emails/index.ts +++ b/backend/src/services/smtp/emails/index.ts @@ -21,6 +21,8 @@ export * from "./SecretLeakIncidentTemplate"; export * from "./SecretReminderTemplate"; export * from "./SecretRequestCompletedTemplate"; export * from "./SecretRotationFailedTemplate"; +export * from "./SecretScanningScanFailedTemplate"; +export * from "./SecretScanningSecretsDetectedTemplate"; export * from "./SecretSyncFailedTemplate"; export * from "./ServiceTokenExpiryNoticeTemplate"; export * from "./SignupEmailVerificationTemplate"; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 12b38ebb2..ac56f0ee4 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -30,6 +30,8 @@ import { SecretReminderTemplate, SecretRequestCompletedTemplate, SecretRotationFailedTemplate, + SecretScanningScanFailedTemplate, + SecretScanningSecretsDetectedTemplate, SecretSyncFailedTemplate, ServiceTokenExpiryNoticeTemplate, SignupEmailVerificationTemplate, @@ -73,7 +75,9 @@ export enum SmtpTemplates { ProjectAccessRequest = "projectAccess", OrgAdminProjectDirectAccess = "orgAdminProjectGrantAccess", OrgAdminBreakglassAccess = "orgAdminBreakglassAccess", - ServiceTokenExpired = "serviceTokenExpired" + ServiceTokenExpired = "serviceTokenExpired", + SecretScanningV2ScanFailed = "secretScanningV2ScanFailed", + SecretScanningV2SecretsDetected = "secretScanningV2SecretsDetected" } export enum SmtpHost { @@ -113,7 +117,9 @@ const EmailTemplateMap: Record> = { [SmtpTemplates.SecretApprovalRequestNeedsReview]: SecretApprovalRequestNeedsReviewTemplate, [SmtpTemplates.ResetPassword]: PasswordResetTemplate, [SmtpTemplates.SetupPassword]: PasswordSetupTemplate, - [SmtpTemplates.PkiExpirationAlert]: PkiExpirationAlertTemplate + [SmtpTemplates.PkiExpirationAlert]: PkiExpirationAlertTemplate, + [SmtpTemplates.SecretScanningV2ScanFailed]: SecretScanningScanFailedTemplate, + [SmtpTemplates.SecretScanningV2SecretsDetected]: SecretScanningSecretsDetectedTemplate }; export const smtpServiceFactory = (cfg: TSmtpConfig) => { diff --git a/backend/src/services/super-admin/invalidate-cache-queue.ts b/backend/src/services/super-admin/invalidate-cache-queue.ts new file mode 100644 index 000000000..c2a12f5d5 --- /dev/null +++ b/backend/src/services/super-admin/invalidate-cache-queue.ts @@ -0,0 +1,49 @@ +import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +import { CacheType } from "./super-admin-types"; + +export type TInvalidateCacheQueueFactoryDep = { + queueService: TQueueServiceFactory; + + keyStore: Pick; +}; + +export type TInvalidateCacheQueueFactory = ReturnType; + +export const invalidateCacheQueueFactory = ({ queueService, keyStore }: TInvalidateCacheQueueFactoryDep) => { + const startInvalidate = async (dto: { + data: { + type: CacheType; + }; + }) => { + await queueService.queue(QueueName.InvalidateCache, QueueJobs.InvalidateCache, dto, { + removeOnComplete: true, + removeOnFail: true, + jobId: `invalidate-cache-${dto.data.type}` + }); + }; + + queueService.start(QueueName.InvalidateCache, async (job) => { + try { + const { + data: { type } + } = job.data; + + await keyStore.setItemWithExpiry("invalidating-cache", 1800, "true"); // 30 minutes max (in case the job somehow silently fails) + + if (type === CacheType.ALL || type === CacheType.SECRETS) + await keyStore.deleteItems({ pattern: "secret-manager:*" }); + + await keyStore.deleteItem("invalidating-cache"); + } catch (err) { + logger.error(err, "Failed to invalidate cache"); + await keyStore.deleteItem("invalidating-cache"); + } + }); + + return { + startInvalidate + }; +}; diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 8687826c8..ff319796e 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -11,7 +11,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; import { TAuthLoginFactory } from "../auth/auth-login-service"; -import { AuthMethod, AuthTokenType } from "../auth/auth-type"; +import { ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; @@ -21,17 +21,22 @@ import { TKmsRootConfigDALFactory } from "../kms/kms-root-config-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; import { RootKeyEncryptionStrategy } from "../kms/kms-types"; import { TMicrosoftTeamsServiceFactory } from "../microsoft-teams/microsoft-teams-service"; +import { TOrgDALFactory } from "../org/org-dal"; import { TOrgServiceFactory } from "../org/org-service"; +import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal"; import { TUserDALFactory } from "../user/user-dal"; import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; import { UserAliasType } from "../user-alias/user-alias-types"; +import { TInvalidateCacheQueueFactory } from "./invalidate-cache-queue"; import { TSuperAdminDALFactory } from "./super-admin-dal"; import { + CacheType, LoginMethod, TAdminBootstrapInstanceDTO, TAdminGetIdentitiesDTO, TAdminGetUsersDTO, - TAdminSignUpDTO + TAdminSignUpDTO, + TGetOrganizationsDTO } from "./super-admin-types"; type TSuperAdminServiceFactoryDep = { @@ -39,6 +44,8 @@ type TSuperAdminServiceFactoryDep = { identityTokenAuthDAL: TIdentityTokenAuthDALFactory; identityAccessTokenDAL: TIdentityAccessTokenDALFactory; identityOrgMembershipDAL: TIdentityOrgDALFactory; + orgDAL: TOrgDALFactory; + orgMembershipDAL: TOrgMembershipDALFactory; serverCfgDAL: TSuperAdminDALFactory; userDAL: TUserDALFactory; userAliasDAL: Pick; @@ -46,9 +53,10 @@ type TSuperAdminServiceFactoryDep = { kmsService: Pick; kmsRootConfigDAL: TKmsRootConfigDALFactory; orgService: Pick; - keyStore: Pick; + keyStore: Pick; licenseService: Pick; microsoftTeamsService: Pick; + invalidateCacheQueue: TInvalidateCacheQueueFactory; }; export type TSuperAdminServiceFactory = ReturnType; @@ -64,12 +72,14 @@ export let getServerCfg: () => Promise< const ADMIN_CONFIG_KEY = "infisical-admin-cfg"; const ADMIN_CONFIG_KEY_EXP = 60; // 60s -const ADMIN_CONFIG_DB_UUID = "00000000-0000-0000-0000-000000000000"; +export const ADMIN_CONFIG_DB_UUID = "00000000-0000-0000-0000-000000000000"; export const superAdminServiceFactory = ({ serverCfgDAL, userDAL, identityDAL, + orgDAL, + orgMembershipDAL, userAliasDAL, authService, orgService, @@ -80,7 +90,8 @@ export const superAdminServiceFactory = ({ identityAccessTokenDAL, identityTokenAuthDAL, identityOrgMembershipDAL, - microsoftTeamsService + microsoftTeamsService, + invalidateCacheQueue }: TSuperAdminServiceFactoryDep) => { const initServerCfg = async () => { // TODO(akhilmhdh): bad pattern time less change this later to me itself @@ -242,7 +253,8 @@ export const superAdminServiceFactory = ({ await microsoftTeamsService.initializeTeamsBot({ botAppId: decryptedAppId.toString(), - botAppPassword: decryptedAppPassword.toString() + botAppPassword: decryptedAppPassword.toString(), + lastUpdatedAt: updatedServerCfg.updatedAt }); } @@ -252,8 +264,8 @@ export const superAdminServiceFactory = ({ const adminSignUp = async ({ lastName, firstName, - salt, email, + salt, password, verifier, publicKey, @@ -267,7 +279,8 @@ export const superAdminServiceFactory = ({ userAgent }: TAdminSignUpDTO) => { const appCfg = getConfig(); - const existingUser = await userDAL.findOne({ email }); + const sanitizedEmail = email.trim().toLowerCase(); + const existingUser = await userDAL.findOne({ username: sanitizedEmail }); if (existingUser) throw new BadRequestError({ name: "Admin sign up", message: "User already exists" }); const privateKey = await getUserPrivateKey(password, { @@ -287,8 +300,8 @@ export const superAdminServiceFactory = ({ { firstName, lastName, - username: email, - email, + username: sanitizedEmail, + email: sanitizedEmail, superAdmin: true, isGhost: false, isAccepted: true, @@ -343,12 +356,13 @@ export const superAdminServiceFactory = ({ const bootstrapInstance = async ({ email, password, organizationName }: TAdminBootstrapInstanceDTO) => { const appCfg = getConfig(); + const sanitizedEmail = email.trim().toLowerCase(); const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); if (serverCfg?.initialized) { throw new BadRequestError({ message: "Instance has already been set up" }); } - const existingUser = await userDAL.findOne({ email }); + const existingUser = await userDAL.findOne({ email: sanitizedEmail }); if (existingUser) throw new BadRequestError({ name: "Instance initialization", message: "User already exists" }); const userInfo = await userDAL.transaction(async (tx) => { @@ -356,8 +370,8 @@ export const superAdminServiceFactory = ({ { firstName: "Admin", lastName: "User", - username: email, - email, + username: sanitizedEmail, + email: sanitizedEmail, superAdmin: true, isGhost: false, isAccepted: true, @@ -367,7 +381,7 @@ export const superAdminServiceFactory = ({ tx ); const { tag, encoding, ciphertext, iv } = infisicalSymmetricEncypt(password); - const encKeys = await generateUserSrpKeys(email, password); + const encKeys = await generateUserSrpKeys(sanitizedEmail, password); const userEnc = await userDAL.createUserEncryption( { @@ -514,6 +528,47 @@ export const superAdminServiceFactory = ({ return updatedUser; }; + const getOrganizations = async ({ offset, limit, searchTerm }: TGetOrganizationsDTO) => { + const organizations = await orgDAL.findOrganizationsByFilter({ + offset, + searchTerm, + sortBy: "name", + limit + }); + return organizations; + }; + + const deleteOrganization = async (organizationId: string) => { + const organization = await orgDAL.deleteById(organizationId); + return organization; + }; + + const deleteOrganizationMembership = async ( + organizationId: string, + membershipId: string, + actorId: string, + actorType: ActorType + ) => { + if (actorType === ActorType.USER) { + const orgMembership = await orgMembershipDAL.findById(membershipId); + if (!orgMembership) { + throw new NotFoundError({ name: "Organization Membership", message: "Organization membership not found" }); + } + + if (orgMembership.userId === actorId) { + throw new BadRequestError({ + message: "You cannot remove yourself from the organization from the instance management panel." + }); + } + } + + const [organizationMembership] = await orgMembershipDAL.delete({ + orgId: organizationId, + id: membershipId + }); + return organizationMembership; + }; + const getIdentities = async ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => { const identities = await identityDAL.getIdentitiesByFilter({ limit, @@ -631,6 +686,16 @@ export const superAdminServiceFactory = ({ await kmsService.updateEncryptionStrategy(strategy); }; + const invalidateCache = async (type: CacheType) => { + await invalidateCacheQueue.startInvalidate({ + data: { type } + }); + }; + + const checkIfInvalidatingCache = async () => { + return (await keyStore.getItem("invalidating-cache")) !== null; + }; + return { initServerCfg, updateServerCfg, @@ -644,6 +709,11 @@ export const superAdminServiceFactory = ({ getConfiguredEncryptionStrategies, grantServerAdminAccessToUser, deleteIdentitySuperAdminAccess, - deleteUserSuperAdminAccess + deleteUserSuperAdminAccess, + invalidateCache, + checkIfInvalidatingCache, + getOrganizations, + deleteOrganization, + deleteOrganizationMembership }; }; diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index 64ec92632..22803a650 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -35,6 +35,12 @@ export type TAdminGetIdentitiesDTO = { searchTerm: string; }; +export type TGetOrganizationsDTO = { + offset: number; + limit: number; + searchTerm: string; +}; + export enum LoginMethod { EMAIL = "email", GOOGLE = "google", @@ -44,3 +50,8 @@ export enum LoginMethod { LDAP = "ldap", OIDC = "oidc" } + +export enum CacheType { + ALL = "all", + SECRETS = "secrets" +} diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index ab90a71d4..a370d0332 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -21,7 +21,8 @@ export enum PostHogEventTypes { IssueSshHostUserCert = "Issue SSH Host User Certificate", IssueSshHostHostCert = "Issue SSH Host Host Certificate", SignCert = "Sign PKI Certificate", - IssueCert = "Issue PKI Certificate" + IssueCert = "Issue PKI Certificate", + InvalidateCache = "Invalidate Cache" } export type TSecretModifiedEvent = { @@ -188,6 +189,7 @@ export type TSignCertificateEvent = { properties: { caId?: string; certificateTemplateId?: string; + subscriberId?: string; commonName: string; userAgent?: string; }; @@ -198,11 +200,19 @@ export type TIssueCertificateEvent = { properties: { caId?: string; certificateTemplateId?: string; + subscriberId?: string; commonName: string; userAgent?: string; }; }; +export type TInvalidateCacheEvent = { + event: PostHogEventTypes.InvalidateCache; + properties: { + userAgent?: string; + }; +}; + export type TPostHogEvent = { distinctId: string } & ( | TSecretModifiedEvent | TAdminInitEvent @@ -221,4 +231,5 @@ export type TPostHogEvent = { distinctId: string } & ( | TIssueSshHostHostCertEvent | TSignCertificateEvent | TIssueCertificateEvent + | TInvalidateCacheEvent ); diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index eba497f0f..0f623dff1 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -8,16 +8,23 @@ import { TUserEncryptionKeys, TUserEncryptionKeysInsert, TUserEncryptionKeysUpdate, - TUsers + TUsers, + UsersSchema } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; export type TUserDALFactory = ReturnType; export const userDALFactory = (db: TDbClient) => { const userOrm = ormify(db, TableName.Users); - const findUserByUsername = async (username: string, tx?: Knex) => userOrm.findOne({ username }, tx); + const findUserByUsername = async (username: string, tx?: Knex) => + (tx || db)(TableName.Users).whereRaw('lower("username") = :username', { username: username.toLowerCase() }); + + const findUserByEmail = async (email: string, tx?: Knex) => + (tx || db)(TableName.Users).whereRaw('lower("email") = :email', { email: email.toLowerCase() }).where({ + isEmailVerified: true + }); const getUsersByFilter = async ({ limit, @@ -41,7 +48,7 @@ export const userDALFactory = (db: TDbClient) => { .whereILike("email", `%${searchTerm}%`) .orWhereILike("firstName", `%${searchTerm}%`) .orWhereILike("lastName", `%${searchTerm}%`) - .orWhereLike("username", `%${searchTerm}%`); + .orWhereRaw('lower("username") like ?', `%${searchTerm}%`); }); } @@ -65,12 +72,11 @@ export const userDALFactory = (db: TDbClient) => { try { return await db .replicaNode()(TableName.Users) + .whereRaw('lower("username") = :username', { username: username.toLowerCase() }) .where({ - username, isGhost: false }) - .join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`) - .first(); + .join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`); } catch (error) { throw new DatabaseError({ error, name: "Find user enc by email" }); } @@ -168,6 +174,38 @@ export const userDALFactory = (db: TDbClient) => { } }; + const findAllMyAccounts = async (email: string) => { + try { + const doc = await db(TableName.Users) + .where({ email }) + .leftJoin(TableName.OrgMembership, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) + .select(selectAllTableCols(TableName.Users)) + .select( + db.ref("name").withSchema(TableName.Organization).as("orgName"), + db.ref("slug").withSchema(TableName.Organization).as("orgSlug") + ); + const formattedDoc = sqlNestRelationships({ + data: doc, + key: "id", + parentMapper: (el) => UsersSchema.parse(el), + childrenMapper: [ + { + key: "orgSlug", + label: "organizations" as const, + mapper: ({ orgSlug, orgName }) => ({ + slug: orgSlug, + name: orgName + }) + } + ] + }); + return formattedDoc; + } catch (error) { + throw new DatabaseError({ error, name: "Upsert user enc key" }); + } + }; + // USER ACTION FUNCTIONS // --------------------- const findOneUserAction = (filter: TUserActionsUpdate, tx?: Knex) => { @@ -200,6 +238,8 @@ export const userDALFactory = (db: TDbClient) => { createUserEncryption, findOneUserAction, createUserAction, - getUsersByFilter + getUsersByFilter, + findAllMyAccounts, + findUserByEmail }; }; diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 5da5d493c..aae32d91f 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -5,11 +5,11 @@ import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/pe import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TokenType } from "@app/services/auth-token/auth-token-types"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; -import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { AuthMethod } from "../auth/auth-type"; import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; @@ -21,7 +21,7 @@ type TUserServiceFactoryDep = { userDAL: Pick< TUserDALFactory, | "find" - | "findOne" + | "findUserByUsername" | "findById" | "transaction" | "updateById" @@ -31,8 +31,8 @@ type TUserServiceFactoryDep = { | "createUserAction" | "findUserEncKeyByUserId" | "delete" + | "findAllMyAccounts" >; - userAliasDAL: Pick; groupProjectDAL: Pick; orgMembershipDAL: Pick; tokenService: Pick; @@ -45,7 +45,6 @@ export type TUserServiceFactory = ReturnType; export const userServiceFactory = ({ userDAL, - userAliasDAL, orgMembershipDAL, projectMembershipDAL, groupProjectDAL, @@ -54,8 +53,11 @@ export const userServiceFactory = ({ permissionService }: TUserServiceFactoryDep) => { const sendEmailVerificationCode = async (username: string) => { - const user = await userDAL.findOne({ username }); + // akhilmhdh: case sensitive email resolution + const users = await userDAL.findUserByUsername(username); + const user = users?.length > 1 ? users.find((el) => el.username === username) : users?.[0]; if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); + if (!user.email) throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" }); if (user.isEmailVerified) @@ -77,7 +79,21 @@ export const userServiceFactory = ({ }; const verifyEmailVerificationCode = async (username: string, code: string) => { - const user = await userDAL.findOne({ username }); + // akhilmhdh: case sensitive email resolution + const usersByusername = await userDAL.findUserByUsername(username); + + logger.info( + usersByusername.map((user) => ({ + id: user.id, + email: user.email, + username: user.username, + isEmailVerified: user.isEmailVerified + })), + `Verify email users: [username=${username}]` + ); + + const user = + usersByusername?.length > 1 ? usersByusername.find((el) => el.username === username) : usersByusername?.[0]; if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); if (!user.email) throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" }); @@ -90,84 +106,8 @@ export const userServiceFactory = ({ code }); - const { email } = user; - - await userDAL.transaction(async (tx) => { - await userDAL.updateById( - user.id, - { - isEmailVerified: true - }, - tx - ); - - // check if there are verified users with the same email. - const users = await userDAL.find( - { - email, - isEmailVerified: true - }, - { tx } - ); - - if (users.length > 1) { - // merge users - const mergeUser = users.find((u) => u.id !== user.id); - if (!mergeUser) throw new NotFoundError({ name: "Failed to find merge user" }); - - const mergeUserOrgMembershipSet = new Set( - (await orgMembershipDAL.find({ userId: mergeUser.id }, { tx })).map((m) => m.orgId) - ); - const myOrgMemberships = (await orgMembershipDAL.find({ userId: user.id }, { tx })).filter( - (m) => !mergeUserOrgMembershipSet.has(m.orgId) - ); - - const userAliases = await userAliasDAL.find( - { - userId: user.id - }, - { tx } - ); - await userDAL.deleteById(user.id, tx); - - if (myOrgMemberships.length) { - await orgMembershipDAL.insertMany( - myOrgMemberships.map((orgMembership) => ({ - ...orgMembership, - userId: mergeUser.id - })), - tx - ); - } - - if (userAliases.length) { - await userAliasDAL.insertMany( - userAliases.map((userAlias) => ({ - ...userAlias, - userId: mergeUser.id - })), - tx - ); - } - } else { - await userDAL.delete( - { - email, - isAccepted: false, - isEmailVerified: false - }, - tx - ); - - // update current user's username to [email] - await userDAL.updateById( - user.id, - { - username: email - }, - tx - ); - } + await userDAL.updateById(user.id, { + isEmailVerified: true }); }; @@ -212,6 +152,23 @@ export const userServiceFactory = ({ return updatedUser; }; + const getAllMyAccounts = async (email: string, userId: string) => { + const users = await userDAL.findAllMyAccounts(email); + return users?.map((el) => ({ ...el, isMyAccount: el.id === userId })); + }; + + const removeMyDuplicateAccounts = async (email: string, userId: string) => { + const users = await userDAL.find({ email }); + const duplicatedAccounts = users?.filter((el) => el.id !== userId); + const myAccount = users?.find((el) => el.id === userId); + if (duplicatedAccounts.length && myAccount) { + await userDAL.transaction(async (tx) => { + await userDAL.delete({ $in: { id: duplicatedAccounts?.map((el) => el.id) } }, tx); + await userDAL.updateById(userId, { username: (myAccount.email || myAccount.username).toLowerCase() }, tx); + }); + } + }; + const getMe = async (userId: string) => { const user = await userDAL.findUserEncKeyByUserId(userId); if (!user) throw new NotFoundError({ message: `User with ID '${userId}' not found`, name: "GetMe" }); @@ -313,9 +270,11 @@ export const userServiceFactory = ({ }; const listUserGroups = async ({ username, actorOrgId, actor, actorId, actorAuthMethod }: TListUserGroupsDTO) => { - const user = await userDAL.findOne({ - username - }); + // akhilmhdh: case sensitive email resolution + const usersByusername = await userDAL.findUserByUsername(username); + const user = + usersByusername?.length > 1 ? usersByusername.find((el) => el.username === username) : usersByusername?.[0]; + if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); // This makes it so the user can always read information about themselves, but no one else if they don't have the Members Read permission. if (user.id !== actorId) { @@ -346,7 +305,9 @@ export const userServiceFactory = ({ getUserAction, unlockUser, getUserPrivateKey, + getAllMyAccounts, getUserProjectFavorites, + removeMyDuplicateAccounts, updateUserProjectFavorites }; }; diff --git a/cli/config/allowlist_test.go b/cli/config/allowlist_test.go deleted file mode 100644 index 52766e3cd..000000000 --- a/cli/config/allowlist_test.go +++ /dev/null @@ -1,115 +0,0 @@ -// MIT License - -// Copyright (c) 2019 Zachary Rice - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package config - -import ( - "regexp" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestCommitAllowed(t *testing.T) { - tests := []struct { - allowlist Allowlist - commit string - commitAllowed bool - }{ - { - allowlist: Allowlist{ - Commits: []string{"commitA"}, - }, - commit: "commitA", - commitAllowed: true, - }, - { - allowlist: Allowlist{ - Commits: []string{"commitB"}, - }, - commit: "commitA", - commitAllowed: false, - }, - { - allowlist: Allowlist{ - Commits: []string{"commitB"}, - }, - commit: "", - commitAllowed: false, - }, - } - for _, tt := range tests { - assert.Equal(t, tt.commitAllowed, tt.allowlist.CommitAllowed(tt.commit)) - } -} - -func TestRegexAllowed(t *testing.T) { - tests := []struct { - allowlist Allowlist - secret string - regexAllowed bool - }{ - { - allowlist: Allowlist{ - Regexes: []*regexp.Regexp{regexp.MustCompile("matchthis")}, - }, - secret: "a secret: matchthis, done", - regexAllowed: true, - }, - { - allowlist: Allowlist{ - Regexes: []*regexp.Regexp{regexp.MustCompile("matchthis")}, - }, - secret: "a secret", - regexAllowed: false, - }, - } - for _, tt := range tests { - assert.Equal(t, tt.regexAllowed, tt.allowlist.RegexAllowed(tt.secret)) - } -} - -func TestPathAllowed(t *testing.T) { - tests := []struct { - allowlist Allowlist - path string - pathAllowed bool - }{ - { - allowlist: Allowlist{ - Paths: []*regexp.Regexp{regexp.MustCompile("path")}, - }, - path: "a path", - pathAllowed: true, - }, - { - allowlist: Allowlist{ - Paths: []*regexp.Regexp{regexp.MustCompile("path")}, - }, - path: "a ???", - pathAllowed: false, - }, - } - for _, tt := range tests { - assert.Equal(t, tt.pathAllowed, tt.allowlist.PathAllowed(tt.path)) - } -} diff --git a/cli/config/config.go b/cli/config/config.go deleted file mode 100644 index b1ce08e2b..000000000 --- a/cli/config/config.go +++ /dev/null @@ -1,279 +0,0 @@ -// MIT License - -// Copyright (c) 2019 Zachary Rice - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package config - -import ( - _ "embed" - "fmt" - "regexp" - "strings" - - "github.com/rs/zerolog/log" - "github.com/spf13/viper" -) - -//go:embed infisical-scan.toml -var DefaultConfig string - -// use to keep track of how many configs we can extend -// yea I know, globals bad -var extendDepth int - -const maxExtendDepth = 2 - -const DefaultScanConfigFileName = ".infisical-scan.toml" -const DefaultScanConfigEnvName = "INFISICAL_SCAN_CONFIG" -const DefaultInfisicalIgnoreFineName = ".infisicalignore" - -// ViperConfig is the config struct used by the Viper config package -// to parse the config file. This struct does not include regular expressions. -// It is used as an intermediary to convert the Viper config to the Config struct. -type ViperConfig struct { - Description string - Extend Extend - Rules []struct { - ID string - Description string - Entropy float64 - SecretGroup int - Regex string - Keywords []string - Path string - Tags []string - - Allowlist struct { - RegexTarget string - Regexes []string - Paths []string - Commits []string - StopWords []string - } - } - Allowlist struct { - RegexTarget string - Regexes []string - Paths []string - Commits []string - StopWords []string - } -} - -// Config is a configuration struct that contains rules and an allowlist if present. -type Config struct { - Extend Extend - Path string - Description string - Rules map[string]Rule - Allowlist Allowlist - Keywords []string - - // used to keep sarif results consistent - orderedRules []string -} - -// Extend is a struct that allows users to define how they want their -// configuration extended by other configuration files. -type Extend struct { - Path string - URL string - UseDefault bool -} - -func (vc *ViperConfig) Translate() (Config, error) { - var ( - keywords []string - orderedRules []string - ) - rulesMap := make(map[string]Rule) - - for _, r := range vc.Rules { - var allowlistRegexes []*regexp.Regexp - for _, a := range r.Allowlist.Regexes { - allowlistRegexes = append(allowlistRegexes, regexp.MustCompile(a)) - } - var allowlistPaths []*regexp.Regexp - for _, a := range r.Allowlist.Paths { - allowlistPaths = append(allowlistPaths, regexp.MustCompile(a)) - } - - if r.Keywords == nil { - r.Keywords = []string{} - } else { - for _, k := range r.Keywords { - keywords = append(keywords, strings.ToLower(k)) - } - } - - if r.Tags == nil { - r.Tags = []string{} - } - - var configRegex *regexp.Regexp - var configPathRegex *regexp.Regexp - if r.Regex == "" { - configRegex = nil - } else { - configRegex = regexp.MustCompile(r.Regex) - } - if r.Path == "" { - configPathRegex = nil - } else { - configPathRegex = regexp.MustCompile(r.Path) - } - r := Rule{ - Description: r.Description, - RuleID: r.ID, - Regex: configRegex, - Path: configPathRegex, - SecretGroup: r.SecretGroup, - Entropy: r.Entropy, - Tags: r.Tags, - Keywords: r.Keywords, - Allowlist: Allowlist{ - RegexTarget: r.Allowlist.RegexTarget, - Regexes: allowlistRegexes, - Paths: allowlistPaths, - Commits: r.Allowlist.Commits, - StopWords: r.Allowlist.StopWords, - }, - } - orderedRules = append(orderedRules, r.RuleID) - - if r.Regex != nil && r.SecretGroup > r.Regex.NumSubexp() { - return Config{}, fmt.Errorf("%s invalid regex secret group %d, max regex secret group %d", r.Description, r.SecretGroup, r.Regex.NumSubexp()) - } - rulesMap[r.RuleID] = r - } - var allowlistRegexes []*regexp.Regexp - for _, a := range vc.Allowlist.Regexes { - allowlistRegexes = append(allowlistRegexes, regexp.MustCompile(a)) - } - var allowlistPaths []*regexp.Regexp - for _, a := range vc.Allowlist.Paths { - allowlistPaths = append(allowlistPaths, regexp.MustCompile(a)) - } - c := Config{ - Description: vc.Description, - Extend: vc.Extend, - Rules: rulesMap, - Allowlist: Allowlist{ - RegexTarget: vc.Allowlist.RegexTarget, - Regexes: allowlistRegexes, - Paths: allowlistPaths, - Commits: vc.Allowlist.Commits, - StopWords: vc.Allowlist.StopWords, - }, - Keywords: keywords, - orderedRules: orderedRules, - } - - if maxExtendDepth != extendDepth { - // disallow both usedefault and path from being set - if c.Extend.Path != "" && c.Extend.UseDefault { - log.Fatal().Msg("unable to load config due to extend.path and extend.useDefault being set") - } - if c.Extend.UseDefault { - c.extendDefault() - } else if c.Extend.Path != "" { - c.extendPath() - } - - } - - return c, nil -} - -func (c *Config) OrderedRules() []Rule { - var orderedRules []Rule - for _, id := range c.orderedRules { - if _, ok := c.Rules[id]; ok { - orderedRules = append(orderedRules, c.Rules[id]) - } - } - return orderedRules -} - -func (c *Config) extendDefault() { - extendDepth++ - viper.SetConfigType("toml") - if err := viper.ReadConfig(strings.NewReader(DefaultConfig)); err != nil { - log.Fatal().Msgf("failed to load extended config, err: %s", err) - return - } - defaultViperConfig := ViperConfig{} - if err := viper.Unmarshal(&defaultViperConfig); err != nil { - log.Fatal().Msgf("failed to load extended config, err: %s", err) - return - } - cfg, err := defaultViperConfig.Translate() - if err != nil { - log.Fatal().Msgf("failed to load extended config, err: %s", err) - return - } - log.Debug().Msg("extending config with default config") - c.extend(cfg) - -} - -func (c *Config) extendPath() { - extendDepth++ - viper.SetConfigFile(c.Extend.Path) - if err := viper.ReadInConfig(); err != nil { - log.Fatal().Msgf("failed to load extended config, err: %s", err) - return - } - extensionViperConfig := ViperConfig{} - if err := viper.Unmarshal(&extensionViperConfig); err != nil { - log.Fatal().Msgf("failed to load extended config, err: %s", err) - return - } - cfg, err := extensionViperConfig.Translate() - if err != nil { - log.Fatal().Msgf("failed to load extended config, err: %s", err) - return - } - log.Debug().Msgf("extending config with %s", c.Extend.Path) - c.extend(cfg) -} - -func (c *Config) extendURL() { - // TODO -} - -func (c *Config) extend(extensionConfig Config) { - for ruleID, rule := range extensionConfig.Rules { - if _, ok := c.Rules[ruleID]; !ok { - log.Trace().Msgf("adding %s to base config", ruleID) - c.Rules[ruleID] = rule - c.Keywords = append(c.Keywords, rule.Keywords...) - } - } - - // append allowlists, not attempting to merge - c.Allowlist.Commits = append(c.Allowlist.Commits, - extensionConfig.Allowlist.Commits...) - c.Allowlist.Paths = append(c.Allowlist.Paths, - extensionConfig.Allowlist.Paths...) - c.Allowlist.Regexes = append(c.Allowlist.Regexes, - extensionConfig.Allowlist.Regexes...) -} diff --git a/cli/config/config_test.go b/cli/config/config_test.go deleted file mode 100644 index e8f4d47b1..000000000 --- a/cli/config/config_test.go +++ /dev/null @@ -1,170 +0,0 @@ -// MIT License - -// Copyright (c) 2019 Zachary Rice - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package config - -import ( - "fmt" - "regexp" - "testing" - - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" -) - -const configPath = "../testdata/config/" - -func TestTranslate(t *testing.T) { - tests := []struct { - cfgName string - cfg Config - wantError error - }{ - { - cfgName: "allow_aws_re", - cfg: Config{ - Rules: map[string]Rule{"aws-access-key": { - Description: "AWS Access Key", - Regex: regexp.MustCompile("(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}"), - Tags: []string{"key", "AWS"}, - Keywords: []string{}, - RuleID: "aws-access-key", - Allowlist: Allowlist{ - Regexes: []*regexp.Regexp{ - regexp.MustCompile("AKIALALEMEL33243OLIA"), - }, - }, - }, - }, - }, - }, - { - cfgName: "allow_commit", - cfg: Config{ - Rules: map[string]Rule{"aws-access-key": { - Description: "AWS Access Key", - Regex: regexp.MustCompile("(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}"), - Tags: []string{"key", "AWS"}, - Keywords: []string{}, - RuleID: "aws-access-key", - Allowlist: Allowlist{ - Commits: []string{"allowthiscommit"}, - }, - }, - }, - }, - }, - { - cfgName: "allow_path", - cfg: Config{ - Rules: map[string]Rule{"aws-access-key": { - Description: "AWS Access Key", - Regex: regexp.MustCompile("(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}"), - Tags: []string{"key", "AWS"}, - Keywords: []string{}, - RuleID: "aws-access-key", - Allowlist: Allowlist{ - Paths: []*regexp.Regexp{ - regexp.MustCompile(".go"), - }, - }, - }, - }, - }, - }, - { - cfgName: "entropy_group", - cfg: Config{ - Rules: map[string]Rule{"discord-api-key": { - Description: "Discord API key", - Regex: regexp.MustCompile(`(?i)(discord[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-h0-9]{64})['\"]`), - RuleID: "discord-api-key", - Allowlist: Allowlist{}, - Entropy: 3.5, - SecretGroup: 3, - Tags: []string{}, - Keywords: []string{}, - }, - }, - }, - }, - { - cfgName: "bad_entropy_group", - cfg: Config{}, - wantError: fmt.Errorf("Discord API key invalid regex secret group 5, max regex secret group 3"), - }, - { - cfgName: "base", - cfg: Config{ - Rules: map[string]Rule{ - "aws-access-key": { - Description: "AWS Access Key", - Regex: regexp.MustCompile("(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}"), - Tags: []string{"key", "AWS"}, - Keywords: []string{}, - RuleID: "aws-access-key", - }, - "aws-secret-key": { - Description: "AWS Secret Key", - Regex: regexp.MustCompile(`(?i)aws_(.{0,20})?=?.[\'\"0-9a-zA-Z\/+]{40}`), - Tags: []string{"key", "AWS"}, - Keywords: []string{}, - RuleID: "aws-secret-key", - }, - "aws-secret-key-again": { - Description: "AWS Secret Key", - Regex: regexp.MustCompile(`(?i)aws_(.{0,20})?=?.[\'\"0-9a-zA-Z\/+]{40}`), - Tags: []string{"key", "AWS"}, - Keywords: []string{}, - RuleID: "aws-secret-key-again", - }, - }, - }, - }, - } - - for _, tt := range tests { - viper.Reset() - viper.AddConfigPath(configPath) - viper.SetConfigName(tt.cfgName) - viper.SetConfigType("toml") - err := viper.ReadInConfig() - if err != nil { - t.Error(err) - } - - var vc ViperConfig - err = viper.Unmarshal(&vc) - if err != nil { - t.Error(err) - } - cfg, err := vc.Translate() - if tt.wantError != nil { - if err == nil { - t.Errorf("expected error") - } - assert.Equal(t, tt.wantError, err) - } - - assert.Equal(t, cfg.Rules, tt.cfg.Rules) - } -} diff --git a/cli/config/example-infisical-relay.yaml b/cli/config/example-infisical-relay.yaml deleted file mode 100644 index c913ed757..000000000 --- a/cli/config/example-infisical-relay.yaml +++ /dev/null @@ -1,8 +0,0 @@ -public_ip: 127.0.0.1 -auth_secret: example-auth-secret -realm: infisical.org -# set port 5349 for tls -# port: 5349 -# tls_private_key_path: /full-path -# tls_ca_path: /full-path -# tls_cert_path: /full-path diff --git a/cli/config/infisical-relay.yaml b/cli/config/infisical-relay.yaml deleted file mode 100644 index 89c6b5e45..000000000 --- a/cli/config/infisical-relay.yaml +++ /dev/null @@ -1,8 +0,0 @@ -public_ip: 127.0.0.1 -auth_secret: changeThisOnProduction -realm: infisical.org -# set port 5349 for tls -# port: 5349 -# tls_private_key_path: /full-path -# tls_ca_path: /full-path -# tls_cert_path: /full-path diff --git a/cli/config/infisical-scan.toml b/cli/config/infisical-scan.toml deleted file mode 100644 index 193883444..000000000 --- a/cli/config/infisical-scan.toml +++ /dev/null @@ -1,2803 +0,0 @@ - -# This file has been auto-generated. Do not edit manually. -# If you would like to contribute new rules, please use -# cmd/generate/config/main.go and follow the contributing guidelines -# at https://github.com/zricethezav/gitleaks/blob/master/CONTRIBUTING.md - -# This is the default gitleaks configuration file. -# Rules and allowlists are defined within this file. -# Rules instruct gitleaks on what should be considered a secret. -# Allowlists instruct gitleaks on what is allowed, i.e. not a secret. - -title = "gitleaks config" - -[allowlist] -description = "global allow lists" -paths = [ - '''infisical-scan.toml''', - '''(.*?)(jpg|gif|doc|docx|zip|xls|pdf|bin|svg|socket)$''', - '''(go.mod|go.sum)$''', - '''gradle.lockfile''', - '''node_modules''', - '''package-lock.json''', - '''pnpm-lock.yaml''', - '''Database.refactorlog''', - '''vendor''', -] - -[[rules]] -description = "Adafruit API Key" -id = "adafruit-api-key" -regex = '''(?i)(?:adafruit)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9_-]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "adafruit", -] - -[[rules]] -description = "Adobe Client ID (OAuth Web)" -id = "adobe-client-id" -regex = '''(?i)(?:adobe)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-f0-9]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "adobe", -] - -[[rules]] -description = "Adobe Client Secret" -id = "adobe-client-secret" -regex = '''(?i)\b((p8e-)(?i)[a-z0-9]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -keywords = [ - "p8e-", -] - -[[rules]] -description = "Age secret key" -id = "age secret key" -regex = '''AGE-SECRET-KEY-1[QPZRY9X8GF2TVDW0S3JN54KHCE6MUA7L]{58}''' -keywords = [ - "age-secret-key-1", -] - -[[rules]] -description = "Airtable API Key" -id = "airtable-api-key" -regex = '''(?i)(?:airtable)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{17})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "airtable", -] - -[[rules]] -description = "Algolia API Key" -id = "algolia-api-key" -regex = '''(?i)(?:algolia)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -keywords = [ - "algolia", -] - -[[rules]] -description = "Alibaba AccessKey ID" -id = "alibaba-access-key-id" -regex = '''(?i)\b((LTAI)(?i)[a-z0-9]{20})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -keywords = [ - "ltai", -] - -[[rules]] -description = "Alibaba Secret Key" -id = "alibaba-secret-key" -regex = '''(?i)(?:alibaba)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{30})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "alibaba", -] - -[[rules]] -description = "Asana Client ID" -id = "asana-client-id" -regex = '''(?i)(?:asana)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([0-9]{16})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "asana", -] - -[[rules]] -description = "Asana Client Secret" -id = "asana-client-secret" -regex = '''(?i)(?:asana)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "asana", -] - -[[rules]] -description = "Atlassian API token" -id = "atlassian-api-token" -regex = '''(?i)(?:atlassian|confluence|jira)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{24})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "atlassian","confluence","jira", -] - -[[rules]] -description = "Authress Service Client Access Key" -id = "authress-service-client-access-key" -regex = '''(?i)\b((?:sc|ext|scauth|authress)_[a-z0-9]{5,30}\.[a-z0-9]{4,6}\.acc_[a-z0-9-]{10,32}\.[a-z0-9+/_=-]{30,120})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "sc_","ext_","scauth_","authress_", -] - -[[rules]] -description = "AWS" -id = "aws-access-token" -regex = '''(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}''' -keywords = [ - "akia","agpa","aida","aroa","aipa","anpa","anva","asia", -] - -[[rules]] -description = "Beamer API token" -id = "beamer-api-token" -regex = '''(?i)(?:beamer)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}(b_[a-z0-9=_\-]{44})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "beamer", -] - -[[rules]] -description = "Bitbucket Client ID" -id = "bitbucket-client-id" -regex = '''(?i)(?:bitbucket)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "bitbucket", -] - -[[rules]] -description = "Bitbucket Client Secret" -id = "bitbucket-client-secret" -regex = '''(?i)(?:bitbucket)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9=_\-]{64})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "bitbucket", -] - -[[rules]] -description = "Bittrex Access Key" -id = "bittrex-access-key" -regex = '''(?i)(?:bittrex)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "bittrex", -] - -[[rules]] -description = "Bittrex Secret Key" -id = "bittrex-secret-key" -regex = '''(?i)(?:bittrex)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "bittrex", -] - -[[rules]] -description = "Clojars API token" -id = "clojars-api-token" -regex = '''(?i)(CLOJARS_)[a-z0-9]{60}''' -keywords = [ - "clojars", -] - -[[rules]] -description = "Codecov Access Token" -id = "codecov-access-token" -regex = '''(?i)(?:codecov)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "codecov", -] - -[[rules]] -description = "Coinbase Access Token" -id = "coinbase-access-token" -regex = '''(?i)(?:coinbase)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9_-]{64})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "coinbase", -] - -[[rules]] -description = "Confluent Access Token" -id = "confluent-access-token" -regex = '''(?i)(?:confluent)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{16})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "confluent", -] - -[[rules]] -description = "Confluent Secret Key" -id = "confluent-secret-key" -regex = '''(?i)(?:confluent)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{64})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "confluent", -] - -[[rules]] -description = "Contentful delivery API token" -id = "contentful-delivery-api-token" -regex = '''(?i)(?:contentful)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9=_\-]{43})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "contentful", -] - -[[rules]] -description = "Databricks API token" -id = "databricks-api-token" -regex = '''(?i)\b(dapi[a-h0-9]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -keywords = [ - "dapi", -] - -[[rules]] -description = "Datadog Access Token" -id = "datadog-access-token" -regex = '''(?i)(?:datadog)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{40})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "datadog", -] - -[[rules]] -description = "Defined Networking API token" -id = "defined-networking-api-token" -regex = '''(?i)(?:dnkey)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}(dnkey-[a-z0-9=_\-]{26}-[a-z0-9=_\-]{52})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "dnkey", -] - -[[rules]] -description = "DigitalOcean OAuth Access Token" -id = "digitalocean-access-token" -regex = '''(?i)\b(doo_v1_[a-f0-9]{64})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "doo_v1_", -] - -[[rules]] -description = "DigitalOcean Personal Access Token" -id = "digitalocean-pat" -regex = '''(?i)\b(dop_v1_[a-f0-9]{64})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "dop_v1_", -] - -[[rules]] -description = "DigitalOcean OAuth Refresh Token" -id = "digitalocean-refresh-token" -regex = '''(?i)\b(dor_v1_[a-f0-9]{64})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "dor_v1_", -] - -[[rules]] -description = "Discord API key" -id = "discord-api-token" -regex = '''(?i)(?:discord)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-f0-9]{64})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "discord", -] - -[[rules]] -description = "Discord client ID" -id = "discord-client-id" -regex = '''(?i)(?:discord)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([0-9]{18})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "discord", -] - -[[rules]] -description = "Discord client secret" -id = "discord-client-secret" -regex = '''(?i)(?:discord)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9=_\-]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "discord", -] - -[[rules]] -description = "Doppler API token" -id = "doppler-api-token" -regex = '''(dp\.pt\.)(?i)[a-z0-9]{43}''' -keywords = [ - "doppler", -] - -[[rules]] -description = "Droneci Access Token" -id = "droneci-access-token" -regex = '''(?i)(?:droneci)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "droneci", -] - -[[rules]] -description = "Dropbox API secret" -id = "dropbox-api-token" -regex = '''(?i)(?:dropbox)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{15})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "dropbox", -] - -[[rules]] -description = "Dropbox long lived API token" -id = "dropbox-long-lived-api-token" -regex = '''(?i)(?:dropbox)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{11}(AAAAAAAAAA)[a-z0-9\-_=]{43})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -keywords = [ - "dropbox", -] - -[[rules]] -description = "Dropbox short lived API token" -id = "dropbox-short-lived-api-token" -regex = '''(?i)(?:dropbox)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}(sl\.[a-z0-9\-=_]{135})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -keywords = [ - "dropbox", -] - -[[rules]] -description = "Duffel API token" -id = "duffel-api-token" -regex = '''duffel_(test|live)_(?i)[a-z0-9_\-=]{43}''' -keywords = [ - "duffel", -] - -[[rules]] -description = "Dynatrace API token" -id = "dynatrace-api-token" -regex = '''dt0c01\.(?i)[a-z0-9]{24}\.[a-z0-9]{64}''' -keywords = [ - "dynatrace", -] - -[[rules]] -description = "EasyPost API token" -id = "easypost-api-token" -regex = '''\bEZAK(?i)[a-z0-9]{54}''' -keywords = [ - "ezak", -] - -[[rules]] -description = "EasyPost test API token" -id = "easypost-test-api-token" -regex = '''\bEZTK(?i)[a-z0-9]{54}''' -keywords = [ - "eztk", -] - -[[rules]] -description = "Etsy Access Token" -id = "etsy-access-token" -regex = '''(?i)(?:etsy)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{24})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "etsy", -] - -[[rules]] -description = "Facebook Access Token" -id = "facebook" -regex = '''(?i)(?:facebook)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-f0-9]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "facebook", -] - -[[rules]] -description = "Fastly API key" -id = "fastly-api-token" -regex = '''(?i)(?:fastly)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9=_\-]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "fastly", -] - -[[rules]] -description = "Finicity API token" -id = "finicity-api-token" -regex = '''(?i)(?:finicity)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-f0-9]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "finicity", -] - -[[rules]] -description = "Finicity Client Secret" -id = "finicity-client-secret" -regex = '''(?i)(?:finicity)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{20})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "finicity", -] - -[[rules]] -description = "Finnhub Access Token" -id = "finnhub-access-token" -regex = '''(?i)(?:finnhub)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{20})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "finnhub", -] - -[[rules]] -description = "Flickr Access Token" -id = "flickr-access-token" -regex = '''(?i)(?:flickr)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "flickr", -] - -[[rules]] -description = "Flutterwave Encryption Key" -id = "flutterwave-encryption-key" -regex = '''FLWSECK_TEST-(?i)[a-h0-9]{12}''' -keywords = [ - "flwseck_test", -] - -[[rules]] -description = "Finicity Public Key" -id = "flutterwave-public-key" -regex = '''FLWPUBK_TEST-(?i)[a-h0-9]{32}-X''' -keywords = [ - "flwpubk_test", -] - -[[rules]] -description = "Flutterwave Secret Key" -id = "flutterwave-secret-key" -regex = '''FLWSECK_TEST-(?i)[a-h0-9]{32}-X''' -keywords = [ - "flwseck_test", -] - -[[rules]] -description = "Frame.io API token" -id = "frameio-api-token" -regex = '''fio-u-(?i)[a-z0-9\-_=]{64}''' -keywords = [ - "fio-u-", -] - -[[rules]] -description = "Freshbooks Access Token" -id = "freshbooks-access-token" -regex = '''(?i)(?:freshbooks)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{64})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "freshbooks", -] - -[[rules]] -description = "GCP API key" -id = "gcp-api-key" -regex = '''(?i)\b(AIza[0-9A-Za-z\\-_]{35})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "aiza", -] - -[[rules]] -description = "Generic API Key" -id = "generic-api-key" -regex = '''(?i)(?:key|api|token|secret|client|passwd|password|auth|access)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([0-9a-z\-_.=]{10,150})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -entropy = 3.5 -keywords = [ - "key","api","token","secret","client","passwd","password","auth","access", -] -[rules.allowlist] -stopwords= [ - "client", - "endpoint", - "vpn", - "_ec2_", - "aws_", - "authorize", - "author", - "define", - "config", - "credential", - "setting", - "sample", - "xxxxxx", - "000000", - "buffer", - "delete", - "aaaaaa", - "fewfwef", - "getenv", - "env_", - "system", - "example", - "ecdsa", - "sha256", - "sha1", - "sha2", - "md5", - "alert", - "wizard", - "target", - "onboard", - "welcome", - "page", - "exploit", - "experiment", - "expire", - "rabbitmq", - "scraper", - "widget", - "music", - "dns_", - "dns-", - "yahoo", - "want", - "json", - "action", - "script", - "fix_", - "fix-", - "develop", - "compas", - "stripe", - "service", - "master", - "metric", - "tech", - "gitignore", - "rich", - "open", - "stack", - "irc_", - "irc-", - "sublime", - "kohana", - "has_", - "has-", - "fabric", - "wordpres", - "role", - "osx_", - "osx-", - "boost", - "addres", - "queue", - "working", - "sandbox", - "internet", - "print", - "vision", - "tracking", - "being", - "generator", - "traffic", - "world", - "pull", - "rust", - "watcher", - "small", - "auth", - "full", - "hash", - "more", - "install", - "auto", - "complete", - "learn", - "paper", - "installer", - "research", - "acces", - "last", - "binding", - "spine", - "into", - "chat", - "algorithm", - "resource", - "uploader", - "video", - "maker", - "next", - "proc", - "lock", - "robot", - "snake", - "patch", - "matrix", - "drill", - "terminal", - "term", - "stuff", - "genetic", - "generic", - "identity", - "audit", - "pattern", - "audio", - "web_", - "web-", - "crud", - "problem", - "statu", - "cms-", - "cms_", - "arch", - "coffee", - "workflow", - "changelog", - "another", - "uiview", - "content", - "kitchen", - "gnu_", - "gnu-", - "gnu.", - "conf", - "couchdb", - "client", - "opencv", - "rendering", - "update", - "concept", - "varnish", - "gui_", - "gui-", - "gui.", - "version", - "shared", - "extra", - "product", - "still", - "not_", - "not-", - "not.", - "drop", - "ring", - "png_", - "png-", - "png.", - "actively", - "import", - "output", - "backup", - "start", - "embedded", - "registry", - "pool", - "semantic", - "instagram", - "bash", - "system", - "ninja", - "drupal", - "jquery", - "polyfill", - "physic", - "league", - "guide", - "pack", - "synopsi", - "sketch", - "injection", - "svg_", - "svg-", - "svg.", - "friendly", - "wave", - "convert", - "manage", - "camera", - "link", - "slide", - "timer", - "wrapper", - "gallery", - "url_", - "url-", - "url.", - "todomvc", - "requirej", - "party", - "http", - "payment", - "async", - "library", - "home", - "coco", - "gaia", - "display", - "universal", - "func", - "metadata", - "hipchat", - "under", - "room", - "config", - "personal", - "realtime", - "resume", - "database", - "testing", - "tiny", - "basic", - "forum", - "meetup", - "yet_", - "yet-", - "yet.", - "cento", - "dead", - "fluentd", - "editor", - "utilitie", - "run_", - "run-", - "run.", - "box_", - "box-", - "box.", - "bot_", - "bot-", - "bot.", - "making", - "sample", - "group", - "monitor", - "ajax", - "parallel", - "cassandra", - "ultimate", - "site", - "get_", - "get-", - "get.", - "gen_", - "gen-", - "gen.", - "gem_", - "gem-", - "gem.", - "extended", - "image", - "knife", - "asset", - "nested", - "zero", - "plugin", - "bracket", - "mule", - "mozilla", - "number", - "act_", - "act-", - "act.", - "map_", - "map-", - "map.", - "micro", - "debug", - "openshift", - "chart", - "expres", - "backend", - "task", - "source", - "translate", - "jbos", - "composer", - "sqlite", - "profile", - "mustache", - "mqtt", - "yeoman", - "have", - "builder", - "smart", - "like", - "oauth", - "school", - "guideline", - "captcha", - "filter", - "bitcoin", - "bridge", - "color", - "toolbox", - "discovery", - "new_", - "new-", - "new.", - "dashboard", - "when", - "setting", - "level", - "post", - "standard", - "port", - "platform", - "yui_", - "yui-", - "yui.", - "grunt", - "animation", - "haskell", - "icon", - "latex", - "cheat", - "lua_", - "lua-", - "lua.", - "gulp", - "case", - "author", - "without", - "simulator", - "wifi", - "directory", - "lisp", - "list", - "flat", - "adventure", - "story", - "storm", - "gpu_", - "gpu-", - "gpu.", - "store", - "caching", - "attention", - "solr", - "logger", - "demo", - "shortener", - "hadoop", - "finder", - "phone", - "pipeline", - "range", - "textmate", - "showcase", - "app_", - "app-", - "app.", - "idiomatic", - "edit", - "our_", - "our-", - "our.", - "out_", - "out-", - "out.", - "sentiment", - "linked", - "why_", - "why-", - "why.", - "local", - "cube", - "gmail", - "job_", - "job-", - "job.", - "rpc_", - "rpc-", - "rpc.", - "contest", - "tcp_", - "tcp-", - "tcp.", - "usage", - "buildout", - "weather", - "transfer", - "automated", - "sphinx", - "issue", - "sas_", - "sas-", - "sas.", - "parallax", - "jasmine", - "addon", - "machine", - "solution", - "dsl_", - "dsl-", - "dsl.", - "episode", - "menu", - "theme", - "best", - "adapter", - "debugger", - "chrome", - "tutorial", - "life", - "step", - "people", - "joomla", - "paypal", - "developer", - "solver", - "team", - "current", - "love", - "visual", - "date", - "data", - "canva", - "container", - "future", - "xml_", - "xml-", - "xml.", - "twig", - "nagio", - "spatial", - "original", - "sync", - "archived", - "refinery", - "science", - "mapping", - "gitlab", - "play", - "ext_", - "ext-", - "ext.", - "session", - "impact", - "set_", - "set-", - "set.", - "see_", - "see-", - "see.", - "migration", - "commit", - "community", - "shopify", - "what'", - "cucumber", - "statamic", - "mysql", - "location", - "tower", - "line", - "code", - "amqp", - "hello", - "send", - "index", - "high", - "notebook", - "alloy", - "python", - "field", - "document", - "soap", - "edition", - "email", - "php_", - "php-", - "php.", - "command", - "transport", - "official", - "upload", - "study", - "secure", - "angularj", - "akka", - "scalable", - "package", - "request", - "con_", - "con-", - "con.", - "flexible", - "security", - "comment", - "module", - "flask", - "graph", - "flash", - "apache", - "change", - "window", - "space", - "lambda", - "sheet", - "bookmark", - "carousel", - "friend", - "objective", - "jekyll", - "bootstrap", - "first", - "article", - "gwt_", - "gwt-", - "gwt.", - "classic", - "media", - "websocket", - "touch", - "desktop", - "real", - "read", - "recorder", - "moved", - "storage", - "validator", - "add-on", - "pusher", - "scs_", - "scs-", - "scs.", - "inline", - "asp_", - "asp-", - "asp.", - "timeline", - "base", - "encoding", - "ffmpeg", - "kindle", - "tinymce", - "pretty", - "jpa_", - "jpa-", - "jpa.", - "used", - "user", - "required", - "webhook", - "download", - "resque", - "espresso", - "cloud", - "mongo", - "benchmark", - "pure", - "cakephp", - "modx", - "mode", - "reactive", - "fuel", - "written", - "flickr", - "mail", - "brunch", - "meteor", - "dynamic", - "neo_", - "neo-", - "neo.", - "new_", - "new-", - "new.", - "net_", - "net-", - "net.", - "typo", - "type", - "keyboard", - "erlang", - "adobe", - "logging", - "ckeditor", - "message", - "iso_", - "iso-", - "iso.", - "hook", - "ldap", - "folder", - "reference", - "railscast", - "www_", - "www-", - "www.", - "tracker", - "azure", - "fork", - "form", - "digital", - "exporter", - "skin", - "string", - "template", - "designer", - "gollum", - "fluent", - "entity", - "language", - "alfred", - "summary", - "wiki", - "kernel", - "calendar", - "plupload", - "symfony", - "foundry", - "remote", - "talk", - "search", - "dev_", - "dev-", - "dev.", - "del_", - "del-", - "del.", - "token", - "idea", - "sencha", - "selector", - "interface", - "create", - "fun_", - "fun-", - "fun.", - "groovy", - "query", - "grail", - "red_", - "red-", - "red.", - "laravel", - "monkey", - "slack", - "supported", - "instant", - "value", - "center", - "latest", - "work", - "but_", - "but-", - "but.", - "bug_", - "bug-", - "bug.", - "virtual", - "tweet", - "statsd", - "studio", - "path", - "real-time", - "frontend", - "notifier", - "coding", - "tool", - "firmware", - "flow", - "random", - "mediawiki", - "bosh", - "been", - "beer", - "lightbox", - "theory", - "origin", - "redmine", - "hub_", - "hub-", - "hub.", - "require", - "pro_", - "pro-", - "pro.", - "ant_", - "ant-", - "ant.", - "any_", - "any-", - "any.", - "recipe", - "closure", - "mapper", - "event", - "todo", - "model", - "redi", - "provider", - "rvm_", - "rvm-", - "rvm.", - "program", - "memcached", - "rail", - "silex", - "foreman", - "activity", - "license", - "strategy", - "batch", - "streaming", - "fast", - "use_", - "use-", - "use.", - "usb_", - "usb-", - "usb.", - "impres", - "academy", - "slider", - "please", - "layer", - "cros", - "now_", - "now-", - "now.", - "miner", - "extension", - "own_", - "own-", - "own.", - "app_", - "app-", - "app.", - "debian", - "symphony", - "example", - "feature", - "serie", - "tree", - "project", - "runner", - "entry", - "leetcode", - "layout", - "webrtc", - "logic", - "login", - "worker", - "toolkit", - "mocha", - "support", - "back", - "inside", - "device", - "jenkin", - "contact", - "fake", - "awesome", - "ocaml", - "bit_", - "bit-", - "bit.", - "drive", - "screen", - "prototype", - "gist", - "binary", - "nosql", - "rest", - "overview", - "dart", - "dark", - "emac", - "mongoid", - "solarized", - "homepage", - "emulator", - "commander", - "django", - "yandex", - "gradle", - "xcode", - "writer", - "crm_", - "crm-", - "crm.", - "jade", - "startup", - "error", - "using", - "format", - "name", - "spring", - "parser", - "scratch", - "magic", - "try_", - "try-", - "try.", - "rack", - "directive", - "challenge", - "slim", - "counter", - "element", - "chosen", - "doc_", - "doc-", - "doc.", - "meta", - "should", - "button", - "packet", - "stream", - "hardware", - "android", - "infinite", - "password", - "software", - "ghost", - "xamarin", - "spec", - "chef", - "interview", - "hubot", - "mvc_", - "mvc-", - "mvc.", - "exercise", - "leaflet", - "launcher", - "air_", - "air-", - "air.", - "photo", - "board", - "boxen", - "way_", - "way-", - "way.", - "computing", - "welcome", - "notepad", - "portfolio", - "cat_", - "cat-", - "cat.", - "can_", - "can-", - "can.", - "magento", - "yaml", - "domain", - "card", - "yii_", - "yii-", - "yii.", - "checker", - "browser", - "upgrade", - "only", - "progres", - "aura", - "ruby_", - "ruby-", - "ruby.", - "polymer", - "util", - "lite", - "hackathon", - "rule", - "log_", - "log-", - "log.", - "opengl", - "stanford", - "skeleton", - "history", - "inspector", - "help", - "soon", - "selenium", - "lab_", - "lab-", - "lab.", - "scheme", - "schema", - "look", - "ready", - "leveldb", - "docker", - "game", - "minimal", - "logstash", - "messaging", - "within", - "heroku", - "mongodb", - "kata", - "suite", - "picker", - "win_", - "win-", - "win.", - "wip_", - "wip-", - "wip.", - "panel", - "started", - "starter", - "front-end", - "detector", - "deploy", - "editing", - "based", - "admin", - "capture", - "spree", - "page", - "bundle", - "goal", - "rpg_", - "rpg-", - "rpg.", - "setup", - "side", - "mean", - "reader", - "cookbook", - "mini", - "modern", - "seed", - "dom_", - "dom-", - "dom.", - "doc_", - "doc-", - "doc.", - "dot_", - "dot-", - "dot.", - "syntax", - "sugar", - "loader", - "website", - "make", - "kit_", - "kit-", - "kit.", - "protocol", - "human", - "daemon", - "golang", - "manager", - "countdown", - "connector", - "swagger", - "map_", - "map-", - "map.", - "mac_", - "mac-", - "mac.", - "man_", - "man-", - "man.", - "orm_", - "orm-", - "orm.", - "org_", - "org-", - "org.", - "little", - "zsh_", - "zsh-", - "zsh.", - "shop", - "show", - "workshop", - "money", - "grid", - "server", - "octopres", - "svn_", - "svn-", - "svn.", - "ember", - "embed", - "general", - "file", - "important", - "dropbox", - "portable", - "public", - "docpad", - "fish", - "sbt_", - "sbt-", - "sbt.", - "done", - "para", - "network", - "common", - "readme", - "popup", - "simple", - "purpose", - "mirror", - "single", - "cordova", - "exchange", - "object", - "design", - "gateway", - "account", - "lamp", - "intellij", - "math", - "mit_", - "mit-", - "mit.", - "control", - "enhanced", - "emitter", - "multi", - "add_", - "add-", - "add.", - "about", - "socket", - "preview", - "vagrant", - "cli_", - "cli-", - "cli.", - "powerful", - "top_", - "top-", - "top.", - "radio", - "watch", - "fluid", - "amazon", - "report", - "couchbase", - "automatic", - "detection", - "sprite", - "pyramid", - "portal", - "advanced", - "plu_", - "plu-", - "plu.", - "runtime", - "git_", - "git-", - "git.", - "uri_", - "uri-", - "uri.", - "haml", - "node", - "sql_", - "sql-", - "sql.", - "cool", - "core", - "obsolete", - "handler", - "iphone", - "extractor", - "array", - "copy", - "nlp_", - "nlp-", - "nlp.", - "reveal", - "pop_", - "pop-", - "pop.", - "engine", - "parse", - "check", - "html", - "nest", - "all_", - "all-", - "all.", - "chinese", - "buildpack", - "what", - "tag_", - "tag-", - "tag.", - "proxy", - "style", - "cookie", - "feed", - "restful", - "compiler", - "creating", - "prelude", - "context", - "java", - "rspec", - "mock", - "backbone", - "light", - "spotify", - "flex", - "related", - "shell", - "which", - "clas", - "webapp", - "swift", - "ansible", - "unity", - "console", - "tumblr", - "export", - "campfire", - "conway'", - "made", - "riak", - "hero", - "here", - "unix", - "unit", - "glas", - "smtp", - "how_", - "how-", - "how.", - "hot_", - "hot-", - "hot.", - "debug", - "release", - "diff", - "player", - "easy", - "right", - "old_", - "old-", - "old.", - "animate", - "time", - "push", - "explorer", - "course", - "training", - "nette", - "router", - "draft", - "structure", - "note", - "salt", - "where", - "spark", - "trello", - "power", - "method", - "social", - "via_", - "via-", - "via.", - "vim_", - "vim-", - "vim.", - "select", - "webkit", - "github", - "ftp_", - "ftp-", - "ftp.", - "creator", - "mongoose", - "led_", - "led-", - "led.", - "movie", - "currently", - "pdf_", - "pdf-", - "pdf.", - "load", - "markdown", - "phalcon", - "input", - "custom", - "atom", - "oracle", - "phonegap", - "ubuntu", - "great", - "rdf_", - "rdf-", - "rdf.", - "popcorn", - "firefox", - "zip_", - "zip-", - "zip.", - "cuda", - "dotfile", - "static", - "openwrt", - "viewer", - "powered", - "graphic", - "les_", - "les-", - "les.", - "doe_", - "doe-", - "doe.", - "maven", - "word", - "eclipse", - "lab_", - "lab-", - "lab.", - "hacking", - "steam", - "analytic", - "option", - "abstract", - "archive", - "reality", - "switcher", - "club", - "write", - "kafka", - "arduino", - "angular", - "online", - "title", - "don't", - "contao", - "notice", - "analyzer", - "learning", - "zend", - "external", - "staging", - "busines", - "tdd_", - "tdd-", - "tdd.", - "scanner", - "building", - "snippet", - "modular", - "bower", - "stm_", - "stm-", - "stm.", - "lib_", - "lib-", - "lib.", - "alpha", - "mobile", - "clean", - "linux", - "nginx", - "manifest", - "some", - "raspberry", - "gnome", - "ide_", - "ide-", - "ide.", - "block", - "statistic", - "info", - "drag", - "youtube", - "koan", - "facebook", - "paperclip", - "art_", - "art-", - "art.", - "quality", - "tab_", - "tab-", - "tab.", - "need", - "dojo", - "shield", - "computer", - "stat", - "state", - "twitter", - "utility", - "converter", - "hosting", - "devise", - "liferay", - "updated", - "force", - "tip_", - "tip-", - "tip.", - "behavior", - "active", - "call", - "answer", - "deck", - "better", - "principle", - "ches", - "bar_", - "bar-", - "bar.", - "reddit", - "three", - "haxe", - "just", - "plug-in", - "agile", - "manual", - "tetri", - "super", - "beta", - "parsing", - "doctrine", - "minecraft", - "useful", - "perl", - "sharing", - "agent", - "switch", - "view", - "dash", - "channel", - "repo", - "pebble", - "profiler", - "warning", - "cluster", - "running", - "markup", - "evented", - "mod_", - "mod-", - "mod.", - "share", - "csv_", - "csv-", - "csv.", - "response", - "good", - "house", - "connect", - "built", - "build", - "find", - "ipython", - "webgl", - "big_", - "big-", - "big.", - "google", - "scala", - "sdl_", - "sdl-", - "sdl.", - "sdk_", - "sdk-", - "sdk.", - "native", - "day_", - "day-", - "day.", - "puppet", - "text", - "routing", - "helper", - "linkedin", - "crawler", - "host", - "guard", - "merchant", - "poker", - "over", - "writing", - "free", - "classe", - "component", - "craft", - "nodej", - "phoenix", - "longer", - "quick", - "lazy", - "memory", - "clone", - "hacker", - "middleman", - "factory", - "motion", - "multiple", - "tornado", - "hack", - "ssh_", - "ssh-", - "ssh.", - "review", - "vimrc", - "driver", - "driven", - "blog", - "particle", - "table", - "intro", - "importer", - "thrift", - "xmpp", - "framework", - "refresh", - "react", - "font", - "librarie", - "variou", - "formatter", - "analysi", - "karma", - "scroll", - "tut_", - "tut-", - "tut.", - "apple", - "tag_", - "tag-", - "tag.", - "tab_", - "tab-", - "tab.", - "category", - "ionic", - "cache", - "homebrew", - "reverse", - "english", - "getting", - "shipping", - "clojure", - "boot", - "book", - "branch", - "combination", - "combo", -] -[[rules]] -description = "GitHub App Token" -id = "github-app-token" -regex = '''(ghu|ghs)_[0-9a-zA-Z]{36}''' -keywords = [ - "ghu_","ghs_", -] - -[[rules]] -description = "GitHub Fine-Grained Personal Access Token" -id = "github-fine-grained-pat" -regex = '''github_pat_[0-9a-zA-Z_]{82}''' -keywords = [ - "github_pat_", -] - -[[rules]] -description = "GitHub OAuth Access Token" -id = "github-oauth" -regex = '''gho_[0-9a-zA-Z]{36}''' -keywords = [ - "gho_", -] - -[[rules]] -description = "GitHub Personal Access Token" -id = "github-pat" -regex = '''ghp_[0-9a-zA-Z]{36}''' -keywords = [ - "ghp_", -] - -[[rules]] -description = "GitHub Refresh Token" -id = "github-refresh-token" -regex = '''ghr_[0-9a-zA-Z]{36}''' -keywords = [ - "ghr_", -] - -[[rules]] -description = "GitLab Personal Access Token" -id = "gitlab-pat" -regex = '''glpat-[0-9a-zA-Z\-\_]{20}''' -keywords = [ - "glpat-", -] - -[[rules]] -description = "GitLab Pipeline Trigger Token" -id = "gitlab-ptt" -regex = '''glptt-[0-9a-f]{40}''' -keywords = [ - "glptt-", -] - -[[rules]] -description = "GitLab Runner Registration Token" -id = "gitlab-rrt" -regex = '''GR1348941[0-9a-zA-Z\-\_]{20}''' -keywords = [ - "gr1348941", -] - -[[rules]] -description = "Gitter Access Token" -id = "gitter-access-token" -regex = '''(?i)(?:gitter)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9_-]{40})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "gitter", -] - -[[rules]] -description = "GoCardless API token" -id = "gocardless-api-token" -regex = '''(?i)(?:gocardless)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}(live_(?i)[a-z0-9\-_=]{40})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "live_","gocardless", -] - -[[rules]] -description = "Grafana api key (or Grafana cloud api key)" -id = "grafana-api-key" -regex = '''(?i)\b(eyJrIjoi[A-Za-z0-9]{70,400}={0,2})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "eyjrijoi", -] - -[[rules]] -description = "Grafana cloud api token" -id = "grafana-cloud-api-token" -regex = '''(?i)\b(glc_[A-Za-z0-9+/]{32,400}={0,2})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "glc_", -] - -[[rules]] -description = "Grafana service account token" -id = "grafana-service-account-token" -regex = '''(?i)\b(glsa_[A-Za-z0-9]{32}_[A-Fa-f0-9]{8})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "glsa_", -] - -[[rules]] -description = "HashiCorp Terraform user/org API token" -id = "hashicorp-tf-api-token" -regex = '''(?i)[a-z0-9]{14}\.atlasv1\.[a-z0-9\-_=]{60,70}''' -keywords = [ - "atlasv1", -] - -[[rules]] -description = "Heroku API Key" -id = "heroku-api-key" -regex = '''(?i)(?:heroku)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "heroku", -] - -[[rules]] -description = "HubSpot API Token" -id = "hubspot-api-key" -regex = '''(?i)(?:hubspot)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "hubspot", -] - -[[rules]] -description = "Intercom API Token" -id = "intercom-api-key" -regex = '''(?i)(?:intercom)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9=_\-]{60})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "intercom", -] - -[[rules]] -description = "JSON Web Token" -id = "jwt" -regex = '''(?i)\b(ey[0-9a-z]{30,34}\.ey[0-9a-z-\/_]{30,500}\.[0-9a-zA-Z-\/_]{10,200}={0,2})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -keywords = [ - "ey", -] - -[[rules]] -description = "Kraken Access Token" -id = "kraken-access-token" -regex = '''(?i)(?:kraken)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9\/=_\+\-]{80,90})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "kraken", -] - -[[rules]] -description = "Kucoin Access Token" -id = "kucoin-access-token" -regex = '''(?i)(?:kucoin)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-f0-9]{24})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "kucoin", -] - -[[rules]] -description = "Kucoin Secret Key" -id = "kucoin-secret-key" -regex = '''(?i)(?:kucoin)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "kucoin", -] - -[[rules]] -description = "Launchdarkly Access Token" -id = "launchdarkly-access-token" -regex = '''(?i)(?:launchdarkly)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9=_\-]{40})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "launchdarkly", -] - -[[rules]] -description = "Linear API Token" -id = "linear-api-key" -regex = '''lin_api_(?i)[a-z0-9]{40}''' -keywords = [ - "lin_api_", -] - -[[rules]] -description = "Linear Client Secret" -id = "linear-client-secret" -regex = '''(?i)(?:linear)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-f0-9]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "linear", -] - -[[rules]] -description = "LinkedIn Client ID" -id = "linkedin-client-id" -regex = '''(?i)(?:linkedin|linked-in)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{14})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "linkedin","linked-in", -] - -[[rules]] -description = "LinkedIn Client secret" -id = "linkedin-client-secret" -regex = '''(?i)(?:linkedin|linked-in)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{16})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "linkedin","linked-in", -] - -[[rules]] -description = "Lob API Key" -id = "lob-api-key" -regex = '''(?i)(?:lob)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}((live|test)_[a-f0-9]{35})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "test_","live_", -] - -[[rules]] -description = "Lob Publishable API Key" -id = "lob-pub-api-key" -regex = '''(?i)(?:lob)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}((test|live)_pub_[a-f0-9]{31})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "test_pub","live_pub","_pub", -] - -[[rules]] -description = "Mailchimp API key" -id = "mailchimp-api-key" -regex = '''(?i)(?:mailchimp)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-f0-9]{32}-us20)(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "mailchimp", -] - -[[rules]] -description = "Mailgun private API token" -id = "mailgun-private-api-token" -regex = '''(?i)(?:mailgun)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}(key-[a-f0-9]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "mailgun", -] - -[[rules]] -description = "Mailgun public validation key" -id = "mailgun-pub-key" -regex = '''(?i)(?:mailgun)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}(pubkey-[a-f0-9]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "mailgun", -] - -[[rules]] -description = "Mailgun webhook signing key" -id = "mailgun-signing-key" -regex = '''(?i)(?:mailgun)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-h0-9]{32}-[a-h0-9]{8}-[a-h0-9]{8})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "mailgun", -] - -[[rules]] -description = "MapBox API token" -id = "mapbox-api-token" -regex = '''(?i)(?:mapbox)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}(pk\.[a-z0-9]{60}\.[a-z0-9]{22})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "mapbox", -] - -[[rules]] -description = "Mattermost Access Token" -id = "mattermost-access-token" -regex = '''(?i)(?:mattermost)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{26})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "mattermost", -] - -[[rules]] -description = "MessageBird API token" -id = "messagebird-api-token" -regex = '''(?i)(?:messagebird|message-bird|message_bird)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{25})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "messagebird","message-bird","message_bird", -] - -[[rules]] -description = "MessageBird client ID" -id = "messagebird-client-id" -regex = '''(?i)(?:messagebird|message-bird|message_bird)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "messagebird","message-bird","message_bird", -] - -[[rules]] -description = "Microsoft Teams Webhook" -id = "microsoft-teams-webhook" -regex = '''https:\/\/[a-z0-9]+\.webhook\.office\.com\/webhookb2\/[a-z0-9]{8}-([a-z0-9]{4}-){3}[a-z0-9]{12}@[a-z0-9]{8}-([a-z0-9]{4}-){3}[a-z0-9]{12}\/IncomingWebhook\/[a-z0-9]{32}\/[a-z0-9]{8}-([a-z0-9]{4}-){3}[a-z0-9]{12}''' -keywords = [ - "webhook.office.com","webhookb2","incomingwebhook", -] - -[[rules]] -description = "Netlify Access Token" -id = "netlify-access-token" -regex = '''(?i)(?:netlify)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9=_\-]{40,46})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "netlify", -] - -[[rules]] -description = "New Relic ingest browser API token" -id = "new-relic-browser-api-token" -regex = '''(?i)(?:new-relic|newrelic|new_relic)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}(NRJS-[a-f0-9]{19})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "nrjs-", -] - -[[rules]] -description = "New Relic user API ID" -id = "new-relic-user-api-id" -regex = '''(?i)(?:new-relic|newrelic|new_relic)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{64})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "new-relic","newrelic","new_relic", -] - -[[rules]] -description = "New Relic user API Key" -id = "new-relic-user-api-key" -regex = '''(?i)(?:new-relic|newrelic|new_relic)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}(NRAK-[a-z0-9]{27})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "nrak", -] - -[[rules]] -description = "npm access token" -id = "npm-access-token" -regex = '''(?i)\b(npm_[a-z0-9]{36})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "npm_", -] - -[[rules]] -description = "Nytimes Access Token" -id = "nytimes-access-token" -regex = '''(?i)(?:nytimes|new-york-times,|newyorktimes)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9=_\-]{32})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "nytimes","new-york-times","newyorktimes", -] - -[[rules]] -description = "Okta Access Token" -id = "okta-access-token" -regex = '''(?i)(?:okta)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9=_\-]{42})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "okta", -] - -[[rules]] -description = "Plaid API Token" -id = "plaid-api-token" -regex = '''(?i)(?:plaid)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}(access-(?:sandbox|development|production)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "plaid", -] - -[[rules]] -description = "Plaid Client ID" -id = "plaid-client-id" -regex = '''(?i)(?:plaid)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{24})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "plaid", -] - -[[rules]] -description = "Plaid Secret key" -id = "plaid-secret-key" -regex = '''(?i)(?:plaid)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{30})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "plaid", -] - -[[rules]] -description = "PlanetScale API token" -id = "planetscale-api-token" -regex = '''(?i)\b(pscale_tkn_(?i)[a-z0-9=\-_\.]{32,64})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "pscale_tkn_", -] - -[[rules]] -description = "PlanetScale OAuth token" -id = "planetscale-oauth-token" -regex = '''(?i)\b(pscale_oauth_(?i)[a-z0-9=\-_\.]{32,64})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "pscale_oauth_", -] - -[[rules]] -description = "PlanetScale password" -id = "planetscale-password" -regex = '''(?i)\b(pscale_pw_(?i)[a-z0-9=\-_\.]{32,64})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "pscale_pw_", -] - -[[rules]] -description = "Postman API token" -id = "postman-api-token" -regex = '''(?i)\b(PMAK-(?i)[a-f0-9]{24}\-[a-f0-9]{34})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "pmak-", -] - -[[rules]] -description = "Prefect API token" -id = "prefect-api-token" -regex = '''(?i)\b(pnu_[a-z0-9]{36})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "pnu_", -] - -[[rules]] -description = "Private Key" -id = "private-key" -regex = '''(?i)-----BEGIN[ A-Z0-9_-]{0,100}PRIVATE KEY( BLOCK)?-----[\s\S-]*KEY( BLOCK)?----''' -keywords = [ - "-----begin", -] - -[[rules]] -description = "Pulumi API token" -id = "pulumi-api-token" -regex = '''(?i)\b(pul-[a-f0-9]{40})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "pul-", -] - -[[rules]] -description = "PyPI upload token" -id = "pypi-upload-token" -regex = '''pypi-AgEIcHlwaS5vcmc[A-Za-z0-9\-_]{50,1000}''' -keywords = [ - "pypi-ageichlwas5vcmc", -] - -[[rules]] -description = "RapidAPI Access Token" -id = "rapidapi-access-token" -regex = '''(?i)(?:rapidapi)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9_-]{50})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "rapidapi", -] - -[[rules]] -description = "Readme API token" -id = "readme-api-token" -regex = '''(?i)\b(rdme_[a-z0-9]{70})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "rdme_", -] - -[[rules]] -description = "Rubygem API token" -id = "rubygems-api-token" -regex = '''(?i)\b(rubygems_[a-f0-9]{48})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "rubygems_", -] - -[[rules]] -description = "Sendbird Access ID" -id = "sendbird-access-id" -regex = '''(?i)(?:sendbird)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "sendbird", -] - -[[rules]] -description = "Sendbird Access Token" -id = "sendbird-access-token" -regex = '''(?i)(?:sendbird)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-f0-9]{40})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "sendbird", -] - -[[rules]] -description = "SendGrid API token" -id = "sendgrid-api-token" -regex = '''(?i)\b(SG\.(?i)[a-z0-9=_\-\.]{66})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "sg.", -] - -[[rules]] -description = "Sendinblue API token" -id = "sendinblue-api-token" -regex = '''(?i)\b(xkeysib-[a-f0-9]{64}\-(?i)[a-z0-9]{16})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "xkeysib-", -] - -[[rules]] -description = "Sentry Access Token" -id = "sentry-access-token" -regex = '''(?i)(?:sentry)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-f0-9]{64})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "sentry", -] - -[[rules]] -description = "Shippo API token" -id = "shippo-api-token" -regex = '''(?i)\b(shippo_(live|test)_[a-f0-9]{40})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "shippo_", -] - -[[rules]] -description = "Shopify access token" -id = "shopify-access-token" -regex = '''shpat_[a-fA-F0-9]{32}''' -keywords = [ - "shpat_", -] - -[[rules]] -description = "Shopify custom access token" -id = "shopify-custom-access-token" -regex = '''shpca_[a-fA-F0-9]{32}''' -keywords = [ - "shpca_", -] - -[[rules]] -description = "Shopify private app access token" -id = "shopify-private-app-access-token" -regex = '''shppa_[a-fA-F0-9]{32}''' -keywords = [ - "shppa_", -] - -[[rules]] -description = "Shopify shared secret" -id = "shopify-shared-secret" -regex = '''shpss_[a-fA-F0-9]{32}''' -keywords = [ - "shpss_", -] - -[[rules]] -description = "Sidekiq Secret" -id = "sidekiq-secret" -regex = '''(?i)(?:BUNDLE_ENTERPRISE__CONTRIBSYS__COM|BUNDLE_GEMS__CONTRIBSYS__COM)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-f0-9]{8}:[a-f0-9]{8})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "bundle_enterprise__contribsys__com","bundle_gems__contribsys__com", -] - -[[rules]] -description = "Sidekiq Sensitive URL" -id = "sidekiq-sensitive-url" -regex = '''(?i)\b(http(?:s??):\/\/)([a-f0-9]{8}:[a-f0-9]{8})@(?:gems.contribsys.com|enterprise.contribsys.com)(?:[\/|\#|\?|:]|$)''' -secretGroup = 2 -keywords = [ - "gems.contribsys.com","enterprise.contribsys.com", -] - -[[rules]] -description = "Slack token" -id = "slack-access-token" -regex = '''xox[baprs]-([0-9a-zA-Z]{10,48})''' -keywords = [ - "xoxb","xoxa","xoxp","xoxr","xoxs", -] - -[[rules]] -description = "Slack Webhook" -id = "slack-web-hook" -regex = '''https:\/\/hooks.slack.com\/(services|workflows)\/[A-Za-z0-9+\/]{44,46}''' -keywords = [ - "hooks.slack.com", -] - -[[rules]] -description = "Square Access Token" -id = "square-access-token" -regex = '''(?i)\b(sq0atp-[0-9A-Za-z\-_]{22})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -keywords = [ - "sq0atp-", -] - -[[rules]] -description = "Squarespace Access Token" -id = "squarespace-access-token" -regex = '''(?i)(?:squarespace)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "squarespace", -] - -[[rules]] -description = "Stripe Access Token" -id = "stripe-access-token" -regex = '''(?i)(sk|pk)_(test|live)_[0-9a-z]{10,32}''' -keywords = [ - "sk_test","pk_test","sk_live","pk_live", -] - -[[rules]] -description = "SumoLogic Access ID" -id = "sumologic-access-id" -regex = '''(?i)(?:sumo)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{14})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "sumo", -] - -[[rules]] -description = "SumoLogic Access Token" -id = "sumologic-access-token" -regex = '''(?i)(?:sumo)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{64})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "sumo", -] - -[[rules]] -description = "Telegram Bot API Token" -id = "telegram-bot-api-token" -regex = '''(?i)(?:^|[^0-9])([0-9]{5,16}:A[a-zA-Z0-9_\-]{34})(?:$|[^a-zA-Z0-9_\-])''' -secretGroup = 1 -keywords = [ - "telegram","api","bot","token","url", -] - -[[rules]] -description = "Travis CI Access Token" -id = "travisci-access-token" -regex = '''(?i)(?:travis)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{22})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "travis", -] - -[[rules]] -description = "Twilio API Key" -id = "twilio-api-key" -regex = '''SK[0-9a-fA-F]{32}''' -keywords = [ - "twilio", -] - -[[rules]] -description = "Twitch API token" -id = "twitch-api-token" -regex = '''(?i)(?:twitch)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{30})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "twitch", -] - -[[rules]] -description = "Twitter Access Secret" -id = "twitter-access-secret" -regex = '''(?i)(?:twitter)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{45})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "twitter", -] - -[[rules]] -description = "Twitter Access Token" -id = "twitter-access-token" -regex = '''(?i)(?:twitter)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([0-9]{15,25}-[a-zA-Z0-9]{20,40})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "twitter", -] - -[[rules]] -description = "Twitter API Key" -id = "twitter-api-key" -regex = '''(?i)(?:twitter)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{25})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "twitter", -] - -[[rules]] -description = "Twitter API Secret" -id = "twitter-api-secret" -regex = '''(?i)(?:twitter)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{50})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "twitter", -] - -[[rules]] -description = "Twitter Bearer Token" -id = "twitter-bearer-token" -regex = '''(?i)(?:twitter)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}(A{22}[a-zA-Z0-9%]{80,100})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "twitter", -] - -[[rules]] -description = "Typeform API token" -id = "typeform-api-token" -regex = '''(?i)(?:typeform)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}(tfp_[a-z0-9\-_\.=]{59})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "tfp_", -] - -[[rules]] -description = "Vault Batch Token" -id = "vault-batch-token" -regex = '''(?i)\b(hvb\.[a-z0-9_-]{138,212})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -keywords = [ - "hvb", -] - -[[rules]] -description = "Vault Service Token" -id = "vault-service-token" -regex = '''(?i)\b(hvs\.[a-z0-9_-]{90,100})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -keywords = [ - "hvs", -] - -[[rules]] -description = "Yandex Access Token" -id = "yandex-access-token" -regex = '''(?i)(?:yandex)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}(t1\.[A-Z0-9a-z_-]+[=]{0,2}\.[A-Z0-9a-z_-]{86}[=]{0,2})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "yandex", -] - -[[rules]] -description = "Yandex API Key" -id = "yandex-api-key" -regex = '''(?i)(?:yandex)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}(AQVN[A-Za-z0-9_\-]{35,38})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "yandex", -] - -[[rules]] -description = "Yandex AWS Access Token" -id = "yandex-aws-access-token" -regex = '''(?i)(?:yandex)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}(YC[a-zA-Z0-9_\-]{38})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "yandex", -] - -[[rules]] -description = "Zendesk Secret Key" -id = "zendesk-secret-key" -regex = '''(?i)(?:zendesk)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3}(?:=|>|:=|\|\|:|<=|=>|:)(?:'|\"|\s|=|\x60){0,5}([a-z0-9]{40})(?:['|\"|\n|\r|\s|\x60|;]|$)''' -secretGroup = 1 -keywords = [ - "zendesk", -] - - diff --git a/cli/config/rule.go b/cli/config/rule.go deleted file mode 100644 index b7c8c1518..000000000 --- a/cli/config/rule.go +++ /dev/null @@ -1,43 +0,0 @@ -package config - -import ( - "regexp" -) - -// Rules contain information that define details on how to detect secrets -type Rule struct { - // Description is the description of the rule. - Description string - - // RuleID is a unique identifier for this rule - RuleID string - - // Entropy is a float representing the minimum shannon - // entropy a regex group must have to be considered a secret. - Entropy float64 - - // SecretGroup is an int used to extract secret from regex - // match and used as the group that will have its entropy - // checked if `entropy` is set. - SecretGroup int - - // Regex is a golang regular expression used to detect secrets. - Regex *regexp.Regexp - - // Path is a golang regular expression used to - // filter secrets by path - Path *regexp.Regexp - - // Tags is an array of strings used for metadata - // and reporting purposes. - Tags []string - - // Keywords are used for pre-regex check filtering. Rules that contain - // keywords will perform a quick string compare check to make sure the - // keyword(s) are in the content being scanned. - Keywords []string - - // Allowlist allows a rule to be ignored for specific - // regexes, paths, and/or commits - Allowlist Allowlist -} diff --git a/cli/config/utils.go b/cli/config/utils.go deleted file mode 100644 index ada6ff0fe..000000000 --- a/cli/config/utils.go +++ /dev/null @@ -1,24 +0,0 @@ -package config - -import ( - "regexp" -) - -func anyRegexMatch(f string, res []*regexp.Regexp) bool { - for _, re := range res { - if regexMatched(f, re) { - return true - } - } - return false -} - -func regexMatched(f string, re *regexp.Regexp) bool { - if re == nil { - return false - } - if re.FindString(f) != "" { - return true - } - return false -} diff --git a/cli/detect/baseline.go b/cli/detect/baseline.go index bd4c25665..eeaa2a73a 100644 --- a/cli/detect/baseline.go +++ b/cli/detect/baseline.go @@ -25,35 +25,31 @@ package detect import ( "encoding/json" "fmt" - "io" "os" + "path/filepath" - "github.com/rs/zerolog/log" - - "github.com/Infisical/infisical-merge/report" + "github.com/Infisical/infisical-merge/detect/report" ) -func IsNew(finding report.Finding, baseline []report.Finding) bool { +func IsNew(finding report.Finding, redact uint, baseline []report.Finding) bool { // Explicitly testing each property as it gives significantly better performance in comparison to cmp.Equal(). Drawback is that - // the code requires maintanance if/when the Finding struct changes + // the code requires maintenance if/when the Finding struct changes for _, b := range baseline { - - if finding.Author == b.Author && - finding.Commit == b.Commit && - finding.Date == b.Date && + if finding.RuleID == b.RuleID && finding.Description == b.Description && - finding.Email == b.Email && - finding.EndColumn == b.EndColumn && + finding.StartLine == b.StartLine && finding.EndLine == b.EndLine && - finding.Entropy == b.Entropy && - finding.File == b.File && - // Omit checking finding.Fingerprint - if the format of the fingerprint changes, the users will see unexpected behaviour - finding.Match == b.Match && - finding.Message == b.Message && - finding.RuleID == b.RuleID && - finding.Secret == b.Secret && finding.StartColumn == b.StartColumn && - finding.StartLine == b.StartLine { + finding.EndColumn == b.EndColumn && + (redact > 0 || (finding.Match == b.Match && finding.Secret == b.Secret)) && + finding.File == b.File && + finding.Commit == b.Commit && + finding.Author == b.Author && + finding.Email == b.Email && + finding.Date == b.Date && + finding.Message == b.Message && + // Omit checking finding.Fingerprint - if the format of the fingerprint changes, the users will see unexpected behaviour + finding.Entropy == b.Entropy { return false } } @@ -61,23 +57,12 @@ func IsNew(finding report.Finding, baseline []report.Finding) bool { } func LoadBaseline(baselinePath string) ([]report.Finding, error) { - var previousFindings []report.Finding - jsonFile, err := os.Open(baselinePath) + bytes, err := os.ReadFile(baselinePath) if err != nil { return nil, fmt.Errorf("could not open %s", baselinePath) } - defer func() { - if cerr := jsonFile.Close(); cerr != nil { - log.Warn().Err(cerr).Msg("problem closing jsonFile handle") - } - }() - - bytes, err := io.ReadAll(jsonFile) - if err != nil { - return nil, fmt.Errorf("could not read data from the file %s", baselinePath) - } - + var previousFindings []report.Finding err = json.Unmarshal(bytes, &previousFindings) if err != nil { return nil, fmt.Errorf("the format of the file %s is not supported", baselinePath) @@ -85,3 +70,34 @@ func LoadBaseline(baselinePath string) ([]report.Finding, error) { return previousFindings, nil } + +func (d *Detector) AddBaseline(baselinePath string, source string) error { + if baselinePath != "" { + absoluteSource, err := filepath.Abs(source) + if err != nil { + return err + } + + absoluteBaseline, err := filepath.Abs(baselinePath) + if err != nil { + return err + } + + relativeBaseline, err := filepath.Rel(absoluteSource, absoluteBaseline) + if err != nil { + return err + } + + baseline, err := LoadBaseline(baselinePath) + if err != nil { + return err + } + + d.baseline = baseline + baselinePath = relativeBaseline + + } + + d.baselinePath = baselinePath + return nil +} diff --git a/cli/detect/baseline_test.go b/cli/detect/baseline_test.go deleted file mode 100644 index 91d2eb72e..000000000 --- a/cli/detect/baseline_test.go +++ /dev/null @@ -1,160 +0,0 @@ -// MIT License - -// Copyright (c) 2019 Zachary Rice - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package detect - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/Infisical/infisical-merge/report" -) - -func TestIsNew(t *testing.T) { - tests := []struct { - findings report.Finding - baseline []report.Finding - expect bool - }{ - { - findings: report.Finding{ - Author: "a", - Commit: "0000", - }, - baseline: []report.Finding{ - { - Author: "a", - Commit: "0000", - }, - }, - expect: false, - }, - { - findings: report.Finding{ - Author: "a", - Commit: "0000", - }, - baseline: []report.Finding{ - { - Author: "a", - Commit: "0002", - }, - }, - expect: true, - }, - { - findings: report.Finding{ - Author: "a", - Commit: "0000", - Tags: []string{"a", "b"}, - }, - baseline: []report.Finding{ - { - Author: "a", - Commit: "0000", - Tags: []string{"a", "c"}, - }, - }, - expect: false, // Updated tags doesn't make it a new finding - }, - } - for _, test := range tests { - assert.Equal(t, test.expect, IsNew(test.findings, test.baseline)) - } -} - -func TestFileLoadBaseline(t *testing.T) { - tests := []struct { - Filename string - ExpectedError error - }{ - { - Filename: "../testdata/baseline/baseline.csv", - ExpectedError: errors.New("the format of the file ../testdata/baseline/baseline.csv is not supported"), - }, - { - Filename: "../testdata/baseline/baseline.sarif", - ExpectedError: errors.New("the format of the file ../testdata/baseline/baseline.sarif is not supported"), - }, - { - Filename: "../testdata/baseline/notfound.json", - ExpectedError: errors.New("could not open ../testdata/baseline/notfound.json"), - }, - } - - for _, test := range tests { - _, err := LoadBaseline(test.Filename) - assert.Equal(t, test.ExpectedError.Error(), err.Error()) - } -} - -func TestIgnoreIssuesInBaseline(t *testing.T) { - tests := []struct { - findings []report.Finding - baseline []report.Finding - expectCount int - }{ - { - findings: []report.Finding{ - { - Author: "a", - Commit: "5", - }, - }, - baseline: []report.Finding{ - { - Author: "a", - Commit: "5", - }, - }, - expectCount: 0, - }, - { - findings: []report.Finding{ - { - Author: "a", - Commit: "5", - Fingerprint: "a", - }, - }, - baseline: []report.Finding{ - { - Author: "a", - Commit: "5", - Fingerprint: "b", - }, - }, - expectCount: 0, - }, - } - - for _, test := range tests { - d, _ := NewDetectorDefaultConfig() - d.baseline = test.baseline - for _, finding := range test.findings { - d.addFinding(finding) - } - assert.Equal(t, test.expectCount, len(d.findings)) - } -} diff --git a/cli/detect/cmd/scm/scm.go b/cli/detect/cmd/scm/scm.go new file mode 100644 index 000000000..66868aadc --- /dev/null +++ b/cli/detect/cmd/scm/scm.go @@ -0,0 +1,66 @@ +// MIT License + +// Copyright (c) 2019 Zachary Rice + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package scm + +import ( + "fmt" + "strings" +) + +type Platform int + +const ( + UnknownPlatform Platform = iota + NoPlatform // Explicitly disable the feature + GitHubPlatform + GitLabPlatform + AzureDevOpsPlatform + // TODO: Add others. +) + +func (p Platform) String() string { + return [...]string{ + "unknown", + "none", + "github", + "gitlab", + "azuredevops", + }[p] +} + +func PlatformFromString(s string) (Platform, error) { + switch strings.ToLower(s) { + case "", "unknown": + return UnknownPlatform, nil + case "none": + return NoPlatform, nil + case "github": + return GitHubPlatform, nil + case "gitlab": + return GitLabPlatform, nil + case "azuredevops": + return AzureDevOpsPlatform, nil + default: + return UnknownPlatform, fmt.Errorf("invalid scm platform value: %s", s) + } +} diff --git a/cli/config/allowlist.go b/cli/detect/config/allowlist.go similarity index 53% rename from cli/config/allowlist.go rename to cli/detect/config/allowlist.go index 373325758..d91188f68 100644 --- a/cli/config/allowlist.go +++ b/cli/detect/config/allowlist.go @@ -23,63 +23,137 @@ package config import ( - "regexp" + "fmt" "strings" + + "golang.org/x/exp/maps" + + "github.com/Infisical/infisical-merge/detect/regexp" ) +type AllowlistMatchCondition int + +const ( + AllowlistMatchOr AllowlistMatchCondition = iota + AllowlistMatchAnd +) + +func (a AllowlistMatchCondition) String() string { + return [...]string{ + "OR", + "AND", + }[a] +} + // Allowlist allows a rule to be ignored for specific // regexes, paths, and/or commits type Allowlist struct { // Short human readable description of the allowlist. Description string - // Regexes is slice of content regular expressions that are allowed to be ignored. - Regexes []*regexp.Regexp + // MatchCondition determines whether all criteria must match. + MatchCondition AllowlistMatchCondition - // RegexTarget - RegexTarget string + // Commits is a slice of commit SHAs that are allowed to be ignored. Defaults to "OR". + Commits []string // Paths is a slice of path regular expressions that are allowed to be ignored. Paths []*regexp.Regexp - // Commits is a slice of commit SHAs that are allowed to be ignored. - Commits []string + // Can be `match` or `line`. + // + // If `match` the _Regexes_ will be tested against the match of the _Rule.Regex_. + // + // If `line` the _Regexes_ will be tested against the entire line. + // + // If RegexTarget is empty, it will be tested against the found secret. + RegexTarget string + + // Regexes is slice of content regular expressions that are allowed to be ignored. + Regexes []*regexp.Regexp // StopWords is a slice of stop words that are allowed to be ignored. // This targets the _secret_, not the content of the regex match like the // Regexes slice. StopWords []string + + // validated is an internal flag to track whether `Validate()` has been called. + validated bool +} + +func (a *Allowlist) Validate() error { + if a.validated { + return nil + } + + // Disallow empty allowlists. + if len(a.Commits) == 0 && + len(a.Paths) == 0 && + len(a.Regexes) == 0 && + len(a.StopWords) == 0 { + return fmt.Errorf("must contain at least one check for: commits, paths, regexes, or stopwords") + } + + // Deduplicate commits and stopwords. + if len(a.Commits) > 0 { + uniqueCommits := make(map[string]struct{}) + for _, commit := range a.Commits { + uniqueCommits[commit] = struct{}{} + } + a.Commits = maps.Keys(uniqueCommits) + } + if len(a.StopWords) > 0 { + uniqueStopwords := make(map[string]struct{}) + for _, stopWord := range a.StopWords { + uniqueStopwords[stopWord] = struct{}{} + } + a.StopWords = maps.Keys(uniqueStopwords) + } + + a.validated = true + return nil } // CommitAllowed returns true if the commit is allowed to be ignored. -func (a *Allowlist) CommitAllowed(c string) bool { - if c == "" { - return false +func (a *Allowlist) CommitAllowed(c string) (bool, string) { + if a == nil || c == "" { + return false, "" } + for _, commit := range a.Commits { if commit == c { - return true + return true, c } } - return false + return false, "" } // PathAllowed returns true if the path is allowed to be ignored. func (a *Allowlist) PathAllowed(path string) bool { + if a == nil || path == "" { + return false + } return anyRegexMatch(path, a.Paths) } // RegexAllowed returns true if the regex is allowed to be ignored. -func (a *Allowlist) RegexAllowed(s string) bool { - return anyRegexMatch(s, a.Regexes) +func (a *Allowlist) RegexAllowed(secret string) bool { + if a == nil || secret == "" { + return false + } + return anyRegexMatch(secret, a.Regexes) } -func (a *Allowlist) ContainsStopWord(s string) bool { +func (a *Allowlist) ContainsStopWord(s string) (bool, string) { + if a == nil || s == "" { + return false, "" + } + s = strings.ToLower(s) for _, stopWord := range a.StopWords { if strings.Contains(s, strings.ToLower(stopWord)) { - return true + return true, stopWord } } - return false + return false, "" } diff --git a/cli/detect/config/config.go b/cli/detect/config/config.go new file mode 100644 index 000000000..10c6db7e0 --- /dev/null +++ b/cli/detect/config/config.go @@ -0,0 +1,426 @@ +// MIT License + +// Copyright (c) 2019 Zachary Rice + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package config + +import ( + _ "embed" + "errors" + "fmt" + "sort" + "strings" + + "github.com/spf13/viper" + + "github.com/Infisical/infisical-merge/detect/logging" + "github.com/Infisical/infisical-merge/detect/regexp" +) + +const DefaultScanConfigFileName = ".infisical-scan.toml" +const DefaultScanConfigEnvName = "INFISICAL_SCAN_CONFIG" +const DefaultInfisicalIgnoreFineName = ".infisicalignore" + +var ( + //go:embed gitleaks.toml + DefaultConfig string + + // use to keep track of how many configs we can extend + // yea I know, globals bad + extendDepth int +) + +const maxExtendDepth = 2 + +// ViperConfig is the config struct used by the Viper config package +// to parse the config file. This struct does not include regular expressions. +// It is used as an intermediary to convert the Viper config to the Config struct. +type ViperConfig struct { + Title string + Description string + Extend Extend + Rules []struct { + ID string + Description string + Path string + Regex string + SecretGroup int + Entropy float64 + Keywords []string + Tags []string + + // Deprecated: this is a shim for backwards-compatibility. + // TODO: Remove this in 9.x. + AllowList *viperRuleAllowlist + Allowlists []*viperRuleAllowlist + } + // Deprecated: this is a shim for backwards-compatibility. + // TODO: Remove this in 9.x. + AllowList *viperGlobalAllowlist + Allowlists []*viperGlobalAllowlist +} + +type viperRuleAllowlist struct { + Description string + Condition string + Commits []string + Paths []string + RegexTarget string + Regexes []string + StopWords []string +} + +type viperGlobalAllowlist struct { + TargetRules []string + viperRuleAllowlist `mapstructure:",squash"` +} + +// Config is a configuration struct that contains rules and an allowlist if present. +type Config struct { + Title string + Extend Extend + Path string + Description string + Rules map[string]Rule + Keywords map[string]struct{} + // used to keep sarif results consistent + OrderedRules []string + Allowlists []*Allowlist +} + +// Extend is a struct that allows users to define how they want their +// configuration extended by other configuration files. +type Extend struct { + Path string + URL string + UseDefault bool + DisabledRules []string +} + +func (vc *ViperConfig) Translate() (Config, error) { + var ( + keywords = make(map[string]struct{}) + orderedRules []string + rulesMap = make(map[string]Rule) + ruleAllowlists = make(map[string][]*Allowlist) + ) + + // Validate individual rules. + for _, vr := range vc.Rules { + var ( + pathPat *regexp.Regexp + regexPat *regexp.Regexp + ) + if vr.Path != "" { + pathPat = regexp.MustCompile(vr.Path) + } + if vr.Regex != "" { + regexPat = regexp.MustCompile(vr.Regex) + } + if vr.Keywords == nil { + vr.Keywords = []string{} + } else { + for i, k := range vr.Keywords { + keyword := strings.ToLower(k) + keywords[keyword] = struct{}{} + vr.Keywords[i] = keyword + } + } + if vr.Tags == nil { + vr.Tags = []string{} + } + cr := Rule{ + RuleID: vr.ID, + Description: vr.Description, + Regex: regexPat, + SecretGroup: vr.SecretGroup, + Entropy: vr.Entropy, + Path: pathPat, + Keywords: vr.Keywords, + Tags: vr.Tags, + } + + // Parse the rule allowlists, including the older format for backwards compatibility. + if vr.AllowList != nil { + // TODO: Remove this in v9. + if len(vr.Allowlists) > 0 { + return Config{}, fmt.Errorf("%s: [rules.allowlist] is deprecated, it cannot be used alongside [[rules.allowlist]]", cr.RuleID) + } + vr.Allowlists = append(vr.Allowlists, vr.AllowList) + } + for _, a := range vr.Allowlists { + allowlist, err := parseAllowlist(a) + if err != nil { + return Config{}, fmt.Errorf("%s: [[rules.allowlists]] %w", cr.RuleID, err) + } + cr.Allowlists = append(cr.Allowlists, allowlist) + } + orderedRules = append(orderedRules, cr.RuleID) + rulesMap[cr.RuleID] = cr + } + + // Assemble the config. + c := Config{ + Title: vc.Title, + Description: vc.Description, + Extend: vc.Extend, + Rules: rulesMap, + Keywords: keywords, + OrderedRules: orderedRules, + } + // Parse the config allowlists, including the older format for backwards compatibility. + if vc.AllowList != nil { + // TODO: Remove this in v9. + if len(vc.Allowlists) > 0 { + return Config{}, errors.New("[allowlist] is deprecated, it cannot be used alongside [[allowlists]]") + } + vc.Allowlists = append(vc.Allowlists, vc.AllowList) + } + for _, a := range vc.Allowlists { + allowlist, err := parseAllowlist(&a.viperRuleAllowlist) + if err != nil { + return Config{}, fmt.Errorf("[[allowlists]] %w", err) + } + // Allowlists with |targetRules| aren't added to the global list. + if len(a.TargetRules) > 0 { + for _, ruleID := range a.TargetRules { + // It's not possible to validate |ruleID| until after extend. + ruleAllowlists[ruleID] = append(ruleAllowlists[ruleID], allowlist) + } + } else { + c.Allowlists = append(c.Allowlists, allowlist) + } + } + + if maxExtendDepth != extendDepth { + // disallow both usedefault and path from being set + if c.Extend.Path != "" && c.Extend.UseDefault { + return Config{}, errors.New("unable to load config due to extend.path and extend.useDefault being set") + } + if c.Extend.UseDefault { + if err := c.extendDefault(); err != nil { + return Config{}, err + } + } else if c.Extend.Path != "" { + if err := c.extendPath(); err != nil { + return Config{}, err + } + } + } + + // Validate the rules after everything has been assembled (including extended configs). + if extendDepth == 0 { + for _, rule := range c.Rules { + if err := rule.Validate(); err != nil { + return Config{}, err + } + } + + // Populate targeted configs. + for ruleID, allowlists := range ruleAllowlists { + rule, ok := c.Rules[ruleID] + if !ok { + return Config{}, fmt.Errorf("[[allowlists]] target rule ID '%s' does not exist", ruleID) + } + rule.Allowlists = append(rule.Allowlists, allowlists...) + c.Rules[ruleID] = rule + } + } + + return c, nil +} + +func parseAllowlist(a *viperRuleAllowlist) (*Allowlist, error) { + var matchCondition AllowlistMatchCondition + switch strings.ToUpper(a.Condition) { + case "AND", "&&": + matchCondition = AllowlistMatchAnd + case "", "OR", "||": + matchCondition = AllowlistMatchOr + default: + return nil, fmt.Errorf("unknown allowlist |condition| '%s' (expected 'and', 'or')", a.Condition) + } + + // Validate the target. + regexTarget := a.RegexTarget + if regexTarget != "" { + switch regexTarget { + case "secret": + regexTarget = "" + case "match", "line": + // do nothing + default: + return nil, fmt.Errorf("unknown allowlist |regexTarget| '%s' (expected 'match', 'line')", regexTarget) + } + } + var allowlistRegexes []*regexp.Regexp + for _, a := range a.Regexes { + allowlistRegexes = append(allowlistRegexes, regexp.MustCompile(a)) + } + var allowlistPaths []*regexp.Regexp + for _, a := range a.Paths { + allowlistPaths = append(allowlistPaths, regexp.MustCompile(a)) + } + + allowlist := &Allowlist{ + Description: a.Description, + MatchCondition: matchCondition, + Commits: a.Commits, + Paths: allowlistPaths, + RegexTarget: regexTarget, + Regexes: allowlistRegexes, + StopWords: a.StopWords, + } + if err := allowlist.Validate(); err != nil { + return nil, err + } + return allowlist, nil +} + +func (c *Config) GetOrderedRules() []Rule { + var orderedRules []Rule + for _, id := range c.OrderedRules { + if _, ok := c.Rules[id]; ok { + orderedRules = append(orderedRules, c.Rules[id]) + } + } + return orderedRules +} + +func (c *Config) extendDefault() error { + extendDepth++ + viper.SetConfigType("toml") + if err := viper.ReadConfig(strings.NewReader(DefaultConfig)); err != nil { + return fmt.Errorf("failed to load extended default config, err: %w", err) + } + defaultViperConfig := ViperConfig{} + if err := viper.Unmarshal(&defaultViperConfig); err != nil { + return fmt.Errorf("failed to load extended default config, err: %w", err) + } + cfg, err := defaultViperConfig.Translate() + if err != nil { + return fmt.Errorf("failed to load extended default config, err: %w", err) + + } + logging.Debug().Msg("extending config with default config") + c.extend(cfg) + return nil +} + +func (c *Config) extendPath() error { + extendDepth++ + viper.SetConfigFile(c.Extend.Path) + if err := viper.ReadInConfig(); err != nil { + return fmt.Errorf("failed to load extended config, err: %w", err) + } + extensionViperConfig := ViperConfig{} + if err := viper.Unmarshal(&extensionViperConfig); err != nil { + return fmt.Errorf("failed to load extended config, err: %w", err) + } + cfg, err := extensionViperConfig.Translate() + if err != nil { + return fmt.Errorf("failed to load extended config, err: %w", err) + } + logging.Debug().Msgf("extending config with %s", c.Extend.Path) + c.extend(cfg) + return nil +} + +func (c *Config) extendURL() { + // TODO +} + +func (c *Config) extend(extensionConfig Config) { + // Get config name for helpful log messages. + var configName string + if c.Extend.Path != "" { + configName = c.Extend.Path + } else { + configName = "default" + } + // Convert |Config.DisabledRules| into a map for ease of access. + disabledRuleIDs := map[string]struct{}{} + for _, id := range c.Extend.DisabledRules { + if _, ok := extensionConfig.Rules[id]; !ok { + logging.Warn(). + Str("rule-id", id). + Str("config", configName). + Msg("Disabled rule doesn't exist in extended config.") + } + disabledRuleIDs[id] = struct{}{} + } + + for ruleID, baseRule := range extensionConfig.Rules { + // Skip the rule. + if _, ok := disabledRuleIDs[ruleID]; ok { + logging.Debug(). + Str("rule-id", ruleID). + Str("config", configName). + Msg("Ignoring rule from extended config.") + continue + } + + currentRule, ok := c.Rules[ruleID] + if !ok { + // Rule doesn't exist, add it to the config. + c.Rules[ruleID] = baseRule + for _, k := range baseRule.Keywords { + c.Keywords[k] = struct{}{} + } + c.OrderedRules = append(c.OrderedRules, ruleID) + } else { + // Rule exists, merge our changes into the base. + if currentRule.Description != "" { + baseRule.Description = currentRule.Description + } + if currentRule.Entropy != 0 { + baseRule.Entropy = currentRule.Entropy + } + if currentRule.SecretGroup != 0 { + baseRule.SecretGroup = currentRule.SecretGroup + } + if currentRule.Regex != nil { + baseRule.Regex = currentRule.Regex + } + if currentRule.Path != nil { + baseRule.Path = currentRule.Path + } + baseRule.Tags = append(baseRule.Tags, currentRule.Tags...) + baseRule.Keywords = append(baseRule.Keywords, currentRule.Keywords...) + for _, a := range currentRule.Allowlists { + baseRule.Allowlists = append(baseRule.Allowlists, a) + } + // The keywords from the base rule and the extended rule must be merged into the global keywords list + for _, k := range baseRule.Keywords { + c.Keywords[k] = struct{}{} + } + c.Rules[ruleID] = baseRule + } + } + + // append allowlists, not attempting to merge + for _, a := range extensionConfig.Allowlists { + c.Allowlists = append(c.Allowlists, a) + } + + // sort to keep extended rules in order + sort.Strings(c.OrderedRules) +} diff --git a/cli/detect/config/gitleaks.toml b/cli/detect/config/gitleaks.toml new file mode 100644 index 000000000..92a06a319 --- /dev/null +++ b/cli/detect/config/gitleaks.toml @@ -0,0 +1,3130 @@ +# This file has been auto-generated. Do not edit manually. +# If you would like to contribute new rules, please use +# cmd/generate/config/main.go and follow the contributing guidelines +# at https://github.com/gitleaks/gitleaks/blob/master/CONTRIBUTING.md +# +# How the hell does secret scanning work? Read this: +# https://lookingatcomputer.substack.com/p/regex-is-almost-all-you-need +# +# This is the default gitleaks configuration file. +# Rules and allowlists are defined within this file. +# Rules instruct gitleaks on what should be considered a secret. +# Allowlists instruct gitleaks on what is allowed, i.e. not a secret. + +title = "gitleaks config" + +# TODO: change to [[allowlists]] +[allowlist] +description = "global allow lists" +paths = [ + '''gitleaks\.toml''', + '''(?i)\.(?:bmp|gif|jpe?g|png|svg|tiff?)$''', + '''(?i)\.(?:eot|[ot]tf|woff2?)$''', + '''(?i)\.(?:docx?|xlsx?|pdf|bin|socket|vsidx|v2|suo|wsuo|.dll|pdb|exe|gltf|zip)$''', + '''go\.(?:mod|sum|work(?:\.sum)?)$''', + '''(?:^|/)vendor/modules\.txt$''', + '''(?:^|/)vendor/(?:github\.com|golang\.org/x|google\.golang\.org|gopkg\.in|istio\.io|k8s\.io|sigs\.k8s\.io)(?:/.*)?$''', + '''(?:^|/)gradlew(?:\.bat)?$''', + '''(?:^|/)gradle\.lockfile$''', + '''(?:^|/)mvnw(?:\.cmd)?$''', + '''(?:^|/)\.mvn/wrapper/MavenWrapperDownloader\.java$''', + '''(?:^|/)node_modules(?:/.*)?$''', + '''(?:^|/)(?:deno\.lock|npm-shrinkwrap\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock)$''', + '''(?:^|/)bower_components(?:/.*)?$''', + '''(?:^|/)(?:angular|bootstrap|jquery(?:-?ui)?|plotly|swagger-?ui)[a-zA-Z0-9.-]*(?:\.min)?\.js(?:\.map)?$''', + '''(?:^|/)javascript\.json$''', + '''(?:^|/)(?:Pipfile|poetry)\.lock$''', + '''(?i)(?:^|/)(?:v?env|virtualenv)/lib(?:64)?(?:/.*)?$''', + '''(?i)(?:^|/)(?:lib(?:64)?/python[23](?:\.\d{1,2})+|python/[23](?:\.\d{1,2})+/lib(?:64)?)(?:/.*)?$''', + '''(?i)(?:^|/)[a-z0-9_.]+-[0-9.]+\.dist-info(?:/.+)?$''', + '''(?:^|/)vendor/(?:bundle|ruby)(?:/.*?)?$''', + '''\.gem$''', + '''verification-metadata\.xml''', + '''Database.refactorlog''', +] +regexes = [ + '''(?i)^true|false|null$''', + '''^(?i:a+|b+|c+|d+|e+|f+|g+|h+|i+|j+|k+|l+|m+|n+|o+|p+|q+|r+|s+|t+|u+|v+|w+|x+|y+|z+|\*+|\.+)$''', + '''^\$(?:\d+|{\d+})$''', + '''^\$(?:[A-Z_]+|[a-z_]+)$''', + '''^\${(?:[A-Z_]+|[a-z_]+)}$''', + '''^\{\{[ \t]*[\w ().|]+[ \t]*}}$''', + '''^\$\{\{[ \t]*(?:(?:env|github|secrets|vars)(?:\.[A-Za-z]\w+)+[\w "'&./=|]*)[ \t]*}}$''', + '''^%(?:[A-Z_]+|[a-z_]+)%$''', + '''^%[+\-# 0]?[bcdeEfFgGoOpqstTUvxX]$''', + '''^\{\d{0,2}}$''', + '''^@(?:[A-Z_]+|[a-z_]+)@$''', + '''^/Users/(?i)[a-z0-9]+/[\w .-/]+$''', + '''^/(?:bin|etc|home|opt|tmp|usr|var)/[\w ./-]+$''', +] +stopwords = [ + "abcdefghijklmnopqrstuvwxyz", + "014df517-39d1-4453-b7b3-9930c563627c", +] + +[[rules]] +id = "1password-secret-key" +description = "Uncovered a possible 1Password secret key, potentially compromising access to secrets in vaults." +regex = '''\bA3-[A-Z0-9]{6}-(?:(?:[A-Z0-9]{11})|(?:[A-Z0-9]{6}-[A-Z0-9]{5}))-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}\b''' +entropy = 3.8 +keywords = ["a3-"] + +[[rules]] +id = "1password-service-account-token" +description = "Uncovered a possible 1Password service account token, potentially compromising access to secrets in vaults." +regex = '''ops_eyJ[a-zA-Z0-9+/]{250,}={0,3}''' +entropy = 4 +keywords = ["ops_"] + +[[rules]] +id = "adafruit-api-key" +description = "Identified a potential Adafruit API Key, which could lead to unauthorized access to Adafruit services and sensitive data exposure." +regex = '''(?i)[\w.-]{0,50}?(?:adafruit)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9_-]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["adafruit"] + +[[rules]] +id = "adobe-client-id" +description = "Detected a pattern that resembles an Adobe OAuth Web Client ID, posing a risk of compromised Adobe integrations and data breaches." +regex = '''(?i)[\w.-]{0,50}?(?:adobe)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-f0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["adobe"] + +[[rules]] +id = "adobe-client-secret" +description = "Discovered a potential Adobe Client Secret, which, if exposed, could allow unauthorized Adobe service access and data manipulation." +regex = '''\b(p8e-(?i)[a-z0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["p8e-"] + +[[rules]] +id = "age-secret-key" +description = "Discovered a potential Age encryption tool secret key, risking data decryption and unauthorized access to sensitive information." +regex = '''AGE-SECRET-KEY-1[QPZRY9X8GF2TVDW0S3JN54KHCE6MUA7L]{58}''' +keywords = ["age-secret-key-1"] + +[[rules]] +id = "airtable-api-key" +description = "Uncovered a possible Airtable API Key, potentially compromising database access and leading to data leakage or alteration." +regex = '''(?i)[\w.-]{0,50}?(?:airtable)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{17})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["airtable"] + +[[rules]] +id = "algolia-api-key" +description = "Identified an Algolia API Key, which could result in unauthorized search operations and data exposure on Algolia-managed platforms." +regex = '''(?i)[\w.-]{0,50}?(?:algolia)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["algolia"] + +[[rules]] +id = "alibaba-access-key-id" +description = "Detected an Alibaba Cloud AccessKey ID, posing a risk of unauthorized cloud resource access and potential data compromise." +regex = '''\b(LTAI(?i)[a-z0-9]{20})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["ltai"] + +[[rules]] +id = "alibaba-secret-key" +description = "Discovered a potential Alibaba Cloud Secret Key, potentially allowing unauthorized operations and data access within Alibaba Cloud." +regex = '''(?i)[\w.-]{0,50}?(?:alibaba)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{30})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["alibaba"] + +[[rules]] +id = "asana-client-id" +description = "Discovered a potential Asana Client ID, risking unauthorized access to Asana projects and sensitive task information." +regex = '''(?i)[\w.-]{0,50}?(?:asana)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([0-9]{16})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["asana"] + +[[rules]] +id = "asana-client-secret" +description = "Identified an Asana Client Secret, which could lead to compromised project management integrity and unauthorized access." +regex = '''(?i)[\w.-]{0,50}?(?:asana)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["asana"] + +[[rules]] +id = "atlassian-api-token" +description = "Detected an Atlassian API token, posing a threat to project management and collaboration tool security and data confidentiality." +regex = '''[\w.-]{0,50}?(?i:[\w.-]{0,50}?(?:atlassian|confluence|jira)(?:[ \t\w.-]{0,20})[\s'"]{0,3})(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-zA-Z0-9]{24})(?:[\x60'"\s;]|\\[nr]|$)|\b(ATATT3[A-Za-z0-9_\-=]{186})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3.5 +keywords = [ + "atlassian", + "confluence", + "jira", + "atatt3", +] + +[[rules]] +id = "authress-service-client-access-key" +description = "Uncovered a possible Authress Service Client Access Key, which may compromise access control services and sensitive data." +regex = '''\b((?:sc|ext|scauth|authress)_(?i)[a-z0-9]{5,30}\.[a-z0-9]{4,6}\.(?-i:acc)[_-][a-z0-9-]{10,32}\.[a-z0-9+/_=-]{30,120})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = [ + "sc_", + "ext_", + "scauth_", + "authress_", +] + +[[rules]] +id = "aws-access-token" +description = "Identified a pattern that may indicate AWS credentials, risking unauthorized cloud resource access and data breaches on AWS platforms." +regex = '''\b((?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16})\b''' +entropy = 3 +keywords = [ + "a3t", + "akia", + "asia", + "abia", + "acca", +] +[[rules.allowlists]] +regexes = [ + '''.+EXAMPLE$''', +] + +[[rules]] +id = "azure-ad-client-secret" +description = "Azure AD Client Secret" +regex = '''(?:^|[\\'"\x60\s>=:(,)])([a-zA-Z0-9_~.]{3}\dQ~[a-zA-Z0-9_~.-]{31,34})(?:$|[\\'"\x60\s<),])''' +entropy = 3 +keywords = ["q~"] + +[[rules]] +id = "beamer-api-token" +description = "Detected a Beamer API token, potentially compromising content management and exposing sensitive notifications and updates." +regex = '''(?i)[\w.-]{0,50}?(?:beamer)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(b_[a-z0-9=_\-]{44})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["beamer"] + +[[rules]] +id = "bitbucket-client-id" +description = "Discovered a potential Bitbucket Client ID, risking unauthorized repository access and potential codebase exposure." +regex = '''(?i)[\w.-]{0,50}?(?:bitbucket)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["bitbucket"] + +[[rules]] +id = "bitbucket-client-secret" +description = "Discovered a potential Bitbucket Client Secret, posing a risk of compromised code repositories and unauthorized access." +regex = '''(?i)[\w.-]{0,50}?(?:bitbucket)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9=_\-]{64})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["bitbucket"] + +[[rules]] +id = "bittrex-access-key" +description = "Identified a Bittrex Access Key, which could lead to unauthorized access to cryptocurrency trading accounts and financial loss." +regex = '''(?i)[\w.-]{0,50}?(?:bittrex)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["bittrex"] + +[[rules]] +id = "bittrex-secret-key" +description = "Detected a Bittrex Secret Key, potentially compromising cryptocurrency transactions and financial security." +regex = '''(?i)[\w.-]{0,50}?(?:bittrex)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["bittrex"] + +[[rules]] +id = "cisco-meraki-api-key" +description = "Cisco Meraki is a cloud-managed IT solution that provides networking, security, and device management through an easy-to-use interface." +regex = '''[\w.-]{0,50}?(?i:[\w.-]{0,50}?(?:(?-i:[Mm]eraki|MERAKI))(?:[ \t\w.-]{0,20})[\s'"]{0,3})(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([0-9a-f]{40})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["meraki"] + +[[rules]] +id = "clickhouse-cloud-api-secret-key" +description = "Identified a pattern that may indicate clickhouse cloud API secret key, risking unauthorized clickhouse cloud api access and data breaches on ClickHouse Cloud platforms." +regex = '''\b(4b1d[A-Za-z0-9]{38})\b''' +entropy = 3 +keywords = ["4b1d"] + +[[rules]] +id = "clojars-api-token" +description = "Uncovered a possible Clojars API token, risking unauthorized access to Clojure libraries and potential code manipulation." +regex = '''(?i)CLOJARS_[a-z0-9]{60}''' +entropy = 2 +keywords = ["clojars_"] + +[[rules]] +id = "cloudflare-api-key" +description = "Detected a Cloudflare API Key, potentially compromising cloud application deployments and operational security." +regex = '''(?i)[\w.-]{0,50}?(?:cloudflare)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9_-]{40})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["cloudflare"] + +[[rules]] +id = "cloudflare-global-api-key" +description = "Detected a Cloudflare Global API Key, potentially compromising cloud application deployments and operational security." +regex = '''(?i)[\w.-]{0,50}?(?:cloudflare)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-f0-9]{37})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["cloudflare"] + +[[rules]] +id = "cloudflare-origin-ca-key" +description = "Detected a Cloudflare Origin CA Key, potentially compromising cloud application deployments and operational security." +regex = '''\b(v1\.0-[a-f0-9]{24}-[a-f0-9]{146})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = [ + "cloudflare", + "v1.0-", +] + +[[rules]] +id = "codecov-access-token" +description = "Found a pattern resembling a Codecov Access Token, posing a risk of unauthorized access to code coverage reports and sensitive data." +regex = '''(?i)[\w.-]{0,50}?(?:codecov)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["codecov"] + +[[rules]] +id = "cohere-api-token" +description = "Identified a Cohere Token, posing a risk of unauthorized access to AI services and data manipulation." +regex = '''[\w.-]{0,50}?(?i:[\w.-]{0,50}?(?:cohere|CO_API_KEY)(?:[ \t\w.-]{0,20})[\s'"]{0,3})(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-zA-Z0-9]{40})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 4 +keywords = [ + "cohere", + "co_api_key", +] + +[[rules]] +id = "coinbase-access-token" +description = "Detected a Coinbase Access Token, posing a risk of unauthorized access to cryptocurrency accounts and financial transactions." +regex = '''(?i)[\w.-]{0,50}?(?:coinbase)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9_-]{64})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["coinbase"] + +[[rules]] +id = "confluent-access-token" +description = "Identified a Confluent Access Token, which could compromise access to streaming data platforms and sensitive data flow." +regex = '''(?i)[\w.-]{0,50}?(?:confluent)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{16})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["confluent"] + +[[rules]] +id = "confluent-secret-key" +description = "Found a Confluent Secret Key, potentially risking unauthorized operations and data access within Confluent services." +regex = '''(?i)[\w.-]{0,50}?(?:confluent)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{64})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["confluent"] + +[[rules]] +id = "contentful-delivery-api-token" +description = "Discovered a Contentful delivery API token, posing a risk to content management systems and data integrity." +regex = '''(?i)[\w.-]{0,50}?(?:contentful)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9=_\-]{43})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["contentful"] + +[[rules]] +id = "curl-auth-header" +description = "Discovered a potential authorization token provided in a curl command header, which could compromise the curl accessed resource." +regex = '''\bcurl\b(?:.*?|.*?(?:[\r\n]{1,2}.*?){1,5})[ \t\n\r](?:-H|--header)(?:=|[ \t]{0,5})(?:"(?i)(?:Authorization:[ \t]{0,5}(?:Basic[ \t]([a-z0-9+/]{8,}={0,3})|(?:Bearer|(?:Api-)?Token)[ \t]([\w=~@.+/-]{8,})|([\w=~@.+/-]{8,}))|(?:(?:X-(?:[a-z]+-)?)?(?:Api-?)?(?:Key|Token)):[ \t]{0,5}([\w=~@.+/-]{8,}))"|'(?i)(?:Authorization:[ \t]{0,5}(?:Basic[ \t]([a-z0-9+/]{8,}={0,3})|(?:Bearer|(?:Api-)?Token)[ \t]([\w=~@.+/-]{8,})|([\w=~@.+/-]{8,}))|(?:(?:X-(?:[a-z]+-)?)?(?:Api-?)?(?:Key|Token)):[ \t]{0,5}([\w=~@.+/-]{8,}))')(?:\B|\s|\z)''' +entropy = 2.75 +keywords = ["curl"] + +[[rules]] +id = "curl-auth-user" +description = "Discovered a potential basic authorization token provided in a curl command, which could compromise the curl accessed resource." +regex = '''\bcurl\b(?:.*|.*(?:[\r\n]{1,2}.*){1,5})[ \t\n\r](?:-u|--user)(?:=|[ \t]{0,5})("(:[^"]{3,}|[^:"]{3,}:|[^:"]{3,}:[^"]{3,})"|'([^:']{3,}:[^']{3,})'|((?:"[^"]{3,}"|'[^']{3,}'|[\w$@.-]+):(?:"[^"]{3,}"|'[^']{3,}'|[\w${}@.-]+)))(?:\s|\z)''' +entropy = 2 +keywords = ["curl"] +[[rules.allowlists]] +regexes = [ + '''[^:]+:(?:change(?:it|me)|pass(?:word)?|pwd|test|token|\*+|x+)''', + '''['"]?<[^>]+>['"]?:['"]?<[^>]+>|<[^:]+:[^>]+>['"]?''', + '''[^:]+:\[[^]]+]''', + '''['"]?[^:]+['"]?:['"]?\$(?:\d|\w+|\{(?:\d|\w+)})['"]?''', + '''\$\([^)]+\):\$\([^)]+\)''', + '''['"]?\$?{{[^}]+}}['"]?:['"]?\$?{{[^}]+}}['"]?''', +] + +[[rules]] +id = "databricks-api-token" +description = "Uncovered a Databricks API token, which may compromise big data analytics platforms and sensitive data processing." +regex = '''\b(dapi[a-f0-9]{32}(?:-\d)?)(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["dapi"] + +[[rules]] +id = "datadog-access-token" +description = "Detected a Datadog Access Token, potentially risking monitoring and analytics data exposure and manipulation." +regex = '''(?i)[\w.-]{0,50}?(?:datadog)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{40})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["datadog"] + +[[rules]] +id = "defined-networking-api-token" +description = "Identified a Defined Networking API token, which could lead to unauthorized network operations and data breaches." +regex = '''(?i)[\w.-]{0,50}?(?:dnkey)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(dnkey-[a-z0-9=_\-]{26}-[a-z0-9=_\-]{52})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["dnkey"] + +[[rules]] +id = "digitalocean-access-token" +description = "Found a DigitalOcean OAuth Access Token, risking unauthorized cloud resource access and data compromise." +regex = '''\b(doo_v1_[a-f0-9]{64})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["doo_v1_"] + +[[rules]] +id = "digitalocean-pat" +description = "Discovered a DigitalOcean Personal Access Token, posing a threat to cloud infrastructure security and data privacy." +regex = '''\b(dop_v1_[a-f0-9]{64})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["dop_v1_"] + +[[rules]] +id = "digitalocean-refresh-token" +description = "Uncovered a DigitalOcean OAuth Refresh Token, which could allow prolonged unauthorized access and resource manipulation." +regex = '''(?i)\b(dor_v1_[a-f0-9]{64})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["dor_v1_"] + +[[rules]] +id = "discord-api-token" +description = "Detected a Discord API key, potentially compromising communication channels and user data privacy on Discord." +regex = '''(?i)[\w.-]{0,50}?(?:discord)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-f0-9]{64})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["discord"] + +[[rules]] +id = "discord-client-id" +description = "Identified a Discord client ID, which may lead to unauthorized integrations and data exposure in Discord applications." +regex = '''(?i)[\w.-]{0,50}?(?:discord)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([0-9]{18})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["discord"] + +[[rules]] +id = "discord-client-secret" +description = "Discovered a potential Discord client secret, risking compromised Discord bot integrations and data leaks." +regex = '''(?i)[\w.-]{0,50}?(?:discord)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9=_\-]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["discord"] + +[[rules]] +id = "doppler-api-token" +description = "Discovered a Doppler API token, posing a risk to environment and secrets management security." +regex = '''dp\.pt\.(?i)[a-z0-9]{43}''' +entropy = 2 +keywords = ["dp.pt."] + +[[rules]] +id = "droneci-access-token" +description = "Detected a Droneci Access Token, potentially compromising continuous integration and deployment workflows." +regex = '''(?i)[\w.-]{0,50}?(?:droneci)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["droneci"] + +[[rules]] +id = "dropbox-api-token" +description = "Identified a Dropbox API secret, which could lead to unauthorized file access and data breaches in Dropbox storage." +regex = '''(?i)[\w.-]{0,50}?(?:dropbox)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{15})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["dropbox"] + +[[rules]] +id = "dropbox-long-lived-api-token" +description = "Found a Dropbox long-lived API token, risking prolonged unauthorized access to cloud storage and sensitive data." +regex = '''(?i)[\w.-]{0,50}?(?:dropbox)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{11}(AAAAAAAAAA)[a-z0-9\-_=]{43})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["dropbox"] + +[[rules]] +id = "dropbox-short-lived-api-token" +description = "Discovered a Dropbox short-lived API token, posing a risk of temporary but potentially harmful data access and manipulation." +regex = '''(?i)[\w.-]{0,50}?(?:dropbox)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(sl\.[a-z0-9\-=_]{135})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["dropbox"] + +[[rules]] +id = "duffel-api-token" +description = "Uncovered a Duffel API token, which may compromise travel platform integrations and sensitive customer data." +regex = '''duffel_(?:test|live)_(?i)[a-z0-9_\-=]{43}''' +entropy = 2 +keywords = ["duffel_"] + +[[rules]] +id = "dynatrace-api-token" +description = "Detected a Dynatrace API token, potentially risking application performance monitoring and data exposure." +regex = '''dt0c01\.(?i)[a-z0-9]{24}\.[a-z0-9]{64}''' +entropy = 4 +keywords = ["dt0c01."] + +[[rules]] +id = "easypost-api-token" +description = "Identified an EasyPost API token, which could lead to unauthorized postal and shipment service access and data exposure." +regex = '''\bEZAK(?i)[a-z0-9]{54}\b''' +entropy = 2 +keywords = ["ezak"] + +[[rules]] +id = "easypost-test-api-token" +description = "Detected an EasyPost test API token, risking exposure of test environments and potentially sensitive shipment data." +regex = '''\bEZTK(?i)[a-z0-9]{54}\b''' +entropy = 2 +keywords = ["eztk"] + +[[rules]] +id = "etsy-access-token" +description = "Found an Etsy Access Token, potentially compromising Etsy shop management and customer data." +regex = '''(?i)[\w.-]{0,50}?(?:(?-i:ETSY|[Ee]tsy))(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{24})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["etsy"] + +[[rules]] +id = "facebook-access-token" +description = "Discovered a Facebook Access Token, posing a risk of unauthorized access to Facebook accounts and personal data exposure." +regex = '''(?i)\b(\d{15,16}(\||%)[0-9a-z\-_]{27,40})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["facebook"] + +[[rules]] +id = "facebook-page-access-token" +description = "Discovered a Facebook Page Access Token, posing a risk of unauthorized access to Facebook accounts and personal data exposure." +regex = '''\b(EAA[MC](?i)[a-z0-9]{100,})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 4 +keywords = [ + "eaam", + "eaac", +] + +[[rules]] +id = "facebook-secret" +description = "Discovered a Facebook Application secret, posing a risk of unauthorized access to Facebook accounts and personal data exposure." +regex = '''(?i)[\w.-]{0,50}?(?:facebook)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-f0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["facebook"] + +[[rules]] +id = "fastly-api-token" +description = "Uncovered a Fastly API key, which may compromise CDN and edge cloud services, leading to content delivery and security issues." +regex = '''(?i)[\w.-]{0,50}?(?:fastly)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9=_\-]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["fastly"] + +[[rules]] +id = "finicity-api-token" +description = "Detected a Finicity API token, potentially risking financial data access and unauthorized financial operations." +regex = '''(?i)[\w.-]{0,50}?(?:finicity)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-f0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["finicity"] + +[[rules]] +id = "finicity-client-secret" +description = "Identified a Finicity Client Secret, which could lead to compromised financial service integrations and data breaches." +regex = '''(?i)[\w.-]{0,50}?(?:finicity)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{20})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["finicity"] + +[[rules]] +id = "finnhub-access-token" +description = "Found a Finnhub Access Token, risking unauthorized access to financial market data and analytics." +regex = '''(?i)[\w.-]{0,50}?(?:finnhub)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{20})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["finnhub"] + +[[rules]] +id = "flickr-access-token" +description = "Discovered a Flickr Access Token, posing a risk of unauthorized photo management and potential data leakage." +regex = '''(?i)[\w.-]{0,50}?(?:flickr)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["flickr"] + +[[rules]] +id = "flutterwave-encryption-key" +description = "Uncovered a Flutterwave Encryption Key, which may compromise payment processing and sensitive financial information." +regex = '''FLWSECK_TEST-(?i)[a-h0-9]{12}''' +entropy = 2 +keywords = ["flwseck_test"] + +[[rules]] +id = "flutterwave-public-key" +description = "Detected a Finicity Public Key, potentially exposing public cryptographic operations and integrations." +regex = '''FLWPUBK_TEST-(?i)[a-h0-9]{32}-X''' +entropy = 2 +keywords = ["flwpubk_test"] + +[[rules]] +id = "flutterwave-secret-key" +description = "Identified a Flutterwave Secret Key, risking unauthorized financial transactions and data breaches." +regex = '''FLWSECK_TEST-(?i)[a-h0-9]{32}-X''' +entropy = 2 +keywords = ["flwseck_test"] + +[[rules]] +id = "flyio-access-token" +description = "Uncovered a Fly.io API key" +regex = '''\b((?:fo1_[\w-]{43}|fm1[ar]_[a-zA-Z0-9+\/]{100,}={0,3}|fm2_[a-zA-Z0-9+\/]{100,}={0,3}))(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 4 +keywords = [ + "fo1_", + "fm1", + "fm2_", +] + +[[rules]] +id = "frameio-api-token" +description = "Found a Frame.io API token, potentially compromising video collaboration and project management." +regex = '''fio-u-(?i)[a-z0-9\-_=]{64}''' +keywords = ["fio-u-"] + +[[rules]] +id = "freemius-secret-key" +description = "Detected a Freemius secret key, potentially exposing sensitive information." +regex = '''(?i)["']secret_key["']\s*=>\s*["'](sk_[\S]{29})["']''' +path = '''(?i)\.php$''' +keywords = ["secret_key"] + +[[rules]] +id = "freshbooks-access-token" +description = "Discovered a Freshbooks Access Token, posing a risk to accounting software access and sensitive financial data exposure." +regex = '''(?i)[\w.-]{0,50}?(?:freshbooks)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{64})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["freshbooks"] + +[[rules]] +id = "gcp-api-key" +description = "Uncovered a GCP API key, which could lead to unauthorized access to Google Cloud services and data breaches." +regex = '''\b(AIza[\w-]{35})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 4 +keywords = ["aiza"] +[[rules.allowlists]] +regexes = [ + '''AIzaSyabcdefghijklmnopqrstuvwxyz1234567''', + '''AIzaSyAnLA7NfeLquW1tJFpx_eQCxoX-oo6YyIs''', + '''AIzaSyCkEhVjf3pduRDt6d1yKOMitrUEke8agEM''', + '''AIzaSyDMAScliyLx7F0NPDEJi1QmyCgHIAODrlU''', + '''AIzaSyD3asb-2pEZVqMkmL6M9N6nHZRR_znhrh0''', + '''AIzayDNSXIbFmlXbIE6mCzDLQAqITYefhixbX4A''', + '''AIzaSyAdOS2zB6NCsk1pCdZ4-P6GBdi_UUPwX7c''', + '''AIzaSyASWm6HmTMdYWpgMnjRBjxcQ9CKctWmLd4''', + '''AIzaSyANUvH9H9BsUccjsu2pCmEkOPjjaXeDQgY''', + '''AIzaSyA5_iVawFQ8ABuTZNUdcwERLJv_a_p4wtM''', + '''AIzaSyA4UrcGxgwQFTfaI3no3t7Lt1sjmdnP5sQ''', + '''AIzaSyDSb51JiIcB6OJpwwMicseKRhhrOq1cS7g''', + '''AIzaSyBF2RrAIm4a0mO64EShQfqfd2AFnzAvvuU''', + '''AIzaSyBcE-OOIbhjyR83gm4r2MFCu4MJmprNXsw''', + '''AIzaSyB8qGxt4ec15vitgn44duC5ucxaOi4FmqE''', + '''AIzaSyA8vmApnrHNFE0bApF4hoZ11srVL_n0nvY''', +] + +[[rules]] +id = "generic-api-key" +description = "Detected a Generic API Key, potentially exposing access to various services and sensitive operations." +regex = '''(?i)[\w.-]{0,50}?(?:access|auth|(?-i:[Aa]pi|API)|credential|creds|key|passw(?:or)?d|secret|token)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([\w.=-]{10,150}|[a-z0-9][a-z0-9+/]{11,}={0,3})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3.5 +keywords = [ + "access", + "api", + "auth", + "key", + "credential", + "creds", + "passwd", + "password", + "secret", + "token", +] +[[rules.allowlists]] +regexes = [ + '''^[a-zA-Z_.-]+$''', +] +[[rules.allowlists]] +description = "Allowlist for Generic API Keys" +regexTarget = "match" +regexes = [ + '''(?i)(?:access(?:ibility|or)|access[_.-]?id|random[_.-]?access|api[_.-]?(?:id|name|version)|rapid|capital|[a-z0-9-]*?api[a-z0-9-]*?:jar:|author|X-MS-Exchange-Organization-Auth|Authentication-Results|(?:credentials?[_.-]?id|withCredentials)|(?:bucket|foreign|hot|idx|natural|primary|pub(?:lic)?|schema|sequence)[_.-]?key|(?:turkey)|key[_.-]?(?:alias|board|code|frame|id|length|mesh|name|pair|press(?:ed)?|ring|selector|signature|size|stone|storetype|word|up|down|left|right)|key[_.-]?vault[_.-]?(?:id|name)|keyVaultToStoreSecrets|key(?:store|tab)[_.-]?(?:file|path)|issuerkeyhash|(?-i:[DdMm]onkey|[DM]ONKEY)|keying|(?:secret)[_.-]?(?:length|name|size)|UserSecretsId|(?:csrf)[_.-]?token|(?:io\.jsonwebtoken[ \t]?:[ \t]?[\w-]+)|(?:api|credentials|token)[_.-]?(?:endpoint|ur[il])|public[_.-]?token|(?:key|token)[_.-]?file|(?-i:(?:[A-Z_]+=\n[A-Z_]+=|[a-z_]+=\n[a-z_]+=)(?:\n|\z))|(?-i:(?:[A-Z.]+=\n[A-Z.]+=|[a-z.]+=\n[a-z.]+=)(?:\n|\z)))''', +] +stopwords = [ + "000000", + "6fe4476ee5a1832882e326b506d14126", + "_ec2_", + "aaaaaa", + "about", + "abstract", + "academy", + "acces", + "account", + "act-", + "act.", + "act_", + "action", + "active", + "actively", + "activity", + "adapter", + "add-", + "add-on", + "add.", + "add_", + "addon", + "addres", + "admin", + "adobe", + "advanced", + "adventure", + "agent", + "agile", + "air-", + "air.", + "air_", + "ajax", + "akka", + "alert", + "alfred", + "algorithm", + "all-", + "all.", + "all_", + "alloy", + "alpha", + "amazon", + "amqp", + "analysi", + "analytic", + "analyzer", + "android", + "angular", + "angularj", + "animate", + "animation", + "another", + "ansible", + "answer", + "ant-", + "ant.", + "ant_", + "any-", + "any.", + "any_", + "apache", + "app-", + "app.", + "app_", + "apple", + "arch", + "archive", + "archived", + "arduino", + "array", + "art-", + "art.", + "art_", + "article", + "asp-", + "asp.", + "asp_", + "asset", + "async", + "atom", + "attention", + "audio", + "audit", + "aura", + "auth", + "author", + "authorize", + "auto", + "automated", + "automatic", + "awesome", + "aws_", + "azure", + "back", + "backbone", + "backend", + "backup", + "bar-", + "bar.", + "bar_", + "base", + "based", + "bash", + "basic", + "batch", + "been", + "beer", + "behavior", + "being", + "benchmark", + "best", + "beta", + "better", + "big-", + "big.", + "big_", + "binary", + "binding", + "bit-", + "bit.", + "bit_", + "bitcoin", + "block", + "blog", + "board", + "book", + "bookmark", + "boost", + "boot", + "bootstrap", + "bosh", + "bot-", + "bot.", + "bot_", + "bower", + "box-", + "box.", + "box_", + "boxen", + "bracket", + "branch", + "bridge", + "browser", + "brunch", + "buffer", + "bug-", + "bug.", + "bug_", + "build", + "builder", + "building", + "buildout", + "buildpack", + "built", + "bundle", + "busines", + "but-", + "but.", + "but_", + "button", + "cache", + "caching", + "cakephp", + "calendar", + "call", + "camera", + "campfire", + "can-", + "can.", + "can_", + "canva", + "captcha", + "capture", + "card", + "carousel", + "case", + "cassandra", + "cat-", + "cat.", + "cat_", + "category", + "center", + "cento", + "challenge", + "change", + "changelog", + "channel", + "chart", + "chat", + "cheat", + "check", + "checker", + "chef", + "ches", + "chinese", + "chosen", + "chrome", + "ckeditor", + "clas", + "classe", + "classic", + "clean", + "cli-", + "cli.", + "cli_", + "client", + "clojure", + "clone", + "closure", + "cloud", + "club", + "cluster", + "cms-", + "cms_", + "coco", + "code", + "coding", + "coffee", + "color", + "combination", + "combo", + "command", + "commander", + "comment", + "commit", + "common", + "community", + "compas", + "compiler", + "complete", + "component", + "composer", + "computer", + "computing", + "con-", + "con.", + "con_", + "concept", + "conf", + "config", + "connect", + "connector", + "console", + "contact", + "container", + "contao", + "content", + "contest", + "context", + "control", + "convert", + "converter", + "conway'", + "cookbook", + "cookie", + "cool", + "copy", + "cordova", + "core", + "couchbase", + "couchdb", + "countdown", + "counter", + "course", + "craft", + "crawler", + "create", + "creating", + "creator", + "credential", + "crm-", + "crm.", + "crm_", + "cros", + "crud", + "csv-", + "csv.", + "csv_", + "cube", + "cucumber", + "cuda", + "current", + "currently", + "custom", + "daemon", + "dark", + "dart", + "dash", + "dashboard", + "data", + "database", + "date", + "day-", + "day.", + "day_", + "dead", + "debian", + "debug", + "debugger", + "deck", + "define", + "del-", + "del.", + "del_", + "delete", + "demo", + "deploy", + "design", + "designer", + "desktop", + "detection", + "detector", + "dev-", + "dev.", + "dev_", + "develop", + "developer", + "device", + "devise", + "diff", + "digital", + "directive", + "directory", + "discovery", + "display", + "django", + "dns-", + "dns_", + "doc-", + "doc.", + "doc_", + "docker", + "docpad", + "doctrine", + "document", + "doe-", + "doe.", + "doe_", + "dojo", + "dom-", + "dom.", + "dom_", + "domain", + "don't", + "done", + "dot-", + "dot.", + "dot_", + "dotfile", + "download", + "draft", + "drag", + "drill", + "drive", + "driven", + "driver", + "drop", + "dropbox", + "drupal", + "dsl-", + "dsl.", + "dsl_", + "dynamic", + "easy", + "ecdsa", + "eclipse", + "edit", + "editing", + "edition", + "editor", + "element", + "emac", + "email", + "embed", + "embedded", + "ember", + "emitter", + "emulator", + "encoding", + "endpoint", + "engine", + "english", + "enhanced", + "entity", + "entry", + "env_", + "episode", + "erlang", + "error", + "espresso", + "event", + "evented", + "example", + "exchange", + "exercise", + "experiment", + "expire", + "exploit", + "explorer", + "export", + "exporter", + "expres", + "ext-", + "ext.", + "ext_", + "extended", + "extension", + "external", + "extra", + "extractor", + "fabric", + "facebook", + "factory", + "fake", + "fast", + "feature", + "feed", + "fewfwef", + "ffmpeg", + "field", + "file", + "filter", + "find", + "finder", + "firefox", + "firmware", + "first", + "fish", + "fix-", + "fix_", + "flash", + "flask", + "flat", + "flex", + "flexible", + "flickr", + "flow", + "fluent", + "fluentd", + "fluid", + "folder", + "font", + "force", + "foreman", + "fork", + "form", + "format", + "formatter", + "forum", + "foundry", + "framework", + "free", + "friend", + "friendly", + "front-end", + "frontend", + "ftp-", + "ftp.", + "ftp_", + "fuel", + "full", + "fun-", + "fun.", + "fun_", + "func", + "future", + "gaia", + "gallery", + "game", + "gateway", + "gem-", + "gem.", + "gem_", + "gen-", + "gen.", + "gen_", + "general", + "generator", + "generic", + "genetic", + "get-", + "get.", + "get_", + "getenv", + "getting", + "ghost", + "gist", + "git-", + "git.", + "git_", + "github", + "gitignore", + "gitlab", + "glas", + "gmail", + "gnome", + "gnu-", + "gnu.", + "gnu_", + "goal", + "golang", + "gollum", + "good", + "google", + "gpu-", + "gpu.", + "gpu_", + "gradle", + "grail", + "graph", + "graphic", + "great", + "grid", + "groovy", + "group", + "grunt", + "guard", + "gui-", + "gui.", + "gui_", + "guide", + "guideline", + "gulp", + "gwt-", + "gwt.", + "gwt_", + "hack", + "hackathon", + "hacker", + "hacking", + "hadoop", + "haml", + "handler", + "hardware", + "has-", + "has_", + "hash", + "haskell", + "have", + "haxe", + "hello", + "help", + "helper", + "here", + "hero", + "heroku", + "high", + "hipchat", + "history", + "home", + "homebrew", + "homepage", + "hook", + "host", + "hosting", + "hot-", + "hot.", + "hot_", + "house", + "how-", + "how.", + "how_", + "html", + "http", + "hub-", + "hub.", + "hub_", + "hubot", + "human", + "icon", + "ide-", + "ide.", + "ide_", + "idea", + "identity", + "idiomatic", + "image", + "impact", + "import", + "important", + "importer", + "impres", + "index", + "infinite", + "info", + "injection", + "inline", + "input", + "inside", + "inspector", + "instagram", + "install", + "installer", + "instant", + "intellij", + "interface", + "internet", + "interview", + "into", + "intro", + "ionic", + "iphone", + "ipython", + "irc-", + "irc_", + "iso-", + "iso.", + "iso_", + "issue", + "jade", + "jasmine", + "java", + "jbos", + "jekyll", + "jenkin", + "jetbrains", + "job-", + "job.", + "job_", + "joomla", + "jpa-", + "jpa.", + "jpa_", + "jquery", + "json", + "just", + "kafka", + "karma", + "kata", + "kernel", + "keyboard", + "kindle", + "kit-", + "kit.", + "kit_", + "kitchen", + "knife", + "koan", + "kohana", + "lab-", + "lab.", + "lab_", + "lambda", + "lamp", + "language", + "laravel", + "last", + "latest", + "latex", + "launcher", + "layer", + "layout", + "lazy", + "ldap", + "leaflet", + "league", + "learn", + "learning", + "led-", + "led.", + "led_", + "leetcode", + "les-", + "les.", + "les_", + "level", + "leveldb", + "lib-", + "lib.", + "lib_", + "librarie", + "library", + "license", + "life", + "liferay", + "light", + "lightbox", + "like", + "line", + "link", + "linked", + "linkedin", + "linux", + "lisp", + "list", + "lite", + "little", + "load", + "loader", + "local", + "location", + "lock", + "log-", + "log.", + "log_", + "logger", + "logging", + "logic", + "login", + "logstash", + "longer", + "look", + "love", + "lua-", + "lua.", + "lua_", + "mac-", + "mac.", + "mac_", + "machine", + "made", + "magento", + "magic", + "mail", + "make", + "maker", + "making", + "man-", + "man.", + "man_", + "manage", + "manager", + "manifest", + "manual", + "map-", + "map.", + "map_", + "mapper", + "mapping", + "markdown", + "markup", + "master", + "math", + "matrix", + "maven", + "md5", + "mean", + "media", + "mediawiki", + "meetup", + "memcached", + "memory", + "menu", + "merchant", + "message", + "messaging", + "meta", + "metadata", + "meteor", + "method", + "metric", + "micro", + "middleman", + "migration", + "minecraft", + "miner", + "mini", + "minimal", + "mirror", + "mit-", + "mit.", + "mit_", + "mobile", + "mocha", + "mock", + "mod-", + "mod.", + "mod_", + "mode", + "model", + "modern", + "modular", + "module", + "modx", + "money", + "mongo", + "mongodb", + "mongoid", + "mongoose", + "monitor", + "monkey", + "more", + "motion", + "moved", + "movie", + "mozilla", + "mqtt", + "mule", + "multi", + "multiple", + "music", + "mustache", + "mvc-", + "mvc.", + "mvc_", + "mysql", + "nagio", + "name", + "native", + "need", + "neo-", + "neo.", + "neo_", + "nest", + "nested", + "net-", + "net.", + "net_", + "nette", + "network", + "new-", + "new.", + "new_", + "next", + "nginx", + "ninja", + "nlp-", + "nlp.", + "nlp_", + "node", + "nodej", + "nosql", + "not-", + "not.", + "not_", + "note", + "notebook", + "notepad", + "notice", + "notifier", + "now-", + "now.", + "now_", + "number", + "oauth", + "object", + "objective", + "obsolete", + "ocaml", + "octopres", + "official", + "old-", + "old.", + "old_", + "onboard", + "online", + "only", + "open", + "opencv", + "opengl", + "openshift", + "openwrt", + "option", + "oracle", + "org-", + "org.", + "org_", + "origin", + "original", + "orm-", + "orm.", + "orm_", + "osx-", + "osx_", + "our-", + "our.", + "our_", + "out-", + "out.", + "out_", + "output", + "over", + "overview", + "own-", + "own.", + "own_", + "pack", + "package", + "packet", + "page", + "panel", + "paper", + "paperclip", + "para", + "parallax", + "parallel", + "parse", + "parser", + "parsing", + "particle", + "party", + "password", + "patch", + "path", + "pattern", + "payment", + "paypal", + "pdf-", + "pdf.", + "pdf_", + "pebble", + "people", + "perl", + "personal", + "phalcon", + "phoenix", + "phone", + "phonegap", + "photo", + "php-", + "php.", + "php_", + "physic", + "picker", + "pipeline", + "platform", + "play", + "player", + "please", + "plu-", + "plu.", + "plu_", + "plug-in", + "plugin", + "plupload", + "png-", + "png.", + "png_", + "poker", + "polyfill", + "polymer", + "pool", + "pop-", + "pop.", + "pop_", + "popcorn", + "popup", + "port", + "portable", + "portal", + "portfolio", + "post", + "power", + "powered", + "powerful", + "prelude", + "pretty", + "preview", + "principle", + "print", + "pro-", + "pro.", + "pro_", + "problem", + "proc", + "product", + "profile", + "profiler", + "program", + "progres", + "project", + "protocol", + "prototype", + "provider", + "proxy", + "public", + "pull", + "puppet", + "pure", + "purpose", + "push", + "pusher", + "pyramid", + "python", + "quality", + "query", + "queue", + "quick", + "rabbitmq", + "rack", + "radio", + "rail", + "railscast", + "random", + "range", + "raspberry", + "rdf-", + "rdf.", + "rdf_", + "react", + "reactive", + "read", + "reader", + "readme", + "ready", + "real", + "real-time", + "reality", + "realtime", + "recipe", + "recorder", + "red-", + "red.", + "red_", + "reddit", + "redi", + "redmine", + "reference", + "refinery", + "refresh", + "registry", + "related", + "release", + "remote", + "rendering", + "repo", + "report", + "request", + "require", + "required", + "requirej", + "research", + "resource", + "response", + "resque", + "rest", + "restful", + "resume", + "reveal", + "reverse", + "review", + "riak", + "rich", + "right", + "ring", + "robot", + "role", + "room", + "router", + "routing", + "rpc-", + "rpc.", + "rpc_", + "rpg-", + "rpg.", + "rpg_", + "rspec", + "ruby-", + "ruby.", + "ruby_", + "rule", + "run-", + "run.", + "run_", + "runner", + "running", + "runtime", + "rust", + "rvm-", + "rvm.", + "rvm_", + "salt", + "sample", + "sandbox", + "sas-", + "sas.", + "sas_", + "sbt-", + "sbt.", + "sbt_", + "scala", + "scalable", + "scanner", + "schema", + "scheme", + "school", + "science", + "scraper", + "scratch", + "screen", + "script", + "scroll", + "scs-", + "scs.", + "scs_", + "sdk-", + "sdk.", + "sdk_", + "sdl-", + "sdl.", + "sdl_", + "search", + "secure", + "security", + "see-", + "see.", + "see_", + "seed", + "select", + "selector", + "selenium", + "semantic", + "sencha", + "send", + "sentiment", + "serie", + "server", + "service", + "session", + "set-", + "set.", + "set_", + "setting", + "setup", + "sha1", + "sha2", + "sha256", + "share", + "shared", + "sharing", + "sheet", + "shell", + "shield", + "shipping", + "shop", + "shopify", + "shortener", + "should", + "show", + "showcase", + "side", + "silex", + "simple", + "simulator", + "single", + "site", + "skeleton", + "sketch", + "skin", + "slack", + "slide", + "slider", + "slim", + "small", + "smart", + "smtp", + "snake", + "snapshot", + "snippet", + "soap", + "social", + "socket", + "software", + "solarized", + "solr", + "solution", + "solver", + "some", + "soon", + "source", + "space", + "spark", + "spatial", + "spec", + "sphinx", + "spine", + "spotify", + "spree", + "spring", + "sprite", + "sql-", + "sql.", + "sql_", + "sqlite", + "ssh-", + "ssh.", + "ssh_", + "stack", + "staging", + "standard", + "stanford", + "start", + "started", + "starter", + "startup", + "stat", + "statamic", + "state", + "static", + "statistic", + "statsd", + "statu", + "steam", + "step", + "still", + "stm-", + "stm.", + "stm_", + "storage", + "store", + "storm", + "story", + "strategy", + "stream", + "streaming", + "string", + "stripe", + "structure", + "studio", + "study", + "stuff", + "style", + "sublime", + "sugar", + "suite", + "summary", + "super", + "support", + "supported", + "svg-", + "svg.", + "svg_", + "svn-", + "svn.", + "svn_", + "swagger", + "swift", + "switch", + "switcher", + "symfony", + "symphony", + "sync", + "synopsi", + "syntax", + "system", + "tab-", + "tab.", + "tab_", + "table", + "tag-", + "tag.", + "tag_", + "talk", + "target", + "task", + "tcp-", + "tcp.", + "tcp_", + "tdd-", + "tdd.", + "tdd_", + "team", + "tech", + "template", + "term", + "terminal", + "testing", + "tetri", + "text", + "textmate", + "theme", + "theory", + "three", + "thrift", + "time", + "timeline", + "timer", + "tiny", + "tinymce", + "tip-", + "tip.", + "tip_", + "title", + "todo", + "todomvc", + "token", + "tool", + "toolbox", + "toolkit", + "top-", + "top.", + "top_", + "tornado", + "touch", + "tower", + "tracker", + "tracking", + "traffic", + "training", + "transfer", + "translate", + "transport", + "tree", + "trello", + "try-", + "try.", + "try_", + "tumblr", + "tut-", + "tut.", + "tut_", + "tutorial", + "tweet", + "twig", + "twitter", + "type", + "typo", + "ubuntu", + "uiview", + "ultimate", + "under", + "unit", + "unity", + "universal", + "unix", + "update", + "updated", + "upgrade", + "upload", + "uploader", + "uri-", + "uri.", + "uri_", + "url-", + "url.", + "url_", + "usage", + "usb-", + "usb.", + "usb_", + "use-", + "use.", + "use_", + "used", + "useful", + "user", + "using", + "util", + "utilitie", + "utility", + "vagrant", + "validator", + "value", + "variou", + "varnish", + "version", + "via-", + "via.", + "via_", + "video", + "view", + "viewer", + "vim-", + "vim.", + "vim_", + "vimrc", + "virtual", + "vision", + "visual", + "vpn", + "want", + "warning", + "watch", + "watcher", + "wave", + "way-", + "way.", + "way_", + "weather", + "web-", + "web_", + "webapp", + "webgl", + "webhook", + "webkit", + "webrtc", + "website", + "websocket", + "welcome", + "what", + "what'", + "when", + "where", + "which", + "why-", + "why.", + "why_", + "widget", + "wifi", + "wiki", + "win-", + "win.", + "win_", + "window", + "wip-", + "wip.", + "wip_", + "within", + "without", + "wizard", + "word", + "wordpres", + "work", + "worker", + "workflow", + "working", + "workshop", + "world", + "wrapper", + "write", + "writer", + "writing", + "written", + "www-", + "www.", + "www_", + "xamarin", + "xcode", + "xml-", + "xml.", + "xml_", + "xmpp", + "xxxxxx", + "yahoo", + "yaml", + "yandex", + "yeoman", + "yet-", + "yet.", + "yet_", + "yii-", + "yii.", + "yii_", + "youtube", + "yui-", + "yui.", + "yui_", + "zend", + "zero", + "zip-", + "zip.", + "zip_", + "zsh-", + "zsh.", + "zsh_", +] +[[rules.allowlists]] +regexTarget = "line" +regexes = [ + '''--mount=type=secret,''', + '''import[ \t]+{[ \t\w,]+}[ \t]+from[ \t]+['"][^'"]+['"]''', +] +[[rules.allowlists]] +condition = "AND" +paths = [ + '''\.bb$''','''\.bbappend$''','''\.bbclass$''','''\.inc$''', +] +regexTarget = "line" +regexes = [ + '''LICENSE[^=]*=\s*"[^"]+''', + '''LIC_FILES_CHKSUM[^=]*=\s*"[^"]+''', + '''SRC[^=]*=\s*"[a-zA-Z0-9]+''', +] + +[[rules]] +id = "github-app-token" +description = "Identified a GitHub App Token, which may compromise GitHub application integrations and source code security." +regex = '''(?:ghu|ghs)_[0-9a-zA-Z]{36}''' +entropy = 3 +keywords = [ + "ghu_", + "ghs_", +] +[[rules.allowlists]] +paths = [ + '''(?:^|/)@octokit/auth-token/README\.md$''', +] + +[[rules]] +id = "github-fine-grained-pat" +description = "Found a GitHub Fine-Grained Personal Access Token, risking unauthorized repository access and code manipulation." +regex = '''github_pat_\w{82}''' +entropy = 3 +keywords = ["github_pat_"] + +[[rules]] +id = "github-oauth" +description = "Discovered a GitHub OAuth Access Token, posing a risk of compromised GitHub account integrations and data leaks." +regex = '''gho_[0-9a-zA-Z]{36}''' +entropy = 3 +keywords = ["gho_"] + +[[rules]] +id = "github-pat" +description = "Uncovered a GitHub Personal Access Token, potentially leading to unauthorized repository access and sensitive content exposure." +regex = '''ghp_[0-9a-zA-Z]{36}''' +entropy = 3 +keywords = ["ghp_"] +[[rules.allowlists]] +paths = [ + '''(?:^|/)@octokit/auth-token/README\.md$''', +] + +[[rules]] +id = "github-refresh-token" +description = "Detected a GitHub Refresh Token, which could allow prolonged unauthorized access to GitHub services." +regex = '''ghr_[0-9a-zA-Z]{36}''' +entropy = 3 +keywords = ["ghr_"] + +[[rules]] +id = "gitlab-cicd-job-token" +description = "Identified a GitLab CI/CD Job Token, potential access to projects and some APIs on behalf of a user while the CI job is running." +regex = '''glcbt-[0-9a-zA-Z]{1,5}_[0-9a-zA-Z_-]{20}''' +entropy = 3 +keywords = ["glcbt-"] + +[[rules]] +id = "gitlab-deploy-token" +description = "Identified a GitLab Deploy Token, risking access to repositories, packages and containers with write access." +regex = '''gldt-[0-9a-zA-Z_\-]{20}''' +entropy = 3 +keywords = ["gldt-"] + +[[rules]] +id = "gitlab-feature-flag-client-token" +description = "Identified a GitLab feature flag client token, risks exposing user lists and features flags used by an application." +regex = '''glffct-[0-9a-zA-Z_\-]{20}''' +entropy = 3 +keywords = ["glffct-"] + +[[rules]] +id = "gitlab-feed-token" +description = "Identified a GitLab feed token, risking exposure of user data." +regex = '''glft-[0-9a-zA-Z_\-]{20}''' +entropy = 3 +keywords = ["glft-"] + +[[rules]] +id = "gitlab-incoming-mail-token" +description = "Identified a GitLab incoming mail token, risking manipulation of data sent by mail." +regex = '''glimt-[0-9a-zA-Z_\-]{25}''' +entropy = 3 +keywords = ["glimt-"] + +[[rules]] +id = "gitlab-kubernetes-agent-token" +description = "Identified a GitLab Kubernetes Agent token, risking access to repos and registry of projects connected via agent." +regex = '''glagent-[0-9a-zA-Z_\-]{50}''' +entropy = 3 +keywords = ["glagent-"] + +[[rules]] +id = "gitlab-oauth-app-secret" +description = "Identified a GitLab OIDC Application Secret, risking access to apps using GitLab as authentication provider." +regex = '''gloas-[0-9a-zA-Z_\-]{64}''' +entropy = 3 +keywords = ["gloas-"] + +[[rules]] +id = "gitlab-pat" +description = "Identified a GitLab Personal Access Token, risking unauthorized access to GitLab repositories and codebase exposure." +regex = '''glpat-[\w-]{20}''' +entropy = 3 +keywords = ["glpat-"] + +[[rules]] +id = "gitlab-pat-routable" +description = "Identified a GitLab Personal Access Token (routable), risking unauthorized access to GitLab repositories and codebase exposure." +regex = '''\bglpat-[0-9a-zA-Z_-]{27,300}\.[0-9a-z]{2}[0-9a-z]{7}\b''' +entropy = 4 +keywords = ["glpat-"] + +[[rules]] +id = "gitlab-ptt" +description = "Found a GitLab Pipeline Trigger Token, potentially compromising continuous integration workflows and project security." +regex = '''glptt-[0-9a-f]{40}''' +entropy = 3 +keywords = ["glptt-"] + +[[rules]] +id = "gitlab-rrt" +description = "Discovered a GitLab Runner Registration Token, posing a risk to CI/CD pipeline integrity and unauthorized access." +regex = '''GR1348941[\w-]{20}''' +entropy = 3 +keywords = ["gr1348941"] + +[[rules]] +id = "gitlab-runner-authentication-token" +description = "Discovered a GitLab Runner Authentication Token, posing a risk to CI/CD pipeline integrity and unauthorized access." +regex = '''glrt-[0-9a-zA-Z_\-]{20}''' +entropy = 3 +keywords = ["glrt-"] + +[[rules]] +id = "gitlab-runner-authentication-token-routable" +description = "Discovered a GitLab Runner Authentication Token (Routable), posing a risk to CI/CD pipeline integrity and unauthorized access." +regex = '''\bglrt-t\d_[0-9a-zA-Z_\-]{27,300}\.[0-9a-z]{2}[0-9a-z]{7}\b''' +entropy = 4 +keywords = ["glrt-"] + +[[rules]] +id = "gitlab-scim-token" +description = "Discovered a GitLab SCIM Token, posing a risk to unauthorized access for a organization or instance." +regex = '''glsoat-[0-9a-zA-Z_\-]{20}''' +entropy = 3 +keywords = ["glsoat-"] + +[[rules]] +id = "gitlab-session-cookie" +description = "Discovered a GitLab Session Cookie, posing a risk to unauthorized access to a user account." +regex = '''_gitlab_session=[0-9a-z]{32}''' +entropy = 3 +keywords = ["_gitlab_session="] + +[[rules]] +id = "gitter-access-token" +description = "Uncovered a Gitter Access Token, which may lead to unauthorized access to chat and communication services." +regex = '''(?i)[\w.-]{0,50}?(?:gitter)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9_-]{40})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["gitter"] + +[[rules]] +id = "gocardless-api-token" +description = "Detected a GoCardless API token, potentially risking unauthorized direct debit payment operations and financial data exposure." +regex = '''(?i)[\w.-]{0,50}?(?:gocardless)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(live_(?i)[a-z0-9\-_=]{40})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = [ + "live_", + "gocardless", +] + +[[rules]] +id = "grafana-api-key" +description = "Identified a Grafana API key, which could compromise monitoring dashboards and sensitive data analytics." +regex = '''(?i)\b(eyJrIjoi[A-Za-z0-9]{70,400}={0,3})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["eyjrijoi"] + +[[rules]] +id = "grafana-cloud-api-token" +description = "Found a Grafana cloud API token, risking unauthorized access to cloud-based monitoring services and data exposure." +regex = '''(?i)\b(glc_[A-Za-z0-9+/]{32,400}={0,3})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["glc_"] + +[[rules]] +id = "grafana-service-account-token" +description = "Discovered a Grafana service account token, posing a risk of compromised monitoring services and data integrity." +regex = '''(?i)\b(glsa_[A-Za-z0-9]{32}_[A-Fa-f0-9]{8})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["glsa_"] + +[[rules]] +id = "harness-api-key" +description = "Identified a Harness Access Token (PAT or SAT), risking unauthorized access to a Harness account." +regex = '''(?:pat|sat)\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9]{24}\.[a-zA-Z0-9]{20}''' +keywords = [ + "pat.", + "sat.", +] + +[[rules]] +id = "hashicorp-tf-api-token" +description = "Uncovered a HashiCorp Terraform user/org API token, which may lead to unauthorized infrastructure management and security breaches." +regex = '''(?i)[a-z0-9]{14}\.(?-i:atlasv1)\.[a-z0-9\-_=]{60,70}''' +entropy = 3.5 +keywords = ["atlasv1"] + +[[rules]] +id = "hashicorp-tf-password" +description = "Identified a HashiCorp Terraform password field, risking unauthorized infrastructure configuration and security breaches." +regex = '''(?i)[\w.-]{0,50}?(?:administrator_login_password|password)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}("[a-z0-9=_\-]{8,20}")(?:[\x60'"\s;]|\\[nr]|$)''' +path = '''(?i)\.(?:tf|hcl)$''' +entropy = 2 +keywords = [ + "administrator_login_password", + "password", +] + +[[rules]] +id = "heroku-api-key" +description = "Detected a Heroku API Key, potentially compromising cloud application deployments and operational security." +regex = '''(?i)[\w.-]{0,50}?(?:heroku)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["heroku"] + +[[rules]] +id = "hubspot-api-key" +description = "Found a HubSpot API Token, posing a risk to CRM data integrity and unauthorized marketing operations." +regex = '''(?i)[\w.-]{0,50}?(?:hubspot)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["hubspot"] + +[[rules]] +id = "huggingface-access-token" +description = "Discovered a Hugging Face Access token, which could lead to unauthorized access to AI models and sensitive data." +regex = '''\b(hf_(?i:[a-z]{34}))(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["hf_"] + +[[rules]] +id = "huggingface-organization-api-token" +description = "Uncovered a Hugging Face Organization API token, potentially compromising AI organization accounts and associated data." +regex = '''\b(api_org_(?i:[a-z]{34}))(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["api_org_"] + +[[rules]] +id = "infracost-api-token" +description = "Detected an Infracost API Token, risking unauthorized access to cloud cost estimation tools and financial data." +regex = '''\b(ico-[a-zA-Z0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["ico-"] + +[[rules]] +id = "intercom-api-key" +description = "Identified an Intercom API Token, which could compromise customer communication channels and data privacy." +regex = '''(?i)[\w.-]{0,50}?(?:intercom)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9=_\-]{60})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["intercom"] + +[[rules]] +id = "intra42-client-secret" +description = "Found a Intra42 client secret, which could lead to unauthorized access to the 42School API and sensitive data." +regex = '''\b(s-s4t2(?:ud|af)-(?i)[abcdef0123456789]{64})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = [ + "intra", + "s-s4t2ud-", + "s-s4t2af-", +] + +[[rules]] +id = "jfrog-api-key" +description = "Found a JFrog API Key, posing a risk of unauthorized access to software artifact repositories and build pipelines." +regex = '''(?i)[\w.-]{0,50}?(?:jfrog|artifactory|bintray|xray)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{73})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = [ + "jfrog", + "artifactory", + "bintray", + "xray", +] + +[[rules]] +id = "jfrog-identity-token" +description = "Discovered a JFrog Identity Token, potentially compromising access to JFrog services and sensitive software artifacts." +regex = '''(?i)[\w.-]{0,50}?(?:jfrog|artifactory|bintray|xray)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{64})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = [ + "jfrog", + "artifactory", + "bintray", + "xray", +] + +[[rules]] +id = "jwt" +description = "Uncovered a JSON Web Token, which may lead to unauthorized access to web applications and sensitive user data." +regex = '''\b(ey[a-zA-Z0-9]{17,}\.ey[a-zA-Z0-9\/\\_-]{17,}\.(?:[a-zA-Z0-9\/\\_-]{10,}={0,2})?)(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["ey"] + +[[rules]] +id = "jwt-base64" +description = "Detected a Base64-encoded JSON Web Token, posing a risk of exposing encoded authentication and data exchange information." +regex = '''\bZXlK(?:(?PaGJHY2lPaU)|(?PaGNIVWlPaU)|(?PaGNIWWlPaU)|(?PaGRXUWlPaU)|(?PaU5qUWlP)|(?PamNtbDBJanBi)|(?PamRIa2lPaU)|(?PbGNHc2lPbn)|(?PbGJtTWlPaU)|(?PcWEzVWlPaU)|(?PcWQyc2lPb)|(?PcGMzTWlPaU)|(?PcGRpSTZJ)|(?PcmFXUWlP)|(?PclpYbGZiM0J6SWpwY)|(?PcmRIa2lPaUp)|(?PdWIyNWpaU0k2)|(?Pd01tTWlP)|(?Pd01uTWlPaU)|(?Pd2NIUWlPaU)|(?PemRXSWlPaU)|(?PemRuUWlP)|(?PMFlXY2lPaU)|(?PMGVYQWlPaUp)|(?PMWNtd2l)|(?PMWMyVWlPaUp)|(?PMlpYSWlPaU)|(?PMlpYSnphVzl1SWpv)|(?PNElqb2)|(?PNE5XTWlP)|(?PNE5YUWlPaU)|(?PNE5YUWpVekkxTmlJNkl)|(?PNE5YVWlPaU)|(?PNmFYQWlPaU))[a-zA-Z0-9\/\\_+\-\r\n]{40,}={0,2}''' +entropy = 2 +keywords = ["zxlk"] + +[[rules]] +id = "kraken-access-token" +description = "Identified a Kraken Access Token, potentially compromising cryptocurrency trading accounts and financial security." +regex = '''(?i)[\w.-]{0,50}?(?:kraken)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9\/=_\+\-]{80,90})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["kraken"] + +[[rules]] +id = "kubernetes-secret-yaml" +description = "Possible Kubernetes Secret detected, posing a risk of leaking credentials/tokens from your deployments" +regex = '''(?i)(?:\bkind:[ \t]*["']?\bsecret\b["']?(?s:.){0,200}?\bdata:(?s:.){0,100}?\s+([\w.-]+:(?:[ \t]*(?:\||>[-+]?)\s+)?[ \t]*(?:["']?[a-z0-9+/]{10,}={0,3}["']?|\{\{[ \t\w"|$:=,.-]+}}|""|''))|\bdata:(?s:.){0,100}?\s+([\w.-]+:(?:[ \t]*(?:\||>[-+]?)\s+)?[ \t]*(?:["']?[a-z0-9+/]{10,}={0,3}["']?|\{\{[ \t\w"|$:=,.-]+}}|""|''))(?s:.){0,200}?\bkind:[ \t]*["']?\bsecret\b["']?)''' +path = '''(?i)\.ya?ml$''' +keywords = ["secret"] +[[rules.allowlists]] +regexes = [ + '''[\w.-]+:(?:[ \t]*(?:\||>[-+]?)\s+)?[ \t]*(?:\{\{[ \t\w"|$:=,.-]+}}|""|'')''', +] +[[rules.allowlists]] +regexTarget = "match" +regexes = [ + '''(kind:(?s:.)+\n---\n(?s:.)+\bdata:|data:(?s:.)+\n---\n(?s:.)+\bkind:)''', +] + +[[rules]] +id = "kucoin-access-token" +description = "Found a Kucoin Access Token, risking unauthorized access to cryptocurrency exchange services and transactions." +regex = '''(?i)[\w.-]{0,50}?(?:kucoin)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-f0-9]{24})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["kucoin"] + +[[rules]] +id = "kucoin-secret-key" +description = "Discovered a Kucoin Secret Key, which could lead to compromised cryptocurrency operations and financial data breaches." +regex = '''(?i)[\w.-]{0,50}?(?:kucoin)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["kucoin"] + +[[rules]] +id = "launchdarkly-access-token" +description = "Uncovered a Launchdarkly Access Token, potentially compromising feature flag management and application functionality." +regex = '''(?i)[\w.-]{0,50}?(?:launchdarkly)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9=_\-]{40})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["launchdarkly"] + +[[rules]] +id = "linear-api-key" +description = "Detected a Linear API Token, posing a risk to project management tools and sensitive task data." +regex = '''lin_api_(?i)[a-z0-9]{40}''' +entropy = 2 +keywords = ["lin_api_"] + +[[rules]] +id = "linear-client-secret" +description = "Identified a Linear Client Secret, which may compromise secure integrations and sensitive project management data." +regex = '''(?i)[\w.-]{0,50}?(?:linear)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-f0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["linear"] + +[[rules]] +id = "linkedin-client-id" +description = "Found a LinkedIn Client ID, risking unauthorized access to LinkedIn integrations and professional data exposure." +regex = '''(?i)[\w.-]{0,50}?(?:linked[_-]?in)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{14})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = [ + "linkedin", + "linked_in", + "linked-in", +] + +[[rules]] +id = "linkedin-client-secret" +description = "Discovered a LinkedIn Client secret, potentially compromising LinkedIn application integrations and user data." +regex = '''(?i)[\w.-]{0,50}?(?:linked[_-]?in)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{16})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = [ + "linkedin", + "linked_in", + "linked-in", +] + +[[rules]] +id = "lob-api-key" +description = "Uncovered a Lob API Key, which could lead to unauthorized access to mailing and address verification services." +regex = '''(?i)[\w.-]{0,50}?(?:lob)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}((live|test)_[a-f0-9]{35})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = [ + "test_", + "live_", +] + +[[rules]] +id = "lob-pub-api-key" +description = "Detected a Lob Publishable API Key, posing a risk of exposing mail and print service integrations." +regex = '''(?i)[\w.-]{0,50}?(?:lob)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}((test|live)_pub_[a-f0-9]{31})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = [ + "test_pub", + "live_pub", + "_pub", +] + +[[rules]] +id = "mailchimp-api-key" +description = "Identified a Mailchimp API key, potentially compromising email marketing campaigns and subscriber data." +regex = '''(?i)[\w.-]{0,50}?(?:MailchimpSDK.initialize|mailchimp)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-f0-9]{32}-us\d\d)(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["mailchimp"] + +[[rules]] +id = "mailgun-private-api-token" +description = "Found a Mailgun private API token, risking unauthorized email service operations and data breaches." +regex = '''(?i)[\w.-]{0,50}?(?:mailgun)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(key-[a-f0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["mailgun"] + +[[rules]] +id = "mailgun-pub-key" +description = "Discovered a Mailgun public validation key, which could expose email verification processes and associated data." +regex = '''(?i)[\w.-]{0,50}?(?:mailgun)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(pubkey-[a-f0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["mailgun"] + +[[rules]] +id = "mailgun-signing-key" +description = "Uncovered a Mailgun webhook signing key, potentially compromising email automation and data integrity." +regex = '''(?i)[\w.-]{0,50}?(?:mailgun)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-h0-9]{32}-[a-h0-9]{8}-[a-h0-9]{8})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["mailgun"] + +[[rules]] +id = "mapbox-api-token" +description = "Detected a MapBox API token, posing a risk to geospatial services and sensitive location data exposure." +regex = '''(?i)[\w.-]{0,50}?(?:mapbox)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(pk\.[a-z0-9]{60}\.[a-z0-9]{22})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["mapbox"] + +[[rules]] +id = "mattermost-access-token" +description = "Identified a Mattermost Access Token, which may compromise team communication channels and data privacy." +regex = '''(?i)[\w.-]{0,50}?(?:mattermost)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{26})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["mattermost"] + +[[rules]] +id = "maxmind-license-key" +description = "Discovered a potential MaxMind license key." +regex = '''\b([A-Za-z0-9]{6}_[A-Za-z0-9]{29}_mmk)(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 4 +keywords = ["_mmk"] + +[[rules]] +id = "messagebird-api-token" +description = "Found a MessageBird API token, risking unauthorized access to communication platforms and message data." +regex = '''(?i)[\w.-]{0,50}?(?:message[_-]?bird)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{25})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = [ + "messagebird", + "message-bird", + "message_bird", +] + +[[rules]] +id = "messagebird-client-id" +description = "Discovered a MessageBird client ID, potentially compromising API integrations and sensitive communication data." +regex = '''(?i)[\w.-]{0,50}?(?:message[_-]?bird)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = [ + "messagebird", + "message-bird", + "message_bird", +] + +[[rules]] +id = "microsoft-teams-webhook" +description = "Uncovered a Microsoft Teams Webhook, which could lead to unauthorized access to team collaboration tools and data leaks." +regex = '''https://[a-z0-9]+\.webhook\.office\.com/webhookb2/[a-z0-9]{8}-([a-z0-9]{4}-){3}[a-z0-9]{12}@[a-z0-9]{8}-([a-z0-9]{4}-){3}[a-z0-9]{12}/IncomingWebhook/[a-z0-9]{32}/[a-z0-9]{8}-([a-z0-9]{4}-){3}[a-z0-9]{12}''' +keywords = [ + "webhook.office.com", + "webhookb2", + "incomingwebhook", +] + +[[rules]] +id = "netlify-access-token" +description = "Detected a Netlify Access Token, potentially compromising web hosting services and site management." +regex = '''(?i)[\w.-]{0,50}?(?:netlify)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9=_\-]{40,46})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["netlify"] + +[[rules]] +id = "new-relic-browser-api-token" +description = "Identified a New Relic ingest browser API token, risking unauthorized access to application performance data and analytics." +regex = '''(?i)[\w.-]{0,50}?(?:new-relic|newrelic|new_relic)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(NRJS-[a-f0-9]{19})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["nrjs-"] + +[[rules]] +id = "new-relic-insert-key" +description = "Discovered a New Relic insight insert key, compromising data injection into the platform." +regex = '''(?i)[\w.-]{0,50}?(?:new-relic|newrelic|new_relic)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(NRII-[a-z0-9-]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["nrii-"] + +[[rules]] +id = "new-relic-user-api-id" +description = "Found a New Relic user API ID, posing a risk to application monitoring services and data integrity." +regex = '''(?i)[\w.-]{0,50}?(?:new-relic|newrelic|new_relic)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{64})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = [ + "new-relic", + "newrelic", + "new_relic", +] + +[[rules]] +id = "new-relic-user-api-key" +description = "Discovered a New Relic user API Key, which could lead to compromised application insights and performance monitoring." +regex = '''(?i)[\w.-]{0,50}?(?:new-relic|newrelic|new_relic)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(NRAK-[a-z0-9]{27})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["nrak"] + +[[rules]] +id = "npm-access-token" +description = "Uncovered an npm access token, potentially compromising package management and code repository access." +regex = '''(?i)\b(npm_[a-z0-9]{36})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["npm_"] + +[[rules]] +id = "nuget-config-password" +description = "Identified a password within a Nuget config file, potentially compromising package management access." +regex = '''(?i)''' +path = '''(?i)nuget\.config$''' +entropy = 1 +keywords = ["|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9=_\-]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = [ + "nytimes", + "new-york-times", + "newyorktimes", +] + +[[rules]] +id = "octopus-deploy-api-key" +description = "Discovered a potential Octopus Deploy API key, risking application deployments and operational security." +regex = '''\b(API-[A-Z0-9]{26})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["api-"] + +[[rules]] +id = "okta-access-token" +description = "Identified an Okta Access Token, which may compromise identity management services and user authentication data." +regex = '''[\w.-]{0,50}?(?i:[\w.-]{0,50}?(?:(?-i:[Oo]kta|OKTA))(?:[ \t\w.-]{0,20})[\s'"]{0,3})(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(00[\w=\-]{40})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 4 +keywords = ["okta"] + +[[rules]] +id = "openai-api-key" +description = "Found an OpenAI API Key, posing a risk of unauthorized access to AI services and data manipulation." +regex = '''\b(sk-(?:proj|svcacct|admin)-(?:[A-Za-z0-9_-]{74}|[A-Za-z0-9_-]{58})T3BlbkFJ(?:[A-Za-z0-9_-]{74}|[A-Za-z0-9_-]{58})\b|sk-[a-zA-Z0-9]{20}T3BlbkFJ[a-zA-Z0-9]{20})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["t3blbkfj"] + +[[rules]] +id = "openshift-user-token" +description = "Found an OpenShift user token, potentially compromising an OpenShift/Kubernetes cluster." +regex = '''\b(sha256~[\w-]{43})(?:[^\w-]|\z)''' +entropy = 3.5 +keywords = ["sha256~"] + +[[rules]] +id = "perplexity-api-key" +description = "Detected a Perplexity API key, which could lead to unauthorized access to Perplexity AI services and data exposure." +regex = '''\b(pplx-[a-zA-Z0-9]{48})(?:[\x60'"\s;]|\\[nr]|$|\b)''' +entropy = 4 +keywords = ["pplx-"] + +[[rules]] +id = "pkcs12-file" +description = "Found a PKCS #12 file, which commonly contain bundled private keys." +path = '''(?i)(?:^|\/)[^\/]+\.p(?:12|fx)$''' + +[[rules]] +id = "plaid-api-token" +description = "Discovered a Plaid API Token, potentially compromising financial data aggregation and banking services." +regex = '''(?i)[\w.-]{0,50}?(?:plaid)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(access-(?:sandbox|development|production)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["plaid"] + +[[rules]] +id = "plaid-client-id" +description = "Uncovered a Plaid Client ID, which could lead to unauthorized financial service integrations and data breaches." +regex = '''(?i)[\w.-]{0,50}?(?:plaid)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{24})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3.5 +keywords = ["plaid"] + +[[rules]] +id = "plaid-secret-key" +description = "Detected a Plaid Secret key, risking unauthorized access to financial accounts and sensitive transaction data." +regex = '''(?i)[\w.-]{0,50}?(?:plaid)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{30})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3.5 +keywords = ["plaid"] + +[[rules]] +id = "planetscale-api-token" +description = "Identified a PlanetScale API token, potentially compromising database management and operations." +regex = '''\b(pscale_tkn_(?i)[\w=\.-]{32,64})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["pscale_tkn_"] + +[[rules]] +id = "planetscale-oauth-token" +description = "Found a PlanetScale OAuth token, posing a risk to database access control and sensitive data integrity." +regex = '''\b(pscale_oauth_[\w=\.-]{32,64})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["pscale_oauth_"] + +[[rules]] +id = "planetscale-password" +description = "Discovered a PlanetScale password, which could lead to unauthorized database operations and data breaches." +regex = '''(?i)\b(pscale_pw_(?i)[\w=\.-]{32,64})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["pscale_pw_"] + +[[rules]] +id = "postman-api-token" +description = "Uncovered a Postman API token, potentially compromising API testing and development workflows." +regex = '''\b(PMAK-(?i)[a-f0-9]{24}\-[a-f0-9]{34})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["pmak-"] + +[[rules]] +id = "prefect-api-token" +description = "Detected a Prefect API token, risking unauthorized access to workflow management and automation services." +regex = '''\b(pnu_[a-zA-Z0-9]{36})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["pnu_"] + +[[rules]] +id = "private-key" +description = "Identified a Private Key, which may compromise cryptographic security and sensitive data encryption." +regex = '''(?i)-----BEGIN[ A-Z0-9_-]{0,100}PRIVATE KEY(?: BLOCK)?-----[\s\S-]{64,}?KEY(?: BLOCK)?-----''' +keywords = ["-----begin"] + +[[rules]] +id = "privateai-api-token" +description = "Identified a PrivateAI Token, posing a risk of unauthorized access to AI services and data manipulation." +regex = '''[\w.-]{0,50}?(?i:[\w.-]{0,50}?(?:private[_-]?ai)(?:[ \t\w.-]{0,20})[\s'"]{0,3})(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = [ + "privateai", + "private_ai", + "private-ai", +] + +[[rules]] +id = "pulumi-api-token" +description = "Found a Pulumi API token, posing a risk to infrastructure as code services and cloud resource management." +regex = '''\b(pul-[a-f0-9]{40})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["pul-"] + +[[rules]] +id = "pypi-upload-token" +description = "Discovered a PyPI upload token, potentially compromising Python package distribution and repository integrity." +regex = '''pypi-AgEIcHlwaS5vcmc[\w-]{50,1000}''' +entropy = 3 +keywords = ["pypi-ageichlwas5vcmc"] + +[[rules]] +id = "rapidapi-access-token" +description = "Uncovered a RapidAPI Access Token, which could lead to unauthorized access to various APIs and data services." +regex = '''(?i)[\w.-]{0,50}?(?:rapidapi)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9_-]{50})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["rapidapi"] + +[[rules]] +id = "readme-api-token" +description = "Detected a Readme API token, risking unauthorized documentation management and content exposure." +regex = '''\b(rdme_[a-z0-9]{70})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["rdme_"] + +[[rules]] +id = "rubygems-api-token" +description = "Identified a Rubygem API token, potentially compromising Ruby library distribution and package management." +regex = '''\b(rubygems_[a-f0-9]{48})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["rubygems_"] + +[[rules]] +id = "scalingo-api-token" +description = "Found a Scalingo API token, posing a risk to cloud platform services and application deployment security." +regex = '''\b(tk-us-[\w-]{48})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["tk-us-"] + +[[rules]] +id = "sendbird-access-id" +description = "Discovered a Sendbird Access ID, which could compromise chat and messaging platform integrations." +regex = '''(?i)[\w.-]{0,50}?(?:sendbird)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["sendbird"] + +[[rules]] +id = "sendbird-access-token" +description = "Uncovered a Sendbird Access Token, potentially risking unauthorized access to communication services and user data." +regex = '''(?i)[\w.-]{0,50}?(?:sendbird)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-f0-9]{40})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["sendbird"] + +[[rules]] +id = "sendgrid-api-token" +description = "Detected a SendGrid API token, posing a risk of unauthorized email service operations and data exposure." +regex = '''\b(SG\.(?i)[a-z0-9=_\-\.]{66})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["sg."] + +[[rules]] +id = "sendinblue-api-token" +description = "Identified a Sendinblue API token, which may compromise email marketing services and subscriber data privacy." +regex = '''\b(xkeysib-[a-f0-9]{64}\-(?i)[a-z0-9]{16})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["xkeysib-"] + +[[rules]] +id = "sentry-access-token" +description = "Found a Sentry.io Access Token (old format), risking unauthorized access to error tracking services and sensitive application data." +regex = '''(?i)[\w.-]{0,50}?(?:sentry)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-f0-9]{64})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["sentry"] + +[[rules]] +id = "sentry-org-token" +description = "Found a Sentry.io Organization Token, risking unauthorized access to error tracking services and sensitive application data." +regex = '''\bsntrys_eyJpYXQiO[a-zA-Z0-9+/]{10,200}(?:LCJyZWdpb25fdXJs|InJlZ2lvbl91cmwi|cmVnaW9uX3VybCI6)[a-zA-Z0-9+/]{10,200}={0,2}_[a-zA-Z0-9+/]{43}(?:[^a-zA-Z0-9+/]|\z)''' +entropy = 4.5 +keywords = ["sntrys_eyjpyxqio"] + +[[rules]] +id = "sentry-user-token" +description = "Found a Sentry.io User Token, risking unauthorized access to error tracking services and sensitive application data." +regex = '''\b(sntryu_[a-f0-9]{64})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3.5 +keywords = ["sntryu_"] + +[[rules]] +id = "settlemint-application-access-token" +description = "Found a Settlemint Application Access Token." +regex = '''\b(sm_aat_[a-zA-Z0-9]{16})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["sm_aat"] + +[[rules]] +id = "settlemint-personal-access-token" +description = "Found a Settlemint Personal Access Token." +regex = '''\b(sm_pat_[a-zA-Z0-9]{16})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["sm_pat"] + +[[rules]] +id = "settlemint-service-access-token" +description = "Found a Settlemint Service Access Token." +regex = '''\b(sm_sat_[a-zA-Z0-9]{16})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["sm_sat"] + +[[rules]] +id = "shippo-api-token" +description = "Discovered a Shippo API token, potentially compromising shipping services and customer order data." +regex = '''\b(shippo_(?:live|test)_[a-fA-F0-9]{40})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = ["shippo_"] + +[[rules]] +id = "shopify-access-token" +description = "Uncovered a Shopify access token, which could lead to unauthorized e-commerce platform access and data breaches." +regex = '''shpat_[a-fA-F0-9]{32}''' +entropy = 2 +keywords = ["shpat_"] + +[[rules]] +id = "shopify-custom-access-token" +description = "Detected a Shopify custom access token, potentially compromising custom app integrations and e-commerce data security." +regex = '''shpca_[a-fA-F0-9]{32}''' +entropy = 2 +keywords = ["shpca_"] + +[[rules]] +id = "shopify-private-app-access-token" +description = "Identified a Shopify private app access token, risking unauthorized access to private app data and store operations." +regex = '''shppa_[a-fA-F0-9]{32}''' +entropy = 2 +keywords = ["shppa_"] + +[[rules]] +id = "shopify-shared-secret" +description = "Found a Shopify shared secret, posing a risk to application authentication and e-commerce platform security." +regex = '''shpss_[a-fA-F0-9]{32}''' +entropy = 2 +keywords = ["shpss_"] + +[[rules]] +id = "sidekiq-secret" +description = "Discovered a Sidekiq Secret, which could lead to compromised background job processing and application data breaches." +regex = '''(?i)[\w.-]{0,50}?(?:BUNDLE_ENTERPRISE__CONTRIBSYS__COM|BUNDLE_GEMS__CONTRIBSYS__COM)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-f0-9]{8}:[a-f0-9]{8})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = [ + "bundle_enterprise__contribsys__com", + "bundle_gems__contribsys__com", +] + +[[rules]] +id = "sidekiq-sensitive-url" +description = "Uncovered a Sidekiq Sensitive URL, potentially exposing internal job queues and sensitive operation details." +regex = '''(?i)\bhttps?://([a-f0-9]{8}:[a-f0-9]{8})@(?:gems.contribsys.com|enterprise.contribsys.com)(?:[\/|\#|\?|:]|$)''' +keywords = [ + "gems.contribsys.com", + "enterprise.contribsys.com", +] + +[[rules]] +id = "slack-app-token" +description = "Detected a Slack App-level token, risking unauthorized access to Slack applications and workspace data." +regex = '''(?i)xapp-\d-[A-Z0-9]+-\d+-[a-z0-9]+''' +entropy = 2 +keywords = ["xapp"] + +[[rules]] +id = "slack-bot-token" +description = "Identified a Slack Bot token, which may compromise bot integrations and communication channel security." +regex = '''xoxb-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*''' +entropy = 3 +keywords = ["xoxb"] + +[[rules]] +id = "slack-config-access-token" +description = "Found a Slack Configuration access token, posing a risk to workspace configuration and sensitive data access." +regex = '''(?i)xoxe.xox[bp]-\d-[A-Z0-9]{163,166}''' +entropy = 2 +keywords = [ + "xoxe.xoxb-", + "xoxe.xoxp-", +] + +[[rules]] +id = "slack-config-refresh-token" +description = "Discovered a Slack Configuration refresh token, potentially allowing prolonged unauthorized access to configuration settings." +regex = '''(?i)xoxe-\d-[A-Z0-9]{146}''' +entropy = 2 +keywords = ["xoxe-"] + +[[rules]] +id = "slack-legacy-bot-token" +description = "Uncovered a Slack Legacy bot token, which could lead to compromised legacy bot operations and data exposure." +regex = '''xoxb-[0-9]{8,14}-[a-zA-Z0-9]{18,26}''' +entropy = 2 +keywords = ["xoxb"] + +[[rules]] +id = "slack-legacy-token" +description = "Detected a Slack Legacy token, risking unauthorized access to older Slack integrations and user data." +regex = '''xox[os]-\d+-\d+-\d+-[a-fA-F\d]+''' +entropy = 2 +keywords = [ + "xoxo", + "xoxs", +] + +[[rules]] +id = "slack-legacy-workspace-token" +description = "Identified a Slack Legacy Workspace token, potentially compromising access to workspace data and legacy features." +regex = '''xox[ar]-(?:\d-)?[0-9a-zA-Z]{8,48}''' +entropy = 2 +keywords = [ + "xoxa", + "xoxr", +] + +[[rules]] +id = "slack-user-token" +description = "Found a Slack User token, posing a risk of unauthorized user impersonation and data access within Slack workspaces." +regex = '''xox[pe](?:-[0-9]{10,13}){3}-[a-zA-Z0-9-]{28,34}''' +entropy = 2 +keywords = [ + "xoxp-", + "xoxe-", +] + +[[rules]] +id = "slack-webhook-url" +description = "Discovered a Slack Webhook, which could lead to unauthorized message posting and data leakage in Slack channels." +regex = '''(?:https?://)?hooks.slack.com/(?:services|workflows|triggers)/[A-Za-z0-9+/]{43,56}''' +keywords = ["hooks.slack.com"] + +[[rules]] +id = "snyk-api-token" +description = "Uncovered a Snyk API token, potentially compromising software vulnerability scanning and code security." +regex = '''(?i)[\w.-]{0,50}?(?:snyk[_.-]?(?:(?:api|oauth)[_.-]?)?(?:key|token))(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["snyk"] + +[[rules]] +id = "sonar-api-token" +description = "Uncovered a Sonar API token, potentially compromising software vulnerability scanning and code security." +regex = '''(?i)[\w.-]{0,50}?(?:sonar[_.-]?(login|token))(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9=_\-]{40})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["sonar"] + +[[rules]] +id = "sourcegraph-access-token" +description = "Sourcegraph is a code search and navigation engine." +regex = '''(?i)\b(\b(sgp_(?:[a-fA-F0-9]{16}|local)_[a-fA-F0-9]{40}|sgp_[a-fA-F0-9]{40}|[a-fA-F0-9]{40})\b)(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = [ + "sgp_", + "sourcegraph", +] + +[[rules]] +id = "square-access-token" +description = "Detected a Square Access Token, risking unauthorized payment processing and financial transaction exposure." +regex = '''\b((?:EAAA|sq0atp-)[\w-]{22,60})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = [ + "sq0atp-", + "eaaa", +] + +[[rules]] +id = "squarespace-access-token" +description = "Identified a Squarespace Access Token, which may compromise website management and content control on Squarespace." +regex = '''(?i)[\w.-]{0,50}?(?:squarespace)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["squarespace"] + +[[rules]] +id = "stripe-access-token" +description = "Found a Stripe Access Token, posing a risk to payment processing services and sensitive financial data." +regex = '''\b((?:sk|rk)_(?:test|live|prod)_[a-zA-Z0-9]{10,99})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 2 +keywords = [ + "sk_test", + "sk_live", + "sk_prod", + "rk_test", + "rk_live", + "rk_prod", +] + +[[rules]] +id = "sumologic-access-id" +description = "Discovered a SumoLogic Access ID, potentially compromising log management services and data analytics integrity." +regex = '''[\w.-]{0,50}?(?i:[\w.-]{0,50}?(?:(?-i:[Ss]umo|SUMO))(?:[ \t\w.-]{0,20})[\s'"]{0,3})(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(su[a-zA-Z0-9]{12})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["sumo"] + +[[rules]] +id = "sumologic-access-token" +description = "Uncovered a SumoLogic Access Token, which could lead to unauthorized access to log data and analytics insights." +regex = '''(?i)[\w.-]{0,50}?(?:(?-i:[Ss]umo|SUMO))(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{64})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3 +keywords = ["sumo"] + +[[rules]] +id = "telegram-bot-api-token" +description = "Detected a Telegram Bot API Token, risking unauthorized bot operations and message interception on Telegram." +regex = '''(?i)[\w.-]{0,50}?(?:telegr)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([0-9]{5,16}:(?-i:A)[a-z0-9_\-]{34})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["telegr"] + +[[rules]] +id = "travisci-access-token" +description = "Identified a Travis CI Access Token, potentially compromising continuous integration services and codebase security." +regex = '''(?i)[\w.-]{0,50}?(?:travis)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{22})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["travis"] + +[[rules]] +id = "twilio-api-key" +description = "Found a Twilio API Key, posing a risk to communication services and sensitive customer interaction data." +regex = '''SK[0-9a-fA-F]{32}''' +entropy = 3 +keywords = ["sk"] + +[[rules]] +id = "twitch-api-token" +description = "Discovered a Twitch API token, which could compromise streaming services and account integrations." +regex = '''(?i)[\w.-]{0,50}?(?:twitch)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{30})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["twitch"] + +[[rules]] +id = "twitter-access-secret" +description = "Uncovered a Twitter Access Secret, potentially risking unauthorized Twitter integrations and data breaches." +regex = '''(?i)[\w.-]{0,50}?(?:twitter)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{45})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["twitter"] + +[[rules]] +id = "twitter-access-token" +description = "Detected a Twitter Access Token, posing a risk of unauthorized account operations and social media data exposure." +regex = '''(?i)[\w.-]{0,50}?(?:twitter)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([0-9]{15,25}-[a-zA-Z0-9]{20,40})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["twitter"] + +[[rules]] +id = "twitter-api-key" +description = "Identified a Twitter API Key, which may compromise Twitter application integrations and user data security." +regex = '''(?i)[\w.-]{0,50}?(?:twitter)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{25})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["twitter"] + +[[rules]] +id = "twitter-api-secret" +description = "Found a Twitter API Secret, risking the security of Twitter app integrations and sensitive data access." +regex = '''(?i)[\w.-]{0,50}?(?:twitter)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{50})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["twitter"] + +[[rules]] +id = "twitter-bearer-token" +description = "Discovered a Twitter Bearer Token, potentially compromising API access and data retrieval from Twitter." +regex = '''(?i)[\w.-]{0,50}?(?:twitter)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(A{22}[a-zA-Z0-9%]{80,100})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["twitter"] + +[[rules]] +id = "typeform-api-token" +description = "Uncovered a Typeform API token, which could lead to unauthorized survey management and data collection." +regex = '''(?i)[\w.-]{0,50}?(?:typeform)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(tfp_[a-z0-9\-_\.=]{59})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["tfp_"] + +[[rules]] +id = "vault-batch-token" +description = "Detected a Vault Batch Token, risking unauthorized access to secret management services and sensitive data." +regex = '''\b(hvb\.[\w-]{138,300})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 4 +keywords = ["hvb."] + +[[rules]] +id = "vault-service-token" +description = "Identified a Vault Service Token, potentially compromising infrastructure security and access to sensitive credentials." +regex = '''\b((?:hvs\.[\w-]{90,120}|s\.(?i:[a-z0-9]{24})))(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 3.5 +keywords = [ + "hvs.", + "s.", +] +[[rules.allowlists]] +regexes = [ + '''s\.[A-Za-z]{24}''', +] + +[[rules]] +id = "yandex-access-token" +description = "Found a Yandex Access Token, posing a risk to Yandex service integrations and user data privacy." +regex = '''(?i)[\w.-]{0,50}?(?:yandex)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(t1\.[A-Z0-9a-z_-]+[=]{0,2}\.[A-Z0-9a-z_-]{86}[=]{0,2})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["yandex"] + +[[rules]] +id = "yandex-api-key" +description = "Discovered a Yandex API Key, which could lead to unauthorized access to Yandex services and data manipulation." +regex = '''(?i)[\w.-]{0,50}?(?:yandex)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(AQVN[A-Za-z0-9_\-]{35,38})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["yandex"] + +[[rules]] +id = "yandex-aws-access-token" +description = "Uncovered a Yandex AWS Access Token, potentially compromising cloud resource access and data security on Yandex Cloud." +regex = '''(?i)[\w.-]{0,50}?(?:yandex)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(YC[a-zA-Z0-9_\-]{38})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["yandex"] + +[[rules]] +id = "zendesk-secret-key" +description = "Detected a Zendesk Secret Key, risking unauthorized access to customer support services and sensitive ticketing data." +regex = '''(?i)[\w.-]{0,50}?(?:zendesk)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{40})(?:[\x60'"\s;]|\\[nr]|$)''' +keywords = ["zendesk"] + diff --git a/cli/detect/config/rule.go b/cli/detect/config/rule.go new file mode 100644 index 000000000..6d2b61326 --- /dev/null +++ b/cli/detect/config/rule.go @@ -0,0 +1,114 @@ +// MIT License + +// Copyright (c) 2019 Zachary Rice + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package config + +import ( + "fmt" + "strings" + + "github.com/Infisical/infisical-merge/detect/regexp" +) + +// Rules contain information that define details on how to detect secrets +type Rule struct { + // RuleID is a unique identifier for this rule + RuleID string + + // Description is the description of the rule. + Description string + + // Entropy is a float representing the minimum shannon + // entropy a regex group must have to be considered a secret. + Entropy float64 + + // SecretGroup is an int used to extract secret from regex + // match and used as the group that will have its entropy + // checked if `entropy` is set. + SecretGroup int + + // Regex is a golang regular expression used to detect secrets. + Regex *regexp.Regexp + + // Path is a golang regular expression used to + // filter secrets by path + Path *regexp.Regexp + + // Tags is an array of strings used for metadata + // and reporting purposes. + Tags []string + + // Keywords are used for pre-regex check filtering. Rules that contain + // keywords will perform a quick string compare check to make sure the + // keyword(s) are in the content being scanned. + Keywords []string + + // Allowlists allows a rule to be ignored for specific commits, paths, regexes, and/or stopwords. + Allowlists []*Allowlist + + // validated is an internal flag to track whether `Validate()` has been called. + validated bool +} + +// Validate guards against common misconfigurations. +func (r *Rule) Validate() error { + if r.validated { + return nil + } + + // Ensure |id| is present. + if strings.TrimSpace(r.RuleID) == "" { + // Try to provide helpful context, since |id| is empty. + var context string + if r.Regex != nil { + context = ", regex: " + r.Regex.String() + } else if r.Path != nil { + context = ", path: " + r.Path.String() + } else if r.Description != "" { + context = ", description: " + r.Description + } + return fmt.Errorf("rule |id| is missing or empty" + context) + } + + // Ensure the rule actually matches something. + if r.Regex == nil && r.Path == nil { + return fmt.Errorf("%s: both |regex| and |path| are empty, this rule will have no effect", r.RuleID) + } + + // Ensure |secretGroup| works. + if r.Regex != nil && r.SecretGroup > r.Regex.NumSubexp() { + return fmt.Errorf("%s: invalid regex secret group %d, max regex secret group %d", r.RuleID, r.SecretGroup, r.Regex.NumSubexp()) + } + + for _, allowlist := range r.Allowlists { + // This will probably never happen. + if allowlist == nil { + continue + } + if err := allowlist.Validate(); err != nil { + return fmt.Errorf("%s: %w", r.RuleID, err) + } + } + + r.validated = true + return nil +} diff --git a/cli/report/report.go b/cli/detect/config/utils.go similarity index 65% rename from cli/report/report.go rename to cli/detect/config/utils.go index 1191a4f33..e28a5cb37 100644 --- a/cli/report/report.go +++ b/cli/detect/config/utils.go @@ -20,35 +20,27 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -package report +package config import ( - "os" - "strings" - - "github.com/Infisical/infisical-merge/config" + "github.com/Infisical/infisical-merge/detect/regexp" ) -const ( - // https://cwe.mitre.org/data/definitions/798.html - CWE = "CWE-798" - CWE_DESCRIPTION = "Use of Hard-coded Credentials" -) - -func Write(findings []Finding, cfg config.Config, ext string, reportPath string) error { - file, err := os.Create(reportPath) - if err != nil { - return err +func anyRegexMatch(f string, res []*regexp.Regexp) bool { + for _, re := range res { + if regexMatched(f, re) { + return true + } } - ext = strings.ToLower(ext) - switch ext { - case ".json", "json": - err = writeJson(findings, file) - case ".csv", "csv": - err = writeCsv(findings, file) - case ".sarif", "sarif": - err = writeSarif(cfg, findings, file) + return false +} + +func regexMatched(f string, re *regexp.Regexp) bool { + if re == nil { + return false + } + if re.FindString(f) != "" { + return true } - - return err + return false } diff --git a/cli/detect/decoder.go b/cli/detect/decoder.go new file mode 100644 index 000000000..6ec509757 --- /dev/null +++ b/cli/detect/decoder.go @@ -0,0 +1,328 @@ +// MIT License + +// Copyright (c) 2019 Zachary Rice + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package detect + +import ( + "bytes" + "encoding/base64" + "fmt" + "regexp" + "unicode" + + "github.com/Infisical/infisical-merge/detect/logging" +) + +var b64LikelyChars [128]byte +var b64Regexp = regexp.MustCompile(`[\w/+-]{16,}={0,3}`) +var decoders = []func(string) ([]byte, error){ + base64.StdEncoding.DecodeString, + base64.RawURLEncoding.DecodeString, +} + +func init() { + // Basically look for anything that isn't just letters + for _, c := range `0123456789+/-_` { + b64LikelyChars[c] = 1 + } +} + +// EncodedSegment represents a portion of text that is encoded in some way. +// `decode` supports recusive decoding and can result in "segment trees". +// There can be multiple segments in the original text, so each can be thought +// of as its own tree with the root being the original segment. +type EncodedSegment struct { + // The parent segment in a segment tree. If nil, it is a root segment + parent *EncodedSegment + + // Relative start/end are the bounds of the encoded value in the current pass. + relativeStart int + relativeEnd int + + // Absolute start/end refer to the bounds of the root segment in this segment + // tree + absoluteStart int + absoluteEnd int + + // Decoded start/end refer to the bounds of the decoded value in the current + // pass. These can differ from relative values because decoding can shrink + // or grow the size of the segment. + decodedStart int + decodedEnd int + + // This is the actual decoded content in the segment + decodedValue string + + // This is the type of encoding + encoding string +} + +// isChildOf inspects the bounds of two segments to determine +// if one should be the child of another +func (s EncodedSegment) isChildOf(parent EncodedSegment) bool { + return parent.decodedStart <= s.relativeStart && parent.decodedEnd >= s.relativeEnd +} + +// decodedOverlaps checks if the decoded bounds of the segment overlaps a range +func (s EncodedSegment) decodedOverlaps(start, end int) bool { + return start <= s.decodedEnd && end >= s.decodedStart +} + +// adjustMatchIndex takes the matchIndex from the current decoding pass and +// updates it to match the absolute matchIndex in the original text. +func (s EncodedSegment) adjustMatchIndex(matchIndex []int) []int { + // The match is within the bounds of the segment so we just return + // the absolute start and end of the root segment. + if s.decodedStart <= matchIndex[0] && matchIndex[1] <= s.decodedEnd { + return []int{ + s.absoluteStart, + s.absoluteEnd, + } + } + + // Since it overlaps one side and/or the other, we're going to have to adjust + // and climb parents until we're either at the root or we've determined + // we're fully inside one of the parent segments. + adjustedMatchIndex := make([]int, 2) + + if matchIndex[0] < s.decodedStart { + // It starts before the encoded segment so adjust the start to match + // the location before it was decoded + matchStartDelta := s.decodedStart - matchIndex[0] + adjustedMatchIndex[0] = s.relativeStart - matchStartDelta + } else { + // It starts within the encoded segment so set the bound to the + // relative start + adjustedMatchIndex[0] = s.relativeStart + } + + if matchIndex[1] > s.decodedEnd { + // It ends after the encoded segment so adjust the end to match + // the location before it was decoded + matchEndDelta := matchIndex[1] - s.decodedEnd + adjustedMatchIndex[1] = s.relativeEnd + matchEndDelta + } else { + // It ends within the encoded segment so set the bound to the relative end + adjustedMatchIndex[1] = s.relativeEnd + } + + // We're still not at a root segment so we'll need to keep on adjusting + if s.parent != nil { + return s.parent.adjustMatchIndex(adjustedMatchIndex) + } + + return adjustedMatchIndex +} + +// depth reports how many levels of decoding needed to be done (default is 1) +func (s EncodedSegment) depth() int { + depth := 1 + + // Climb the tree and increment the depth + for current := &s; current.parent != nil; current = current.parent { + depth++ + } + + return depth +} + +// tags returns additional meta data tags related to the types of segments +func (s EncodedSegment) tags() []string { + return []string{ + fmt.Sprintf("decoded:%s", s.encoding), + fmt.Sprintf("decode-depth:%d", s.depth()), + } +} + +// Decoder decodes various types of data in place +type Decoder struct { + decodedMap map[string]string +} + +// NewDecoder creates a default decoder struct +func NewDecoder() *Decoder { + return &Decoder{ + decodedMap: make(map[string]string), + } +} + +// decode returns the data with the values decoded in-place +func (d *Decoder) decode(data string, parentSegments []EncodedSegment) (string, []EncodedSegment) { + segments := d.findEncodedSegments(data, parentSegments) + + if len(segments) > 0 { + result := bytes.NewBuffer(make([]byte, 0, len(data))) + + relativeStart := 0 + for _, segment := range segments { + result.WriteString(data[relativeStart:segment.relativeStart]) + result.WriteString(segment.decodedValue) + relativeStart = segment.relativeEnd + } + result.WriteString(data[relativeStart:]) + + return result.String(), segments + } + + return data, segments +} + +// findEncodedSegments finds the encoded segments in the data and updates the +// segment tree for this pass +func (d *Decoder) findEncodedSegments(data string, parentSegments []EncodedSegment) []EncodedSegment { + if len(data) == 0 { + return []EncodedSegment{} + } + + matchIndices := b64Regexp.FindAllStringIndex(data, -1) + if matchIndices == nil { + return []EncodedSegment{} + } + + segments := make([]EncodedSegment, 0, len(matchIndices)) + + // Keeps up with offsets from the text changing size as things are decoded + decodedShift := 0 + + for _, matchIndex := range matchIndices { + encodedValue := data[matchIndex[0]:matchIndex[1]] + + if !isLikelyB64(encodedValue) { + d.decodedMap[encodedValue] = "" + continue + } + + decodedValue, alreadyDecoded := d.decodedMap[encodedValue] + + // We haven't decoded this yet, so go ahead and decode it + if !alreadyDecoded { + decodedValue = decodeValue(encodedValue) + d.decodedMap[encodedValue] = decodedValue + } + + // Skip this segment because there was nothing to check + if len(decodedValue) == 0 { + continue + } + + // Create a segment for the encoded data + segment := EncodedSegment{ + relativeStart: matchIndex[0], + relativeEnd: matchIndex[1], + absoluteStart: matchIndex[0], + absoluteEnd: matchIndex[1], + decodedStart: matchIndex[0] + decodedShift, + decodedEnd: matchIndex[0] + decodedShift + len(decodedValue), + decodedValue: decodedValue, + encoding: "base64", + } + + // Shift decoded start and ends based on size changes + decodedShift += len(decodedValue) - len(encodedValue) + + // Adjust the absolute position of segments contained in parent segments + for _, parentSegment := range parentSegments { + if segment.isChildOf(parentSegment) { + segment.absoluteStart = parentSegment.absoluteStart + segment.absoluteEnd = parentSegment.absoluteEnd + segment.parent = &parentSegment + break + } + } + + logging.Debug().Msgf("segment found: %#v", segment) + segments = append(segments, segment) + } + + return segments +} + +// decoders tries a list of decoders and returns the first successful one +func decodeValue(encodedValue string) string { + for _, decoder := range decoders { + decodedValue, err := decoder(encodedValue) + + if err == nil && len(decodedValue) > 0 && isASCII(decodedValue) { + return string(decodedValue) + } + } + + return "" +} + +func isASCII(b []byte) bool { + for i := 0; i < len(b); i++ { + if b[i] > unicode.MaxASCII || b[i] < '\t' { + return false + } + } + + return true +} + +// Skip a lot of method signatures and things at the risk of missing about +// 1% of base64 +func isLikelyB64(s string) bool { + for _, c := range s { + if b64LikelyChars[c] != 0 { + return true + } + } + + return false +} + +// Find a segment where the decoded bounds overlaps a range +func segmentWithDecodedOverlap(encodedSegments []EncodedSegment, start, end int) *EncodedSegment { + for _, segment := range encodedSegments { + if segment.decodedOverlaps(start, end) { + return &segment + } + } + + return nil +} + +func (s EncodedSegment) currentLine(currentRaw string) string { + start := 0 + end := len(currentRaw) + + // Find the start of the range + for i := s.decodedStart; i > -1; i-- { + c := currentRaw[i] + if c == '\n' { + start = i + break + } + } + + // Find the end of the range + for i := s.decodedEnd; i < end; i++ { + c := currentRaw[i] + if c == '\n' { + end = i + break + } + } + + return currentRaw[start:end] +} diff --git a/cli/detect/detect.go b/cli/detect/detect.go index 84f058d4b..f2e42cccc 100644 --- a/cli/detect/detect.go +++ b/cli/detect/detect.go @@ -26,39 +26,37 @@ import ( "bufio" "context" "fmt" - "io" - "io/fs" "os" - "path/filepath" - "regexp" + "runtime" "strings" "sync" + "sync/atomic" + "time" - "github.com/h2non/filetype" - - "github.com/Infisical/infisical-merge/config" - "github.com/Infisical/infisical-merge/detect/git" - "github.com/Infisical/infisical-merge/report" + "github.com/Infisical/infisical-merge/detect/config" + "github.com/Infisical/infisical-merge/detect/logging" + "github.com/Infisical/infisical-merge/detect/regexp" + "github.com/Infisical/infisical-merge/detect/report" + ahocorasick "github.com/BobuSumisu/aho-corasick" "github.com/fatih/semgroup" - "github.com/gitleaks/go-gitdiff/gitdiff" - ahocorasick "github.com/petar-dambovaliev/aho-corasick" - "github.com/rs/zerolog/log" + "github.com/rs/zerolog" "github.com/spf13/viper" + "golang.org/x/exp/maps" ) -// Type used to differentiate between git scan types: -// $ gitleaks detect -// $ gitleaks protect -// $ gitleaks protect staged -type GitScanType int - const ( - DetectType GitScanType = iota - ProtectType - ProtectStagedType + gitleaksAllowSignature = "gitleaks:allow" + chunkSize = 100 * 1_000 // 100kb - gitleaksAllowSignature = "infisical-scan:ignore" + // SlowWarningThreshold is the amount of time to wait before logging that a file is slow. + // This is useful for identifying problematic files and tuning the allowlist. + SlowWarningThreshold = 5 * time.Second +) + +var ( + newLineRegexp = regexp.MustCompile("\n") + isWindows = runtime.GOOS == "windows" ) // Detector is the main detector struct @@ -69,11 +67,14 @@ type Detector struct { // Redact is a flag to redact findings. This is exported // so users using gitleaks as a library can set this flag // without calling `detector.Start(cmd *cobra.Command)` - Redact bool + Redact uint // verbose is a flag to print findings Verbose bool + // MaxDecodeDepths limits how many recursive decoding passes are allowed + MaxDecodeDepth int + // files larger than this will be skipped MaxTargetMegaBytes int @@ -83,6 +84,9 @@ type Detector struct { // NoColor is a flag to disable color output NoColor bool + // IgnoreGitleaksAllow is a flag to ignore gitleaks:allow comments. + IgnoreGitleaksAllow bool + // commitMap is used to keep track of commits that have been scanned. // This is only used for logging purposes and git scans. commitMap map[string]bool @@ -98,7 +102,7 @@ type Detector struct { // prefilter is a ahocorasick struct used for doing efficient string // matching given a set of words (keywords from the rules in the config) - prefilter ahocorasick.AhoCorasick + prefilter ahocorasick.Trie // a list of known findings that should be ignored baseline []report.Finding @@ -107,7 +111,16 @@ type Detector struct { baselinePath string // gitleaksIgnore - gitleaksIgnore map[string]bool + gitleaksIgnore map[string]struct{} + + // Sema (https://github.com/fatih/semgroup) controls the concurrency + Sema *semgroup.Group + + // report-related settings. + ReportPath string + Reporter report.Reporter + + TotalBytes atomic.Uint64 } // Fragment contains the data to be scanned @@ -115,9 +128,15 @@ type Fragment struct { // Raw is the raw content of the fragment Raw string - // FilePath is the path to the file if applicable + Bytes []byte + + // FilePath is the path to the file, if applicable. + // The path separator MUST be normalized to `/`. FilePath string SymlinkFile string + // WindowsFilePath is the path with the original separator. + // This provides a backwards-compatible solution to https://github.com/gitleaks/gitleaks/issues/1565. + WindowsFilePath string `json:"-"` // TODO: remove this in v9. // CommitSHA is the SHA of the commit if applicable CommitSHA string @@ -125,28 +144,18 @@ type Fragment struct { // newlineIndices is a list of indices of newlines in the raw content. // This is used to calculate the line location of a finding newlineIndices [][]int - - // keywords is a map of all the keywords contain within the contents - // of this fragment - keywords map[string]bool } // NewDetector creates a new detector with the given config func NewDetector(cfg config.Config) *Detector { - builder := ahocorasick.NewAhoCorasickBuilder(ahocorasick.Opts{ - AsciiCaseInsensitive: true, - MatchOnlyWholeWords: false, - MatchKind: ahocorasick.LeftMostLongestMatch, - DFA: true, - }) - return &Detector{ commitMap: make(map[string]bool), - gitleaksIgnore: make(map[string]bool), + gitleaksIgnore: make(map[string]struct{}), findingMutex: &sync.Mutex{}, findings: make([]report.Finding, 0), Config: cfg, - prefilter: builder.Build(cfg.Keywords), + prefilter: *ahocorasick.NewTrieBuilder().AddStrings(maps.Keys(cfg.Keywords)).Build(), + Sema: semgroup.NewGroup(context.Background(), 40), } } @@ -170,58 +179,47 @@ func NewDetectorDefaultConfig() (*Detector, error) { } func (d *Detector) AddGitleaksIgnore(gitleaksIgnorePath string) error { - log.Debug().Msg("found .gitleaksignore file") + logging.Debug().Msgf("found .gitleaksignore file: %s", gitleaksIgnorePath) file, err := os.Open(gitleaksIgnorePath) - if err != nil { return err } - - // https://github.com/securego/gosec/issues/512 defer func() { + // https://github.com/securego/gosec/issues/512 if err := file.Close(); err != nil { - log.Warn().Msgf("Error closing .gitleaksignore file: %s\n", err) + logging.Warn().Msgf("Error closing .gitleaksignore file: %s\n", err) } }() + scanner := bufio.NewScanner(file) - + replacer := strings.NewReplacer("\\", "/") for scanner.Scan() { - d.gitleaksIgnore[scanner.Text()] = true + line := strings.TrimSpace(scanner.Text()) + // Skip lines that start with a comment + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Normalize the path. + // TODO: Make this a breaking change in v9. + s := strings.Split(line, ":") + switch len(s) { + case 3: + // Global fingerprint. + // `file:rule-id:start-line` + s[0] = replacer.Replace(s[0]) + case 4: + // Commit fingerprint. + // `commit:file:rule-id:start-line` + s[1] = replacer.Replace(s[1]) + default: + logging.Warn().Str("fingerprint", line).Msg("Invalid .gitleaksignore entry") + } + d.gitleaksIgnore[strings.Join(s, ":")] = struct{}{} } return nil } -func (d *Detector) AddBaseline(baselinePath string, source string) error { - if baselinePath != "" { - absoluteSource, err := filepath.Abs(source) - if err != nil { - return err - } - - absoluteBaseline, err := filepath.Abs(baselinePath) - if err != nil { - return err - } - - relativeBaseline, err := filepath.Rel(absoluteSource, absoluteBaseline) - if err != nil { - return err - } - - baseline, err := LoadBaseline(baselinePath) - if err != nil { - return err - } - - d.baseline = baseline - baselinePath = relativeBaseline - - } - - d.baselinePath = baselinePath - return nil -} - // DetectBytes scans the given bytes and returns a list of findings func (d *Detector) DetectBytes(content []byte) []report.Finding { return d.DetectString(string(content)) @@ -234,56 +232,179 @@ func (d *Detector) DetectString(content string) []report.Finding { }) } -// detectRule scans the given fragment for the given rule and returns a list of findings -func (d *Detector) detectRule(fragment Fragment, rule config.Rule) []report.Finding { - var findings []report.Finding +// Detect scans the given fragment and returns a list of findings +func (d *Detector) Detect(fragment Fragment) []report.Finding { + if fragment.Bytes == nil { + d.TotalBytes.Add(uint64(len(fragment.Raw))) + } + d.TotalBytes.Add(uint64(len(fragment.Bytes))) - // check if filepath or commit is allowed for this rule - if rule.Allowlist.CommitAllowed(fragment.CommitSHA) || - rule.Allowlist.PathAllowed(fragment.FilePath) { + var ( + findings []report.Finding + logger = func() zerolog.Logger { + l := logging.With().Str("path", fragment.FilePath) + if fragment.CommitSHA != "" { + l = l.Str("commit", fragment.CommitSHA) + } + return l.Logger() + }() + ) + + // check if filepath is allowed + if fragment.FilePath != "" { + // is the path our config or baseline file? + if fragment.FilePath == d.Config.Path || (d.baselinePath != "" && fragment.FilePath == d.baselinePath) { + logging.Trace().Msg("skipping file: matches config or baseline path") + return findings + } + } + // check if commit or filepath is allowed. + if isAllowed, event := checkCommitOrPathAllowed(logger, fragment, d.Config.Allowlists); isAllowed { + event.Msg("skipping file: global allowlist") return findings } - if rule.Path != nil && rule.Regex == nil { - // Path _only_ rule - if rule.Path.Match([]byte(fragment.FilePath)) { - finding := report.Finding{ - Description: rule.Description, - File: fragment.FilePath, - SymlinkFile: fragment.SymlinkFile, - RuleID: rule.RuleID, - Match: fmt.Sprintf("file detected: %s", fragment.FilePath), - Tags: rule.Tags, - } - return append(findings, finding) + // add newline indices for location calculation in detectRule + fragment.newlineIndices = newLineRegexp.FindAllStringIndex(fragment.Raw, -1) + + // setup variables to handle different decoding passes + currentRaw := fragment.Raw + encodedSegments := []EncodedSegment{} + currentDecodeDepth := 0 + decoder := NewDecoder() + + for { + // build keyword map for prefiltering rules + keywords := make(map[string]bool) + normalizedRaw := strings.ToLower(currentRaw) + matches := d.prefilter.MatchString(normalizedRaw) + for _, m := range matches { + keywords[normalizedRaw[m.Pos():int(m.Pos())+len(m.Match())]] = true } - } else if rule.Path != nil { - // if path is set _and_ a regex is set, then we need to check both - // so if the path does not match, then we should return early and not - // consider the regex - if !rule.Path.Match([]byte(fragment.FilePath)) { - return findings + + for _, rule := range d.Config.Rules { + if len(rule.Keywords) == 0 { + // if no keywords are associated with the rule always scan the + // fragment using the rule + findings = append(findings, d.detectRule(fragment, currentRaw, rule, encodedSegments)...) + continue + } + + // check if keywords are in the fragment + for _, k := range rule.Keywords { + if _, ok := keywords[strings.ToLower(k)]; ok { + findings = append(findings, d.detectRule(fragment, currentRaw, rule, encodedSegments)...) + break + } + } + } + + // increment the depth by 1 as we start our decoding pass + currentDecodeDepth++ + + // stop the loop if we've hit our max decoding depth + if currentDecodeDepth > d.MaxDecodeDepth { + break + } + + // decode the currentRaw for the next pass + currentRaw, encodedSegments = decoder.decode(currentRaw, encodedSegments) + + // stop the loop when there's nothing else to decode + if len(encodedSegments) == 0 { + break + } + } + + return filter(findings, d.Redact) +} + +// detectRule scans the given fragment for the given rule and returns a list of findings +func (d *Detector) detectRule(fragment Fragment, currentRaw string, r config.Rule, encodedSegments []EncodedSegment) []report.Finding { + var ( + findings []report.Finding + logger = func() zerolog.Logger { + l := logging.With().Str("rule-id", r.RuleID).Str("path", fragment.FilePath) + if fragment.CommitSHA != "" { + l = l.Str("commit", fragment.CommitSHA) + } + return l.Logger() + }() + ) + + // check if commit or file is allowed for this rule. + if isAllowed, event := checkCommitOrPathAllowed(logger, fragment, r.Allowlists); isAllowed { + event.Msg("skipping file: rule allowlist") + return findings + } + + if r.Path != nil { + if r.Regex == nil && len(encodedSegments) == 0 { + // Path _only_ rule + if r.Path.MatchString(fragment.FilePath) || (fragment.WindowsFilePath != "" && r.Path.MatchString(fragment.WindowsFilePath)) { + finding := report.Finding{ + RuleID: r.RuleID, + Description: r.Description, + File: fragment.FilePath, + SymlinkFile: fragment.SymlinkFile, + Match: fmt.Sprintf("file detected: %s", fragment.FilePath), + Tags: r.Tags, + } + return append(findings, finding) + } + } else { + // if path is set _and_ a regex is set, then we need to check both + // so if the path does not match, then we should return early and not + // consider the regex + if !(r.Path.MatchString(fragment.FilePath) || (fragment.WindowsFilePath != "" && r.Path.MatchString(fragment.WindowsFilePath))) { + return findings + } } } // if path only rule, skip content checks - if rule.Regex == nil { + if r.Regex == nil { return findings } - // If flag configure and raw data size bigger then the flag + // if flag configure and raw data size bigger then the flag if d.MaxTargetMegaBytes > 0 { - rawLength := len(fragment.Raw) / 1000000 + rawLength := len(currentRaw) / 1000000 if rawLength > d.MaxTargetMegaBytes { - log.Debug().Msgf("skipping file: %s scan due to size: %d", fragment.FilePath, rawLength) + logger.Debug(). + Int("size", rawLength). + Int("max-size", d.MaxTargetMegaBytes). + Msg("skipping fragment: size") return findings } } - matchIndices := rule.Regex.FindAllStringIndex(fragment.Raw, -1) - for _, matchIndex := range matchIndices { - // extract secret from match - secret := strings.Trim(fragment.Raw[matchIndex[0]:matchIndex[1]], "\n") + // use currentRaw instead of fragment.Raw since this represents the current + // decoding pass on the text + for _, matchIndex := range r.Regex.FindAllStringIndex(currentRaw, -1) { + // Extract secret from match + secret := strings.Trim(currentRaw[matchIndex[0]:matchIndex[1]], "\n") + + // For any meta data from decoding + var metaTags []string + currentLine := "" + + // Check if the decoded portions of the segment overlap with the match + // to see if its potentially a new match + if len(encodedSegments) > 0 { + if segment := segmentWithDecodedOverlap(encodedSegments, matchIndex[0], matchIndex[1]); segment != nil { + matchIndex = segment.adjustMatchIndex(matchIndex) + metaTags = append(metaTags, segment.tags()...) + currentLine = segment.currentLine(currentRaw) + } else { + // This item has already been added to a finding + continue + } + } else { + // Fixes: https://github.com/gitleaks/gitleaks/issues/1352 + // removes the incorrectly following line that was detected by regex expression '\n' + matchIndex[1] = matchIndex[0] + len(secret) + } // determine location of match. Note that the location // in the finding will be the line/column numbers of the _match_ @@ -296,345 +417,112 @@ func (d *Detector) detectRule(fragment Fragment, rule config.Rule) []report.Find } finding := report.Finding{ - Description: rule.Description, - File: fragment.FilePath, - SymlinkFile: fragment.SymlinkFile, - RuleID: rule.RuleID, + RuleID: r.RuleID, + Description: r.Description, StartLine: loc.startLine, EndLine: loc.endLine, StartColumn: loc.startColumn, EndColumn: loc.endColumn, - Secret: secret, - Match: secret, - Tags: rule.Tags, Line: fragment.Raw[loc.startLineIndex:loc.endLineIndex], + Match: secret, + Secret: secret, + File: fragment.FilePath, + SymlinkFile: fragment.SymlinkFile, + Tags: append(r.Tags, metaTags...), } - if strings.Contains(fragment.Raw[loc.startLineIndex:loc.endLineIndex], - gitleaksAllowSignature) { + if !d.IgnoreGitleaksAllow && strings.Contains(finding.Line, gitleaksAllowSignature) { + logger.Trace(). + Str("finding", finding.Secret). + Msg("skipping finding: 'gitleaks:allow' signature") continue } - // extract secret from secret group if set - if rule.SecretGroup != 0 { - groups := rule.Regex.FindStringSubmatch(secret) - if len(groups) <= rule.SecretGroup || len(groups) == 0 { - // Config validation should prevent this - continue + if currentLine == "" { + currentLine = finding.Line + } + + // Set the value of |secret|, if the pattern contains at least one capture group. + // (The first element is the full match, hence we check >= 2.) + groups := r.Regex.FindStringSubmatch(finding.Secret) + if len(groups) >= 2 { + if r.SecretGroup > 0 { + if len(groups) <= r.SecretGroup { + // Config validation should prevent this + continue + } + finding.Secret = groups[r.SecretGroup] + } else { + // If |secretGroup| is not set, we will use the first suitable capture group. + for _, s := range groups[1:] { + if len(s) > 0 { + finding.Secret = s + break + } + } } - secret = groups[rule.SecretGroup] - finding.Secret = secret - } - - // check if the regexTarget is defined in the allowlist "regexes" entry - allowlistTarget := finding.Secret - switch rule.Allowlist.RegexTarget { - case "match": - allowlistTarget = finding.Match - case "line": - allowlistTarget = finding.Line - } - - globalAllowlistTarget := finding.Secret - switch d.Config.Allowlist.RegexTarget { - case "match": - globalAllowlistTarget = finding.Match - case "line": - globalAllowlistTarget = finding.Line - } - if rule.Allowlist.RegexAllowed(allowlistTarget) || - d.Config.Allowlist.RegexAllowed(globalAllowlistTarget) { - continue - } - - // check if the secret is in the list of stopwords - if rule.Allowlist.ContainsStopWord(finding.Secret) || - d.Config.Allowlist.ContainsStopWord(finding.Secret) { - continue } // check entropy entropy := shannonEntropy(finding.Secret) finding.Entropy = float32(entropy) - if rule.Entropy != 0.0 { - if entropy <= rule.Entropy { - // entropy is too low, skip this finding + if r.Entropy != 0.0 { + // entropy is too low, skip this finding + if entropy <= r.Entropy { + logger.Trace(). + Str("finding", finding.Secret). + Float32("entropy", finding.Entropy). + Msg("skipping finding: low entropy") continue } - // NOTE: this is a goofy hack to get around the fact there golang's regex engine - // does not support positive lookaheads. Ideally we would want to add a - // restriction on generic rules regex that requires the secret match group - // contains both numbers and alphabetical characters, not just alphabetical characters. - // What this bit of code does is check if the ruleid is prepended with "generic" and enforces the - // secret contains both digits and alphabetical characters. - // TODO: this should be replaced with stop words - if strings.HasPrefix(rule.RuleID, "generic") { - if !containsDigit(secret) { - continue - } - } } + // check if the result matches any of the global allowlists. + if isAllowed, event := checkFindingAllowed(logger, finding, fragment, currentLine, d.Config.Allowlists); isAllowed { + event.Msg("skipping finding: global allowlist") + continue + } + + // check if the result matches any of the rule allowlists. + if isAllowed, event := checkFindingAllowed(logger, finding, fragment, currentLine, r.Allowlists); isAllowed { + event.Msg("skipping finding: rule allowlist") + continue + } findings = append(findings, finding) } return findings } -// GitScan accepts a *gitdiff.File channel which contents a git history generated from -// the output of `git log -p ...`. startGitScan will look at each file (patch) in the history -// and determine if the patch contains any findings. -func (d *Detector) DetectGit(source string, logOpts string, gitScanType GitScanType) ([]report.Finding, error) { - var ( - gitdiffFiles <-chan *gitdiff.File - err error - ) - switch gitScanType { - case DetectType: - gitdiffFiles, err = git.GitLog(source, logOpts) - if err != nil { - return d.findings, err - } - case ProtectType: - gitdiffFiles, err = git.GitDiff(source, false) - if err != nil { - return d.findings, err - } - case ProtectStagedType: - gitdiffFiles, err = git.GitDiff(source, true) - if err != nil { - return d.findings, err - } - } - - s := semgroup.NewGroup(context.Background(), 4) - - for gitdiffFile := range gitdiffFiles { - gitdiffFile := gitdiffFile - - // skip binary files - if gitdiffFile.IsBinary || gitdiffFile.IsDelete { - continue - } - - // Check if commit is allowed - commitSHA := "" - if gitdiffFile.PatchHeader != nil { - commitSHA = gitdiffFile.PatchHeader.SHA - if d.Config.Allowlist.CommitAllowed(gitdiffFile.PatchHeader.SHA) { - continue - } - } - d.addCommit(commitSHA) - - s.Go(func() error { - for _, textFragment := range gitdiffFile.TextFragments { - if textFragment == nil { - return nil - } - - fragment := Fragment{ - Raw: textFragment.Raw(gitdiff.OpAdd), - CommitSHA: commitSHA, - FilePath: gitdiffFile.NewName, - } - - for _, finding := range d.Detect(fragment) { - d.addFinding(augmentGitFinding(finding, textFragment, gitdiffFile)) - } - } - return nil - }) - } - - if err := s.Wait(); err != nil { - return d.findings, err - } - log.Info().Msgf("%d commits scanned.", len(d.commitMap)) - log.Debug().Msg("Note: this number might be smaller than expected due to commits with no additions") - if git.ErrEncountered { - return d.findings, fmt.Errorf("%s", "git error encountered, see logs") - } - return d.findings, nil -} - -type scanTarget struct { - Path string - Symlink string -} - -// DetectFiles accepts a path to a source directory or file and begins a scan of the -// file or directory. -func (d *Detector) DetectFiles(source string) ([]report.Finding, error) { - s := semgroup.NewGroup(context.Background(), 4) - paths := make(chan scanTarget) - s.Go(func() error { - defer close(paths) - return filepath.Walk(source, - func(path string, fInfo os.FileInfo, err error) error { - if err != nil { - return err - } - if fInfo.Name() == ".git" && fInfo.IsDir() { - return filepath.SkipDir - } - if fInfo.Size() == 0 { - return nil - } - if fInfo.Mode().IsRegular() { - paths <- scanTarget{ - Path: path, - Symlink: "", - } - } - if fInfo.Mode().Type() == fs.ModeSymlink && d.FollowSymlinks { - realPath, err := filepath.EvalSymlinks(path) - if err != nil { - return err - } - realPathFileInfo, _ := os.Stat(realPath) - if realPathFileInfo.IsDir() { - log.Debug().Msgf("found symlinked directory: %s -> %s [skipping]", path, realPath) - return nil - } - paths <- scanTarget{ - Path: realPath, - Symlink: path, - } - } - return nil - }) - }) - for pa := range paths { - p := pa - s.Go(func() error { - b, err := os.ReadFile(p.Path) - if err != nil { - return err - } - - mimetype, err := filetype.Match(b) - if err != nil { - return err - } - if mimetype.MIME.Type == "application" { - return nil // skip binary files - } - - fragment := Fragment{ - Raw: string(b), - FilePath: p.Path, - } - if p.Symlink != "" { - fragment.SymlinkFile = p.Symlink - } - for _, finding := range d.Detect(fragment) { - // need to add 1 since line counting starts at 1 - finding.EndLine++ - finding.StartLine++ - d.addFinding(finding) - } - - return nil - }) - } - - if err := s.Wait(); err != nil { - return d.findings, err - } - - return d.findings, nil -} - -// DetectReader accepts an io.Reader and a buffer size for the reader in KB -func (d *Detector) DetectReader(r io.Reader, bufSize int) ([]report.Finding, error) { - reader := bufio.NewReader(r) - buf := make([]byte, 0, 1000*bufSize) - findings := []report.Finding{} - - for { - n, err := reader.Read(buf[:cap(buf)]) - buf = buf[:n] - if err != nil { - if err != io.EOF { - return findings, err - } - break - } - - fragment := Fragment{ - Raw: string(buf), - } - for _, finding := range d.Detect(fragment) { - findings = append(findings, finding) - if d.Verbose { - printFinding(finding, d.NoColor) - } - } - } - - return findings, nil -} - -// Detect scans the given fragment and returns a list of findings -func (d *Detector) Detect(fragment Fragment) []report.Finding { - var findings []report.Finding - - // initiate fragment keywords - fragment.keywords = make(map[string]bool) - - // check if filepath is allowed - if fragment.FilePath != "" && (d.Config.Allowlist.PathAllowed(fragment.FilePath) || - fragment.FilePath == d.Config.Path || (d.baselinePath != "" && fragment.FilePath == d.baselinePath)) { - return findings - } - - // add newline indices for location calculation in detectRule - fragment.newlineIndices = regexp.MustCompile("\n").FindAllStringIndex(fragment.Raw, -1) - - // build keyword map for prefiltering rules - normalizedRaw := strings.ToLower(fragment.Raw) - matches := d.prefilter.FindAll(normalizedRaw) - for _, m := range matches { - fragment.keywords[normalizedRaw[m.Start():m.End()]] = true - } - - for _, rule := range d.Config.Rules { - if len(rule.Keywords) == 0 { - // if not keywords are associated with the rule always scan the - // fragment using the rule - findings = append(findings, d.detectRule(fragment, rule)...) - continue - } - fragmentContainsKeyword := false - // check if keywords are in the fragment - for _, k := range rule.Keywords { - if _, ok := fragment.keywords[strings.ToLower(k)]; ok { - fragmentContainsKeyword = true - } - } - if fragmentContainsKeyword { - findings = append(findings, d.detectRule(fragment, rule)...) - } - } - return filter(findings, d.Redact) -} - -// addFinding synchronously adds a finding to the findings slice -func (d *Detector) addFinding(finding report.Finding) { - if finding.Commit == "" { - finding.Fingerprint = fmt.Sprintf("%s:%s:%d", finding.File, finding.RuleID, finding.StartLine) - } else { +// AddFinding synchronously adds a finding to the findings slice +func (d *Detector) AddFinding(finding report.Finding) { + globalFingerprint := fmt.Sprintf("%s:%s:%d", finding.File, finding.RuleID, finding.StartLine) + if finding.Commit != "" { finding.Fingerprint = fmt.Sprintf("%s:%s:%s:%d", finding.Commit, finding.File, finding.RuleID, finding.StartLine) - } - // check if we should ignore this finding - if _, ok := d.gitleaksIgnore[finding.Fingerprint]; ok { - log.Debug().Msgf("ignoring finding with Fingerprint %s", - finding.Fingerprint) - return + } else { + finding.Fingerprint = globalFingerprint } - if d.baseline != nil && !IsNew(finding, d.baseline) { - log.Debug().Msgf("baseline duplicate -- ignoring finding with Fingerprint %s", finding.Fingerprint) + // check if we should ignore this finding + logger := logging.With().Str("finding", finding.Secret).Logger() + if _, ok := d.gitleaksIgnore[globalFingerprint]; ok { + logger.Debug(). + Str("fingerprint", globalFingerprint). + Msg("skipping finding: global fingerprint") + return + } else if finding.Commit != "" { + // Awkward nested if because I'm not sure how to chain these two conditions. + if _, ok := d.gitleaksIgnore[finding.Fingerprint]; ok { + logger.Debug(). + Str("fingerprint", finding.Fingerprint). + Msgf("skipping finding: fingerprint") + return + } + } + + if d.baseline != nil && !IsNew(finding, d.Redact, d.baseline) { + logger.Debug(). + Str("fingerprint", finding.Fingerprint). + Msgf("skipping finding: baseline") return } @@ -646,7 +534,166 @@ func (d *Detector) addFinding(finding report.Finding) { d.findingMutex.Unlock() } -// addCommit synchronously adds a commit to the commit slice +// Findings returns the findings added to the detector +func (d *Detector) Findings() []report.Finding { + return d.findings +} + +// AddCommit synchronously adds a commit to the commit slice func (d *Detector) addCommit(commit string) { d.commitMap[commit] = true } + +// checkCommitOrPathAllowed evaluates |fragment| against all provided |allowlists|. +// +// If the match condition is "OR", only commit and path are checked. +// Otherwise, if regexes or stopwords are defined this will fail. +func checkCommitOrPathAllowed( + logger zerolog.Logger, + fragment Fragment, + allowlists []*config.Allowlist, +) (bool, *zerolog.Event) { + if fragment.FilePath == "" && fragment.CommitSHA == "" { + return false, nil + } + + for _, a := range allowlists { + var ( + isAllowed bool + allowlistChecks []bool + commitAllowed, _ = a.CommitAllowed(fragment.CommitSHA) + pathAllowed = a.PathAllowed(fragment.FilePath) || (fragment.WindowsFilePath != "" && a.PathAllowed(fragment.WindowsFilePath)) + ) + // If the condition is "AND" we need to check all conditions. + if a.MatchCondition == config.AllowlistMatchAnd { + if len(a.Commits) > 0 { + allowlistChecks = append(allowlistChecks, commitAllowed) + } + if len(a.Paths) > 0 { + allowlistChecks = append(allowlistChecks, pathAllowed) + } + // These will be checked later. + if len(a.Regexes) > 0 { + continue + } + if len(a.StopWords) > 0 { + continue + } + + isAllowed = allTrue(allowlistChecks) + } else { + isAllowed = commitAllowed || pathAllowed + } + if isAllowed { + event := logger.Trace().Str("condition", a.MatchCondition.String()) + if commitAllowed { + event.Bool("allowed-commit", commitAllowed) + } + if pathAllowed { + event.Bool("allowed-path", pathAllowed) + } + return true, event + } + } + return false, nil +} + +// checkFindingAllowed evaluates |finding| against all provided |allowlists|. +// +// If the match condition is "OR", only regex and stopwords are run. (Commit and path should be handled separately). +// Otherwise, all conditions are checked. +// +// TODO: The method signature is awkward. I can't think of a better way to log helpful info. +func checkFindingAllowed( + logger zerolog.Logger, + finding report.Finding, + fragment Fragment, + currentLine string, + allowlists []*config.Allowlist, +) (bool, *zerolog.Event) { + for _, a := range allowlists { + allowlistTarget := finding.Secret + switch a.RegexTarget { + case "match": + allowlistTarget = finding.Match + case "line": + allowlistTarget = currentLine + } + + var ( + checks []bool + isAllowed bool + commitAllowed bool + commit string + pathAllowed bool + regexAllowed = a.RegexAllowed(allowlistTarget) + containsStopword, word = a.ContainsStopWord(finding.Secret) + ) + // If the condition is "AND" we need to check all conditions. + if a.MatchCondition == config.AllowlistMatchAnd { + // Determine applicable checks. + if len(a.Commits) > 0 { + commitAllowed, commit = a.CommitAllowed(fragment.CommitSHA) + checks = append(checks, commitAllowed) + } + if len(a.Paths) > 0 { + pathAllowed = a.PathAllowed(fragment.FilePath) || (fragment.WindowsFilePath != "" && a.PathAllowed(fragment.WindowsFilePath)) + checks = append(checks, pathAllowed) + } + if len(a.Regexes) > 0 { + checks = append(checks, regexAllowed) + } + if len(a.StopWords) > 0 { + checks = append(checks, containsStopword) + } + + isAllowed = allTrue(checks) + } else { + isAllowed = regexAllowed || containsStopword + } + + if isAllowed { + event := logger.Trace(). + Str("finding", finding.Secret). + Str("condition", a.MatchCondition.String()) + if commitAllowed { + event.Str("allowed-commit", commit) + } + if pathAllowed { + event.Bool("allowed-path", pathAllowed) + } + if regexAllowed { + event.Bool("allowed-regex", regexAllowed) + } + if containsStopword { + event.Str("allowed-stopword", word) + } + return true, event + } + } + return false, nil +} + +func allTrue(bools []bool) bool { + for _, check := range bools { + if !check { + return false + } + } + return true +} + +func fileExists(fileName string) bool { + // check for a .infisicalignore file + info, err := os.Stat(fileName) + if err != nil && !os.IsNotExist(err) { + return false + } + + if info != nil && err == nil { + if !info.IsDir() { + return true + } + } + return false +} diff --git a/cli/detect/detect_test.go b/cli/detect/detect_test.go deleted file mode 100644 index 5a0f50828..000000000 --- a/cli/detect/detect_test.go +++ /dev/null @@ -1,754 +0,0 @@ -// MIT License - -// Copyright (c) 2019 Zachary Rice - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package detect - -import ( - "fmt" - "os" - "path/filepath" - "testing" - - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - - "github.com/Infisical/infisical-merge/config" - "github.com/Infisical/infisical-merge/report" -) - -const configPath = "../testdata/config/" -const repoBasePath = "../testdata/repos/" - -func TestDetect(t *testing.T) { - tests := []struct { - cfgName string - baselinePath string - fragment Fragment - // NOTE: for expected findings, all line numbers will be 0 - // because line deltas are added _after_ the finding is created. - // I.e, if the finding is from a --no-git file, the line number will be - // increase by 1 in DetectFromFiles(). If the finding is from git, - // the line number will be increased by the patch delta. - expectedFindings []report.Finding - wantError error - }{ - { - cfgName: "simple", - fragment: Fragment{ - Raw: `awsToken := \"AKIALALEMEL33243OKIA\ // infisical-scan:ignore"`, - FilePath: "tmp.go", - }, - expectedFindings: []report.Finding{}, - }, - { - cfgName: "simple", - fragment: Fragment{ - Raw: `awsToken := \ - - \"AKIALALEMEL33243OKIA\ // infisical-scan:ignore" - - `, - FilePath: "tmp.go", - }, - expectedFindings: []report.Finding{}, - }, - { - cfgName: "simple", - fragment: Fragment{ - Raw: `awsToken := \"AKIALALEMEL33243OKIA\" - - // infisical-scan:ignore" - - `, - FilePath: "tmp.go", - }, - expectedFindings: []report.Finding{ - { - Description: "AWS Access Key", - Secret: "AKIALALEMEL33243OKIA", - Match: "AKIALALEMEL33243OKIA", - File: "tmp.go", - Line: `awsToken := \"AKIALALEMEL33243OKIA\"`, - RuleID: "aws-access-key", - Tags: []string{"key", "AWS"}, - StartLine: 0, - EndLine: 0, - StartColumn: 15, - EndColumn: 34, - Entropy: 3.1464393, - }, - }, - }, - { - cfgName: "escaped_character_group", - fragment: Fragment{ - Raw: `pypi-AgEIcHlwaS5vcmcAAAAAAAAAA-AAAAAAAAAA-AAAAAAAAAA-AAAAAAAAAA-AAAAAAAAAA-AAAAAAAAAAB`, - FilePath: "tmp.go", - }, - expectedFindings: []report.Finding{ - { - Description: "PyPI upload token", - Secret: "pypi-AgEIcHlwaS5vcmcAAAAAAAAAA-AAAAAAAAAA-AAAAAAAAAA-AAAAAAAAAA-AAAAAAAAAA-AAAAAAAAAAB", - Match: "pypi-AgEIcHlwaS5vcmcAAAAAAAAAA-AAAAAAAAAA-AAAAAAAAAA-AAAAAAAAAA-AAAAAAAAAA-AAAAAAAAAAB", - Line: `pypi-AgEIcHlwaS5vcmcAAAAAAAAAA-AAAAAAAAAA-AAAAAAAAAA-AAAAAAAAAA-AAAAAAAAAA-AAAAAAAAAAB`, - File: "tmp.go", - RuleID: "pypi-upload-token", - Tags: []string{"key", "pypi"}, - StartLine: 0, - EndLine: 0, - StartColumn: 1, - EndColumn: 86, - Entropy: 1.9606875, - }, - }, - }, - { - cfgName: "simple", - fragment: Fragment{ - Raw: `awsToken := \"AKIALALEMEL33243OLIA\"`, - FilePath: "tmp.go", - }, - expectedFindings: []report.Finding{ - { - Description: "AWS Access Key", - Secret: "AKIALALEMEL33243OLIA", - Match: "AKIALALEMEL33243OLIA", - Line: `awsToken := \"AKIALALEMEL33243OLIA\"`, - File: "tmp.go", - RuleID: "aws-access-key", - Tags: []string{"key", "AWS"}, - StartLine: 0, - EndLine: 0, - StartColumn: 15, - EndColumn: 34, - Entropy: 3.0841837, - }, - }, - }, - { - cfgName: "simple", - fragment: Fragment{ - Raw: `export BUNDLE_ENTERPRISE__CONTRIBSYS__COM=cafebabe:deadbeef;`, - FilePath: "tmp.sh", - }, - expectedFindings: []report.Finding{ - { - Description: "Sidekiq Secret", - Match: "BUNDLE_ENTERPRISE__CONTRIBSYS__COM=cafebabe:deadbeef;", - Secret: "cafebabe:deadbeef", - Line: `export BUNDLE_ENTERPRISE__CONTRIBSYS__COM=cafebabe:deadbeef;`, - File: "tmp.sh", - RuleID: "sidekiq-secret", - Tags: []string{}, - Entropy: 2.6098502, - StartLine: 0, - EndLine: 0, - StartColumn: 8, - EndColumn: 60, - }, - }, - }, - { - cfgName: "simple", - fragment: Fragment{ - Raw: `echo hello1; export BUNDLE_ENTERPRISE__CONTRIBSYS__COM="cafebabe:deadbeef" && echo hello2`, - FilePath: "tmp.sh", - }, - expectedFindings: []report.Finding{ - { - Description: "Sidekiq Secret", - Match: "BUNDLE_ENTERPRISE__CONTRIBSYS__COM=\"cafebabe:deadbeef\"", - Secret: "cafebabe:deadbeef", - File: "tmp.sh", - Line: `echo hello1; export BUNDLE_ENTERPRISE__CONTRIBSYS__COM="cafebabe:deadbeef" && echo hello2`, - RuleID: "sidekiq-secret", - Tags: []string{}, - Entropy: 2.6098502, - StartLine: 0, - EndLine: 0, - StartColumn: 21, - EndColumn: 74, - }, - }, - }, - { - cfgName: "simple", - fragment: Fragment{ - Raw: `url = "http://cafeb4b3:d3adb33f@enterprise.contribsys.com:80/path?param1=true¶m2=false#heading1"`, - FilePath: "tmp.sh", - }, - expectedFindings: []report.Finding{ - { - Description: "Sidekiq Sensitive URL", - Match: "http://cafeb4b3:d3adb33f@enterprise.contribsys.com:", - Secret: "cafeb4b3:d3adb33f", - File: "tmp.sh", - Line: `url = "http://cafeb4b3:d3adb33f@enterprise.contribsys.com:80/path?param1=true¶m2=false#heading1"`, - RuleID: "sidekiq-sensitive-url", - Tags: []string{}, - Entropy: 2.984234, - StartLine: 0, - EndLine: 0, - StartColumn: 8, - EndColumn: 58, - }, - }, - }, - { - cfgName: "allow_aws_re", - fragment: Fragment{ - Raw: `awsToken := \"AKIALALEMEL33243OLIA\"`, - FilePath: "tmp.go", - }, - expectedFindings: []report.Finding{}, - }, - { - cfgName: "allow_path", - fragment: Fragment{ - Raw: `awsToken := \"AKIALALEMEL33243OLIA\"`, - FilePath: "tmp.go", - }, - expectedFindings: []report.Finding{}, - }, - { - cfgName: "allow_commit", - fragment: Fragment{ - Raw: `awsToken := \"AKIALALEMEL33243OLIA\"`, - FilePath: "tmp.go", - CommitSHA: "allowthiscommit", - }, - expectedFindings: []report.Finding{}, - }, - { - cfgName: "entropy_group", - fragment: Fragment{ - Raw: `const Discord_Public_Key = "e7322523fb86ed64c836a979cf8465fbd436378c653c1db38f9ae87bc62a6fd5"`, - FilePath: "tmp.go", - }, - expectedFindings: []report.Finding{ - { - Description: "Discord API key", - Match: "Discord_Public_Key = \"e7322523fb86ed64c836a979cf8465fbd436378c653c1db38f9ae87bc62a6fd5\"", - Secret: "e7322523fb86ed64c836a979cf8465fbd436378c653c1db38f9ae87bc62a6fd5", - Line: `const Discord_Public_Key = "e7322523fb86ed64c836a979cf8465fbd436378c653c1db38f9ae87bc62a6fd5"`, - File: "tmp.go", - RuleID: "discord-api-key", - Tags: []string{}, - Entropy: 3.7906237, - StartLine: 0, - EndLine: 0, - StartColumn: 7, - EndColumn: 93, - }, - }, - }, - { - cfgName: "generic_with_py_path", - fragment: Fragment{ - Raw: `const Discord_Public_Key = "e7322523fb86ed64c836a979cf8465fbd436378c653c1db38f9ae87bc62a6fd5"`, - FilePath: "tmp.go", - }, - expectedFindings: []report.Finding{}, - }, - { - cfgName: "generic_with_py_path", - fragment: Fragment{ - Raw: `const Discord_Public_Key = "e7322523fb86ed64c836a979cf8465fbd436378c653c1db38f9ae87bc62a6fd5"`, - FilePath: "tmp.py", - }, - expectedFindings: []report.Finding{ - { - Description: "Generic API Key", - Match: "Key = \"e7322523fb86ed64c836a979cf8465fbd436378c653c1db38f9ae87bc62a6fd5\"", - Secret: "e7322523fb86ed64c836a979cf8465fbd436378c653c1db38f9ae87bc62a6fd5", - Line: `const Discord_Public_Key = "e7322523fb86ed64c836a979cf8465fbd436378c653c1db38f9ae87bc62a6fd5"`, - File: "tmp.py", - RuleID: "generic-api-key", - Tags: []string{}, - Entropy: 3.7906237, - StartLine: 0, - EndLine: 0, - StartColumn: 22, - EndColumn: 93, - }, - }, - }, - { - cfgName: "path_only", - fragment: Fragment{ - Raw: `const Discord_Public_Key = "e7322523fb86ed64c836a979cf8465fbd436378c653c1db38f9ae87bc62a6fd5"`, - FilePath: "tmp.py", - }, - expectedFindings: []report.Finding{ - { - Description: "Python Files", - Match: "file detected: tmp.py", - File: "tmp.py", - RuleID: "python-files-only", - Tags: []string{}, - }, - }, - }, - { - cfgName: "bad_entropy_group", - fragment: Fragment{ - Raw: `const Discord_Public_Key = "e7322523fb86ed64c836a979cf8465fbd436378c653c1db38f9ae87bc62a6fd5"`, - FilePath: "tmp.go", - }, - expectedFindings: []report.Finding{}, - wantError: fmt.Errorf("Discord API key invalid regex secret group 5, max regex secret group 3"), - }, - { - cfgName: "simple", - fragment: Fragment{ - Raw: `awsToken := \"AKIALALEMEL33243OLIA\"`, - FilePath: filepath.Join(configPath, "simple.toml"), - }, - expectedFindings: []report.Finding{}, - }, - { - cfgName: "allow_global_aws_re", - fragment: Fragment{ - Raw: `awsToken := \"AKIALALEMEL33243OLIA\"`, - FilePath: "tmp.go", - }, - expectedFindings: []report.Finding{}, - }, - { - cfgName: "generic_with_py_path", - fragment: Fragment{ - Raw: `const Discord_Public_Key = "load2523fb86ed64c836a979cf8465fbd436378c653c1db38f9ae87bc62a6fd5"`, - FilePath: "tmp.py", - }, - expectedFindings: []report.Finding{}, - }, - { - cfgName: "path_only", - baselinePath: ".baseline.json", - fragment: Fragment{ - Raw: `const Discord_Public_Key = "e7322523fb86ed64c836a979cf8465fbd436378c653c1db38f9ae87bc62a6fd5"`, - FilePath: ".baseline.json", - }, - expectedFindings: []report.Finding{}, - }, - } - - for _, tt := range tests { - viper.Reset() - viper.AddConfigPath(configPath) - viper.SetConfigName(tt.cfgName) - viper.SetConfigType("toml") - err := viper.ReadInConfig() - if err != nil { - t.Error(err) - } - - var vc config.ViperConfig - err = viper.Unmarshal(&vc) - if err != nil { - t.Error(err) - } - cfg, err := vc.Translate() - cfg.Path = filepath.Join(configPath, tt.cfgName+".toml") - if tt.wantError != nil { - if err == nil { - t.Errorf("expected error") - } - assert.Equal(t, tt.wantError, err) - } - d := NewDetector(cfg) - d.baselinePath = tt.baselinePath - - findings := d.Detect(tt.fragment) - assert.ElementsMatch(t, tt.expectedFindings, findings) - } -} - -// TestFromGit tests the FromGit function -func TestFromGit(t *testing.T) { - tests := []struct { - cfgName string - source string - logOpts string - expectedFindings []report.Finding - }{ - { - source: filepath.Join(repoBasePath, "small"), - cfgName: "simple", - expectedFindings: []report.Finding{ - { - Description: "AWS Access Key", - StartLine: 20, - EndLine: 20, - StartColumn: 19, - EndColumn: 38, - Line: "\n awsToken := \"AKIALALEMEL33243OLIA\"", - Secret: "AKIALALEMEL33243OLIA", - Match: "AKIALALEMEL33243OLIA", - File: "main.go", - Date: "2021-11-02T23:37:53Z", - Commit: "1b6da43b82b22e4eaa10bcf8ee591e91abbfc587", - Author: "Zachary Rice", - Email: "zricer@protonmail.com", - Message: "Accidentally add a secret", - RuleID: "aws-access-key", - Tags: []string{"key", "AWS"}, - Entropy: 3.0841837, - Fingerprint: "1b6da43b82b22e4eaa10bcf8ee591e91abbfc587:main.go:aws-access-key:20", - }, - { - Description: "AWS Access Key", - StartLine: 9, - EndLine: 9, - StartColumn: 17, - EndColumn: 36, - Secret: "AKIALALEMEL33243OLIA", - Match: "AKIALALEMEL33243OLIA", - Line: "\n\taws_token := \"AKIALALEMEL33243OLIA\"", - File: "foo/foo.go", - Date: "2021-11-02T23:48:06Z", - Commit: "491504d5a31946ce75e22554cc34203d8e5ff3ca", - Author: "Zach Rice", - Email: "zricer@protonmail.com", - Message: "adding foo package with secret", - RuleID: "aws-access-key", - Tags: []string{"key", "AWS"}, - Entropy: 3.0841837, - Fingerprint: "491504d5a31946ce75e22554cc34203d8e5ff3ca:foo/foo.go:aws-access-key:9", - }, - }, - }, - { - source: filepath.Join(repoBasePath, "small"), - logOpts: "--all foo...", - cfgName: "simple", - expectedFindings: []report.Finding{ - { - Description: "AWS Access Key", - StartLine: 9, - EndLine: 9, - StartColumn: 17, - EndColumn: 36, - Secret: "AKIALALEMEL33243OLIA", - Line: "\n\taws_token := \"AKIALALEMEL33243OLIA\"", - Match: "AKIALALEMEL33243OLIA", - Date: "2021-11-02T23:48:06Z", - File: "foo/foo.go", - Commit: "491504d5a31946ce75e22554cc34203d8e5ff3ca", - Author: "Zach Rice", - Email: "zricer@protonmail.com", - Message: "adding foo package with secret", - RuleID: "aws-access-key", - Tags: []string{"key", "AWS"}, - Entropy: 3.0841837, - Fingerprint: "491504d5a31946ce75e22554cc34203d8e5ff3ca:foo/foo.go:aws-access-key:9", - }, - }, - }, - } - - err := moveDotGit("dotGit", ".git") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := moveDotGit(".git", "dotGit"); err != nil { - t.Error(err) - } - }() - - for _, tt := range tests { - - viper.AddConfigPath(configPath) - viper.SetConfigName("simple") - viper.SetConfigType("toml") - err = viper.ReadInConfig() - if err != nil { - t.Error(err) - } - - var vc config.ViperConfig - err = viper.Unmarshal(&vc) - if err != nil { - t.Error(err) - } - cfg, err := vc.Translate() - if err != nil { - t.Error(err) - } - detector := NewDetector(cfg) - findings, err := detector.DetectGit(tt.source, tt.logOpts, DetectType) - if err != nil { - t.Error(err) - } - - for _, f := range findings { - f.Match = "" // remove lines cause copying and pasting them has some wack formatting - } - assert.ElementsMatch(t, tt.expectedFindings, findings) - } -} -func TestFromGitStaged(t *testing.T) { - tests := []struct { - cfgName string - source string - logOpts string - expectedFindings []report.Finding - }{ - { - source: filepath.Join(repoBasePath, "staged"), - cfgName: "simple", - expectedFindings: []report.Finding{ - { - Description: "AWS Access Key", - StartLine: 7, - EndLine: 7, - StartColumn: 18, - EndColumn: 37, - Line: "\n\taws_token2 := \"AKIALALEMEL33243OLIA\" // this one is not", - Match: "AKIALALEMEL33243OLIA", - Secret: "AKIALALEMEL33243OLIA", - File: "api/api.go", - SymlinkFile: "", - Commit: "", - Entropy: 3.0841837, - Author: "", - Email: "", - Date: "0001-01-01T00:00:00Z", - Message: "", - Tags: []string{ - "key", - "AWS", - }, - RuleID: "aws-access-key", - Fingerprint: "api/api.go:aws-access-key:7", - }, - }, - }, - } - - err := moveDotGit("dotGit", ".git") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := moveDotGit(".git", "dotGit"); err != nil { - t.Error(err) - } - }() - - for _, tt := range tests { - - viper.AddConfigPath(configPath) - viper.SetConfigName("simple") - viper.SetConfigType("toml") - err = viper.ReadInConfig() - if err != nil { - t.Error(err) - } - - var vc config.ViperConfig - err = viper.Unmarshal(&vc) - if err != nil { - t.Error(err) - } - cfg, err := vc.Translate() - if err != nil { - t.Error(err) - } - detector := NewDetector(cfg) - detector.AddGitleaksIgnore(filepath.Join(tt.source, ".gitleaksignore")) - findings, err := detector.DetectGit(tt.source, tt.logOpts, ProtectStagedType) - if err != nil { - t.Error(err) - } - - for _, f := range findings { - f.Match = "" // remove lines cause copying and pasting them has some wack formatting - } - assert.ElementsMatch(t, tt.expectedFindings, findings) - } -} - -// TestFromFiles tests the FromFiles function -func TestFromFiles(t *testing.T) { - tests := []struct { - cfgName string - source string - expectedFindings []report.Finding - }{ - { - source: filepath.Join(repoBasePath, "nogit"), - cfgName: "simple", - expectedFindings: []report.Finding{ - { - Description: "AWS Access Key", - StartLine: 20, - EndLine: 20, - StartColumn: 16, - EndColumn: 35, - Match: "AKIALALEMEL33243OLIA", - Secret: "AKIALALEMEL33243OLIA", - Line: "\n\tawsToken := \"AKIALALEMEL33243OLIA\"", - File: "../testdata/repos/nogit/main.go", - SymlinkFile: "", - RuleID: "aws-access-key", - Tags: []string{"key", "AWS"}, - Entropy: 3.0841837, - Fingerprint: "../testdata/repos/nogit/main.go:aws-access-key:20", - }, - }, - }, - { - source: filepath.Join(repoBasePath, "nogit", "main.go"), - cfgName: "simple", - expectedFindings: []report.Finding{ - { - Description: "AWS Access Key", - StartLine: 20, - EndLine: 20, - StartColumn: 16, - EndColumn: 35, - Match: "AKIALALEMEL33243OLIA", - Secret: "AKIALALEMEL33243OLIA", - Line: "\n\tawsToken := \"AKIALALEMEL33243OLIA\"", - File: "../testdata/repos/nogit/main.go", - RuleID: "aws-access-key", - Tags: []string{"key", "AWS"}, - Entropy: 3.0841837, - Fingerprint: "../testdata/repos/nogit/main.go:aws-access-key:20", - }, - }, - }, - } - - for _, tt := range tests { - viper.AddConfigPath(configPath) - viper.SetConfigName("simple") - viper.SetConfigType("toml") - err := viper.ReadInConfig() - if err != nil { - t.Error(err) - } - - var vc config.ViperConfig - err = viper.Unmarshal(&vc) - if err != nil { - t.Error(err) - } - cfg, _ := vc.Translate() - detector := NewDetector(cfg) - detector.FollowSymlinks = true - findings, err := detector.DetectFiles(tt.source) - if err != nil { - t.Error(err) - } - - assert.ElementsMatch(t, tt.expectedFindings, findings) - } -} - -func TestDetectWithSymlinks(t *testing.T) { - tests := []struct { - cfgName string - source string - expectedFindings []report.Finding - }{ - { - source: filepath.Join(repoBasePath, "symlinks/file_symlink"), - cfgName: "simple", - expectedFindings: []report.Finding{ - { - Description: "Asymmetric Private Key", - StartLine: 1, - EndLine: 1, - StartColumn: 1, - EndColumn: 35, - Match: "-----BEGIN OPENSSH PRIVATE KEY-----", - Secret: "-----BEGIN OPENSSH PRIVATE KEY-----", - Line: "-----BEGIN OPENSSH PRIVATE KEY-----", - File: "../testdata/repos/symlinks/source_file/id_ed25519", - SymlinkFile: "../testdata/repos/symlinks/file_symlink/symlinked_id_ed25519", - RuleID: "apkey", - Tags: []string{"key", "AsymmetricPrivateKey"}, - Entropy: 3.587164, - Fingerprint: "../testdata/repos/symlinks/source_file/id_ed25519:apkey:1", - }, - }, - }, - } - - for _, tt := range tests { - viper.AddConfigPath(configPath) - viper.SetConfigName("simple") - viper.SetConfigType("toml") - err := viper.ReadInConfig() - if err != nil { - t.Error(err) - } - - var vc config.ViperConfig - err = viper.Unmarshal(&vc) - if err != nil { - t.Error(err) - } - cfg, _ := vc.Translate() - detector := NewDetector(cfg) - detector.FollowSymlinks = true - findings, err := detector.DetectFiles(tt.source) - if err != nil { - t.Error(err) - } - assert.ElementsMatch(t, tt.expectedFindings, findings) - } -} - -func moveDotGit(from, to string) error { - repoDirs, err := os.ReadDir("../testdata/repos") - if err != nil { - return err - } - for _, dir := range repoDirs { - if to == ".git" { - _, err := os.Stat(fmt.Sprintf("%s/%s/%s", repoBasePath, dir.Name(), "dotGit")) - if os.IsNotExist(err) { - // dont want to delete the only copy of .git accidentally - continue - } - os.RemoveAll(fmt.Sprintf("%s/%s/%s", repoBasePath, dir.Name(), ".git")) - } - if !dir.IsDir() { - continue - } - _, err := os.Stat(fmt.Sprintf("%s/%s/%s", repoBasePath, dir.Name(), from)) - if os.IsNotExist(err) { - continue - } - - err = os.Rename(fmt.Sprintf("%s/%s/%s", repoBasePath, dir.Name(), from), - fmt.Sprintf("%s/%s/%s", repoBasePath, dir.Name(), to)) - if err != nil { - return err - } - } - return nil -} diff --git a/cli/detect/directory.go b/cli/detect/directory.go new file mode 100644 index 000000000..56f4999f2 --- /dev/null +++ b/cli/detect/directory.go @@ -0,0 +1,225 @@ +// MIT License + +// Copyright (c) 2019 Zachary Rice + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package detect + +import ( + "bufio" + "bytes" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/h2non/filetype" + + "github.com/Infisical/infisical-merge/detect/logging" + "github.com/Infisical/infisical-merge/detect/report" + "github.com/Infisical/infisical-merge/detect/sources" +) + +const maxPeekSize = 25 * 1_000 // 10kb + +func (d *Detector) DetectFiles(paths <-chan sources.ScanTarget) ([]report.Finding, error) { + for pa := range paths { + d.Sema.Go(func() error { + logger := logging.With().Str("path", pa.Path).Logger() + logger.Trace().Msg("Scanning path") + + f, err := os.Open(pa.Path) + if err != nil { + if os.IsPermission(err) { + logger.Warn().Msg("Skipping file: permission denied") + return nil + } + return err + } + defer func() { + _ = f.Close() + }() + + // Get file size + fileInfo, err := f.Stat() + if err != nil { + return err + } + fileSize := fileInfo.Size() + if d.MaxTargetMegaBytes > 0 { + rawLength := fileSize / 1000000 + if rawLength > int64(d.MaxTargetMegaBytes) { + logger.Debug(). + Int64("size", rawLength). + Msg("Skipping file: exceeds --max-target-megabytes") + return nil + } + } + + var ( + // Buffer to hold file chunks + reader = bufio.NewReaderSize(f, chunkSize) + buf = make([]byte, chunkSize) + totalLines = 0 + ) + for { + n, err := reader.Read(buf) + + // "Callers should always process the n > 0 bytes returned before considering the error err." + // https://pkg.go.dev/io#Reader + if n > 0 { + // Only check the filetype at the start of file. + if totalLines == 0 { + // TODO: could other optimizations be introduced here? + if mimetype, err := filetype.Match(buf[:n]); err != nil { + return nil + } else if mimetype.MIME.Type == "application" { + return nil // skip binary files + } + } + + // Try to split chunks across large areas of whitespace, if possible. + peekBuf := bytes.NewBuffer(buf[:n]) + if readErr := readUntilSafeBoundary(reader, n, maxPeekSize, peekBuf); readErr != nil { + return readErr + } + + // Count the number of newlines in this chunk + chunk := peekBuf.String() + linesInChunk := strings.Count(chunk, "\n") + totalLines += linesInChunk + fragment := Fragment{ + Raw: chunk, + Bytes: peekBuf.Bytes(), + } + if pa.Symlink != "" { + fragment.SymlinkFile = pa.Symlink + } + + if isWindows { + fragment.FilePath = filepath.ToSlash(pa.Path) + fragment.SymlinkFile = filepath.ToSlash(fragment.SymlinkFile) + fragment.WindowsFilePath = pa.Path + } else { + fragment.FilePath = pa.Path + } + + timer := time.AfterFunc(SlowWarningThreshold, func() { + logger.Debug().Msgf("Taking longer than %s to inspect fragment", SlowWarningThreshold.String()) + }) + for _, finding := range d.Detect(fragment) { + // need to add 1 since line counting starts at 1 + finding.StartLine += (totalLines - linesInChunk) + 1 + finding.EndLine += (totalLines - linesInChunk) + 1 + d.AddFinding(finding) + } + if timer != nil { + timer.Stop() + timer = nil + } + } + + if err != nil { + if err == io.EOF { + return nil + } + return err + } + } + }) + } + + if err := d.Sema.Wait(); err != nil { + return d.findings, err + } + + return d.findings, nil +} + +// readUntilSafeBoundary consumes |f| until it finds two consecutive `\n` characters, up to |maxPeekSize|. +// This hopefully avoids splitting. (https://github.com/gitleaks/gitleaks/issues/1651) +func readUntilSafeBoundary(r *bufio.Reader, n int, maxPeekSize int, peekBuf *bytes.Buffer) error { + if peekBuf.Len() == 0 { + return nil + } + + // Does the buffer end in consecutive newlines? + var ( + data = peekBuf.Bytes() + lastChar = data[len(data)-1] + newlineCount = 0 // Tracks consecutive newlines + ) + if isWhitespace(lastChar) { + for i := len(data) - 1; i >= 0; i-- { + lastChar = data[i] + if lastChar == '\n' { + newlineCount++ + + // Stop if two consecutive newlines are found + if newlineCount >= 2 { + return nil + } + } else if lastChar == '\r' || lastChar == ' ' || lastChar == '\t' { + // The presence of other whitespace characters (`\r`, ` `, `\t`) shouldn't reset the count. + // (Intentionally do nothing.) + } else { + break + } + } + } + + // If not, read ahead until we (hopefully) find some. + newlineCount = 0 + for { + data = peekBuf.Bytes() + // Check if the last character is a newline. + lastChar = data[len(data)-1] + if lastChar == '\n' { + newlineCount++ + + // Stop if two consecutive newlines are found + if newlineCount >= 2 { + break + } + } else if lastChar == '\r' || lastChar == ' ' || lastChar == '\t' { + // The presence of other whitespace characters (`\r`, ` `, `\t`) shouldn't reset the count. + // (Intentionally do nothing.) + } else { + newlineCount = 0 // Reset if a non-newline character is found + } + + // Stop growing the buffer if it reaches maxSize + if (peekBuf.Len() - n) >= maxPeekSize { + break + } + + // Read additional data into a temporary buffer + b, err := r.ReadByte() + if err != nil { + if err == io.EOF { + break + } + return err + } + peekBuf.WriteByte(b) + } + return nil +} diff --git a/cli/detect/git.go b/cli/detect/git.go new file mode 100644 index 000000000..ddde0757d --- /dev/null +++ b/cli/detect/git.go @@ -0,0 +1,214 @@ +// MIT License + +// Copyright (c) 2019 Zachary Rice + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package detect + +import ( + "bytes" + "errors" + "fmt" + "net/url" + "os/exec" + "regexp" + "strings" + "time" + + "github.com/Infisical/infisical-merge/detect/cmd/scm" + "github.com/gitleaks/go-gitdiff/gitdiff" + + "github.com/Infisical/infisical-merge/detect/logging" + "github.com/Infisical/infisical-merge/detect/report" + "github.com/Infisical/infisical-merge/detect/sources" +) + +func (d *Detector) DetectGit(cmd *sources.GitCmd, remote *RemoteInfo) ([]report.Finding, error) { + defer cmd.Wait() + var ( + diffFilesCh = cmd.DiffFilesCh() + errCh = cmd.ErrCh() + ) + + // loop to range over both DiffFiles (stdout) and ErrCh (stderr) + for diffFilesCh != nil || errCh != nil { + select { + case gitdiffFile, open := <-diffFilesCh: + if !open { + diffFilesCh = nil + break + } + + // skip binary files + if gitdiffFile.IsBinary || gitdiffFile.IsDelete { + continue + } + + // Check if commit is allowed + commitSHA := "" + if gitdiffFile.PatchHeader != nil { + commitSHA = gitdiffFile.PatchHeader.SHA + for _, a := range d.Config.Allowlists { + if ok, c := a.CommitAllowed(gitdiffFile.PatchHeader.SHA); ok { + logging.Trace().Str("allowed-commit", c).Msg("skipping commit: global allowlist") + continue + } + } + } + d.addCommit(commitSHA) + + d.Sema.Go(func() error { + for _, textFragment := range gitdiffFile.TextFragments { + if textFragment == nil { + return nil + } + + fragment := Fragment{ + Raw: textFragment.Raw(gitdiff.OpAdd), + CommitSHA: commitSHA, + FilePath: gitdiffFile.NewName, + } + + timer := time.AfterFunc(SlowWarningThreshold, func() { + logging.Debug(). + Str("commit", commitSHA[:7]). + Str("path", fragment.FilePath). + Msgf("Taking longer than %s to inspect fragment", SlowWarningThreshold.String()) + }) + for _, finding := range d.Detect(fragment) { + d.AddFinding(augmentGitFinding(remote, finding, textFragment, gitdiffFile)) + } + if timer != nil { + timer.Stop() + timer = nil + } + } + return nil + }) + case err, open := <-errCh: + if !open { + errCh = nil + break + } + + return d.findings, err + } + } + + if err := d.Sema.Wait(); err != nil { + return d.findings, err + } + logging.Info().Msgf("%d commits scanned.", len(d.commitMap)) + logging.Debug().Msg("Note: this number might be smaller than expected due to commits with no additions") + return d.findings, nil +} + +type RemoteInfo struct { + Platform scm.Platform + Url string +} + +func NewRemoteInfo(platform scm.Platform, source string) *RemoteInfo { + if platform == scm.NoPlatform { + return &RemoteInfo{Platform: platform} + } + + remoteUrl, err := getRemoteUrl(source) + if err != nil { + if strings.Contains(err.Error(), "No remote configured") { + logging.Debug().Msg("skipping finding links: repository has no configured remote.") + platform = scm.NoPlatform + } else { + logging.Error().Err(err).Msg("skipping finding links: unable to parse remote URL") + } + goto End + } + + if platform == scm.UnknownPlatform { + platform = platformFromHost(remoteUrl) + if platform == scm.UnknownPlatform { + logging.Info(). + Str("host", remoteUrl.Hostname()). + Msg("Unknown SCM platform. Use --platform to include links in findings.") + } else { + logging.Debug(). + Str("host", remoteUrl.Hostname()). + Str("platform", platform.String()). + Msg("SCM platform parsed from host") + } + } + +End: + var rUrl string + if remoteUrl != nil { + rUrl = remoteUrl.String() + } + return &RemoteInfo{ + Platform: platform, + Url: rUrl, + } +} + +var sshUrlpat = regexp.MustCompile(`^git@([a-zA-Z0-9.-]+):([\w/.-]+?)(?:\.git)?$`) + +func getRemoteUrl(source string) (*url.URL, error) { + // This will return the first remote — typically, "origin". + cmd := exec.Command("git", "ls-remote", "--quiet", "--get-url") + if source != "." { + cmd.Dir = source + } + + stdout, err := cmd.Output() + if err != nil { + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return nil, fmt.Errorf("command failed (%d): %w, stderr: %s", exitError.ExitCode(), err, string(bytes.TrimSpace(exitError.Stderr))) + } + return nil, err + } + + remoteUrl := string(bytes.TrimSpace(stdout)) + if matches := sshUrlpat.FindStringSubmatch(remoteUrl); matches != nil { + remoteUrl = fmt.Sprintf("https://%s/%s", matches[1], matches[2]) + } + remoteUrl = strings.TrimSuffix(remoteUrl, ".git") + + parsedUrl, err := url.Parse(remoteUrl) + if err != nil { + return nil, fmt.Errorf("unable to parse remote URL: %w", err) + } + + // Remove any user info. + parsedUrl.User = nil + return parsedUrl, nil +} + +func platformFromHost(u *url.URL) scm.Platform { + switch strings.ToLower(u.Hostname()) { + case "github.com": + return scm.GitHubPlatform + case "gitlab.com": + return scm.GitLabPlatform + case "dev.azure.com", "visualstudio.com": + return scm.AzureDevOpsPlatform + default: + return scm.UnknownPlatform + } +} diff --git a/cli/detect/git/git.go b/cli/detect/git/git.go deleted file mode 100644 index 6eb5e9d6d..000000000 --- a/cli/detect/git/git.go +++ /dev/null @@ -1,143 +0,0 @@ -// MIT License - -// Copyright (c) 2019 Zachary Rice - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package git - -import ( - "bufio" - "io" - "os/exec" - "path/filepath" - "strings" - "time" - - "github.com/gitleaks/go-gitdiff/gitdiff" - "github.com/rs/zerolog/log" -) - -var ErrEncountered bool - -// GitLog returns a channel of gitdiff.File objects from the -// git log -p command for the given source. -func GitLog(source string, logOpts string) (<-chan *gitdiff.File, error) { - sourceClean := filepath.Clean(source) - var cmd *exec.Cmd - if logOpts != "" { - args := []string{"-C", sourceClean, "log", "-p", "-U0"} - args = append(args, strings.Split(logOpts, " ")...) - cmd = exec.Command("git", args...) - } else { - cmd = exec.Command("git", "-C", sourceClean, "log", "-p", "-U0", - "--full-history", "--all") - } - - log.Debug().Msgf("executing: %s", cmd.String()) - - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, err - } - stderr, err := cmd.StderrPipe() - if err != nil { - return nil, err - } - - go listenForStdErr(stderr) - - if err := cmd.Start(); err != nil { - return nil, err - } - // HACK: to avoid https://github.com/zricethezav/gitleaks/issues/722 - time.Sleep(50 * time.Millisecond) - - return gitdiff.Parse(cmd, stdout) -} - -// GitDiff returns a channel of gitdiff.File objects from -// the git diff command for the given source. -func GitDiff(source string, staged bool) (<-chan *gitdiff.File, error) { - sourceClean := filepath.Clean(source) - var cmd *exec.Cmd - cmd = exec.Command("git", "-C", sourceClean, "diff", "-U0", ".") - if staged { - cmd = exec.Command("git", "-C", sourceClean, "diff", "-U0", - "--staged", ".") - } - log.Debug().Msgf("executing: %s", cmd.String()) - - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, err - } - stderr, err := cmd.StderrPipe() - if err != nil { - return nil, err - } - - go listenForStdErr(stderr) - - if err := cmd.Start(); err != nil { - return nil, err - } - // HACK: to avoid https://github.com/zricethezav/gitleaks/issues/722 - time.Sleep(50 * time.Millisecond) - - return gitdiff.Parse(cmd, stdout) -} - -// listenForStdErr listens for stderr output from git and prints it to stdout -// then exits with exit code 1 -func listenForStdErr(stderr io.ReadCloser) { - scanner := bufio.NewScanner(stderr) - for scanner.Scan() { - // if git throws one of the following errors: - // - // exhaustive rename detection was skipped due to too many files. - // you may want to set your diff.renameLimit variable to at least - // (some large number) and retry the command. - // - // inexact rename detection was skipped due to too many files. - // you may want to set your diff.renameLimit variable to at least - // (some large number) and retry the command. - // - // we skip exiting the program as git log -p/git diff will continue - // to send data to stdout and finish executing. This next bit of - // code prevents gitleaks from stopping mid scan if this error is - // encountered - if strings.Contains(scanner.Text(), - "exhaustive rename detection was skipped") || - strings.Contains(scanner.Text(), - "inexact rename detection was skipped") || - strings.Contains(scanner.Text(), - "you may want to set your diff.renameLimit") { - log.Warn().Msg(scanner.Text()) - } else { - log.Error().Msgf("[git] %s", scanner.Text()) - - // asynchronously set this error flag to true so that we can - // capture a log message and exit with a non-zero exit code - // This value should get set before the `git` command exits so it's - // safe-ish, although I know I know, bad practice. - ErrEncountered = true - } - } -} diff --git a/cli/detect/git/git_test.go b/cli/detect/git/git_test.go deleted file mode 100644 index 3a2ea9c35..000000000 --- a/cli/detect/git/git_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package git_test - -// TODO: commenting out this test for now because it's flaky. Alternatives to consider to get this working: -// -- use `git stash` instead of `restore()` - -// const repoBasePath = "../../testdata/repos/" - -// const expectPath = "../../testdata/expected/" - -// func TestGitLog(t *testing.T) { -// tests := []struct { -// source string -// logOpts string -// expected string -// }{ -// { -// source: filepath.Join(repoBasePath, "small"), -// expected: filepath.Join(expectPath, "git", "small.txt"), -// }, -// { -// source: filepath.Join(repoBasePath, "small"), -// expected: filepath.Join(expectPath, "git", "small-branch-foo.txt"), -// logOpts: "--all foo...", -// }, -// } - -// err := moveDotGit("dotGit", ".git") -// if err != nil { -// t.Fatal(err) -// } -// defer func() { -// if err = moveDotGit(".git", "dotGit"); err != nil { -// t.Fatal(err) -// } -// }() - -// for _, tt := range tests { -// files, err := git.GitLog(tt.source, tt.logOpts) -// if err != nil { -// t.Error(err) -// } - -// var diffSb strings.Builder -// for f := range files { -// for _, tf := range f.TextFragments { -// diffSb.WriteString(tf.Raw(gitdiff.OpAdd)) -// } -// } - -// expectedBytes, err := os.ReadFile(tt.expected) -// if err != nil { -// t.Error(err) -// } -// expected := string(expectedBytes) -// if expected != diffSb.String() { -// // write string builder to .got file using os.Create -// err = os.WriteFile(strings.Replace(tt.expected, ".txt", ".got.txt", 1), []byte(diffSb.String()), 0644) -// if err != nil { -// t.Error(err) -// } -// t.Error("expected: ", expected, "got: ", diffSb.String()) -// } -// } -// } - -// func TestGitDiff(t *testing.T) { -// tests := []struct { -// source string -// expected string -// additions string -// target string -// }{ -// { -// source: filepath.Join(repoBasePath, "small"), -// expected: "this line is added\nand another one", -// additions: "this line is added\nand another one", -// target: filepath.Join(repoBasePath, "small", "main.go"), -// }, -// } - -// err := moveDotGit("dotGit", ".git") -// if err != nil { -// t.Fatal(err) -// } -// defer func() { -// if err = moveDotGit(".git", "dotGit"); err != nil { -// t.Fatal(err) -// } -// }() - -// for _, tt := range tests { -// noChanges, err := os.ReadFile(tt.target) -// if err != nil { -// t.Error(err) -// } -// err = os.WriteFile(tt.target, []byte(tt.additions), 0644) -// if err != nil { -// restore(tt.target, noChanges, t) -// t.Error(err) -// } - -// files, err := git.GitDiff(tt.source, false) -// if err != nil { -// restore(tt.target, noChanges, t) -// t.Error(err) -// } - -// for f := range files { -// sb := strings.Builder{} -// for _, tf := range f.TextFragments { -// sb.WriteString(tf.Raw(gitdiff.OpAdd)) -// } -// if sb.String() != tt.expected { -// restore(tt.target, noChanges, t) -// t.Error("expected: ", tt.expected, "got: ", sb.String()) -// } -// } -// restore(tt.target, noChanges, t) -// } -// } - -// func restore(path string, data []byte, t *testing.T) { -// err := os.WriteFile(path, data, 0644) -// if err != nil { -// t.Fatal(err) -// } -// } - -// func moveDotGit(from, to string) error { -// repoDirs, err := os.ReadDir("../../testdata/repos") -// if err != nil { -// return err -// } -// for _, dir := range repoDirs { -// if to == ".git" { -// _, err := os.Stat(fmt.Sprintf("%s/%s/%s", repoBasePath, dir.Name(), "dotGit")) -// if os.IsNotExist(err) { -// // dont want to delete the only copy of .git accidentally -// continue -// } -// os.RemoveAll(fmt.Sprintf("%s/%s/%s", repoBasePath, dir.Name(), ".git")) -// } -// if !dir.IsDir() { -// continue -// } -// _, err := os.Stat(fmt.Sprintf("%s/%s/%s", repoBasePath, dir.Name(), from)) -// if os.IsNotExist(err) { -// continue -// } - -// err = os.Rename(fmt.Sprintf("%s/%s/%s", repoBasePath, dir.Name(), from), -// fmt.Sprintf("%s/%s/%s", repoBasePath, dir.Name(), to)) -// if err != nil { -// return err -// } -// } -// return nil -// } diff --git a/cli/detect/location.go b/cli/detect/location.go index 418af83f6..81419511c 100644 --- a/cli/detect/location.go +++ b/cli/detect/location.go @@ -72,6 +72,7 @@ func location(fragment Fragment, matchIndex []int) Location { location.endColumn = (end - prevNewLine) location.endLineIndex = newLineByteIndex } + prevNewLine = pair[0] } diff --git a/cli/detect/location_test.go b/cli/detect/location_test.go deleted file mode 100644 index f76a6f814..000000000 --- a/cli/detect/location_test.go +++ /dev/null @@ -1,82 +0,0 @@ -// MIT License - -// Copyright (c) 2019 Zachary Rice - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package detect - -import ( - "testing" -) - -// TestGetLocation tests the getLocation function. -func TestGetLocation(t *testing.T) { - tests := []struct { - linePairs [][]int - start int - end int - wantLocation Location - }{ - { - linePairs: [][]int{ - {0, 39}, - {40, 55}, - {56, 57}, - }, - start: 35, - end: 38, - wantLocation: Location{ - startLine: 1, - startColumn: 36, - endLine: 1, - endColumn: 38, - startLineIndex: 0, - endLineIndex: 40, - }, - }, - { - linePairs: [][]int{ - {0, 39}, - {40, 55}, - {56, 57}, - }, - start: 40, - end: 44, - wantLocation: Location{ - startLine: 2, - startColumn: 1, - endLine: 2, - endColumn: 4, - startLineIndex: 40, - endLineIndex: 56, - }, - }, - } - - for _, test := range tests { - loc := location(Fragment{newlineIndices: test.linePairs}, []int{test.start, test.end}) - if loc != test.wantLocation { - t.Errorf("\nstartLine %d\nstartColumn: %d\nendLine: %d\nendColumn: %d\nstartLineIndex: %d\nendlineIndex %d", - loc.startLine, loc.startColumn, loc.endLine, loc.endColumn, loc.startLineIndex, loc.endLineIndex) - - t.Error("got", loc, "want", test.wantLocation) - } - } -} diff --git a/cli/detect/logging/log.go b/cli/detect/logging/log.go new file mode 100644 index 000000000..efac01725 --- /dev/null +++ b/cli/detect/logging/log.go @@ -0,0 +1,72 @@ +// MIT License + +// Copyright (c) 2019 Zachary Rice + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package logging + +import ( + "os" + + "github.com/rs/zerolog" +) + +var Logger zerolog.Logger + +func init() { + // send all logs to stdout + Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr}). + Level(zerolog.InfoLevel). + With().Timestamp().Logger() +} + +func With() zerolog.Context { + return Logger.With() +} + +func Trace() *zerolog.Event { + return Logger.Trace() +} + +func Debug() *zerolog.Event { + return Logger.Debug() +} +func Info() *zerolog.Event { + return Logger.Info() +} +func Warn() *zerolog.Event { + return Logger.Warn() +} + +func Error() *zerolog.Event { + return Logger.Error() +} + +func Err(err error) *zerolog.Event { + return Logger.Err(err) +} + +func Fatal() *zerolog.Event { + return Logger.Fatal() +} + +func Panic() *zerolog.Event { + return Logger.Panic() +} diff --git a/cli/detect/reader.go b/cli/detect/reader.go new file mode 100644 index 000000000..d3559b68a --- /dev/null +++ b/cli/detect/reader.go @@ -0,0 +1,149 @@ +// MIT License + +// Copyright (c) 2019 Zachary Rice + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package detect + +import ( + "bufio" + "bytes" + "errors" + "io" + + "github.com/Infisical/infisical-merge/detect/report" +) + +// DetectReader accepts an io.Reader and a buffer size for the reader in KB +func (d *Detector) DetectReader(r io.Reader, bufSize int) ([]report.Finding, error) { + reader := bufio.NewReader(r) + buf := make([]byte, 1000*bufSize) + findings := []report.Finding{} + + for { + n, err := reader.Read(buf) + + // "Callers should always process the n > 0 bytes returned before considering the error err." + // https://pkg.go.dev/io#Reader + if n > 0 { + // Try to split chunks across large areas of whitespace, if possible. + peekBuf := bytes.NewBuffer(buf[:n]) + if readErr := readUntilSafeBoundary(reader, n, maxPeekSize, peekBuf); readErr != nil { + return findings, readErr + } + + fragment := Fragment{ + Raw: peekBuf.String(), + } + for _, finding := range d.Detect(fragment) { + findings = append(findings, finding) + if d.Verbose { + printFinding(finding, d.NoColor) + } + } + } + + if err != nil { + if err == io.EOF { + break + } + return findings, err + } + } + + return findings, nil +} + +// StreamDetectReader streams the detection results from the provided io.Reader. +// It reads data using the specified buffer size (in KB) and processes each chunk through +// the existing detection logic. Findings are sent down the returned findings channel as soon as +// they are detected, while a separate error channel signals a terminal error (or nil upon successful completion). +// The function returns two channels: +// - findingsCh: a receive-only channel that emits report.Finding objects as they are found. +// - errCh: a receive-only channel that emits a single final error (or nil if no error occurred) +// once the stream ends. +// +// Recommended Usage: +// +// Since there will only ever be a single value on the errCh, it is recommended to consume the findingsCh +// first. Once findingsCh is closed, the consumer should then read from errCh to determine +// if the stream completed successfully or if an error occurred. +// +// This design avoids the need for a select loop, keeping client code simple. +// +// Example: +// +// // Assume detector is an instance of *Detector and myReader implements io.Reader. +// findingsCh, errCh := detector.StreamDetectReader(myReader, 64) // using 64 KB buffer size +// +// // Process findings as they arrive. +// for finding := range findingsCh { +// fmt.Printf("Found secret: %+v\n", finding) +// } +// +// // After the findings channel is closed, check the final error. +// if err := <-errCh; err != nil { +// log.Fatalf("StreamDetectReader encountered an error: %v", err) +// } else { +// fmt.Println("Scanning completed successfully.") +// } +func (d *Detector) StreamDetectReader(r io.Reader, bufSize int) (<-chan report.Finding, <-chan error) { + findingsCh := make(chan report.Finding, 1) + errCh := make(chan error, 1) + + go func() { + defer close(findingsCh) + defer close(errCh) + + reader := bufio.NewReader(r) + buf := make([]byte, 1000*bufSize) + + for { + n, err := reader.Read(buf) + + if n > 0 { + peekBuf := bytes.NewBuffer(buf[:n]) + if readErr := readUntilSafeBoundary(reader, n, maxPeekSize, peekBuf); readErr != nil { + errCh <- readErr + return + } + + fragment := Fragment{Raw: peekBuf.String()} + for _, finding := range d.Detect(fragment) { + findingsCh <- finding + if d.Verbose { + printFinding(finding, d.NoColor) + } + } + } + + if err != nil { + if errors.Is(err, io.EOF) { + errCh <- nil + return + } + errCh <- err + return + } + } + }() + + return findingsCh, errCh +} diff --git a/cli/detect/regexp/stdlib_regex.go b/cli/detect/regexp/stdlib_regex.go new file mode 100644 index 000000000..81e2089b7 --- /dev/null +++ b/cli/detect/regexp/stdlib_regex.go @@ -0,0 +1,37 @@ +// MIT License + +// Copyright (c) 2019 Zachary Rice + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build !gore2regex + +package regexp + +import ( + re "regexp" +) + +const Version = "stdlib" + +type Regexp = re.Regexp + +func MustCompile(str string) *re.Regexp { + return re.MustCompile(str) +} diff --git a/cli/detect/regexp/wasilibs_regex.go b/cli/detect/regexp/wasilibs_regex.go new file mode 100644 index 000000000..bc64fb14b --- /dev/null +++ b/cli/detect/regexp/wasilibs_regex.go @@ -0,0 +1,37 @@ +// MIT License + +// Copyright (c) 2019 Zachary Rice + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build gore2regex + +package regexp + +import ( + re "github.com/wasilibs/go-re2" +) + +const Version = "github.com/wasilibs/go-re2" + +type Regexp = re.Regexp + +func MustCompile(str string) *re.Regexp { + return re.MustCompile(str) +} diff --git a/cli/report/constants.go b/cli/detect/report/constants.go similarity index 99% rename from cli/report/constants.go rename to cli/detect/report/constants.go index 8bad495cb..c4f06a9a3 100644 --- a/cli/report/constants.go +++ b/cli/detect/report/constants.go @@ -19,6 +19,7 @@ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. + package report const version = "v8.0.0" diff --git a/cli/report/csv.go b/cli/detect/report/csv.go similarity index 72% rename from cli/report/csv.go rename to cli/detect/report/csv.go index 0a30c9fd5..1f8812f97 100644 --- a/cli/report/csv.go +++ b/cli/detect/report/csv.go @@ -26,16 +26,24 @@ import ( "encoding/csv" "io" "strconv" + "strings" ) -// writeCsv writes the list of findings to a writeCloser. -func writeCsv(f []Finding, w io.WriteCloser) error { - if len(f) == 0 { +type CsvReporter struct { +} + +var _ Reporter = (*CsvReporter)(nil) + +func (r *CsvReporter) Write(w io.WriteCloser, findings []Finding) error { + if len(findings) == 0 { return nil } - defer w.Close() - cw := csv.NewWriter(w) - err := cw.Write([]string{"RuleID", + + var ( + cw = csv.NewWriter(w) + err error + ) + columns := []string{"RuleID", "Commit", "File", "SymlinkFile", @@ -50,12 +58,18 @@ func writeCsv(f []Finding, w io.WriteCloser) error { "Date", "Email", "Fingerprint", - }) - if err != nil { + "Tags", + } + // A miserable attempt at "omitempty" so tests don't yell at me. + if findings[0].Link != "" { + columns = append(columns, "Link") + } + + if err = cw.Write(columns); err != nil { return err } - for _, f := range f { - err = cw.Write([]string{f.RuleID, + for _, f := range findings { + row := []string{f.RuleID, f.Commit, f.File, f.SymlinkFile, @@ -70,8 +84,13 @@ func writeCsv(f []Finding, w io.WriteCloser) error { f.Date, f.Email, f.Fingerprint, - }) - if err != nil { + strings.Join(f.Tags, " "), + } + if findings[0].Link != "" { + row = append(row, f.Link) + } + + if err = cw.Write(row); err != nil { return err } } diff --git a/cli/report/finding.go b/cli/detect/report/finding.go similarity index 75% rename from cli/report/finding.go rename to cli/detect/report/finding.go index be461072b..c53f16ee7 100644 --- a/cli/report/finding.go +++ b/cli/detect/report/finding.go @@ -23,13 +23,17 @@ package report import ( + "math" "strings" ) // Finding contains information about strings that // have been captured by a tree-sitter query. type Finding struct { + // Rule is the name of the rule that was matched + RuleID string Description string + StartLine int EndLine int StartColumn int @@ -47,6 +51,7 @@ type Finding struct { File string SymlinkFile string Commit string + Link string `json:",omitempty"` // Entropy is the shannon entropy of Value Entropy float32 @@ -57,16 +62,31 @@ type Finding struct { Message string Tags []string - // Rule is the name of the rule that was matched - RuleID string - - // unique identifer + // unique identifier Fingerprint string } // Redact removes sensitive information from a finding. -func (f *Finding) Redact() { - f.Line = strings.Replace(f.Line, f.Secret, "REDACTED", -1) - f.Match = strings.Replace(f.Match, f.Secret, "REDACTED", -1) - f.Secret = "REDACTED" +func (f *Finding) Redact(percent uint) { + secret := maskSecret(f.Secret, percent) + if percent >= 100 { + secret = "REDACTED" + } + f.Line = strings.Replace(f.Line, f.Secret, secret, -1) + f.Match = strings.Replace(f.Match, f.Secret, secret, -1) + f.Secret = secret +} + +func maskSecret(secret string, percent uint) string { + if percent > 100 { + percent = 100 + } + len := float64(len(secret)) + if len <= 0 { + return secret + } + prc := float64(100 - percent) + lth := int64(math.RoundToEven(len * prc / float64(100))) + + return secret[:lth] + "..." } diff --git a/cli/report/json.go b/cli/detect/report/json.go similarity index 89% rename from cli/report/json.go rename to cli/detect/report/json.go index d091ac3c5..f47b7eee0 100644 --- a/cli/report/json.go +++ b/cli/detect/report/json.go @@ -27,10 +27,12 @@ import ( "io" ) -func writeJson(findings []Finding, w io.WriteCloser) error { - if len(findings) == 0 { - findings = []Finding{} - } +type JsonReporter struct { +} + +var _ Reporter = (*JsonReporter)(nil) + +func (t *JsonReporter) Write(w io.WriteCloser, findings []Finding) error { encoder := json.NewEncoder(w) encoder.SetIndent("", " ") return encoder.Encode(findings) diff --git a/cli/detect/report/junit.go b/cli/detect/report/junit.go new file mode 100644 index 000000000..0862a45f1 --- /dev/null +++ b/cli/detect/report/junit.go @@ -0,0 +1,129 @@ +// MIT License + +// Copyright (c) 2019 Zachary Rice + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package report + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "io" + "strconv" +) + +type JunitReporter struct { +} + +var _ Reporter = (*JunitReporter)(nil) + +func (r *JunitReporter) Write(w io.WriteCloser, findings []Finding) error { + testSuites := TestSuites{ + TestSuites: getTestSuites(findings), + } + + io.WriteString(w, xml.Header) + encoder := xml.NewEncoder(w) + encoder.Indent("", "\t") + return encoder.Encode(testSuites) +} + +func getTestSuites(findings []Finding) []TestSuite { + return []TestSuite{ + { + Failures: strconv.Itoa(len(findings)), + Name: "gitleaks", + Tests: strconv.Itoa(len(findings)), + TestCases: getTestCases(findings), + Time: "", + }, + } +} + +func getTestCases(findings []Finding) []TestCase { + testCases := []TestCase{} + for _, f := range findings { + testCase := TestCase{ + Classname: f.Description, + Failure: getFailure(f), + File: f.File, + Name: getMessage(f), + Time: "", + } + testCases = append(testCases, testCase) + } + return testCases +} + +func getFailure(f Finding) Failure { + return Failure{ + Data: getData(f), + Message: getMessage(f), + Type: f.Description, + } +} + +func getData(f Finding) string { + data, err := json.MarshalIndent(f, "", "\t") + if err != nil { + fmt.Println(err) + return "" + } + return string(data) +} + +func getMessage(f Finding) string { + if f.Commit == "" { + return fmt.Sprintf("%s has detected a secret in file %s, line %s.", f.RuleID, f.File, strconv.Itoa(f.StartLine)) + } + + return fmt.Sprintf("%s has detected a secret in file %s, line %s, at commit %s.", f.RuleID, f.File, strconv.Itoa(f.StartLine), f.Commit) +} + +type TestSuites struct { + XMLName xml.Name `xml:"testsuites"` + TestSuites []TestSuite +} + +type TestSuite struct { + XMLName xml.Name `xml:"testsuite"` + Failures string `xml:"failures,attr"` + Name string `xml:"name,attr"` + Tests string `xml:"tests,attr"` + TestCases []TestCase `xml:"testcase"` + Time string `xml:"time,attr"` +} + +type TestCase struct { + XMLName xml.Name `xml:"testcase"` + Classname string `xml:"classname,attr"` + Failure Failure `xml:"failure"` + File string `xml:"file,attr"` + Name string `xml:"name,attr"` + Time string `xml:"time,attr"` +} + +type Failure struct { + XMLName xml.Name `xml:"failure"` + Data string `xml:",chardata"` + Message string `xml:"message,attr"` + Type string `xml:"type,attr"` +} diff --git a/cli/report/finding_test.go b/cli/detect/report/report.go similarity index 73% rename from cli/report/finding_test.go rename to cli/detect/report/report.go index cdb74a329..120841bb8 100644 --- a/cli/report/finding_test.go +++ b/cli/detect/report/report.go @@ -19,30 +19,20 @@ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. + package report -import "testing" +import ( + "io" +) -func TestRedact(t *testing.T) { - tests := []struct { - findings []Finding - redact bool - }{ - { - redact: true, - findings: []Finding{ - { - Secret: "line containing secret", - Match: "secret", - }, - }}, - } - for _, test := range tests { - for _, f := range test.findings { - f.Redact() - if f.Secret != "REDACTED" { - t.Error("redact not redacting: ", f.Secret) - } - } - } +const ( + // https://cwe.mitre.org/data/definitions/798.html + CWE = "CWE-798" + CWE_DESCRIPTION = "Use of Hard-coded Credentials" + StdoutReportPath = "-" +) + +type Reporter interface { + Write(w io.WriteCloser, findings []Finding) error } diff --git a/cli/report/sarif.go b/cli/detect/report/sarif.go similarity index 84% rename from cli/report/sarif.go rename to cli/detect/report/sarif.go index e5120887a..f7457eb57 100644 --- a/cli/report/sarif.go +++ b/cli/detect/report/sarif.go @@ -27,14 +27,20 @@ import ( "fmt" "io" - "github.com/Infisical/infisical-merge/config" + "github.com/Infisical/infisical-merge/detect/config" ) -func writeSarif(cfg config.Config, findings []Finding, w io.WriteCloser) error { +type SarifReporter struct { + OrderedRules []config.Rule +} + +var _ Reporter = (*SarifReporter)(nil) + +func (r *SarifReporter) Write(w io.WriteCloser, findings []Finding) error { sarif := Sarif{ Schema: "https://json.schemastore.org/sarif-2.1.0.json", Version: "2.1.0", - Runs: getRuns(cfg, findings), + Runs: r.getRuns(findings), } encoder := json.NewEncoder(w) @@ -42,22 +48,22 @@ func writeSarif(cfg config.Config, findings []Finding, w io.WriteCloser) error { return encoder.Encode(sarif) } -func getRuns(cfg config.Config, findings []Finding) []Runs { +func (r *SarifReporter) getRuns(findings []Finding) []Runs { return []Runs{ { - Tool: getTool(cfg), + Tool: r.getTool(), Results: getResults(findings), }, } } -func getTool(cfg config.Config) Tool { +func (r *SarifReporter) getTool() Tool { tool := Tool{ Driver: Driver{ Name: driver, SemanticVersion: version, - InformationUri: "https://github.com/Infisical/infisical", - Rules: getRules(cfg), + InformationUri: "https://github.com/gitleaks/gitleaks", + Rules: r.getRules(), }, } @@ -73,26 +79,15 @@ func hasEmptyRules(tool Tool) bool { return len(tool.Driver.Rules) == 0 } -func getRules(cfg config.Config) []Rules { +func (r *SarifReporter) getRules() []Rules { // TODO for _, rule := range cfg.Rules { var rules []Rules - for _, rule := range cfg.OrderedRules() { - shortDescription := ShortDescription{ - Text: rule.Description, - } - if rule.Regex != nil { - shortDescription = ShortDescription{ - Text: rule.Regex.String(), - } - } else if rule.Path != nil { - shortDescription = ShortDescription{ - Text: rule.Path.String(), - } - } + for _, rule := range r.OrderedRules { rules = append(rules, Rules{ - ID: rule.RuleID, - Name: rule.Description, - Description: shortDescription, + ID: rule.RuleID, + Description: ShortDescription{ + Text: rule.Description, + }, }) } return rules @@ -125,6 +120,9 @@ func getResults(findings []Finding) []Results { Date: f.Date, Author: f.Author, }, + Properties: Properties{ + Tags: f.Tags, + }, } results = append(results, r) } @@ -180,7 +178,6 @@ type FullDescription struct { type Rules struct { ID string `json:"id"` - Name string `json:"name"` Description ShortDescription `json:"shortDescription"` } @@ -224,11 +221,16 @@ type Locations struct { PhysicalLocation PhysicalLocation `json:"physicalLocation"` } +type Properties struct { + Tags []string `json:"tags"` +} + type Results struct { Message Message `json:"message"` RuleId string `json:"ruleId"` Locations []Locations `json:"locations"` PartialFingerPrints `json:"partialFingerprints"` + Properties Properties `json:"properties"` } type Runs struct { diff --git a/cli/detect/report/template.go b/cli/detect/report/template.go new file mode 100644 index 000000000..094aaaea9 --- /dev/null +++ b/cli/detect/report/template.go @@ -0,0 +1,68 @@ +// MIT License + +// Copyright (c) 2019 Zachary Rice + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package report + +import ( + "fmt" + "io" + "os" + "text/template" + + "github.com/Masterminds/sprig/v3" +) + +type TemplateReporter struct { + template *template.Template +} + +var _ Reporter = (*TemplateReporter)(nil) + +func NewTemplateReporter(templatePath string) (*TemplateReporter, error) { + if templatePath == "" { + return nil, fmt.Errorf("template path cannot be empty") + } + + file, err := os.ReadFile(templatePath) + if err != nil { + return nil, fmt.Errorf("error reading file: %w", err) + } + templateText := string(file) + + // TODO: Add helper functions like escaping for JSON, XML, etc. + t := template.New("custom") + t = t.Funcs(sprig.TxtFuncMap()) + t, err = t.Parse(templateText) + if err != nil { + return nil, fmt.Errorf("error parsing file: %w", err) + } + return &TemplateReporter{template: t}, nil +} + +// writeTemplate renders the findings using the user-provided template. +// https://www.digitalocean.com/community/tutorials/how-to-use-templates-in-go +func (t *TemplateReporter) Write(w io.WriteCloser, findings []Finding) error { + if err := t.template.Execute(w, findings); err != nil { + return err + } + return nil +} diff --git a/cli/detect/sources/directory.go b/cli/detect/sources/directory.go new file mode 100644 index 000000000..0ad46c3d8 --- /dev/null +++ b/cli/detect/sources/directory.go @@ -0,0 +1,127 @@ +// MIT License + +// Copyright (c) 2019 Zachary Rice + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package sources + +import ( + "io/fs" + "os" + "path/filepath" + "runtime" + + "github.com/fatih/semgroup" + + "github.com/Infisical/infisical-merge/detect/config" + "github.com/Infisical/infisical-merge/detect/logging" +) + +type ScanTarget struct { + Path string + Symlink string +} + +var isWindows = runtime.GOOS == "windows" + +func DirectoryTargets(source string, s *semgroup.Group, followSymlinks bool, allowlists []*config.Allowlist) (<-chan ScanTarget, error) { + paths := make(chan ScanTarget) + s.Go(func() error { + defer close(paths) + return filepath.Walk(source, + func(path string, fInfo os.FileInfo, err error) error { + logger := logging.With().Str("path", path).Logger() + + if err != nil { + if os.IsPermission(err) { + // This seems to only fail on directories at this stage. + logger.Warn().Msg("Skipping directory: permission denied") + return filepath.SkipDir + } + return err + } + + // Empty; nothing to do here. + if fInfo.Size() == 0 { + return nil + } + + // Unwrap symlinks, if |followSymlinks| is set. + scanTarget := ScanTarget{ + Path: path, + } + if fInfo.Mode().Type() == fs.ModeSymlink { + if !followSymlinks { + logger.Debug().Msg("Skipping symlink") + return nil + } + + realPath, err := filepath.EvalSymlinks(path) + if err != nil { + return err + } + + realPathFileInfo, _ := os.Stat(realPath) + if realPathFileInfo.IsDir() { + logger.Warn().Str("target", realPath).Msg("Skipping symlinked directory") + return nil + } + + scanTarget.Path = realPath + scanTarget.Symlink = path + } + + // TODO: Also run this check against the resolved symlink? + var skip bool + for _, a := range allowlists { + skip = a.PathAllowed(path) || + // TODO: Remove this in v9. + // This is an awkward hack to mitigate https://github.com/gitleaks/gitleaks/issues/1641. + (isWindows && a.PathAllowed(filepath.ToSlash(path))) + if skip { + break + } + } + if fInfo.IsDir() { + // Directory + if skip { + logger.Debug().Msg("Skipping directory due to global allowlist") + return filepath.SkipDir + } + + if fInfo.Name() == ".git" { + // Don't scan .git directories. + // TODO: Add this to the config allowlist, instead of hard-coding it. + return filepath.SkipDir + } + } else { + // File + if skip { + logger.Debug().Msg("Skipping file due to global allowlist") + return nil + } + + paths <- scanTarget + } + return nil + }) + }) + return paths, nil +} diff --git a/cli/detect/sources/git.go b/cli/detect/sources/git.go new file mode 100644 index 000000000..95b829a9a --- /dev/null +++ b/cli/detect/sources/git.go @@ -0,0 +1,211 @@ +// MIT License + +// Copyright (c) 2019 Zachary Rice + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package sources + +import ( + "bufio" + "errors" + "io" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/gitleaks/go-gitdiff/gitdiff" + + "github.com/Infisical/infisical-merge/detect/logging" +) + +var quotedOptPattern = regexp.MustCompile(`^(?:"[^"]+"|'[^']+')$`) + +// GitCmd helps to work with Git's output. +type GitCmd struct { + cmd *exec.Cmd + diffFilesCh <-chan *gitdiff.File + errCh <-chan error +} + +// NewGitLogCmd returns `*DiffFilesCmd` with two channels: `<-chan *gitdiff.File` and `<-chan error`. +// Caller should read everything from channels until receiving a signal about their closure and call +// the `func (*DiffFilesCmd) Wait()` error in order to release resources. +func NewGitLogCmd(source string, logOpts string) (*GitCmd, error) { + sourceClean := filepath.Clean(source) + var cmd *exec.Cmd + if logOpts != "" { + args := []string{"-C", sourceClean, "log", "-p", "-U0"} + + // Ensure that the user-provided |logOpts| aren't wrapped in quotes. + // https://github.com/gitleaks/gitleaks/issues/1153 + userArgs := strings.Split(logOpts, " ") + var quotedOpts []string + for _, element := range userArgs { + if quotedOptPattern.MatchString(element) { + quotedOpts = append(quotedOpts, element) + } + } + if len(quotedOpts) > 0 { + logging.Warn().Msgf("the following `--log-opts` values may not work as expected: %v\n\tsee https://github.com/gitleaks/gitleaks/issues/1153 for more information", quotedOpts) + } + + args = append(args, userArgs...) + cmd = exec.Command("git", args...) + } else { + cmd = exec.Command("git", "-C", sourceClean, "log", "-p", "-U0", + "--full-history", "--all") + } + + logging.Debug().Msgf("executing: %s", cmd.String()) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + + errCh := make(chan error) + go listenForStdErr(stderr, errCh) + + gitdiffFiles, err := gitdiff.Parse(stdout) + if err != nil { + return nil, err + } + + return &GitCmd{ + cmd: cmd, + diffFilesCh: gitdiffFiles, + errCh: errCh, + }, nil +} + +// NewGitDiffCmd returns `*DiffFilesCmd` with two channels: `<-chan *gitdiff.File` and `<-chan error`. +// Caller should read everything from channels until receiving a signal about their closure and call +// the `func (*DiffFilesCmd) Wait()` error in order to release resources. +func NewGitDiffCmd(source string, staged bool) (*GitCmd, error) { + sourceClean := filepath.Clean(source) + var cmd *exec.Cmd + cmd = exec.Command("git", "-C", sourceClean, "diff", "-U0", "--no-ext-diff", ".") + if staged { + cmd = exec.Command("git", "-C", sourceClean, "diff", "-U0", "--no-ext-diff", + "--staged", ".") + } + logging.Debug().Msgf("executing: %s", cmd.String()) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + + errCh := make(chan error) + go listenForStdErr(stderr, errCh) + + gitdiffFiles, err := gitdiff.Parse(stdout) + if err != nil { + return nil, err + } + + return &GitCmd{ + cmd: cmd, + diffFilesCh: gitdiffFiles, + errCh: errCh, + }, nil +} + +// DiffFilesCh returns a channel with *gitdiff.File. +func (c *GitCmd) DiffFilesCh() <-chan *gitdiff.File { + return c.diffFilesCh +} + +// ErrCh returns a channel that could produce an error if there is something in stderr. +func (c *GitCmd) ErrCh() <-chan error { + return c.errCh +} + +// Wait waits for the command to exit and waits for any copying to +// stdin or copying from stdout or stderr to complete. +// +// Wait also closes underlying stdout and stderr. +func (c *GitCmd) Wait() (err error) { + return c.cmd.Wait() +} + +// listenForStdErr listens for stderr output from git, prints it to stdout, +// sends to errCh and closes it. +func listenForStdErr(stderr io.ReadCloser, errCh chan<- error) { + defer close(errCh) + + var errEncountered bool + + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + // if git throws one of the following errors: + // + // exhaustive rename detection was skipped due to too many files. + // you may want to set your diff.renameLimit variable to at least + // (some large number) and retry the command. + // + // inexact rename detection was skipped due to too many files. + // you may want to set your diff.renameLimit variable to at least + // (some large number) and retry the command. + // + // Auto packing the repository in background for optimum performance. + // See "git help gc" for manual housekeeping. + // + // we skip exiting the program as git log -p/git diff will continue + // to send data to stdout and finish executing. This next bit of + // code prevents gitleaks from stopping mid scan if this error is + // encountered + if strings.Contains(scanner.Text(), + "exhaustive rename detection was skipped") || + strings.Contains(scanner.Text(), + "inexact rename detection was skipped") || + strings.Contains(scanner.Text(), + "you may want to set your diff.renameLimit") || + strings.Contains(scanner.Text(), + "See \"git help gc\" for manual housekeeping") || + strings.Contains(scanner.Text(), + "Auto packing the repository in background for optimum performance") { + logging.Warn().Msg(scanner.Text()) + } else { + logging.Error().Msgf("[git] %s", scanner.Text()) + errEncountered = true + } + } + + if errEncountered { + errCh <- errors.New("stderr is not empty") + return + } +} diff --git a/cli/detect/utils.go b/cli/detect/utils.go index 462716239..255d01fbe 100644 --- a/cli/detect/utils.go +++ b/cli/detect/utils.go @@ -26,20 +26,21 @@ import ( // "encoding/json" "fmt" "math" + "path/filepath" "strings" "time" + "github.com/Infisical/infisical-merge/detect/cmd/scm" + "github.com/Infisical/infisical-merge/detect/logging" + "github.com/Infisical/infisical-merge/detect/report" + "github.com/charmbracelet/lipgloss" - - "github.com/Infisical/infisical-merge/report" - "github.com/gitleaks/go-gitdiff/gitdiff" - "github.com/rs/zerolog/log" ) // augmentGitFinding updates the start and end line numbers of a finding to include the // delta from the git diff -func augmentGitFinding(finding report.Finding, textFragment *gitdiff.TextFragment, f *gitdiff.File) report.Finding { +func augmentGitFinding(remote *RemoteInfo, finding report.Finding, textFragment *gitdiff.TextFragment, f *gitdiff.File) report.Finding { if !strings.HasPrefix(finding.Match, "file detected") { finding.StartLine += int(textFragment.NewPosition) finding.EndLine += int(textFragment.NewPosition) @@ -47,16 +48,76 @@ func augmentGitFinding(finding report.Finding, textFragment *gitdiff.TextFragmen if f.PatchHeader != nil { finding.Commit = f.PatchHeader.SHA - finding.Message = f.PatchHeader.Message() if f.PatchHeader.Author != nil { finding.Author = f.PatchHeader.Author.Name finding.Email = f.PatchHeader.Author.Email } finding.Date = f.PatchHeader.AuthorDate.UTC().Format(time.RFC3339) + finding.Message = f.PatchHeader.Message() + // Results from `git diff` shouldn't have a link. + if finding.Commit != "" { + finding.Link = createScmLink(remote.Platform, remote.Url, finding) + } } return finding } +var linkCleaner = strings.NewReplacer( + " ", "%20", + "%", "%25", +) + +func createScmLink(scmPlatform scm.Platform, remoteUrl string, finding report.Finding) string { + if scmPlatform == scm.UnknownPlatform || scmPlatform == scm.NoPlatform { + return "" + } + + // Clean the path. + var ( + filePath = linkCleaner.Replace(finding.File) + ext = strings.ToLower(filepath.Ext(filePath)) + ) + + switch scmPlatform { + case scm.GitHubPlatform: + link := fmt.Sprintf("%s/blob/%s/%s", remoteUrl, finding.Commit, filePath) + if ext == ".ipynb" || ext == ".md" { + link += "?plain=1" + } + if finding.StartLine != 0 { + link += fmt.Sprintf("#L%d", finding.StartLine) + } + if finding.EndLine != finding.StartLine { + link += fmt.Sprintf("-L%d", finding.EndLine) + } + return link + case scm.GitLabPlatform: + link := fmt.Sprintf("%s/blob/%s/%s", remoteUrl, finding.Commit, filePath) + if finding.StartLine != 0 { + link += fmt.Sprintf("#L%d", finding.StartLine) + } + if finding.EndLine != finding.StartLine { + link += fmt.Sprintf("-%d", finding.EndLine) + } + return link + case scm.AzureDevOpsPlatform: + link := fmt.Sprintf("%s/commit/%s?path=/%s", remoteUrl, finding.Commit, filePath) + // Add line information if applicable + if finding.StartLine != 0 { + link += fmt.Sprintf("&line=%d", finding.StartLine) + } + if finding.EndLine != finding.StartLine { + link += fmt.Sprintf("&lineEnd=%d", finding.EndLine) + } + // This is a bit dirty, but Azure DevOps does not highlight the line when the lineStartColumn and lineEndColumn are not provided + link += "&lineStartColumn=1&lineEndColumn=10000000&type=2&lineStyle=plain&_a=files" + return link + default: + // This should never happen. + return "" + } +} + // shannonEntropy calculates the entropy of data using the formula defined here: // https://en.wiktionary.org/wiki/Shannon_entropy // Another way to think about what this is doing is calculating the number of bits @@ -82,7 +143,7 @@ func shannonEntropy(data string) (entropy float64) { } // filter will dedupe and redact findings -func filter(findings []report.Finding, redact bool) []report.Finding { +func filter(findings []report.Finding, redact uint) []report.Finding { var retFindings []report.Finding for _, f := range findings { include := true @@ -96,15 +157,15 @@ func filter(findings []report.Finding, redact bool) []report.Finding { genericMatch := strings.Replace(f.Match, f.Secret, "REDACTED", -1) betterMatch := strings.Replace(fPrime.Match, fPrime.Secret, "REDACTED", -1) - log.Trace().Msgf("skipping %s finding (%s), %s rule takes precendence (%s)", f.RuleID, genericMatch, fPrime.RuleID, betterMatch) + logging.Trace().Msgf("skipping %s finding (%s), %s rule takes precedence (%s)", f.RuleID, genericMatch, fPrime.RuleID, betterMatch) include = false break } } } - if redact { - f.Redact() + if redact > 0 { + f.Redact(redact) } if include { retFindings = append(retFindings, f) @@ -152,7 +213,7 @@ func printFinding(f report.Finding, noColor bool) { lineEndIdx := matchInLineIDX + len(f.Match) if len(f.Line)-1 <= lineEndIdx { - lineEndIdx = len(f.Line) - 1 + lineEndIdx = len(f.Line) } lineEnd := f.Line[lineEndIdx:] @@ -184,6 +245,9 @@ func printFinding(f report.Finding, noColor bool) { fmt.Println("") return } + if len(f.Tags) > 0 { + fmt.Printf("%-12s %s\n", "Tags:", f.Tags) + } fmt.Printf("%-12s %s\n", "File:", f.File) fmt.Printf("%-12s %d\n", "Line:", f.StartLine) if f.Commit == "" { @@ -196,16 +260,12 @@ func printFinding(f report.Finding, noColor bool) { fmt.Printf("%-12s %s\n", "Email:", f.Email) fmt.Printf("%-12s %s\n", "Date:", f.Date) fmt.Printf("%-12s %s\n", "Fingerprint:", f.Fingerprint) + if f.Link != "" { + fmt.Printf("%-12s %s\n", "Link:", f.Link) + } fmt.Println("") } -func containsDigit(s string) bool { - for _, c := range s { - switch c { - case '1', '2', '3', '4', '5', '6', '7', '8', '9': - return true - } - - } - return false +func isWhitespace(ch byte) bool { + return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' } diff --git a/cli/go.mod b/cli/go.mod index 52cb79f38..fc7322f61 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -5,24 +5,26 @@ go 1.23.0 toolchain go1.23.5 require ( + github.com/BobuSumisu/aho-corasick v1.0.3 + github.com/Masterminds/sprig/v3 v3.3.0 github.com/bradleyjkemp/cupaloy/v2 v2.8.0 github.com/charmbracelet/lipgloss v0.9.1 github.com/creack/pty v1.1.21 github.com/denisbrodbeck/machineid v1.0.1 github.com/fatih/semgroup v1.2.0 - github.com/gitleaks/go-gitdiff v0.8.0 + github.com/gitleaks/go-gitdiff v0.9.1 github.com/h2non/filetype v1.1.3 - github.com/infisical/go-sdk v0.5.92 + github.com/infisical/go-sdk v0.5.95 github.com/infisical/infisical-kmip v0.3.5 github.com/mattn/go-isatty v0.0.20 github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a github.com/muesli/mango-cobra v1.2.0 github.com/muesli/reflow v0.3.0 github.com/muesli/roff v0.1.0 - github.com/petar-dambovaliev/aho-corasick v0.0.0-20211021192214-5ab2d9280aa9 github.com/pion/dtls/v3 v3.0.4 github.com/pion/logging v0.2.3 github.com/pion/turn/v4 v4.0.0 + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/posthog/posthog-go v0.0.0-20221221115252-24dfed35d71a github.com/quic-go/quic-go v0.50.0 github.com/rs/cors v1.11.0 @@ -30,7 +32,9 @@ require ( github.com/spf13/cobra v1.6.1 github.com/spf13/viper v1.8.1 github.com/stretchr/testify v1.10.0 + github.com/wasilibs/go-re2 v1.10.0 golang.org/x/crypto v0.36.0 + golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 golang.org/x/sys v0.31.0 golang.org/x/term v0.30.0 gopkg.in/yaml.v2 v2.4.0 @@ -42,6 +46,9 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect cloud.google.com/go/compute/metadata v0.4.0 // indirect cloud.google.com/go/iam v1.1.11 // indirect + dario.cat/mergo v1.0.1 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/alessio/shellescape v1.4.1 // indirect github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef // indirect github.com/aws/aws-sdk-go-v2 v1.27.2 // indirect @@ -60,7 +67,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/chzyer/readline v1.5.1 // indirect github.com/danieljoos/wincred v1.2.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dvsekhvalnov/jose2go v1.6.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.4.9 // indirect @@ -74,17 +81,21 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 // indirect github.com/google/s2a-go v0.1.7 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/gax-go/v2 v2.12.5 // indirect github.com/gosimple/slug v1.15.0 // indirect github.com/gosimple/unidecode v1.0.1 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/huandu/xstrings v1.5.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.5 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/mapstructure v1.4.1 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/mtibben/percent v0.2.1 // indirect github.com/muesli/mango v0.1.0 // indirect github.com/muesli/mango-pflag v0.1.0 // indirect @@ -96,12 +107,15 @@ require ( github.com/pion/stun/v3 v3.0.0 // indirect github.com/pion/transport/v3 v3.0.7 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/afero v1.6.0 // indirect - github.com/spf13/cast v1.3.1 // indirect + github.com/spf13/cast v1.7.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/subosito/gotenv v1.2.0 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect + github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect go.mongodb.org/mongo-driver v1.10.0 // indirect @@ -112,7 +126,6 @@ require ( go.opentelemetry.io/otel/metric v1.24.0 // indirect go.opentelemetry.io/otel/trace v1.24.0 // indirect go.uber.org/mock v0.5.0 // indirect - golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 // indirect golang.org/x/mod v0.23.0 // indirect golang.org/x/net v0.35.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect diff --git a/cli/go.sum b/cli/go.sum index 49566f1cc..aa8dc1f61 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -44,13 +44,23 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BobuSumisu/aho-corasick v1.0.3 h1:uuf+JHwU9CHP2Vx+wAy6jcksJThhJS9ehR8a+4nPE9g= +github.com/BobuSumisu/aho-corasick v1.0.3/go.mod h1:hm4jLcvZKI2vRF2WDU1N4p/jpWtpOzp3nLmi9AzX/XE= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/Infisical/go-keyring v1.0.2 h1:dWOkI/pB/7RocfSJgGXbXxLDcVYsdslgjEPmVhb+nl8= github.com/Infisical/go-keyring v1.0.2/go.mod h1:LWOnn/sw9FxDW/0VY+jHFAfOFEe03xmwBVSfJnBowto= github.com/Infisical/turn/v4 v4.0.1 h1:omdelNsnFfzS5cu86W5OBR68by68a8sva4ogR0lQQnw= github.com/Infisical/turn/v4 v4.0.1/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= +github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= @@ -117,8 +127,9 @@ github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ= github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI= github.com/dvsekhvalnov/jose2go v1.6.0 h1:Y9gnSnP4qEI0+/uQkHvFXeD2PLPJeXEL+ySMEA2EjTY= @@ -137,11 +148,13 @@ github.com/fatih/semgroup v1.2.0 h1:h/OLXwEM+3NNyAdZEpMiH1OzfplU09i2qXPVThGZvyg= github.com/fatih/semgroup v1.2.0/go.mod h1:1KAD4iIYfXjE4U13B48VM4z9QUwV5Tt8O4rS879kgm8= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/gitleaks/go-gitdiff v0.8.0 h1:7aExTZm+K/M/EQKOyYcub8rIAdWK6ONxPGuRzxmWW+0= -github.com/gitleaks/go-gitdiff v0.8.0/go.mod h1:pKz0X4YzCKZs30BL+weqBIG7mx0jl4tF1uXV9ZyNvrA= +github.com/gitleaks/go-gitdiff v0.9.1 h1:ni6z6/3i9ODT685OLCTf+s/ERlWUNWQF4x1pvoNICw0= +github.com/gitleaks/go-gitdiff v0.9.1/go.mod h1:pKz0X4YzCKZs30BL+weqBIG7mx0jl4tF1uXV9ZyNvrA= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -273,12 +286,18 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/infisical/go-sdk v0.5.92 h1:PoCnVndrd6Dbkipuxl9fFiwlD5vCKsabtQo09mo8lUE= github.com/infisical/go-sdk v0.5.92/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= +github.com/infisical/go-sdk v0.5.94 h1:wKBj+KpJEe+ZzOJ7koXQZDR0dLL9bt0Kqgf/1q+7tG4= +github.com/infisical/go-sdk v0.5.94/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= +github.com/infisical/go-sdk v0.5.95 h1:so0YwPofbT7j6Ao8Xcxee/o3ia33meuEVDU2vWr9yfs= +github.com/infisical/go-sdk v0.5.95/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= github.com/infisical/infisical-kmip v0.3.5 h1:QM3s0e18B+mYv3a9HQNjNAlbwZJBzXq5BAJM2scIeiE= github.com/infisical/infisical-kmip v0.3.5/go.mod h1:bO1M4YtKyutNg1bREPmlyZspC5duSR7hyQ3lPmLzrIs= github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= @@ -293,6 +312,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -315,6 +336,8 @@ github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZ github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= @@ -324,6 +347,8 @@ github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= @@ -355,8 +380,6 @@ github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlR github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/petar-dambovaliev/aho-corasick v0.0.0-20211021192214-5ab2d9280aa9 h1:lL+y4Xv20pVlCGyLzNHRC0I0rIHhIL1lTvHizoS/dU8= -github.com/petar-dambovaliev/aho-corasick v0.0.0-20211021192214-5ab2d9280aa9/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw= github.com/pion/dtls/v3 v3.0.4 h1:44CZekewMzfrn9pmGrj5BNnTMDCFwr+6sLH+cCuLM7U= github.com/pion/dtls/v3 v3.0.4/go.mod h1:R373CsjxWqNPf6MEkfdy3aSe9niZvL/JaKlGeFphtMg= github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= @@ -367,12 +390,15 @@ github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posthog/posthog-go v0.0.0-20221221115252-24dfed35d71a h1:Ey0XWvrg6u6hyIn1Kd/jCCmL+bMv9El81tvuGBbxZGg= github.com/posthog/posthog-go v0.0.0-20221221115252-24dfed35d71a/go.mod h1:oa2sAs9tGai3VldabTV0eWejt/O4/OOD7azP8GaikqU= @@ -384,6 +410,8 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= @@ -393,6 +421,8 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= @@ -400,8 +430,9 @@ github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIK github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= -github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= @@ -429,9 +460,15 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/wasilibs/go-re2 v1.10.0 h1:vQZEBYZOCA9jdBMmrO4+CvqyCj0x4OomXTJ4a5/urQ0= +github.com/wasilibs/go-re2 v1.10.0/go.mod h1:k+5XqO2bCJS+QpGOnqugyfwC04nw0jaglmjrrkG8U6o= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= @@ -639,6 +676,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index ec92f2ad2..83732b64a 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -12,6 +12,35 @@ import ( const USER_AGENT = "cli" +const ( + operationCallGetRawSecretsV3 = "CallGetRawSecretsV3" + operationCallGetEncryptedWorkspaceKey = "CallGetEncryptedWorkspaceKey" + operationCallGetServiceTokenDetails = "CallGetServiceTokenDetails" + operationCallLogin1V3 = "CallLogin1V3" + operationCallVerifyMfaToken = "CallVerifyMfaToken" + operationCallLogin2V3 = "CallLogin2V3" + operationCallGetAllOrganizations = "CallGetAllOrganizations" + operationCallSelectOrganization = "CallSelectOrganization" + operationCallGetAllWorkSpacesUserBelongsTo = "CallGetAllWorkSpacesUserBelongsTo" + operationCallGetProjectById = "CallGetProjectById" + operationCallIsAuthenticated = "CallIsAuthenticated" + operationCallGetNewAccessTokenWithRefreshToken = "CallGetNewAccessTokenWithRefreshToken" + operationCallGetFoldersV1 = "CallGetFoldersV1" + operationCallCreateFolderV1 = "CallCreateFolderV1" + operationCallDeleteFolderV1 = "CallDeleteFolderV1" + operationCallDeleteSecretsV3 = "CallDeleteSecretsV3" + operationCallCreateServiceToken = "CallCreateServiceToken" + operationCallUniversalAuthLogin = "CallUniversalAuthLogin" + operationCallMachineIdentityRefreshAccessToken = "CallMachineIdentityRefreshAccessToken" + operationCallFetchSingleSecretByName = "CallFetchSingleSecretByName" + operationCallCreateRawSecretsV3 = "CallCreateRawSecretsV3" + operationCallUpdateRawSecretsV3 = "CallUpdateRawSecretsV3" + operationCallRegisterGatewayIdentityV1 = "CallRegisterGatewayIdentityV1" + operationCallExchangeRelayCertV1 = "CallExchangeRelayCertV1" + operationCallGatewayHeartBeatV1 = "CallGatewayHeartBeatV1" + operationCallBootstrapInstance = "CallBootstrapInstance" +) + func CallGetEncryptedWorkspaceKey(httpClient *resty.Client, request GetEncryptedWorkspaceKeyRequest) (GetEncryptedWorkspaceKeyResponse, error) { endpoint := fmt.Sprintf("%v/v2/workspace/%v/encrypted-key", config.INFISICAL_URL, request.WorkspaceId) var result GetEncryptedWorkspaceKeyResponse @@ -22,11 +51,11 @@ func CallGetEncryptedWorkspaceKey(httpClient *resty.Client, request GetEncrypted Get(endpoint) if err != nil { - return GetEncryptedWorkspaceKeyResponse{}, fmt.Errorf("CallGetEncryptedWorkspaceKey: Unable to complete api request [err=%s]", err) + return GetEncryptedWorkspaceKeyResponse{}, NewGenericRequestError(operationCallGetEncryptedWorkspaceKey, err) } if response.IsError() { - return GetEncryptedWorkspaceKeyResponse{}, fmt.Errorf("CallGetEncryptedWorkspaceKey: Unsuccessful response [%v %v] [status-code=%v]", response.Request.Method, response.Request.URL, response.StatusCode()) + return GetEncryptedWorkspaceKeyResponse{}, NewAPIErrorWithResponse(operationCallGetEncryptedWorkspaceKey, response, nil) } return result, nil @@ -41,11 +70,11 @@ func CallGetServiceTokenDetailsV2(httpClient *resty.Client) (GetServiceTokenDeta Get(fmt.Sprintf("%v/v2/service-token", config.INFISICAL_URL)) if err != nil { - return GetServiceTokenDetailsResponse{}, fmt.Errorf("CallGetServiceTokenDetails: Unable to complete api request [err=%s]", err) + return GetServiceTokenDetailsResponse{}, NewGenericRequestError(operationCallGetServiceTokenDetails, err) } if response.IsError() { - return GetServiceTokenDetailsResponse{}, fmt.Errorf("CallGetServiceTokenDetails: Unsuccessful response: [response=%s]", response) + return GetServiceTokenDetailsResponse{}, NewAPIErrorWithResponse(operationCallGetServiceTokenDetails, response, nil) } return tokenDetailsResponse, nil @@ -61,11 +90,11 @@ func CallLogin1V2(httpClient *resty.Client, request GetLoginOneV2Request) (GetLo Post(fmt.Sprintf("%v/v3/auth/login1", config.INFISICAL_URL)) if err != nil { - return GetLoginOneV2Response{}, fmt.Errorf("CallLogin1V3: Unable to complete api request [err=%s]", err) + return GetLoginOneV2Response{}, NewGenericRequestError(operationCallLogin1V3, err) } if response.IsError() { - return GetLoginOneV2Response{}, fmt.Errorf("CallLogin1V3: Unsuccessful response: [response=%s]", response) + return GetLoginOneV2Response{}, NewAPIErrorWithResponse(operationCallLogin1V3, response, nil) } return loginOneV2Response, nil @@ -99,7 +128,7 @@ func CallVerifyMfaToken(httpClient *resty.Client, request VerifyMfaTokenRequest) } if err != nil { - return nil, nil, fmt.Errorf("CallVerifyMfaToken: Unable to complete api request [err=%s]", err) + return nil, nil, NewGenericRequestError(operationCallVerifyMfaToken, err) } if response.IsError() { @@ -135,11 +164,11 @@ func CallLogin2V2(httpClient *resty.Client, request GetLoginTwoV2Request) (GetLo } if err != nil { - return GetLoginTwoV2Response{}, fmt.Errorf("CallLogin2V3: Unable to complete api request [err=%s]", err) + return GetLoginTwoV2Response{}, NewGenericRequestError(operationCallLogin2V3, err) } if response.IsError() { - return GetLoginTwoV2Response{}, fmt.Errorf("CallLogin2V3: Unsuccessful response: [response=%s]", response) + return GetLoginTwoV2Response{}, NewAPIErrorWithResponse(operationCallLogin2V3, response, nil) } return loginTwoV2Response, nil @@ -154,11 +183,11 @@ func CallGetAllOrganizations(httpClient *resty.Client) (GetOrganizationsResponse Get(fmt.Sprintf("%v/v1/organization", config.INFISICAL_URL)) if err != nil { - return GetOrganizationsResponse{}, err + return GetOrganizationsResponse{}, NewGenericRequestError(operationCallGetAllOrganizations, err) } if response.IsError() { - return GetOrganizationsResponse{}, fmt.Errorf("CallGetAllOrganizations: Unsuccessful response: [response=%v]", response) + return GetOrganizationsResponse{}, NewAPIErrorWithResponse(operationCallGetAllOrganizations, response, nil) } return orgResponse, nil @@ -175,11 +204,11 @@ func CallSelectOrganization(httpClient *resty.Client, request SelectOrganization Post(fmt.Sprintf("%v/v3/auth/select-organization", config.INFISICAL_URL)) if err != nil { - return SelectOrganizationResponse{}, err + return SelectOrganizationResponse{}, NewGenericRequestError(operationCallSelectOrganization, err) } if response.IsError() { - return SelectOrganizationResponse{}, fmt.Errorf("CallSelectOrganization: Unsuccessful response: [response=%v]", response) + return SelectOrganizationResponse{}, NewAPIErrorWithResponse(operationCallSelectOrganization, response, nil) } return selectOrgResponse, nil @@ -214,11 +243,11 @@ func CallGetProjectById(httpClient *resty.Client, id string) (Project, error) { Get(fmt.Sprintf("%v/v1/workspace/%s", config.INFISICAL_URL, id)) if err != nil { - return Project{}, err + return Project{}, NewGenericRequestError(operationCallGetProjectById, err) } if response.IsError() { - return Project{}, fmt.Errorf("CallGetProjectById: Unsuccessful response: [response=%v]", response) + return Project{}, NewAPIErrorWithResponse(operationCallGetProjectById, response, nil) } return projectResponse.Project, nil @@ -237,7 +266,7 @@ func CallIsAuthenticated(httpClient *resty.Client) bool { } if response.IsError() { - log.Debug().Msgf("CallIsAuthenticated: Unsuccessful response: [response=%v]", response) + log.Debug().Msgf("%s: Unsuccessful response: [response=%v]", operationCallIsAuthenticated, response) return false } @@ -257,11 +286,11 @@ func CallGetNewAccessTokenWithRefreshToken(httpClient *resty.Client, refreshToke Post(fmt.Sprintf("%v/v1/auth/token", config.INFISICAL_URL)) if err != nil { - return GetNewAccessTokenWithRefreshTokenResponse{}, err + return GetNewAccessTokenWithRefreshTokenResponse{}, NewGenericRequestError(operationCallGetNewAccessTokenWithRefreshToken, err) } if response.IsError() { - return GetNewAccessTokenWithRefreshTokenResponse{}, fmt.Errorf("CallGetNewAccessTokenWithRefreshToken: Unsuccessful response: [response=%v]", response) + return GetNewAccessTokenWithRefreshTokenResponse{}, NewAPIErrorWithResponse(operationCallGetNewAccessTokenWithRefreshToken, response, nil) } return newAccessToken, nil @@ -280,11 +309,11 @@ func CallGetFoldersV1(httpClient *resty.Client, request GetFoldersV1Request) (Ge response, err := httpRequest.Get(fmt.Sprintf("%v/v1/folders", config.INFISICAL_URL)) if err != nil { - return GetFoldersV1Response{}, fmt.Errorf("CallGetFoldersV1: Unable to complete api request [err=%v]", err) + return GetFoldersV1Response{}, NewGenericRequestError(operationCallGetFoldersV1, err) } if response.IsError() { - return GetFoldersV1Response{}, fmt.Errorf("CallGetFoldersV1: Unsuccessful [response=%s]", response) + return GetFoldersV1Response{}, NewAPIErrorWithResponse(operationCallGetFoldersV1, response, nil) } return foldersResponse, nil @@ -300,11 +329,11 @@ func CallCreateFolderV1(httpClient *resty.Client, request CreateFolderV1Request) response, err := httpRequest.Post(fmt.Sprintf("%v/v1/folders", config.INFISICAL_URL)) if err != nil { - return CreateFolderV1Response{}, fmt.Errorf("CallCreateFolderV1: Unable to complete api request [err=%s]", err) + return CreateFolderV1Response{}, NewGenericRequestError(operationCallCreateFolderV1, err) } if response.IsError() { - return CreateFolderV1Response{}, fmt.Errorf("CallCreateFolderV1: Unsuccessful [response=%s]", response.String()) + return CreateFolderV1Response{}, NewAPIErrorWithResponse(operationCallCreateFolderV1, response, nil) } return folderResponse, nil @@ -321,11 +350,11 @@ func CallDeleteFolderV1(httpClient *resty.Client, request DeleteFolderV1Request) response, err := httpRequest.Delete(fmt.Sprintf("%v/v1/folders/%v", config.INFISICAL_URL, request.FolderName)) if err != nil { - return DeleteFolderV1Response{}, fmt.Errorf("CallDeleteFolderV1: Unable to complete api request [err=%s]", err) + return DeleteFolderV1Response{}, NewGenericRequestError(operationCallDeleteFolderV1, err) } if response.IsError() { - return DeleteFolderV1Response{}, fmt.Errorf("CallDeleteFolderV1: Unsuccessful [response=%s]", response.String()) + return DeleteFolderV1Response{}, NewAPIErrorWithResponse(operationCallDeleteFolderV1, response, nil) } return folderResponse, nil @@ -342,11 +371,12 @@ func CallDeleteSecretsRawV3(httpClient *resty.Client, request DeleteSecretV3Requ Delete(fmt.Sprintf("%v/v3/secrets/raw/%s", config.INFISICAL_URL, request.SecretName)) if err != nil { - return fmt.Errorf("CallDeleteSecretsV3: Unable to complete api request [err=%s]", err) + return NewGenericRequestError(operationCallDeleteSecretsV3, err) } if response.IsError() { - return fmt.Errorf("CallDeleteSecretsV3: Unsuccessful response. Please make sure your secret path, workspace and environment name are all correct [response=%s]", response) + additionalContext := "Please make sure your secret path, workspace and environment name are all correct." + return NewAPIErrorWithResponse(operationCallDeleteSecretsV3, response, &additionalContext) } return nil @@ -362,11 +392,11 @@ func CallCreateServiceToken(httpClient *resty.Client, request CreateServiceToken Post(fmt.Sprintf("%v/v2/service-token/", config.INFISICAL_URL)) if err != nil { - return CreateServiceTokenResponse{}, fmt.Errorf("CallCreateServiceToken: Unable to complete api request [err=%s]", err) + return CreateServiceTokenResponse{}, NewGenericRequestError(operationCallCreateServiceToken, err) } if response.IsError() { - return CreateServiceTokenResponse{}, fmt.Errorf("CallCreateServiceToken: Unsuccessful response [%v %v] [status-code=%v]", response.Request.Method, response.Request.URL, response.StatusCode()) + return CreateServiceTokenResponse{}, NewAPIErrorWithResponse(operationCallCreateServiceToken, response, nil) } return createServiceTokenResponse, nil @@ -382,11 +412,11 @@ func CallUniversalAuthLogin(httpClient *resty.Client, request UniversalAuthLogin Post(fmt.Sprintf("%v/v1/auth/universal-auth/login/", config.INFISICAL_URL)) if err != nil { - return UniversalAuthLoginResponse{}, fmt.Errorf("CallUniversalAuthLogin: Unable to complete api request [err=%s]", err) + return UniversalAuthLoginResponse{}, NewGenericRequestError(operationCallUniversalAuthLogin, err) } if response.IsError() { - return UniversalAuthLoginResponse{}, fmt.Errorf("CallUniversalAuthLogin: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return UniversalAuthLoginResponse{}, NewAPIErrorWithResponse(operationCallUniversalAuthLogin, response, nil) } return universalAuthLoginResponse, nil @@ -402,11 +432,11 @@ func CallMachineIdentityRefreshAccessToken(httpClient *resty.Client, request Uni Post(fmt.Sprintf("%v/v1/auth/token/renew", config.INFISICAL_URL)) if err != nil { - return UniversalAuthRefreshResponse{}, fmt.Errorf("CallMachineIdentityRefreshAccessToken: Unable to complete api request [err=%s]", err) + return UniversalAuthRefreshResponse{}, NewGenericRequestError(operationCallMachineIdentityRefreshAccessToken, err) } if response.IsError() { - return UniversalAuthRefreshResponse{}, fmt.Errorf("CallMachineIdentityRefreshAccessToken: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return UniversalAuthRefreshResponse{}, NewAPIErrorWithResponse(operationCallMachineIdentityRefreshAccessToken, response, nil) } return universalAuthRefreshResponse, nil @@ -441,19 +471,19 @@ func CallGetRawSecretsV3(httpClient *resty.Client, request GetRawSecretsV3Reques response, err := req.Get(fmt.Sprintf("%v/v3/secrets/raw", config.INFISICAL_URL)) if err != nil { - return GetRawSecretsV3Response{}, fmt.Errorf("CallGetRawSecretsV3: Unable to complete api request [err=%w]", err) + return GetRawSecretsV3Response{}, NewGenericRequestError(operationCallGetRawSecretsV3, err) } if response.IsError() && (strings.Contains(response.String(), "bot_not_found_error") || strings.Contains(strings.ToLower(response.String()), "failed to find bot key") || strings.Contains(strings.ToLower(response.String()), "bot is not active")) { - return GetRawSecretsV3Response{}, fmt.Errorf(`Project with id %s is incompatible with your current CLI version. Upgrade your project by visiting the project settings page. If you're self-hosting and project upgrade option isn't yet available, contact your administrator to upgrade your Infisical instance to the latest release. - `, request.WorkspaceId) + additionalContext := fmt.Sprintf(`Project with id %s is incompatible with your current CLI version. Upgrade your project by visiting the project settings page. If you're self-hosting and project upgrade option isn't yet available, contact your administrator to upgrade your Infisical instance to the latest release.`, request.WorkspaceId) + return GetRawSecretsV3Response{}, NewAPIErrorWithResponse(operationCallGetRawSecretsV3, response, &additionalContext) } if response.IsError() { - return GetRawSecretsV3Response{}, fmt.Errorf("CallGetRawSecretsV3: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return GetRawSecretsV3Response{}, NewAPIErrorWithResponse(operationCallGetRawSecretsV3, response, nil) } getRawSecretsV3Response.ETag = response.Header().Get(("etag")) @@ -477,11 +507,11 @@ func CallFetchSingleSecretByName(httpClient *resty.Client, request GetRawSecretV Get(fmt.Sprintf("%v/v3/secrets/raw/%s", config.INFISICAL_URL, request.SecretName)) if err != nil { - return GetRawSecretV3ByNameResponse{}, fmt.Errorf("CallFetchSingleSecretByName: Unable to complete api request [err=%w]", err) + return GetRawSecretV3ByNameResponse{}, NewGenericRequestError(operationCallFetchSingleSecretByName, err) } if response.IsError() { - return GetRawSecretV3ByNameResponse{}, fmt.Errorf("CallFetchSingleSecretByName: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return GetRawSecretV3ByNameResponse{}, NewAPIErrorWithResponse(operationCallFetchSingleSecretByName, response, nil) } getRawSecretV3ByNameResponse.ETag = response.Header().Get(("etag")) @@ -517,11 +547,11 @@ func CallCreateRawSecretsV3(httpClient *resty.Client, request CreateRawSecretV3R Post(fmt.Sprintf("%v/v3/secrets/raw/%s", config.INFISICAL_URL, request.SecretName)) if err != nil { - return fmt.Errorf("CallCreateRawSecretsV3: Unable to complete api request [err=%w]", err) + return NewGenericRequestError(operationCallCreateRawSecretsV3, err) } if response.IsError() { - return fmt.Errorf("CallCreateRawSecretsV3: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return NewAPIErrorWithResponse(operationCallCreateRawSecretsV3, response, nil) } return nil @@ -535,11 +565,11 @@ func CallUpdateRawSecretsV3(httpClient *resty.Client, request UpdateRawSecretByN Patch(fmt.Sprintf("%v/v3/secrets/raw/%s", config.INFISICAL_URL, request.SecretName)) if err != nil { - return fmt.Errorf("CallUpdateRawSecretsV3: Unable to complete api request [err=%w]", err) + return NewGenericRequestError(operationCallUpdateRawSecretsV3, err) } if response.IsError() { - return fmt.Errorf("CallUpdateRawSecretsV3: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return NewAPIErrorWithResponse(operationCallUpdateRawSecretsV3, response, nil) } return nil @@ -554,11 +584,11 @@ func CallRegisterGatewayIdentityV1(httpClient *resty.Client) (*GetRelayCredentia Post(fmt.Sprintf("%v/v1/gateways/register-identity", config.INFISICAL_URL)) if err != nil { - return nil, fmt.Errorf("CallRegisterGatewayIdentityV1: Unable to complete api request [err=%w]", err) + return nil, NewGenericRequestError(operationCallRegisterGatewayIdentityV1, err) } if response.IsError() { - return nil, fmt.Errorf("CallRegisterGatewayIdentityV1: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return nil, NewAPIErrorWithResponse(operationCallRegisterGatewayIdentityV1, response, nil) } return &resBody, nil @@ -574,11 +604,11 @@ func CallExchangeRelayCertV1(httpClient *resty.Client, request ExchangeRelayCert Post(fmt.Sprintf("%v/v1/gateways/exchange-cert", config.INFISICAL_URL)) if err != nil { - return nil, fmt.Errorf("CallExchangeRelayCertV1: Unable to complete api request [err=%w]", err) + return nil, NewGenericRequestError(operationCallExchangeRelayCertV1, err) } if response.IsError() { - return nil, fmt.Errorf("CallExchangeRelayCertV1: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return nil, NewAPIErrorWithResponse(operationCallExchangeRelayCertV1, response, nil) } return &resBody, nil @@ -591,11 +621,11 @@ func CallGatewayHeartBeatV1(httpClient *resty.Client) error { Post(fmt.Sprintf("%v/v1/gateways/heartbeat", config.INFISICAL_URL)) if err != nil { - return fmt.Errorf("CallGatewayHeartBeatV1: Unable to complete api request [err=%w]", err) + return NewGenericRequestError(operationCallGatewayHeartBeatV1, err) } if response.IsError() { - return fmt.Errorf("CallGatewayHeartBeatV1: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return NewAPIErrorWithResponse(operationCallGatewayHeartBeatV1, response, nil) } return nil @@ -611,11 +641,11 @@ func CallBootstrapInstance(httpClient *resty.Client, request BootstrapInstanceRe Post(fmt.Sprintf("%v/v1/admin/bootstrap", request.Domain)) if err != nil { - return nil, fmt.Errorf("CallBootstrapInstance: Unable to complete api request [err=%w]", err) + return nil, NewGenericRequestError(operationCallBootstrapInstance, err) } if response.IsError() { - return nil, fmt.Errorf("CallBootstrapInstance: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return nil, NewAPIErrorWithResponse(operationCallBootstrapInstance, response, nil) } return resBody, nil diff --git a/cli/packages/api/errors.go b/cli/packages/api/errors.go new file mode 100644 index 000000000..4729d1264 --- /dev/null +++ b/cli/packages/api/errors.go @@ -0,0 +1,80 @@ +package api + +import ( + "fmt" + + "github.com/go-resty/resty/v2" + "github.com/infisical/go-sdk/packages/util" +) + +type GenericRequestError struct { + err error + operation string +} + +func (e *GenericRequestError) Error() string { + return fmt.Sprintf("%s: Unable to complete api request [err=%v]", e.operation, e.err) +} + +func NewGenericRequestError(operation string, err error) *GenericRequestError { + return &GenericRequestError{err: err, operation: operation} +} + +// APIError represents an error response from the API +type APIError struct { + AdditionalContext string `json:"additionalContext,omitempty"` + Operation string `json:"operation"` + Method string `json:"method"` + URL string `json:"url"` + StatusCode int `json:"statusCode"` + ErrorMessage string `json:"message,omitempty"` + ReqId string `json:"reqId,omitempty"` +} + +func (e *APIError) Error() string { + msg := fmt.Sprintf( + "%s Unsuccessful response [%v %v] [status-code=%v] [request-id=%v]", + e.Operation, + e.Method, + e.URL, + e.StatusCode, + e.ReqId, + ) + + if e.ErrorMessage != "" { + msg = fmt.Sprintf("%s [message=\"%s\"]", msg, e.ErrorMessage) + } + + if e.AdditionalContext != "" { + msg = fmt.Sprintf("%s [additional-context=\"%s\"]", msg, e.AdditionalContext) + } + + return msg +} + +func NewAPIErrorWithResponse(operation string, res *resty.Response, additionalContext *string) error { + errorMessage := util.TryParseErrorBody(res) + reqId := util.TryExtractReqId(res) + + if res == nil { + return NewGenericRequestError(operation, fmt.Errorf("response is nil")) + } + + apiError := &APIError{ + Operation: operation, + Method: res.Request.Method, + URL: res.Request.URL, + StatusCode: res.StatusCode(), + ReqId: reqId, + } + + if additionalContext != nil && *additionalContext != "" { + apiError.AdditionalContext = *additionalContext + } + + if errorMessage != "" { + apiError.ErrorMessage = errorMessage + } + + return apiError +} diff --git a/cli/packages/cmd/agent.go b/cli/packages/cmd/agent.go index b14fd04e2..445941674 100644 --- a/cli/packages/cmd/agent.go +++ b/cli/packages/cmd/agent.go @@ -884,6 +884,12 @@ func (tm *AgentManager) MonitorSecretChanges(secretTemplate Template, templateId if err != nil { log.Error().Msgf("unable to process template because %v", err) + + // case: if exit-after-auth is true, it should exit the agent once an error on secret fetching occurs with the appropriate exit code (1) + // previous behavior would exit after 25 sec with status code 0, even if this step errors + if tm.exitAfterAuth { + os.Exit(1) + } } else { if (existingEtag != currentEtag) || firstRun { diff --git a/cli/packages/cmd/dynamic_secrets.go b/cli/packages/cmd/dynamic_secrets.go index 60f356185..8761b84ef 100644 --- a/cli/packages/cmd/dynamic_secrets.go +++ b/cli/packages/cmd/dynamic_secrets.go @@ -63,7 +63,7 @@ func getDynamicSecretList(cmd *cobra.Command, args []string) { if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { - util.HandleError(err, "Unable to get local project details") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId } @@ -72,7 +72,6 @@ func getDynamicSecretList(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -80,8 +79,9 @@ func getDynamicSecretList(cmd *cobra.Command, args []string) { } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } @@ -180,7 +180,7 @@ func createDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { - util.HandleError(err, "Unable to get local project details") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId } @@ -189,7 +189,6 @@ func createDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -197,7 +196,7 @@ func createDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } @@ -311,7 +310,7 @@ func renewDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { - util.HandleError(err, "Unable to get local project details") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId } @@ -320,7 +319,6 @@ func renewDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -328,8 +326,9 @@ func renewDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } @@ -418,7 +417,7 @@ func revokeDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { - util.HandleError(err, "Unable to get local project details") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId } @@ -427,7 +426,6 @@ func revokeDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -435,8 +433,9 @@ func revokeDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } @@ -524,7 +523,7 @@ func listDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { - util.HandleError(err, "Unable to get local project details") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId } @@ -533,7 +532,6 @@ func listDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -541,7 +539,7 @@ func listDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } diff --git a/cli/packages/cmd/folder.go b/cli/packages/cmd/folder.go index 538f1e2dc..b59652191 100644 --- a/cli/packages/cmd/folder.go +++ b/cli/packages/cmd/folder.go @@ -112,7 +112,7 @@ var createCmd = &cobra.Command{ if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { - util.HandleError(err, "Unable to get workspace file") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId @@ -180,7 +180,7 @@ var deleteCmd = &cobra.Command{ if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { - util.HandleError(err, "Unable to get workspace file") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId diff --git a/cli/packages/cmd/gateway.go b/cli/packages/cmd/gateway.go index 51565b6fd..abc4d6949 100644 --- a/cli/packages/cmd/gateway.go +++ b/cli/packages/cmd/gateway.go @@ -7,16 +7,77 @@ import ( "os/exec" "os/signal" "runtime" + "sync/atomic" "syscall" "time" + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/gateway" "github.com/Infisical/infisical-merge/packages/util" + infisicalSdk "github.com/infisical/go-sdk" + "github.com/pkg/errors" "github.com/posthog/posthog-go" "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) +func getInfisicalSdkInstance(cmd *cobra.Command) (infisicalSdk.InfisicalClientInterface, context.CancelFunc, error) { + + ctx, cancel := context.WithCancel(cmd.Context()) + infisicalClient := infisicalSdk.NewInfisicalClient(ctx, infisicalSdk.Config{ + SiteUrl: config.INFISICAL_URL, + UserAgent: api.USER_AGENT, + }) + + token, err := util.GetInfisicalToken(cmd) + if err != nil { + cancel() + return nil, nil, err + } + + // if the --token param is set, we use it directly for authentication + if token != nil { + infisicalClient.Auth().SetAccessToken(token.Token) + return infisicalClient, cancel, nil + } + + // if the --token param is not set, we use the auth-method flag to determine the authentication method, and perform the appropriate login flow based on that + authMethod, err := util.GetCmdFlagOrEnv(cmd, "auth-method", []string{util.INFISICAL_AUTH_METHOD_NAME}) + + if err != nil { + cancel() + return nil, nil, err + } + + authMethodValid, strategy := util.IsAuthMethodValid(authMethod, false) + if !authMethodValid { + util.PrintErrorMessageAndExit(fmt.Sprintf("Invalid login method: %s", authMethod)) + } + + sdkAuthenticator := util.NewSdkAuthenticator(infisicalClient, cmd) + + authStrategies := map[util.AuthStrategyType]func() (credential infisicalSdk.MachineIdentityCredential, e error){ + util.AuthStrategy.UNIVERSAL_AUTH: sdkAuthenticator.HandleUniversalAuthLogin, + util.AuthStrategy.KUBERNETES_AUTH: sdkAuthenticator.HandleKubernetesAuthLogin, + util.AuthStrategy.AZURE_AUTH: sdkAuthenticator.HandleAzureAuthLogin, + util.AuthStrategy.GCP_ID_TOKEN_AUTH: sdkAuthenticator.HandleGcpIdTokenAuthLogin, + util.AuthStrategy.GCP_IAM_AUTH: sdkAuthenticator.HandleGcpIamAuthLogin, + util.AuthStrategy.AWS_IAM_AUTH: sdkAuthenticator.HandleAwsIamAuthLogin, + util.AuthStrategy.OIDC_AUTH: sdkAuthenticator.HandleOidcAuthLogin, + util.AuthStrategy.JWT_AUTH: sdkAuthenticator.HandleJwtAuthLogin, + } + + _, err = authStrategies[strategy]() + + if err != nil { + cancel() + return nil, nil, err + } + + return infisicalClient, cancel, nil +} + var gatewayCmd = &cobra.Command{ Use: "gateway", Short: "Run the Infisical gateway or manage its systemd service", @@ -26,13 +87,18 @@ var gatewayCmd = &cobra.Command{ DisableFlagsInUseLine: true, Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { - token, err := util.GetInfisicalToken(cmd) - if err != nil { - util.HandleError(err, "Unable to parse token flag") - } - if token == nil { - util.HandleError(fmt.Errorf("Token not found")) + infisicalClient, cancelSdk, err := getInfisicalSdkInstance(cmd) + if err != nil { + util.HandleError(err, "unable to get infisical client") + } + defer cancelSdk() + + var accessToken atomic.Value + accessToken.Store(infisicalClient.Auth().GetAccessToken()) + + if accessToken.Load().(string) == "" { + util.HandleError(errors.New("no access token found")) } Telemetry.CaptureEvent("cli-command:gateway", posthog.NewProperties().Set("version", util.CLI_VERSION)) @@ -41,13 +107,14 @@ var gatewayCmd = &cobra.Command{ signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) sigStopCh := make(chan bool, 1) - ctx, cancel := context.WithCancel(cmd.Context()) - defer cancel() + ctx, cancelCmd := context.WithCancel(cmd.Context()) + defer cancelCmd() go func() { <-sigCh close(sigStopCh) - cancel() + cancelCmd() + cancelSdk() // If we get a second signal, force exit <-sigCh @@ -55,6 +122,34 @@ var gatewayCmd = &cobra.Command{ os.Exit(1) }() + var gatewayInstance *gateway.Gateway + + // Token refresh goroutine - runs every 10 seconds + go func() { + tokenRefreshTicker := time.NewTicker(10 * time.Second) + defer tokenRefreshTicker.Stop() + + for { + select { + case <-tokenRefreshTicker.C: + if ctx.Err() != nil { + return + } + + newToken := infisicalClient.Auth().GetAccessToken() + if newToken != "" && newToken != accessToken.Load().(string) { + accessToken.Store(newToken) + if gatewayInstance != nil { + gatewayInstance.UpdateIdentityAccessToken(newToken) + } + } + + case <-ctx.Done(): + return + } + } + }() + // Main gateway retry loop with proper context handling retryTicker := time.NewTicker(5 * time.Second) defer retryTicker.Stop() @@ -64,7 +159,7 @@ var gatewayCmd = &cobra.Command{ log.Info().Msg("Shutting down gateway") return } - gatewayInstance, err := gateway.NewGateway(token.Token) + gatewayInstance, err := gateway.NewGateway(accessToken.Load().(string)) if err != nil { util.HandleError(err) } @@ -126,7 +221,7 @@ var gatewayInstallCmd = &cobra.Command{ } if token == nil { - util.HandleError(fmt.Errorf("Token not found")) + util.HandleError(errors.New("Token not found")) } domain, err := cmd.Flags().GetString("domain") @@ -183,7 +278,7 @@ var gatewayRelayCmd = &cobra.Command{ } if relayConfigFilePath == "" { - util.HandleError(fmt.Errorf("Missing config file")) + util.HandleError(errors.New("Missing config file")) } gatewayRelay, err := gateway.NewGatewayRelay(relayConfigFilePath) @@ -198,7 +293,19 @@ var gatewayRelayCmd = &cobra.Command{ } func init() { - gatewayCmd.Flags().String("token", "", "Connect with Infisical using machine identity access token") + gatewayCmd.Flags().String("token", "", "connect with Infisical using machine identity access token. if not provided, you must set the auth-method flag") + + gatewayCmd.Flags().String("auth-method", "", "login method [universal-auth, kubernetes, azure, gcp-id-token, gcp-iam, aws-iam, oidc-auth]. if not provided, you must set the token flag") + + gatewayCmd.Flags().String("client-id", "", "client id for universal auth") + gatewayCmd.Flags().String("client-secret", "", "client secret for universal auth") + + gatewayCmd.Flags().String("machine-identity-id", "", "machine identity id for kubernetes, azure, gcp-id-token, gcp-iam, and aws-iam auth methods") + gatewayCmd.Flags().String("service-account-token-path", "", "service account token path for kubernetes auth") + gatewayCmd.Flags().String("service-account-key-file-path", "", "service account key file path for GCP IAM auth") + + gatewayCmd.Flags().String("jwt", "", "JWT for jwt-based auth methods [oidc-auth, jwt-auth]") + gatewayInstallCmd.Flags().String("token", "", "Connect with Infisical using machine identity access token") gatewayInstallCmd.Flags().String("domain", "", "Domain of your self-hosted Infisical instance") diff --git a/cli/packages/cmd/init.go b/cli/packages/cmd/init.go index e10a11c06..2ef555a82 100644 --- a/cli/packages/cmd/init.go +++ b/cli/packages/cmd/init.go @@ -46,7 +46,7 @@ var initCmd = &cobra.Command{ } if userCreds.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + userCreds = util.EstablishUserLoginSession() } httpClient, err := util.GetRestyClientWithCustomHeaders() diff --git a/cli/packages/cmd/kmip.go b/cli/packages/cmd/kmip.go index b0c397895..91335d122 100644 --- a/cli/packages/cmd/kmip.go +++ b/cli/packages/cmd/kmip.go @@ -49,13 +49,13 @@ func startKmipServer(cmd *cobra.Command, args []string) { var identityClientSecret string if strategy == util.AuthStrategy.UNIVERSAL_AUTH { - identityClientId, err = util.GetCmdFlagOrEnv(cmd, "identity-client-id", util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME) + identityClientId, err = util.GetCmdFlagOrEnv(cmd, "identity-client-id", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME}) if err != nil { util.HandleError(err, "Unable to parse identity client ID") } - identityClientSecret, err = util.GetCmdFlagOrEnv(cmd, "identity-client-secret", util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME) + identityClientSecret, err = util.GetCmdFlagOrEnv(cmd, "identity-client-secret", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME}) if err != nil { util.HandleError(err, "Unable to parse identity client secret") } diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index b1c868d8c..fd3ce1569 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -9,6 +9,7 @@ import ( "encoding/hex" "encoding/json" "os" + "runtime" "slices" "strings" "time" @@ -20,6 +21,8 @@ import ( "net/url" "regexp" + browser "github.com/pkg/browser" + "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/crypto" @@ -46,97 +49,6 @@ type params struct { keyLength uint32 } -func handleUniversalAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - clientId, err := util.GetCmdFlagOrEnv(cmd, "client-id", util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME) - - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - clientSecret, err := util.GetCmdFlagOrEnv(cmd, "client-secret", util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().UniversalAuthLogin(clientId, clientSecret) -} - -func handleKubernetesAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - serviceAccountTokenPath, err := util.GetCmdFlagOrEnv(cmd, "service-account-token-path", util.INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().KubernetesAuthLogin(identityId, serviceAccountTokenPath) -} - -func handleAzureAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().AzureAuthLogin(identityId, "") -} - -func handleGcpIdTokenAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().GcpIdTokenAuthLogin(identityId) -} - -func handleGcpIamAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - serviceAccountKeyFilePath, err := util.GetCmdFlagOrEnv(cmd, "service-account-key-file-path", util.INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().GcpIamAuthLogin(identityId, serviceAccountKeyFilePath) -} - -func handleAwsIamAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().AwsIamAuthLogin(identityId) -} - -func handleOidcAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - jwt, err := util.GetCmdFlagOrEnv(cmd, "oidc-jwt", util.INFISICAL_OIDC_AUTH_JWT_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().OidcAuthLogin(identityId, jwt) -} - func formatAuthMethod(authMethod string) string { return strings.ReplaceAll(authMethod, "-", " ") } @@ -151,8 +63,22 @@ var loginCmd = &cobra.Command{ Use: "login", Short: "Login into your Infisical account", DisableFlagsInUseLine: true, - Run: func(cmd *cobra.Command, args []string) { + PreRunE: func(cmd *cobra.Command, args []string) error { + // daniel: oidc-jwt is deprecated in favor of `jwt`. we backfill the `jwt` flag with the value of `oidc-jwt` if it's set. + if cmd.Flags().Changed("oidc-jwt") && !cmd.Flags().Changed("jwt") { + oidcJWT, err := cmd.Flags().GetString("oidc-jwt") + if err != nil { + return err + } + err = cmd.Flags().Set("jwt", oidcJWT) + if err != nil { + return err + } + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { presetDomain := config.INFISICAL_URL clearSelfHostedDomains, err := cmd.Flags().GetBool("clear-domains") @@ -307,17 +233,20 @@ var loginCmd = &cobra.Command{ Telemetry.CaptureEvent("cli-command:login", posthog.NewProperties().Set("infisical-backend", config.INFISICAL_URL).Set("version", util.CLI_VERSION)) } else { - authStrategies := map[util.AuthStrategyType]func(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error){ - util.AuthStrategy.UNIVERSAL_AUTH: handleUniversalAuthLogin, - util.AuthStrategy.KUBERNETES_AUTH: handleKubernetesAuthLogin, - util.AuthStrategy.AZURE_AUTH: handleAzureAuthLogin, - util.AuthStrategy.GCP_ID_TOKEN_AUTH: handleGcpIdTokenAuthLogin, - util.AuthStrategy.GCP_IAM_AUTH: handleGcpIamAuthLogin, - util.AuthStrategy.AWS_IAM_AUTH: handleAwsIamAuthLogin, - util.AuthStrategy.OIDC_AUTH: handleOidcAuthLogin, + sdkAuthenticator := util.NewSdkAuthenticator(infisicalClient, cmd) + + authStrategies := map[util.AuthStrategyType]func() (credential infisicalSdk.MachineIdentityCredential, e error){ + util.AuthStrategy.UNIVERSAL_AUTH: sdkAuthenticator.HandleUniversalAuthLogin, + util.AuthStrategy.KUBERNETES_AUTH: sdkAuthenticator.HandleKubernetesAuthLogin, + util.AuthStrategy.AZURE_AUTH: sdkAuthenticator.HandleAzureAuthLogin, + util.AuthStrategy.GCP_ID_TOKEN_AUTH: sdkAuthenticator.HandleGcpIdTokenAuthLogin, + util.AuthStrategy.GCP_IAM_AUTH: sdkAuthenticator.HandleGcpIamAuthLogin, + util.AuthStrategy.AWS_IAM_AUTH: sdkAuthenticator.HandleAwsIamAuthLogin, + util.AuthStrategy.OIDC_AUTH: sdkAuthenticator.HandleOidcAuthLogin, + util.AuthStrategy.JWT_AUTH: sdkAuthenticator.HandleJwtAuthLogin, } - credential, err := authStrategies[strategy](cmd, infisicalClient) + credential, err := authStrategies[strategy]() if err != nil { euErrorMessage := "" @@ -515,14 +444,18 @@ func init() { rootCmd.AddCommand(loginCmd) loginCmd.Flags().Bool("clear-domains", false, "clear all self-hosting domains from the config file") loginCmd.Flags().BoolP("interactive", "i", false, "login via the command line") - loginCmd.Flags().String("method", "user", "login method [user, universal-auth]") loginCmd.Flags().Bool("plain", false, "only output the token without any formatting") + loginCmd.Flags().String("method", "user", "login method [user, universal-auth, kubernetes, azure, gcp-id-token, gcp-iam, aws-iam, oidc-auth]") loginCmd.Flags().String("client-id", "", "client id for universal auth") loginCmd.Flags().String("client-secret", "", "client secret for universal auth") loginCmd.Flags().String("machine-identity-id", "", "machine identity id for kubernetes, azure, gcp-id-token, gcp-iam, and aws-iam auth methods") loginCmd.Flags().String("service-account-token-path", "", "service account token path for kubernetes auth") loginCmd.Flags().String("service-account-key-file-path", "", "service account key file path for GCP IAM auth") - loginCmd.Flags().String("oidc-jwt", "", "JWT for OIDC authentication") + loginCmd.Flags().String("jwt", "", "jwt for jwt-based auth methods [oidc-auth, jwt-auth]") + loginCmd.Flags().String("oidc-jwt", "", "JWT for OIDC authentication. Deprecated, use --jwt instead") + + loginCmd.Flags().MarkDeprecated("oidc-jwt", "use --jwt instead") + } func DomainOverridePrompt() (bool, error) { @@ -981,7 +914,17 @@ func browserCliLogin() (models.UserCredentials, error) { callbackPort := listener.Addr().(*net.TCPAddr).Port url := fmt.Sprintf("%s?callback_port=%d", config.INFISICAL_LOGIN_URL, callbackPort) - fmt.Printf("\n\nTo complete your login, open this address in your browser: %v \n", url) + defaultPrintStatement := fmt.Sprintf("\n\nTo complete your login, open this address in your browser: %v \n", url) + + if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { + if err := browser.OpenURL(url); err != nil { + fmt.Print(defaultPrintStatement) + } else { + fmt.Printf("\n\nPlease proceed to your browser to complete the login process.\nIf the browser doesn't open automatically, please open this address in your browser: %v \n", url) + } + } else { + fmt.Print(defaultPrintStatement) + } //flow channels success := make(chan models.UserCredentials) diff --git a/cli/packages/cmd/scan.go b/cli/packages/cmd/scan.go index 1226e3319..42ff0f1e1 100644 --- a/cli/packages/cmd/scan.go +++ b/cli/packages/cmd/scan.go @@ -32,10 +32,13 @@ import ( "strings" "time" - "github.com/Infisical/infisical-merge/config" "github.com/Infisical/infisical-merge/detect" + "github.com/Infisical/infisical-merge/detect/cmd/scm" + "github.com/Infisical/infisical-merge/detect/config" + "github.com/Infisical/infisical-merge/detect/logging" + "github.com/Infisical/infisical-merge/detect/report" + "github.com/Infisical/infisical-merge/detect/sources" "github.com/Infisical/infisical-merge/packages/util" - "github.com/Infisical/infisical-merge/report" "github.com/manifoldco/promptui" "github.com/posthog/posthog-go" "github.com/rs/zerolog/log" @@ -240,9 +243,17 @@ var scanCmd = &cobra.Command{ log.Fatal().Err(err).Msg("") } // set redact flag - if detector.Redact, err = cmd.Flags().GetBool("redact"); err != nil { + + redactFlag, err := cmd.Flags().GetBool("redact") + if err != nil { log.Fatal().Err(err).Msg("") } + if redactFlag { + detector.Redact = 100 + } else { + detector.Redact = 0 + } + if detector.MaxTargetMegaBytes, err = cmd.Flags().GetInt("max-target-megabytes"); err != nil { log.Fatal().Err(err).Msg("") } @@ -293,31 +304,49 @@ var scanCmd = &cobra.Command{ // start the detector scan if noGit { - findings, err = detector.DetectFiles(source) + paths, err := sources.DirectoryTargets( + source, + detector.Sema, + detector.FollowSymlinks, + detector.Config.Allowlists, + ) if err != nil { + logging.Fatal().Err(err).Send() + } + + if findings, err = detector.DetectFiles(paths); err != nil { // don't exit on error, just log it - log.Error().Err(err).Msg("") + logging.Error().Err(err).Msg("failed scan directory") } } else if fromPipe { - findings, err = detector.DetectReader(os.Stdin, 10) - if err != nil { + if findings, err = detector.DetectReader(os.Stdin, 10); err != nil { // log fatal to exit, no need to continue since a report // will not be generated when scanning from a pipe...for now - log.Fatal().Err(err).Msg("") + logging.Fatal().Err(err).Msg("failed scan input from stdin") } } else { + var ( + gitCmd *sources.GitCmd + scmPlatform scm.Platform + remote *detect.RemoteInfo + ) + var logOpts string logOpts, err = cmd.Flags().GetString("log-opts") - if err != nil { - log.Fatal().Err(err).Msg("") + + if gitCmd, err = sources.NewGitLogCmd(source, logOpts); err != nil { + logging.Fatal().Err(err).Msg("could not create Git cmd") } - findings, err = detector.DetectGit(source, logOpts, detect.DetectType) - if err != nil { + if scmPlatform, err = scm.PlatformFromString("github"); err != nil { + logging.Fatal().Err(err).Send() + } + remote = detect.NewRemoteInfo(scmPlatform, source) + + if findings, err = detector.DetectGit(gitCmd, remote); err != nil { // don't exit on error, just log it - log.Error().Err(err).Msg("") + logging.Error().Err(err).Msg("failed to scan Git repository") } } - // log info about the scan if err == nil { log.Info().Msgf("scan completed in %s", FormatDuration(time.Since(start))) @@ -341,9 +370,7 @@ var scanCmd = &cobra.Command{ reportPath, _ := cmd.Flags().GetString("report-path") ext, _ := cmd.Flags().GetString("report-format") if reportPath != "" { - if err := report.Write(findings, cfg, ext, reportPath); err != nil { - log.Fatal().Err(err).Msg("could not write") - } + reportFindings(findings, reportPath, ext, &cfg) } if err != nil { @@ -375,7 +402,6 @@ var scanGitChangesCmd = &cobra.Command{ cfg.Path, _ = cmd.Flags().GetString("config") exitCode, _ := cmd.Flags().GetInt("exit-code") staged, _ := cmd.Flags().GetBool("staged") - start := time.Now() // Setup detector detector := detect.NewDetector(cfg) @@ -397,9 +423,17 @@ var scanGitChangesCmd = &cobra.Command{ log.Fatal().Err(err).Msg("") } // set redact flag - if detector.Redact, err = cmd.Flags().GetBool("redact"); err != nil { + + redactFlag, err := cmd.Flags().GetBool("redact") + if err != nil { log.Fatal().Err(err).Msg("") } + if redactFlag { + detector.Redact = 100 + } else { + detector.Redact = 0 + } + if detector.MaxTargetMegaBytes, err = cmd.Flags().GetInt("max-target-megabytes"); err != nil { log.Fatal().Err(err).Msg("") } @@ -414,32 +448,22 @@ var scanGitChangesCmd = &cobra.Command{ } } - // get log options for git scan - logOpts, err := cmd.Flags().GetString("log-opts") - if err != nil { - log.Fatal().Err(err).Msg("") - } - - log.Info().Msgf("scanning for exposed secrets...") - // start git scan - var findings []report.Finding - if staged { - findings, err = detector.DetectGit(source, logOpts, detect.ProtectStagedType) - } else { - findings, err = detector.DetectGit(source, logOpts, detect.ProtectType) - } - if err != nil { - // don't exit on error, just log it - log.Error().Err(err).Msg("") - } + var ( + findings []report.Finding - // log info about the scan - log.Info().Msgf("scan completed in %s", FormatDuration(time.Since(start))) - if len(findings) != 0 { - log.Warn().Msgf("leaks found: %d", len(findings)) - } else { - log.Info().Msg("no leaks found") + gitCmd *sources.GitCmd + remote *detect.RemoteInfo + ) + + if gitCmd, err = sources.NewGitDiffCmd(source, staged); err != nil { + logging.Fatal().Err(err).Msg("could not create Git diff cmd") + } + remote = &detect.RemoteInfo{Platform: scm.NoPlatform} + + if findings, err = detector.DetectGit(gitCmd, remote); err != nil { + // don't exit on error, just log it + logging.Error().Err(err).Msg("failed to scan Git repository") } Telemetry.CaptureEvent("cli-command:scan git-changes", posthog.NewProperties().Set("risks", len(findings)).Set("version", util.CLI_VERSION)) @@ -447,9 +471,7 @@ var scanGitChangesCmd = &cobra.Command{ reportPath, _ := cmd.Flags().GetString("report-path") ext, _ := cmd.Flags().GetString("report-format") if reportPath != "" { - if err = report.Write(findings, cfg, ext, reportPath); err != nil { - log.Fatal().Err(err).Msg("") - } + reportFindings(findings, reportPath, ext, &cfg) } if len(findings) != 0 { os.Exit(exitCode) @@ -457,6 +479,36 @@ var scanGitChangesCmd = &cobra.Command{ }, } +func reportFindings(findings []report.Finding, reportPath string, ext string, cfg *config.Config) { + + var reporter report.Reporter + + switch ext { + case "csv": + reporter = &report.CsvReporter{} + case "json": + reporter = &report.JsonReporter{} + case "junit": + reporter = &report.JunitReporter{} + case "sarif": + reporter = &report.SarifReporter{ + OrderedRules: cfg.GetOrderedRules(), + } + default: + logging.Fatal().Msgf("unknown report format %s", ext) + } + + file, err := os.Create(reportPath) + if err != nil { + log.Fatal().Err(err).Msg("could not create file") + } + + if err := reporter.Write(file, findings); err != nil { + log.Fatal().Err(err).Msg("could not write") + } + +} + func fileExists(fileName string) bool { // check for a .infisicalignore file info, err := os.Stat(fileName) diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index fdee3e7c0..930a27a56 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -158,10 +158,6 @@ var secretsSetCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } - if token == nil { - util.RequireLocalWorkspaceFile() - } - environmentName, _ := cmd.Flags().GetString("env") if !cmd.Flags().Changed("env") { environmentFromWorkspace := util.GetEnvFromWorkspaceFile() @@ -175,6 +171,13 @@ var secretsSetCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } + if token == nil && projectId == "" { + _, err := util.GetWorkSpaceFromFile() + if err != nil { + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") + } + } + secretsPath, err := cmd.Flags().GetString("path") if err != nil { util.HandleError(err, "Unable to parse flag") @@ -225,7 +228,7 @@ var secretsSetCmd = &cobra.Command{ if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { - util.HandleError(err, "unable to get your local config details [err=%v]") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId @@ -237,7 +240,7 @@ var secretsSetCmd = &cobra.Command{ } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } secretOperations, err = util.SetRawSecrets(processedArgs, secretType, environmentName, secretsPath, projectId, &models.TokenDetails{ @@ -308,7 +311,7 @@ var secretsDeleteCmd = &cobra.Command{ if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { - util.HandleError(err, "Unable to get local project details") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId } @@ -317,7 +320,6 @@ var secretsDeleteCmd = &cobra.Command{ httpClient.SetAuthToken(token.Token) } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -325,7 +327,7 @@ var secretsDeleteCmd = &cobra.Command{ } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } httpClient.SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken) diff --git a/cli/packages/cmd/ssh.go b/cli/packages/cmd/ssh.go index 7f74d8ee6..4315989bd 100644 --- a/cli/packages/cmd/ssh.go +++ b/cli/packages/cmd/ssh.go @@ -184,7 +184,7 @@ func issueCredentials(cmd *cobra.Command, args []string) { } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } @@ -417,7 +417,7 @@ func signKey(cmd *cobra.Command, args []string) { } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } @@ -612,7 +612,7 @@ func sshConnect(cmd *cobra.Command, args []string) { if err != nil { util.HandleError(err, "Unable to parse flag") } - + var infisicalToken string if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) { @@ -626,7 +626,7 @@ func sshConnect(cmd *cobra.Command, args []string) { } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } @@ -640,7 +640,7 @@ func sshConnect(cmd *cobra.Command, args []string) { if err != nil { util.HandleError(err, "Unable to parse flag") } - + hostname, _ := cmd.Flags().GetString("hostname") loginUser, _ := cmd.Flags().GetString("login-user") @@ -858,7 +858,7 @@ func sshConnect(cmd *cobra.Command, args []string) { err = sshCmd.Run() if err != nil { util.HandleError(err, "SSH connection failed") - } + } } func sshAddHost(cmd *cobra.Command, args []string) { @@ -879,7 +879,7 @@ func sshAddHost(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to authenticate") } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login]") + loggedInUserDetails = util.EstablishUserLoginSession() } infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } @@ -904,7 +904,7 @@ func sshAddHost(cmd *cobra.Command, args []string) { if err != nil { util.HandleError(err, "Unable to parse --alias flag") } - + // if alias == "" { // util.PrintErrorMessageAndExit("You must provide --alias") // } @@ -937,7 +937,7 @@ func sshAddHost(cmd *cobra.Command, args []string) { if configureSshd && (!writeUserCaToFile || !writeHostCertToFile) { util.PrintErrorMessageAndExit("--configure-sshd requires both --write-user-ca-to-file and --write-host-cert-to-file to also be set") } - + // Pre-check for file overwrites before proceeding if writeUserCaToFile { if strings.HasPrefix(userCaOutFilePath, "~") { diff --git a/cli/packages/cmd/tokens.go b/cli/packages/cmd/tokens.go index 386a7eda5..a2e445239 100644 --- a/cli/packages/cmd/tokens.go +++ b/cli/packages/cmd/tokens.go @@ -47,7 +47,7 @@ var tokensCreateCmd = &cobra.Command{ } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } tokenOnly, err := cmd.Flags().GetBool("token-only") diff --git a/cli/packages/cmd/user.go b/cli/packages/cmd/user.go index 2879e4ccb..6c7d54d46 100644 --- a/cli/packages/cmd/user.go +++ b/cli/packages/cmd/user.go @@ -111,8 +111,9 @@ var userGetTokenCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } + if err != nil { util.HandleError(err, "[infisical user get token]: Unable to get logged in user token") } diff --git a/cli/packages/gateway/connection.go b/cli/packages/gateway/connection.go index 58a0503ff..3f4ffdf03 100644 --- a/cli/packages/gateway/connection.go +++ b/cli/packages/gateway/connection.go @@ -4,11 +4,19 @@ import ( "bufio" "bytes" "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" "errors" + "fmt" "io" "net" + "net/http" + "net/url" + "os" "strings" "sync" + "time" "github.com/quic-go/quic-go" "github.com/rs/zerolog/log" @@ -18,9 +26,13 @@ func handleConnection(ctx context.Context, quicConn quic.Connection) { log.Info().Msgf("New connection from: %s", quicConn.RemoteAddr().String()) // Use WaitGroup to track all streams var wg sync.WaitGroup + + contextWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + for { // Accept the first stream, which we'll use for commands - stream, err := quicConn.AcceptStream(ctx) + stream, err := quicConn.AcceptStream(contextWithTimeout) if err != nil { log.Printf("Failed to accept QUIC stream: %v", err) break @@ -44,7 +56,12 @@ func handleStream(stream quic.Stream, quicConn quic.Connection) { // Use buffered reader for better handling of fragmented data reader := bufio.NewReader(stream) - defer stream.Close() + defer func() { + log.Info().Msgf("Closing stream %d", streamID) + if stream != nil { + stream.Close() + } + }() for { msg, err := reader.ReadBytes('\n') @@ -89,6 +106,39 @@ func handleStream(stream quic.Stream, quicConn quic.Connection) { CopyDataFromQuicToTcp(stream, destTarget) log.Info().Msgf("Ending secure transmission between %s->%s", quicConn.LocalAddr().String(), destTarget.LocalAddr().String()) return + + case "FORWARD-HTTP": + argParts := bytes.Split(args, []byte(" ")) + if len(argParts) == 0 { + log.Error().Msg("FORWARD-HTTP requires target URL") + return + } + + targetURL := string(argParts[0]) + + if !isValidURL(targetURL) { + log.Error().Msgf("Invalid target URL: %s", targetURL) + return + } + + // Parse optional parameters + var caCertB64, verifyParam string + for _, part := range argParts[1:] { + partStr := string(part) + if strings.HasPrefix(partStr, "ca=") { + caCertB64 = strings.TrimPrefix(partStr, "ca=") + } else if strings.HasPrefix(partStr, "verify=") { + verifyParam = strings.TrimPrefix(partStr, "verify=") + } + } + + log.Info().Msgf("Starting HTTP proxy to: %s", targetURL) + + if err := handleHTTPProxy(stream, reader, targetURL, caCertB64, verifyParam); err != nil { + log.Error().Msgf("HTTP proxy error: %v", err) + } + return + case "PING": if _, err := stream.Write([]byte("PONG\n")); err != nil { log.Error().Msgf("Error writing PONG response: %v", err) @@ -100,11 +150,142 @@ func handleStream(stream quic.Stream, quicConn quic.Connection) { } } } +func handleHTTPProxy(stream quic.Stream, reader *bufio.Reader, targetURL string, caCertB64 string, verifyParam string) error { + transport := &http.Transport{ + DisableKeepAlives: false, + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + } + + if strings.HasPrefix(targetURL, "https://") { + tlsConfig := &tls.Config{} + + if caCertB64 != "" { + caCert, err := base64.StdEncoding.DecodeString(caCertB64) + if err == nil { + caCertPool := x509.NewCertPool() + if caCertPool.AppendCertsFromPEM(caCert) { + tlsConfig.RootCAs = caCertPool + log.Info().Msg("Using provided CA certificate from gateway client") + } else { + log.Error().Msg("Failed to parse provided CA certificate") + } + } else { + log.Error().Msgf("Failed to decode CA certificate: %v", err) + } + } + + if verifyParam != "" { + tlsConfig.InsecureSkipVerify = verifyParam == "false" + log.Info().Msgf("TLS verification set to: %s", verifyParam) + } + + transport.TLSClientConfig = tlsConfig + } + + client := &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + } + + // Loop to handle multiple HTTP requests on the same stream + for { + req, err := http.ReadRequest(reader) + + if err != nil { + if errors.Is(err, io.EOF) { + log.Info().Msg("Client closed HTTP connection") + return nil + } + return fmt.Errorf("failed to read HTTP request: %v", err) + } + log.Info().Msgf("Received HTTP request: %s", req.URL.Path) + + actionHeader := req.Header.Get("x-infisical-action") + if actionHeader != "" { + if actionHeader == "inject-k8s-sa-auth-token" { + token, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token") + if err != nil { + stream.Write([]byte(buildHttpInternalServerError("failed to read k8s sa auth token"))) + continue // Continue to next request instead of returning + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", string(token))) + log.Info().Msgf("Injected gateway k8s SA auth token in request to %s", targetURL) + } + req.Header.Del("x-infisical-action") + } + + // Build full target URL + var targetFullURL string + if strings.HasPrefix(targetURL, "http://") || strings.HasPrefix(targetURL, "https://") { + baseURL := strings.TrimSuffix(targetURL, "/") + targetFullURL = baseURL + req.URL.Path + if req.URL.RawQuery != "" { + targetFullURL += "?" + req.URL.RawQuery + } + } else { + baseURL := strings.TrimSuffix("http://"+targetURL, "/") + targetFullURL = baseURL + req.URL.Path + if req.URL.RawQuery != "" { + targetFullURL += "?" + req.URL.RawQuery + } + } + + // create the request to the target + proxyReq, err := http.NewRequest(req.Method, targetFullURL, req.Body) + if err != nil { + log.Error().Msgf("Failed to create proxy request: %v", err) + stream.Write([]byte(buildHttpInternalServerError("failed to create proxy request"))) + continue // Continue to next request + } + proxyReq.Header = req.Header.Clone() + + log.Info().Msgf("Proxying %s %s to %s", req.Method, req.URL.Path, targetFullURL) + + resp, err := client.Do(proxyReq) + if err != nil { + log.Error().Msgf("Failed to reach target: %v", err) + stream.Write([]byte(buildHttpInternalServerError(fmt.Sprintf("failed to reach target due to networking error: %s", err.Error())))) + continue // Continue to next request + } + + // Write the entire response (status line, headers, body) to the stream + // http.Response.Write handles this for "Connection: close" correctly. + // For other connection tokens, manual removal might be needed if they cause issues with QUIC. + // For a simple proxy, this is generally sufficient. + resp.Header.Del("Connection") // Good practice for proxies + + log.Info().Msgf("Writing response to stream: %s", resp.Status) + + if err := resp.Write(stream); err != nil { + log.Error().Err(err).Msg("Failed to write response to stream") + resp.Body.Close() + return fmt.Errorf("failed to write response to stream: %w", err) + } + + resp.Body.Close() + + // Check if client wants to close connection + if req.Header.Get("Connection") == "close" { + log.Info().Msg("Client requested connection close") + return nil + } + } +} + +func buildHttpInternalServerError(message string) string { + return fmt.Sprintf("HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\n\r\n{\"message\": \"gateway: %s\"}", message) +} type CloseWrite interface { CloseWrite() error } +func isValidURL(str string) bool { + u, err := url.Parse(str) + return err == nil && u.Scheme != "" && u.Host != "" +} + func CopyDataFromQuicToTcp(quicStream quic.Stream, tcpConn net.Conn) { // Create a WaitGroup to wait for both copy operations var wg sync.WaitGroup diff --git a/cli/packages/gateway/gateway.go b/cli/packages/gateway/gateway.go index d0a25ca9c..eb0c72d5d 100644 --- a/cli/packages/gateway/gateway.go +++ b/cli/packages/gateway/gateway.go @@ -54,6 +54,10 @@ func NewGateway(identityToken string) (Gateway, error) { }, nil } +func (g *Gateway) UpdateIdentityAccessToken(accessToken string) { + g.httpClient.SetAuthToken(accessToken) +} + func (g *Gateway) ConnectWithRelay() error { relayDetails, err := api.CallRegisterGatewayIdentityV1(g.httpClient) if err != nil { diff --git a/cli/packages/util/auth.go b/cli/packages/util/auth.go index cdcd7b50a..eaf7cecc1 100644 --- a/cli/packages/util/auth.go +++ b/cli/packages/util/auth.go @@ -1,5 +1,15 @@ package util +import ( + "fmt" + "os" + "os/exec" + + infisicalSdk "github.com/infisical/go-sdk" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + type AuthStrategyType string var AuthStrategy = struct { @@ -10,6 +20,7 @@ var AuthStrategy = struct { GCP_IAM_AUTH AuthStrategyType AWS_IAM_AUTH AuthStrategyType OIDC_AUTH AuthStrategyType + JWT_AUTH AuthStrategyType }{ UNIVERSAL_AUTH: "universal-auth", KUBERNETES_AUTH: "kubernetes", @@ -18,6 +29,7 @@ var AuthStrategy = struct { GCP_IAM_AUTH: "gcp-iam", AWS_IAM_AUTH: "aws-iam", OIDC_AUTH: "oidc-auth", + JWT_AUTH: "jwt-auth", } var AVAILABLE_AUTH_STRATEGIES = []AuthStrategyType{ @@ -28,6 +40,7 @@ var AVAILABLE_AUTH_STRATEGIES = []AuthStrategyType{ AuthStrategy.GCP_IAM_AUTH, AuthStrategy.AWS_IAM_AUTH, AuthStrategy.OIDC_AUTH, + AuthStrategy.JWT_AUTH, } func IsAuthMethodValid(authMethod string, allowUserAuth bool) (isValid bool, strategy AuthStrategyType) { @@ -43,3 +56,153 @@ func IsAuthMethodValid(authMethod string, allowUserAuth bool) (isValid bool, str } return false, "" } + +// EstablishUserLoginSession handles the login flow to either create a new session or restore an expired one. +// It returns fresh user details if login is successful. +func EstablishUserLoginSession() LoggedInUserDetails { + log.Info().Msg("No valid login session found, triggering login flow") + + exePath, err := os.Executable() + if err != nil { + PrintErrorMessageAndExit(fmt.Sprintf("Failed to determine executable path: %v", err)) + } + + // Spawn infisical login command + loginCmd := exec.Command(exePath, "login", "--silent") + loginCmd.Stdin = os.Stdin + loginCmd.Stdout = os.Stdout + loginCmd.Stderr = os.Stderr + + err = loginCmd.Run() + if err != nil { + PrintErrorMessageAndExit(fmt.Sprintf("Failed to automatically trigger login flow. Please run [infisical login] manually to login.")) + } + + loggedInUserDetails, err := GetCurrentLoggedInUserDetails(true) + if err != nil { + PrintErrorMessageAndExit("You must be logged in to run this command. To login, run [infisical login]") + } + + if loggedInUserDetails.LoginExpired { + PrintErrorMessageAndExit("Your login session has expired. Please run [infisical login]") + } + + return loggedInUserDetails +} + +type SdkAuthenticator struct { + infisicalClient infisicalSdk.InfisicalClientInterface + cmd *cobra.Command +} + +func NewSdkAuthenticator(infisicalClient infisicalSdk.InfisicalClientInterface, cmd *cobra.Command) *SdkAuthenticator { + return &SdkAuthenticator{ + infisicalClient: infisicalClient, + cmd: cmd, + } +} +func (a *SdkAuthenticator) HandleUniversalAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + clientId, err := GetCmdFlagOrEnv(a.cmd, "client-id", []string{INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME}) + + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + clientSecret, err := GetCmdFlagOrEnv(a.cmd, "client-secret", []string{INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().UniversalAuthLogin(clientId, clientSecret) +} + +func (a *SdkAuthenticator) HandleJwtAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + jwt, err := GetCmdFlagOrEnv(a.cmd, "jwt", []string{INFISICAL_JWT_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().JwtAuthLogin(identityId, jwt) +} + +func (a *SdkAuthenticator) HandleKubernetesAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + serviceAccountTokenPath, err := GetCmdFlagOrEnv(a.cmd, "service-account-token-path", []string{INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().KubernetesAuthLogin(identityId, serviceAccountTokenPath) +} + +func (a *SdkAuthenticator) HandleAzureAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().AzureAuthLogin(identityId, "") +} + +func (a *SdkAuthenticator) HandleGcpIdTokenAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().GcpIdTokenAuthLogin(identityId) +} + +func (a *SdkAuthenticator) HandleGcpIamAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + serviceAccountKeyFilePath, err := GetCmdFlagOrEnv(a.cmd, "service-account-key-file-path", []string{INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().GcpIamAuthLogin(identityId, serviceAccountKeyFilePath) +} + +func (a *SdkAuthenticator) HandleAwsIamAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().AwsIamAuthLogin(identityId) +} + +func (a *SdkAuthenticator) HandleOidcAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + jwt, err := GetCmdFlagOrEnv(a.cmd, "jwt", []string{INFISICAL_JWT_NAME, INFISICAL_OIDC_AUTH_JWT_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().OidcAuthLogin(identityId, jwt) +} diff --git a/cli/packages/util/constants.go b/cli/packages/util/constants.go index 8b4c586e6..126e5a5d0 100644 --- a/cli/packages/util/constants.go +++ b/cli/packages/util/constants.go @@ -13,6 +13,8 @@ const ( VAULT_BACKEND_AUTO_MODE = "auto" VAULT_BACKEND_FILE_MODE = "file" + INFISICAL_AUTH_METHOD_NAME = "INFISICAL_AUTH_METHOD" + // Universal Auth INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME = "INFISICAL_UNIVERSAL_AUTH_CLIENT_ID" INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME = "INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET" @@ -24,7 +26,12 @@ const ( INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH_NAME = "INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH" // OIDC Auth - INFISICAL_OIDC_AUTH_JWT_NAME = "INFISICAL_OIDC_AUTH_JWT" + INFISICAL_OIDC_AUTH_JWT_NAME = "INFISICAL_OIDC_AUTH_JWT" // deprecated in favor of INFISICAL_JWT + + // JWT AUTH + INFISICAL_JWT_NAME = "INFISICAL_JWT" + + INFISICAL_GATEWAY_TOKEN_NAME_LEGACY = "TOKEN" // backwards compatibility with gateway helm chart, where token was the only supported auth method // Generic env variable used for auth methods that require a machine identity ID INFISICAL_MACHINE_IDENTITY_ID_NAME = "INFISICAL_MACHINE_IDENTITY_ID" diff --git a/cli/packages/util/folders.go b/cli/packages/util/folders.go index 6bba05842..fb4f2a322 100644 --- a/cli/packages/util/folders.go +++ b/cli/packages/util/folders.go @@ -15,7 +15,6 @@ func GetAllFolders(params models.GetAllFoldersParameters) ([]models.SingleFolder var folderErr error if params.InfisicalToken == "" && params.UniversalAuthAccessToken == "" { RequireLogin() - RequireLocalWorkspaceFile() log.Debug().Msg("GetAllFolders: Trying to fetch folders using logged in details") @@ -25,19 +24,18 @@ func GetAllFolders(params models.GetAllFoldersParameters) ([]models.SingleFolder } if loggedInUserDetails.LoginExpired { - PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = EstablishUserLoginSession() } - workspaceFile, err := GetWorkSpaceFromFile() - if err != nil { - return nil, err + if params.WorkspaceId == "" { + workspaceFile, err := GetWorkSpaceFromFile() + if err != nil { + PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") + } + params.WorkspaceId = workspaceFile.WorkspaceId } - if params.WorkspaceId != "" { - workspaceFile.WorkspaceId = params.WorkspaceId - } - - folders, err := GetFoldersViaJTW(loggedInUserDetails.UserCredentials.JTWToken, workspaceFile.WorkspaceId, params.Environment, params.FoldersPath) + folders, err := GetFoldersViaJTW(loggedInUserDetails.UserCredentials.JTWToken, params.WorkspaceId, params.Environment, params.FoldersPath) folderErr = err foldersToReturn = folders } else if params.InfisicalToken != "" { @@ -186,7 +184,6 @@ func CreateFolder(params models.CreateFolderParameters) (models.SingleFolder, er // If no token is provided, we will try to get the token from the current logged in user if params.InfisicalToken == "" { RequireLogin() - RequireLocalWorkspaceFile() loggedInUserDetails, err := GetCurrentLoggedInUserDetails(true) if err != nil { @@ -194,7 +191,7 @@ func CreateFolder(params models.CreateFolderParameters) (models.SingleFolder, er } if loggedInUserDetails.LoginExpired { - PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = EstablishUserLoginSession() } params.InfisicalToken = loggedInUserDetails.UserCredentials.JTWToken @@ -235,7 +232,6 @@ func DeleteFolder(params models.DeleteFolderParameters) ([]models.SingleFolder, // If no token is provided, we will try to get the token from the current logged in user if params.InfisicalToken == "" { RequireLogin() - RequireLocalWorkspaceFile() loggedInUserDetails, err := GetCurrentLoggedInUserDetails(true) @@ -244,7 +240,7 @@ func DeleteFolder(params models.DeleteFolderParameters) ([]models.SingleFolder, } if loggedInUserDetails.LoginExpired { - PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = EstablishUserLoginSession() } params.InfisicalToken = loggedInUserDetails.UserCredentials.JTWToken diff --git a/cli/packages/util/helper.go b/cli/packages/util/helper.go index 346122a64..abd9768aa 100644 --- a/cli/packages/util/helper.go +++ b/cli/packages/util/helper.go @@ -96,6 +96,11 @@ func GetInfisicalToken(cmd *cobra.Command) (token *models.TokenDetails, err erro infisicalToken = os.Getenv(INFISICAL_TOKEN_NAME) source = fmt.Sprintf("%s environment variable", INFISICAL_TOKEN_NAME) } + + if infisicalToken == "" { // if its still empty, check for the `TOKEN` environment variable (for gateway helm) + infisicalToken = os.Getenv(INFISICAL_GATEWAY_TOKEN_NAME_LEGACY) + source = fmt.Sprintf("%s environment variable", INFISICAL_GATEWAY_TOKEN_NAME_LEGACY) + } } if infisicalToken == "" { // If it's empty, we return nothing at all. @@ -174,7 +179,7 @@ func RequireLogin() { configFile, _ := GetConfigFile() if configFile.LoggedInUserEmail == "" { - PrintErrorMessageAndExit("You must be logged in to run this command. To login, run [infisical login]") + EstablishUserLoginSession() } } @@ -292,13 +297,18 @@ func GetEnvVarOrFileContent(envName string, filePath string) (string, error) { return fileContent, nil } -func GetCmdFlagOrEnv(cmd *cobra.Command, flag, envName string) (string, error) { +func GetCmdFlagOrEnv(cmd *cobra.Command, flag string, envNames []string) (string, error) { value, flagsErr := cmd.Flags().GetString(flag) if flagsErr != nil { return "", flagsErr } if value == "" { - value = os.Getenv(envName) + for _, env := range envNames { + value = strings.TrimSpace(os.Getenv(env)) + if value != "" { + break + } + } } if value == "" { return "", fmt.Errorf("please provide %s flag", flag) diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index 0693db509..814e7da23 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -251,10 +251,15 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo var errorToReturn error if params.InfisicalToken == "" && params.UniversalAuthAccessToken == "" { - if projectConfigFilePath == "" { - RequireLocalWorkspaceFile() - } else { - ValidateWorkspaceFile(projectConfigFilePath) + if params.WorkspaceId == "" { + if projectConfigFilePath == "" { + _, err := GetWorkSpaceFromFile() + if err != nil { + PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") + } + } else { + ValidateWorkspaceFile(projectConfigFilePath) + } } RequireLogin() @@ -273,32 +278,31 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo } if isConnected && loggedInUserDetails.LoginExpired { - PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = EstablishUserLoginSession() } - var infisicalDotJson models.WorkspaceConfigFile + if params.WorkspaceId == "" { + var infisicalDotJson models.WorkspaceConfigFile - if projectConfigFilePath == "" { - projectConfig, err := GetWorkSpaceFromFile() - if err != nil { - return nil, err + if projectConfigFilePath == "" { + projectConfig, err := GetWorkSpaceFromFile() + if err != nil { + PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") + } + + infisicalDotJson = projectConfig + } else { + projectConfig, err := GetWorkSpaceFromFilePath(projectConfigFilePath) + if err != nil { + return nil, err + } + + infisicalDotJson = projectConfig } - - infisicalDotJson = projectConfig - } else { - projectConfig, err := GetWorkSpaceFromFilePath(projectConfigFilePath) - if err != nil { - return nil, err - } - - infisicalDotJson = projectConfig + params.WorkspaceId = infisicalDotJson.WorkspaceId } - if params.WorkspaceId != "" { - infisicalDotJson.WorkspaceId = params.WorkspaceId - } - - res, err := GetPlainTextSecretsV3(loggedInUserDetails.UserCredentials.JTWToken, infisicalDotJson.WorkspaceId, + res, err := GetPlainTextSecretsV3(loggedInUserDetails.UserCredentials.JTWToken, params.WorkspaceId, params.Environment, params.SecretsPath, params.IncludeImport, params.Recursive, params.TagSlugs, true) log.Debug().Msgf("GetAllEnvironmentVariables: Trying to fetch secrets JTW token [err=%s]", err) @@ -307,7 +311,7 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo if err != nil { return nil, err } - WriteBackupSecrets(infisicalDotJson.WorkspaceId, params.Environment, params.SecretsPath, backupEncryptionKey, res.Secrets) + WriteBackupSecrets(params.WorkspaceId, params.Environment, params.SecretsPath, backupEncryptionKey, res.Secrets) } secretsToReturn = res.Secrets @@ -316,7 +320,7 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo if !isConnected { backupEncryptionKey, _ := GetBackupEncryptionKey() if backupEncryptionKey != nil { - backedUpSecrets, err := ReadBackupSecrets(infisicalDotJson.WorkspaceId, params.Environment, params.SecretsPath, backupEncryptionKey) + backedUpSecrets, err := ReadBackupSecrets(params.WorkspaceId, params.Environment, params.SecretsPath, backupEncryptionKey) if len(backedUpSecrets) > 0 { PrintWarning("Unable to fetch the latest secret(s) due to connection error, serving secrets from last successful fetch. For more info, run with --debug") secretsToReturn = backedUpSecrets diff --git a/cli/report/csv_test.go b/cli/report/csv_test.go deleted file mode 100644 index 967026519..000000000 --- a/cli/report/csv_test.go +++ /dev/null @@ -1,108 +0,0 @@ -// MIT License - -// Copyright (c) 2019 Zachary Rice - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package report - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestWriteCSV(t *testing.T) { - tests := []struct { - findings []Finding - testReportName string - expected string - wantEmpty bool - }{ - { - testReportName: "simple", - expected: filepath.Join(expectPath, "report", "csv_simple.csv"), - findings: []Finding{ - { - RuleID: "test-rule", - Match: "line containing secret", - Secret: "a secret", - StartLine: 1, - EndLine: 2, - StartColumn: 1, - EndColumn: 2, - Message: "opps", - File: "auth.py", - SymlinkFile: "", - Commit: "0000000000000000", - Author: "John Doe", - Email: "johndoe@gmail.com", - Date: "10-19-2003", - Fingerprint: "fingerprint", - }, - }}, - { - - wantEmpty: true, - testReportName: "empty", - expected: filepath.Join(expectPath, "report", "this_should_not_exist.csv"), - findings: []Finding{}}, - } - - for _, test := range tests { - tmpfile, err := os.Create(filepath.Join(tmpPath, test.testReportName+".csv")) - if err != nil { - os.Remove(tmpfile.Name()) - t.Error(err) - } - err = writeCsv(test.findings, tmpfile) - if err != nil { - os.Remove(tmpfile.Name()) - t.Error(err) - } - got, err := os.ReadFile(tmpfile.Name()) - if err != nil { - os.Remove(tmpfile.Name()) - t.Error(err) - } - if test.wantEmpty { - if len(got) > 0 { - t.Errorf("Expected empty file, got %s", got) - } - os.Remove(tmpfile.Name()) - continue - } - want, err := os.ReadFile(test.expected) - if err != nil { - os.Remove(tmpfile.Name()) - t.Error(err) - } - - if string(got) != string(want) { - err = os.WriteFile(strings.Replace(test.expected, ".csv", ".got.csv", 1), got, 0644) - if err != nil { - t.Error(err) - } - t.Errorf("got %s, want %s", string(got), string(want)) - } - - os.Remove(tmpfile.Name()) - } -} diff --git a/cli/report/json_test.go b/cli/report/json_test.go deleted file mode 100644 index e81aaa827..000000000 --- a/cli/report/json_test.go +++ /dev/null @@ -1,111 +0,0 @@ -// MIT License - -// Copyright (c) 2019 Zachary Rice - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package report - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestWriteJSON(t *testing.T) { - tests := []struct { - findings []Finding - testReportName string - expected string - wantEmpty bool - }{ - { - testReportName: "simple", - expected: filepath.Join(expectPath, "report", "json_simple.json"), - findings: []Finding{ - { - - Description: "", - RuleID: "test-rule", - Match: "line containing secret", - Secret: "a secret", - StartLine: 1, - EndLine: 2, - StartColumn: 1, - EndColumn: 2, - Message: "opps", - File: "auth.py", - SymlinkFile: "", - Commit: "0000000000000000", - Author: "John Doe", - Email: "johndoe@gmail.com", - Date: "10-19-2003", - Tags: []string{}, - }, - }}, - { - - testReportName: "empty", - expected: filepath.Join(expectPath, "report", "empty.json"), - findings: []Finding{}}, - } - - for _, test := range tests { - // create tmp file using os.TempDir() - tmpfile, err := os.Create(filepath.Join(tmpPath, test.testReportName+".json")) - if err != nil { - os.Remove(tmpfile.Name()) - t.Error(err) - } - err = writeJson(test.findings, tmpfile) - if err != nil { - os.Remove(tmpfile.Name()) - t.Error(err) - } - got, err := os.ReadFile(tmpfile.Name()) - if err != nil { - os.Remove(tmpfile.Name()) - t.Error(err) - } - if test.wantEmpty { - if len(got) > 0 { - os.Remove(tmpfile.Name()) - t.Errorf("Expected empty file, got %s", got) - } - os.Remove(tmpfile.Name()) - continue - } - want, err := os.ReadFile(test.expected) - if err != nil { - os.Remove(tmpfile.Name()) - t.Error(err) - } - - if string(got) != string(want) { - err = os.WriteFile(strings.Replace(test.expected, ".json", ".got.json", 1), got, 0644) - if err != nil { - t.Error(err) - } - t.Errorf("got %s, want %s", string(got), string(want)) - } - - os.Remove(tmpfile.Name()) - } -} diff --git a/cli/report/report_test.go b/cli/report/report_test.go deleted file mode 100644 index ef38f19c2..000000000 --- a/cli/report/report_test.go +++ /dev/null @@ -1,133 +0,0 @@ -// MIT License - -// Copyright (c) 2019 Zachary Rice - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package report - -import ( - "os" - "path/filepath" - "strconv" - "testing" - - "github.com/Infisical/infisical-merge/config" -) - -const ( - expectPath = "../testdata/expected/" - tmpPath = "../testdata/tmp" -) - -func TestReport(t *testing.T) { - tests := []struct { - findings []Finding - ext string - wantEmpty bool - }{ - { - ext: "json", - findings: []Finding{ - { - RuleID: "test-rule", - }, - }, - }, - { - ext: ".json", - findings: []Finding{ - { - RuleID: "test-rule", - }, - }, - }, - { - ext: ".jsonj", - findings: []Finding{ - { - RuleID: "test-rule", - }, - }, - wantEmpty: true, - }, - { - ext: ".csv", - findings: []Finding{ - { - RuleID: "test-rule", - }, - }, - }, - { - ext: "csv", - findings: []Finding{ - { - RuleID: "test-rule", - }, - }, - }, - { - ext: "CSV", - findings: []Finding{ - { - RuleID: "test-rule", - }, - }, - }, - // { - // ext: "SARIF", - // findings: []Finding{ - // { - // RuleID: "test-rule", - // }, - // }, - // }, - } - - for i, test := range tests { - tmpfile, err := os.Create(filepath.Join(tmpPath, strconv.Itoa(i)+test.ext)) - if err != nil { - os.Remove(tmpfile.Name()) - t.Error(err) - } - err = Write(test.findings, config.Config{}, test.ext, tmpfile.Name()) - if err != nil { - os.Remove(tmpfile.Name()) - t.Error(err) - } - got, err := os.ReadFile(tmpfile.Name()) - if err != nil { - os.Remove(tmpfile.Name()) - t.Error(err) - } - os.Remove(tmpfile.Name()) - - if len(got) == 0 && !test.wantEmpty { - t.Errorf("got empty file with extension " + test.ext) - } - - if test.wantEmpty { - if len(got) > 0 { - t.Errorf("Expected empty file, got %s", got) - } - continue - } - } -} diff --git a/cli/report/sarif_test.go b/cli/report/sarif_test.go deleted file mode 100644 index 9331060f1..000000000 --- a/cli/report/sarif_test.go +++ /dev/null @@ -1,122 +0,0 @@ -// MIT License - -// Copyright (c) 2019 Zachary Rice - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package report - -const configPath = "../testdata/config/" - -// func TestWriteSarif(t *testing.T) { -// tests := []struct { -// findings []Finding -// testReportName string -// expected string -// wantEmpty bool -// cfgName string -// }{ -// { -// cfgName: "simple", -// testReportName: "simple", -// expected: filepath.Join(expectPath, "report", "sarif_simple.sarif"), -// findings: []Finding{ -// { - -// Description: "A test rule", -// RuleID: "test-rule", -// Match: "line containing secret", -// Secret: "a secret", -// StartLine: 1, -// EndLine: 2, -// StartColumn: 1, -// EndColumn: 2, -// Message: "opps", -// File: "auth.py", -// Commit: "0000000000000000", -// Author: "John Doe", -// Email: "johndoe@gmail.com", -// Date: "10-19-2003", -// Tags: []string{}, -// }, -// }}, -// } - -// for _, test := range tests { -// // create tmp file using os.TempDir() -// tmpfile, err := os.Create(filepath.Join(tmpPath, test.testReportName+".json")) -// if err != nil { -// os.Remove(tmpfile.Name()) -// t.Error(err) -// } -// viper.Reset() -// viper.AddConfigPath(configPath) -// viper.SetConfigName(test.cfgName) -// viper.SetConfigType("toml") -// err = viper.ReadInConfig() -// if err != nil { -// t.Error(err) -// } - -// var vc config.ViperConfig -// err = viper.Unmarshal(&vc) -// if err != nil { -// t.Error(err) -// } - -// cfg, err := vc.Translate() -// if err != nil { -// t.Error(err) -// } -// err = writeSarif(cfg, test.findings, tmpfile) -// fmt.Println(cfg) -// if err != nil { -// os.Remove(tmpfile.Name()) -// t.Error(err) -// } -// got, err := os.ReadFile(tmpfile.Name()) -// if err != nil { -// os.Remove(tmpfile.Name()) -// t.Error(err) -// } -// if test.wantEmpty { -// if len(got) > 0 { -// os.Remove(tmpfile.Name()) -// t.Errorf("Expected empty file, got %s", got) -// } -// os.Remove(tmpfile.Name()) -// continue -// } -// want, err := os.ReadFile(test.expected) -// if err != nil { -// os.Remove(tmpfile.Name()) -// t.Error(err) -// } - -// if string(got) != string(want) { -// err = os.WriteFile(strings.Replace(test.expected, ".sarif", ".got.sarif", 1), got, 0644) -// if err != nil { -// t.Error(err) -// } -// t.Errorf("got %s, want %s", string(got), string(want)) -// } - -// os.Remove(tmpfile.Name()) -// } -// } diff --git a/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment index b447d947e..6047b33e0 100644 --- a/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment +++ b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment @@ -1,4 +1,4 @@ -error: CallGetRawSecretsV3: Unsuccessful response [GET https://app.infisical.com/api/v3/secrets/raw?environment=invalid-env&expandSecretReferences=true&include_imports=true&recursive=true&secretPath=%2F&workspaceId=bef697d4-849b-4a75-b284-0922f87f8ba2] [status-code=404] [response={"error":"NotFound","message":"Environment with slug 'invalid-env' in project with ID bef697d4-849b-4a75-b284-0922f87f8ba2 not found","statusCode":404}] +error: CallGetRawSecretsV3 Unsuccessful response [GET https://app.infisical.com/api/v3/secrets/raw?environment=invalid-env&expandSecretReferences=true&include_imports=true&recursive=true&secretPath=%2F&workspaceId=bef697d4-849b-4a75-b284-0922f87f8ba2] [status-code=404] [request-id=] [message="Environment with slug 'invalid-env' in project with ID bef697d4-849b-4a75-b284-0922f87f8ba2 not found"] If this issue continues, get support at https://infisical.com/slack diff --git a/cli/test/helper.go b/cli/test/helper.go index 21bd261df..74e56237a 100644 --- a/cli/test/helper.go +++ b/cli/test/helper.go @@ -6,6 +6,7 @@ import ( "log" "os" "os/exec" + "regexp" "strings" ) @@ -71,7 +72,11 @@ func SetupCli() { } func FilterRequestID(input string) string { - // Find the JSON part of the error message + requestIDPattern := regexp.MustCompile(`\[request-id=[^\]]+\]`) + reqIDPattern := regexp.MustCompile(`\[reqId=[^\]]+\]`) + input = requestIDPattern.ReplaceAllString(input, "[request-id=]") + input = reqIDPattern.ReplaceAllString(input, "[reqId=]") + start := strings.Index(input, "{") end := strings.LastIndex(input, "}") + 1 diff --git a/company/handbook/spending-money.mdx b/company/handbook/spending-money.mdx index 1e32aeaf5..864984f10 100644 --- a/company/handbook/spending-money.mdx +++ b/company/handbook/spending-money.mdx @@ -6,9 +6,14 @@ description: "The guide to spending money at Infisical." Fairly frequently, you might run into situations when you need to spend company money. - -Please spend money in a way that you think is in the best interest of the company. - + +# Expensing Meals + +As a perk of working at Infisical, we cover some of your meal expenses. + +HQ team members: meals and unlimited snacks are provided on-site at no cost. + +Remote team members: a food stipend is allocated based on location. # Trivial expenses @@ -18,6 +23,10 @@ This means expenses that are: 1. Non-recurring AND less than $75/month in total. 2. Recurring AND less than $20/month. + +Please spend money in a way that you think is in the best interest of the company. + + ## Saving receipts Make sure you keep copies for all receipts. If you expense something on a company card and cannot provide a receipt, this may be deducted from your pay. diff --git a/docs/api-reference/endpoints/app-connections/1password/available.mdx b/docs/api-reference/endpoints/app-connections/1password/available.mdx new file mode 100644 index 000000000..3797a7556 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/1password/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/1password/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/1password/create.mdx b/docs/api-reference/endpoints/app-connections/1password/create.mdx new file mode 100644 index 000000000..03562b50f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/1password/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/1password" +--- + + + Check out the configuration docs for [1Password Connections](/integrations/app-connections/1password) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/1password/delete.mdx b/docs/api-reference/endpoints/app-connections/1password/delete.mdx new file mode 100644 index 000000000..24e7a2b16 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/1password/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/1password/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/1password/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/1password/get-by-id.mdx new file mode 100644 index 000000000..bcab50f12 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/1password/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/1password/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/1password/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/1password/get-by-name.mdx new file mode 100644 index 000000000..8cb10c351 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/1password/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/1password/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/1password/list.mdx b/docs/api-reference/endpoints/app-connections/1password/list.mdx new file mode 100644 index 000000000..4fa88de81 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/1password/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/1password" +--- diff --git a/docs/api-reference/endpoints/app-connections/1password/update.mdx b/docs/api-reference/endpoints/app-connections/1password/update.mdx new file mode 100644 index 000000000..cbd52a6c6 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/1password/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/1password/{connectionId}" +--- + + + Check out the configuration docs for [1Password Connections](/integrations/app-connections/1password) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/github-radar/available.mdx b/docs/api-reference/endpoints/app-connections/github-radar/available.mdx new file mode 100644 index 000000000..6cfc0758c --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/github-radar/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/github-radar/create.mdx b/docs/api-reference/endpoints/app-connections/github-radar/create.mdx new file mode 100644 index 000000000..0cd66a49b --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/github-radar" +--- + + + GitHub Radar Connections must be created through the Infisical UI. + Check out the configuration docs for [GitHub Radar Connections](/integrations/app-connections/github-radar) for a step-by-step + guide. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/github-radar/delete.mdx b/docs/api-reference/endpoints/app-connections/github-radar/delete.mdx new file mode 100644 index 000000000..64b252538 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/github-radar/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/github-radar/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/github-radar/get-by-id.mdx new file mode 100644 index 000000000..1ffc291c9 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/github-radar/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/github-radar/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/github-radar/get-by-name.mdx new file mode 100644 index 000000000..a5ca5e3f5 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/github-radar/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/github-radar/list.mdx b/docs/api-reference/endpoints/app-connections/github-radar/list.mdx new file mode 100644 index 000000000..2bd832941 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/github-radar" +--- diff --git a/docs/api-reference/endpoints/app-connections/github-radar/update.mdx b/docs/api-reference/endpoints/app-connections/github-radar/update.mdx new file mode 100644 index 000000000..4ffb88dc5 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/github-radar/{connectionId}" +--- + + + GitHub Radar Connections must be updated through the Infisical UI. + Check out the configuration docs for [GitHub Radar Connections](/integrations/app-connections/github-radar) for a step-by-step + guide. + diff --git a/docs/api-reference/endpoints/app-connections/mysql/available.mdx b/docs/api-reference/endpoints/app-connections/mysql/available.mdx new file mode 100644 index 000000000..820f137fb --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mysql/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/mysql/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/mysql/create.mdx b/docs/api-reference/endpoints/app-connections/mysql/create.mdx new file mode 100644 index 000000000..c91826441 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mysql/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/mysql" +--- + + + Check out the configuration docs for [MySQL Connections](/integrations/app-connections/mysql) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/mysql/delete.mdx b/docs/api-reference/endpoints/app-connections/mysql/delete.mdx new file mode 100644 index 000000000..29a0b6afd --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mysql/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/mysql/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/mysql/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/mysql/get-by-id.mdx new file mode 100644 index 000000000..b14cc6b10 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mysql/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/mysql/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/mysql/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/mysql/get-by-name.mdx new file mode 100644 index 000000000..f45c0d178 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mysql/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/mysql/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/mysql/list.mdx b/docs/api-reference/endpoints/app-connections/mysql/list.mdx new file mode 100644 index 000000000..1c175723f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mysql/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/mysql" +--- diff --git a/docs/api-reference/endpoints/app-connections/mysql/update.mdx b/docs/api-reference/endpoints/app-connections/mysql/update.mdx new file mode 100644 index 000000000..8a000199f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mysql/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/mysql/{connectionId}" +--- + + + Check out the configuration docs for [MySQL Connections](/integrations/app-connections/mysql) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/oci/available.mdx b/docs/api-reference/endpoints/app-connections/oci/available.mdx new file mode 100644 index 000000000..19d83e5b7 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/oci/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/oci/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/oci/create.mdx b/docs/api-reference/endpoints/app-connections/oci/create.mdx new file mode 100644 index 000000000..e15877121 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/oci/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/oci" +--- + + + Check out the configuration docs for [OCI Connections](/integrations/app-connections/oci) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/oci/delete.mdx b/docs/api-reference/endpoints/app-connections/oci/delete.mdx new file mode 100644 index 000000000..990700885 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/oci/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/oci/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/oci/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/oci/get-by-id.mdx new file mode 100644 index 000000000..a7541b227 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/oci/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/oci/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/oci/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/oci/get-by-name.mdx new file mode 100644 index 000000000..1c920e14f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/oci/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/oci/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/oci/list.mdx b/docs/api-reference/endpoints/app-connections/oci/list.mdx new file mode 100644 index 000000000..ba4430073 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/oci/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/oci" +--- diff --git a/docs/api-reference/endpoints/app-connections/oci/update.mdx b/docs/api-reference/endpoints/app-connections/oci/update.mdx new file mode 100644 index 000000000..c012009a2 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/oci/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/oci/{connectionId}" +--- + + + Check out the configuration docs for [OCI Connections](/integrations/app-connections/oci) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/certificate-authorities/acme/create.mdx b/docs/api-reference/endpoints/certificate-authorities/acme/create.mdx new file mode 100644 index 000000000..9cc42ed7f --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/acme/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/pki/ca/acme" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/acme/delete.mdx b/docs/api-reference/endpoints/certificate-authorities/acme/delete.mdx new file mode 100644 index 000000000..9decc3b6e --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/acme/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/pki/ca/acme/{caName}" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/acme/list.mdx b/docs/api-reference/endpoints/certificate-authorities/acme/list.mdx new file mode 100644 index 000000000..35bd70727 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/acme/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/pki/ca/acme" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/acme/read.mdx b/docs/api-reference/endpoints/certificate-authorities/acme/read.mdx new file mode 100644 index 000000000..a80e31f9a --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/acme/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Read" +openapi: "GET /api/v1/pki/ca/acme/{caName}" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/acme/update.mdx b/docs/api-reference/endpoints/certificate-authorities/acme/update.mdx new file mode 100644 index 000000000..69f758771 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/acme/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/pki/ca/acme/{caName}" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/create.mdx b/docs/api-reference/endpoints/certificate-authorities/create.mdx index 35e758e4b..276015228 100644 --- a/docs/api-reference/endpoints/certificate-authorities/create.mdx +++ b/docs/api-reference/endpoints/certificate-authorities/create.mdx @@ -1,4 +1,8 @@ --- -title: "Create" +title: "Create (Deprecated)" openapi: "POST /api/v1/pki/ca" --- + + + This endpoint is deprecated. Please use the internal CA endpoint [here](/api-reference/endpoints/certificate-authorities/internal/create). + \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-authorities/delete.mdx b/docs/api-reference/endpoints/certificate-authorities/delete.mdx index f79b8f458..c4ded070d 100644 --- a/docs/api-reference/endpoints/certificate-authorities/delete.mdx +++ b/docs/api-reference/endpoints/certificate-authorities/delete.mdx @@ -1,4 +1,8 @@ --- -title: "Delete" +title: "Delete (Deprecated)" openapi: "DELETE /api/v1/pki/ca/{caId}" --- + + + This endpoint is deprecated. Please use the internal CA endpoint [here](/api-reference/endpoints/certificate-authorities/internal/delete). + \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-authorities/internal/create.mdx b/docs/api-reference/endpoints/certificate-authorities/internal/create.mdx new file mode 100644 index 000000000..babc144f2 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/internal/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/pki/ca/internal" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/internal/delete.mdx b/docs/api-reference/endpoints/certificate-authorities/internal/delete.mdx new file mode 100644 index 000000000..b1b7f20a7 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/internal/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/pki/ca/internal/{caName}" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/internal/list.mdx b/docs/api-reference/endpoints/certificate-authorities/internal/list.mdx new file mode 100644 index 000000000..43f2b7108 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/internal/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/pki/ca/internal" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/internal/read.mdx b/docs/api-reference/endpoints/certificate-authorities/internal/read.mdx new file mode 100644 index 000000000..d269564cf --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/internal/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Read" +openapi: "GET /api/v1/pki/ca/internal/{caName}" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/internal/update.mdx b/docs/api-reference/endpoints/certificate-authorities/internal/update.mdx new file mode 100644 index 000000000..b01899884 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/internal/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/pki/ca/internal/{caName}" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/list.mdx b/docs/api-reference/endpoints/certificate-authorities/list.mdx index ba4a43348..81dd64af6 100644 --- a/docs/api-reference/endpoints/certificate-authorities/list.mdx +++ b/docs/api-reference/endpoints/certificate-authorities/list.mdx @@ -1,4 +1,8 @@ --- -title: "List" +title: "List (Deprecated)" openapi: "GET /api/v2/workspace/{slug}/cas" --- + + + This endpoint is deprecated. Please use the internal CA endpoint [here](/api-reference/endpoints/certificate-authorities/internal/list). + \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-authorities/read.mdx b/docs/api-reference/endpoints/certificate-authorities/read.mdx index 54dc26392..bca5121bd 100644 --- a/docs/api-reference/endpoints/certificate-authorities/read.mdx +++ b/docs/api-reference/endpoints/certificate-authorities/read.mdx @@ -1,4 +1,8 @@ --- -title: "Retrieve" +title: "Retrieve (Deprecated)" openapi: "GET /api/v1/pki/ca/{caId}" --- + + + This endpoint is deprecated. Please use the internal CA endpoint [here](/api-reference/endpoints/certificate-authorities/internal/read). + \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-authorities/update.mdx b/docs/api-reference/endpoints/certificate-authorities/update.mdx index d18a728bf..0cd88ebf6 100644 --- a/docs/api-reference/endpoints/certificate-authorities/update.mdx +++ b/docs/api-reference/endpoints/certificate-authorities/update.mdx @@ -1,4 +1,8 @@ --- -title: "Update" +title: "Update (Deprecated)" openapi: "PATCH /api/v1/pki/ca/{caId}" --- + + + This endpoint is deprecated. Please use the internal CA endpoint [here](/api-reference/endpoints/certificate-authorities/internal/update). + \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificates/bundle.mdx b/docs/api-reference/endpoints/certificates/bundle.mdx index 5fbda7d96..60d37a2d8 100644 --- a/docs/api-reference/endpoints/certificates/bundle.mdx +++ b/docs/api-reference/endpoints/certificates/bundle.mdx @@ -1,6 +1,6 @@ --- title: "Get Certificate Bundle" -openapi: "GET /api/v2/workspace/{slug}/bundle" +openapi: "GET /api/v1/pki/certificates/{serialNumber}/bundle" --- diff --git a/docs/api-reference/endpoints/certificates/private-key.mdx b/docs/api-reference/endpoints/certificates/private-key.mdx index 244aecea3..d0b93e65c 100644 --- a/docs/api-reference/endpoints/certificates/private-key.mdx +++ b/docs/api-reference/endpoints/certificates/private-key.mdx @@ -1,4 +1,4 @@ --- title: "Get Certificate Private Key" -openapi: "GET /api/v2/workspace/{slug}/private-key" +openapi: "GET /api/v1/pki/certificates/{serialNumber}/private-key" --- diff --git a/docs/api-reference/endpoints/ldap-auth/attach.mdx b/docs/api-reference/endpoints/ldap-auth/attach.mdx new file mode 100644 index 000000000..512878887 --- /dev/null +++ b/docs/api-reference/endpoints/ldap-auth/attach.mdx @@ -0,0 +1,4 @@ +--- +title: "Attach" +openapi: "POST /api/v1/auth/ldap-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/ldap-auth/login.mdx b/docs/api-reference/endpoints/ldap-auth/login.mdx new file mode 100644 index 000000000..737afb857 --- /dev/null +++ b/docs/api-reference/endpoints/ldap-auth/login.mdx @@ -0,0 +1,4 @@ +--- +title: "Login" +openapi: "POST /api/v1/auth/ldap-auth/login" +--- diff --git a/docs/api-reference/endpoints/ldap-auth/retrieve.mdx b/docs/api-reference/endpoints/ldap-auth/retrieve.mdx new file mode 100644 index 000000000..fe4974cde --- /dev/null +++ b/docs/api-reference/endpoints/ldap-auth/retrieve.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/auth/ldap-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/ldap-auth/revoke.mdx b/docs/api-reference/endpoints/ldap-auth/revoke.mdx new file mode 100644 index 000000000..2ef0996fd --- /dev/null +++ b/docs/api-reference/endpoints/ldap-auth/revoke.mdx @@ -0,0 +1,4 @@ +--- +title: "Revoke" +openapi: "DELETE /api/v1/auth/ldap-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/ldap-auth/update.mdx b/docs/api-reference/endpoints/ldap-auth/update.mdx new file mode 100644 index 000000000..74b54efd3 --- /dev/null +++ b/docs/api-reference/endpoints/ldap-auth/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/auth/ldap-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/oci-auth/attach.mdx b/docs/api-reference/endpoints/oci-auth/attach.mdx new file mode 100644 index 000000000..039e99064 --- /dev/null +++ b/docs/api-reference/endpoints/oci-auth/attach.mdx @@ -0,0 +1,4 @@ +--- +title: "Attach" +openapi: "POST /api/v1/auth/oci-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/oci-auth/login.mdx b/docs/api-reference/endpoints/oci-auth/login.mdx new file mode 100644 index 000000000..400addcbd --- /dev/null +++ b/docs/api-reference/endpoints/oci-auth/login.mdx @@ -0,0 +1,4 @@ +--- +title: "Login" +openapi: "POST /api/v1/auth/oci-auth/login" +--- diff --git a/docs/api-reference/endpoints/oci-auth/retrieve.mdx b/docs/api-reference/endpoints/oci-auth/retrieve.mdx new file mode 100644 index 000000000..31883fb77 --- /dev/null +++ b/docs/api-reference/endpoints/oci-auth/retrieve.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/auth/oci-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/oci-auth/revoke.mdx b/docs/api-reference/endpoints/oci-auth/revoke.mdx new file mode 100644 index 000000000..5cc609003 --- /dev/null +++ b/docs/api-reference/endpoints/oci-auth/revoke.mdx @@ -0,0 +1,4 @@ +--- +title: "Revoke" +openapi: "DELETE /api/v1/auth/oci-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/oci-auth/update.mdx b/docs/api-reference/endpoints/oci-auth/update.mdx new file mode 100644 index 000000000..72c1dfdf0 --- /dev/null +++ b/docs/api-reference/endpoints/oci-auth/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/auth/oci-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/pki/subscribers/create.mdx b/docs/api-reference/endpoints/pki/subscribers/create.mdx new file mode 100644 index 000000000..14a53b7fa --- /dev/null +++ b/docs/api-reference/endpoints/pki/subscribers/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/pki/subscribers" +--- diff --git a/docs/api-reference/endpoints/pki/subscribers/delete.mdx b/docs/api-reference/endpoints/pki/subscribers/delete.mdx new file mode 100644 index 000000000..5975b89e9 --- /dev/null +++ b/docs/api-reference/endpoints/pki/subscribers/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/pki/subscribers/{subscriberName}" +--- diff --git a/docs/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle.mdx b/docs/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle.mdx new file mode 100644 index 000000000..894c8ed4e --- /dev/null +++ b/docs/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve latest certificate bundle" +openapi: "GET /api/v1/pki/subscribers/{subscriberName}/latest-certificate-bundle" +--- diff --git a/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx b/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx new file mode 100644 index 000000000..c9c71c80d --- /dev/null +++ b/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Issue Certificate" +openapi: "POST /api/v1/pki/subscribers/{subscriberName}/issue-certificate" +--- diff --git a/docs/api-reference/endpoints/pki/subscribers/list-certs.mdx b/docs/api-reference/endpoints/pki/subscribers/list-certs.mdx new file mode 100644 index 000000000..3a4607303 --- /dev/null +++ b/docs/api-reference/endpoints/pki/subscribers/list-certs.mdx @@ -0,0 +1,4 @@ +--- +title: "List Certificates" +openapi: "GET /api/v1/pki/subscribers/{subscriberName}/certificates" +--- diff --git a/docs/api-reference/endpoints/pki/subscribers/order-cert.mdx b/docs/api-reference/endpoints/pki/subscribers/order-cert.mdx new file mode 100644 index 000000000..93abf1433 --- /dev/null +++ b/docs/api-reference/endpoints/pki/subscribers/order-cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Order Certificate" +openapi: "POST /api/v1/pki/subscribers/{subscriberName}/order-certificate" +--- diff --git a/docs/api-reference/endpoints/pki/subscribers/read.mdx b/docs/api-reference/endpoints/pki/subscribers/read.mdx new file mode 100644 index 000000000..0d223217d --- /dev/null +++ b/docs/api-reference/endpoints/pki/subscribers/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/pki/subscribers/{subscriberName}" +--- diff --git a/docs/api-reference/endpoints/pki/subscribers/sign-cert.mdx b/docs/api-reference/endpoints/pki/subscribers/sign-cert.mdx new file mode 100644 index 000000000..d31d30239 --- /dev/null +++ b/docs/api-reference/endpoints/pki/subscribers/sign-cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Sign Certificate" +openapi: "POST /api/v1/pki/subscribers/{subscriberName}/sign-certificate" +--- diff --git a/docs/api-reference/endpoints/pki/subscribers/update.mdx b/docs/api-reference/endpoints/pki/subscribers/update.mdx new file mode 100644 index 000000000..5b62cbe7d --- /dev/null +++ b/docs/api-reference/endpoints/pki/subscribers/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/pki/subscribers/{subscriberName}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mysql-credentials/create.mdx b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/create.mdx new file mode 100644 index 000000000..a0b9b745d --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-rotations/mysql-credentials" +--- + + + Check out the configuration docs for [MySQL Credentials Rotations](/documentation/platform/secret-rotation/mysql-credentials) to learn how to obtain the required parameters. + diff --git a/docs/api-reference/endpoints/secret-rotations/mysql-credentials/delete.mdx b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/delete.mdx new file mode 100644 index 000000000..40e98bf9d --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-rotations/mysql-credentials/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mysql-credentials/get-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/get-by-id.mdx new file mode 100644 index 000000000..5914e275f --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-rotations/mysql-credentials/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mysql-credentials/get-by-name.mdx b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/get-by-name.mdx new file mode 100644 index 000000000..1e4868e75 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-rotations/mysql-credentials/rotation-name/{rotationName}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mysql-credentials/get-generated-credentials-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/get-generated-credentials-by-id.mdx new file mode 100644 index 000000000..b8762fee5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/get-generated-credentials-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Credentials by ID" +openapi: "GET /api/v2/secret-rotations/mysql-credentials/{rotationId}/generated-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mysql-credentials/list.mdx b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/list.mdx new file mode 100644 index 000000000..9065ecf0d --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-rotations/mysql-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mysql-credentials/rotate-secrets.mdx b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/rotate-secrets.mdx new file mode 100644 index 000000000..8ffe010c3 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/rotate-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Rotate Secrets" +openapi: "POST /api/v2/secret-rotations/mysql-credentials/{rotationId}/rotate-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mysql-credentials/update.mdx b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/update.mdx new file mode 100644 index 000000000..25e393688 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-rotations/mysql-credentials/{rotationId}" +--- + + + Check out the configuration docs for [MySQL Credentials Rotations](/documentation/platform/secret-rotation/mysql-credentials) to learn how to obtain the required parameters. + diff --git a/docs/api-reference/endpoints/secret-scanning/config/get-by-project-id.mdx b/docs/api-reference/endpoints/secret-scanning/config/get-by-project-id.mdx new file mode 100644 index 000000000..8204a3098 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/config/get-by-project-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Project ID" +openapi: "GET /api/v2/secret-scanning/configs" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/config/update.mdx b/docs/api-reference/endpoints/secret-scanning/config/update.mdx new file mode 100644 index 000000000..bf068a20f --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/config/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-scanning/configs" +--- + + + Check out the [Configuration Docs](/documentation/platform/secret-scanning/overview#configuration) for an in-depth guide on custom configurations. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/create.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/create.mdx new file mode 100644 index 000000000..248c8cf53 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-scanning/data-sources/github" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/delete.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/delete.mdx new file mode 100644 index 000000000..eb791e6db --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-scanning/data-sources/github/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/get-by-id.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/get-by-id.mdx new file mode 100644 index 000000000..0103ad447 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-scanning/data-sources/github/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/get-by-name.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/get-by-name.mdx new file mode 100644 index 000000000..c86dcc948 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-scanning/data-sources/github/data-source-name/{dataSourceName}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/list-resources.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/list-resources.mdx new file mode 100644 index 000000000..f2627827f --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/list-resources.mdx @@ -0,0 +1,4 @@ +--- +title: "List Resources" +openapi: "GET /api/v2/secret-scanning/data-sources/github/{dataSourceId}/resources" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/list-scans.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/list-scans.mdx new file mode 100644 index 000000000..839b66355 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/list-scans.mdx @@ -0,0 +1,4 @@ +--- +title: "List Scans" +openapi: "GET /api/v2/secret-scanning/data-sources/github/{dataSourceId}/scans" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/list.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/list.mdx new file mode 100644 index 000000000..951f827a8 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-scanning/data-sources/github" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/scan-resource.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/scan-resource.mdx new file mode 100644 index 000000000..5025126fd --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/scan-resource.mdx @@ -0,0 +1,4 @@ +--- +title: "Scan Resource" +openapi: "POST /api/v2/secret-scanning/data-sources/github/{dataSourceId}/resources/{resourceId}/scan" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/scan.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/scan.mdx new file mode 100644 index 000000000..f6b21b485 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/scan.mdx @@ -0,0 +1,4 @@ +--- +title: "Scan" +openapi: "POST /api/v2/secret-scanning/data-sources/github/{dataSourceId}/scan" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/update.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/update.mdx new file mode 100644 index 000000000..2d800d9d5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-scanning/data-sources/github/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/list.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/list.mdx new file mode 100644 index 000000000..0958dc382 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-scanning/data-sources" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/options.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/options.mdx new file mode 100644 index 000000000..3affb5270 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/options.mdx @@ -0,0 +1,4 @@ +--- +title: "Options" +openapi: "GET /api/v2/secret-scanning/data-sources/options" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/findings/list.mdx b/docs/api-reference/endpoints/secret-scanning/findings/list.mdx new file mode 100644 index 000000000..6a97a3a20 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/findings/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-scanning/findings" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/findings/update.mdx b/docs/api-reference/endpoints/secret-scanning/findings/update.mdx new file mode 100644 index 000000000..1c9614f8b --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/findings/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-scanning/findings/{findingId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-syncs/1password/create.mdx b/docs/api-reference/endpoints/secret-syncs/1password/create.mdx new file mode 100644 index 000000000..b8c8a0d9d --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/1password/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/1password" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/1password/delete.mdx b/docs/api-reference/endpoints/secret-syncs/1password/delete.mdx new file mode 100644 index 000000000..4949636bd --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/1password/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/1password/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/1password/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/1password/get-by-id.mdx new file mode 100644 index 000000000..522b94499 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/1password/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/1password/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/1password/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/1password/get-by-name.mdx new file mode 100644 index 000000000..9a904a6cf --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/1password/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/1password/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/1password/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/1password/import-secrets.mdx new file mode 100644 index 000000000..75553aedd --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/1password/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/1password/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/1password/list.mdx b/docs/api-reference/endpoints/secret-syncs/1password/list.mdx new file mode 100644 index 000000000..b7c7ad00d --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/1password/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/1password" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/1password/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/1password/remove-secrets.mdx new file mode 100644 index 000000000..03ce4de83 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/1password/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/1password/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/1password/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/1password/sync-secrets.mdx new file mode 100644 index 000000000..183cd0722 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/1password/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/1password/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/1password/update.mdx b/docs/api-reference/endpoints/secret-syncs/1password/update.mdx new file mode 100644 index 000000000..c7dcf5f1c --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/1password/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/1password/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/oci-vault/create.mdx b/docs/api-reference/endpoints/secret-syncs/oci-vault/create.mdx new file mode 100644 index 000000000..fa3ac2738 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/oci-vault/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/oci-vault" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/oci-vault/delete.mdx b/docs/api-reference/endpoints/secret-syncs/oci-vault/delete.mdx new file mode 100644 index 000000000..81f208308 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/oci-vault/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/oci-vault/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/oci-vault/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/oci-vault/get-by-id.mdx new file mode 100644 index 000000000..52b3201dc --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/oci-vault/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/oci-vault/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/oci-vault/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/oci-vault/get-by-name.mdx new file mode 100644 index 000000000..eabc8794c --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/oci-vault/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/oci-vault/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/oci-vault/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/oci-vault/import-secrets.mdx new file mode 100644 index 000000000..27ca686d6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/oci-vault/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/oci-vault/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/oci-vault/list.mdx b/docs/api-reference/endpoints/secret-syncs/oci-vault/list.mdx new file mode 100644 index 000000000..88cd2a44a --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/oci-vault/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/oci-vault" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/oci-vault/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/oci-vault/remove-secrets.mdx new file mode 100644 index 000000000..e98e7140e --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/oci-vault/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/oci-vault/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/oci-vault/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/oci-vault/sync-secrets.mdx new file mode 100644 index 000000000..38ea4331c --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/oci-vault/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/oci-vault/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/oci-vault/update.mdx b/docs/api-reference/endpoints/secret-syncs/oci-vault/update.mdx new file mode 100644 index 000000000..06f1d9d1c --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/oci-vault/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/oci-vault/{syncId}" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/add-host.mdx b/docs/api-reference/endpoints/ssh/groups/add-host.mdx index 9f903eccd..77257cd40 100644 --- a/docs/api-reference/endpoints/ssh/groups/add-host.mdx +++ b/docs/api-reference/endpoints/ssh/groups/add-host.mdx @@ -1,4 +1,4 @@ --- title: "Add Host" -openapi: "POST /api/v1/ssh/host-groups/{sshHostGroupId}/hosts" +openapi: "POST /api/v1/ssh/host-groups/{sshHostGroupId}/hosts/{hostId}" --- diff --git a/docs/api-reference/endpoints/ssh/groups/remove-host.mdx b/docs/api-reference/endpoints/ssh/groups/remove-host.mdx index 6933e5c9f..b1de7f4ae 100644 --- a/docs/api-reference/endpoints/ssh/groups/remove-host.mdx +++ b/docs/api-reference/endpoints/ssh/groups/remove-host.mdx @@ -1,4 +1,4 @@ --- title: "Remove Host" -openapi: "DELETE /api/v1/ssh/host-groups/{sshHostGroupId}/hosts/{sshHostId}" +openapi: "DELETE /api/v1/ssh/host-groups/{sshHostGroupId}/hosts/{hostId}" --- diff --git a/docs/api-reference/endpoints/ssh/hosts/list-my.mdx b/docs/api-reference/endpoints/ssh/hosts/list-my.mdx index 2b7ab51c0..6ccc4e325 100644 --- a/docs/api-reference/endpoints/ssh/hosts/list-my.mdx +++ b/docs/api-reference/endpoints/ssh/hosts/list-my.mdx @@ -1,4 +1,4 @@ --- title: "List My Hosts" -openapi: "GET /api/v1/ssh/hosts/" +openapi: "GET /api/v1/ssh/hosts" --- diff --git a/docs/api-reference/overview/authentication.mdx b/docs/api-reference/overview/authentication.mdx index bdd7df83b..4358e18d2 100644 --- a/docs/api-reference/overview/authentication.mdx +++ b/docs/api-reference/overview/authentication.mdx @@ -13,17 +13,17 @@ To interact with the Infisical API, you will need to obtain an access token. Fol 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. There are a few reasons for why this might happen: - + - The client secret or access token has expired. - - The identity is insufficently permissioned to interact with the resources you wish to access. + - The identity is insufficiently permissioned to interact with the resources you wish to access. - You are attempting to access a `/raw` secrets endpoint that requires your project to disable E2EE. - The client secret/access token is being used from an untrusted IP. - \ No newline at end of file + diff --git a/docs/cli/commands/gateway.mdx b/docs/cli/commands/gateway.mdx index fd035f1fd..a12493c58 100644 --- a/docs/cli/commands/gateway.mdx +++ b/docs/cli/commands/gateway.mdx @@ -26,28 +26,215 @@ Run the Infisical gateway in the foreground or manage its systemd service instal Run the Infisical gateway in the foreground. The gateway will connect to the relay service and maintain a persistent connection. ```bash - infisical gateway --token= --domain= + infisical gateway --domain= --auth-method= ``` - ### Flags + ### Authentication - - The machine identity access token to authenticate with Infisical. + The Infisical CLI supports multiple authentication methods. Below are the available authentication methods, with their respective flags. + + + + 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 - # Example - infisical gateway --token= + infisical gateway --auth-method=universal-auth --client-id= --client-secret= ``` - You may also expose the token to the CLI by setting the environment variable `INFISICAL_TOKEN` before executing the gateway command. + + 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 + infisical gateway --auth-method=kubernetes --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 + infisical gateway --auth-method=azure --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 + infisical gateway --auth-method=gcp-id-token --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 + infisical gateway --auth-method=gcp-iam --machine-identity-id= --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 + infisical gateway --auth-method=aws-iam --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 + infisical gateway --auth-method=oidc-auth --machine-identity-id= --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 + infisical gateway --auth-method=jwt-auth --jwt= --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 + infisical gateway --token= + ``` + + + + + ### Other Flags Domain of your self-hosted Infisical instance. ```bash # Example - sudo infisical gateway install --domain=https://app.your-domain.com + infisical gateway --domain=https://app.your-domain.com ``` diff --git a/docs/cli/commands/login.mdx b/docs/cli/commands/login.mdx index f493ff5d2..f93e3b4b2 100644 --- a/docs/cli/commands/login.mdx +++ b/docs/cli/commands/login.mdx @@ -190,7 +190,7 @@ The Infisical CLI supports multiple authentication methods. Below are the availa - + The OIDC Auth method is used to authenticate with Infisical via identity tokens with OIDC. @@ -198,7 +198,7 @@ The Infisical CLI supports multiple authentication methods. Below are the availa Your machine identity ID. - + The OIDC JWT from the identity provider. @@ -212,11 +212,35 @@ The Infisical CLI supports multiple authentication methods. Below are the availa Run the `login` command with the following flags to obtain an access token: ```bash - infisical login --method=oidc-auth --machine-identity-id= --oidc-jwt= + infisical login --method=oidc-auth --machine-identity-id= --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. + + + + + + + Run the `login` command with the following flags to obtain an access token: + + ```bash + infisical login --method=jwt-auth --jwt= --machine-identity-id= + ``` + + diff --git a/docs/cli/usage.mdx b/docs/cli/usage.mdx index a77a648d0..a3bfd83f0 100644 --- a/docs/cli/usage.mdx +++ b/docs/cli/usage.mdx @@ -130,7 +130,7 @@ The CLI is designed for a variety of secret management applications ranging from export INFISICAL_CUSTOM_HEADERS="Access-Client-Id=your-client-id Access-Client-Secret=your-client-secret" # Execute Infisical commands after setting the environment variable - infisical secrets ls + infisical secrets ``` This functionality enables secure interaction with Infisical instances that require specific authentication headers. diff --git a/docs/documentation/getting-started/api.mdx b/docs/documentation/getting-started/api.mdx index 48a6f2ee0..c638c4d70 100644 --- a/docs/documentation/getting-started/api.mdx +++ b/docs/documentation/getting-started/api.mdx @@ -10,15 +10,15 @@ In this brief, we'll explore how to fetch a secret back from a project on [Infis To create a project, head to your Organization Overview and press **Add New Project**; we'll call the project **Demo App**. ![create project](../../images/getting-started/api/org-create-project-1.png) - + ![create project](../../images/getting-started/api/org-create-project-2.png) - + Next, let's head to the **Development** environment of the project and add a secret `FOO=BAR` to it. - + ![explore project env](../../images/getting-started/api/project-explore-env.png) - + ![create secret](../../images/getting-started/api/project-create-secret.png) - + ![project dashboard](../../images/getting-started/api/project-dashboard.png) @@ -29,13 +29,13 @@ In this brief, we'll explore how to fetch a secret back from a project on [Infis Next, we need to create an identity to represent your application. To create one, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. ![identities organization](../../images/platform/identities/identities-org.png) - + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. - + ![identities organization create](../../images/platform/identities/identities-org-create.png) - + Once you've created an identity, you'll be prompted to configure the **Universal Auth** authentication method for it. - + ![identities organization create auth method](../../images/platform/identities/identities-org-create-auth-method.png) @@ -44,7 +44,7 @@ In this brief, we'll explore how to fetch a secret back from a project on [Infis of the identity and a **Client Secret** for it; you can think of these credentials akin to a username and password used to authenticate with the Infisical API. With that, press on the key icon on the identity to generate a **Client Secret** for it. - + ![identities client secret create](../../images/platform/identities/identities-org-client-secret.png) ![identities client secret create](../../images/platform/identities/identities-org-client-secret-create-1.png) ![identities client secret create](../../images/platform/identities/identities-org-client-secret-create-2.png) @@ -55,14 +55,14 @@ In this brief, we'll explore how to fetch a secret back from a project on [Infis Next, select the identity you want to add to the project and the role you want to assign it. ![identities project](../../images/platform/identities/identities-project.png) - + ![identities project create](../../images/platform/identities/identities-project-create.png) To access the Infisical API as the identity, you should first perform a login operation that is to exchange the **Client ID** and **Client Secret** of the identity for an access token by making a request to the `/api/v1/auth/universal-auth/login` endpoint. - + #### Sample request ``` @@ -71,9 +71,9 @@ In this brief, we'll explore how to fetch a secret back from a project on [Infis --data-urlencode 'clientSecret=' \ --data-urlencode 'clientId=' ``` - + #### Sample response - + ``` { "accessToken": "...", @@ -83,9 +83,9 @@ In this brief, we'll explore how to fetch a secret back from a project on [Infis ``` Next, we can use the access token to authenticate with the [Infisical API](/api-reference/overview/introduction) to read/write secrets - + - Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; the default TTL is `7200` seconds which can be adjusted. If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, @@ -96,12 +96,12 @@ In this brief, we'll explore how to fetch a secret back from a project on [Infis Finally, you can fetch the secret `FOO=BAR` back from **Step 1** by including the access token in the previous step in another request to the `/api/v3/secrets/raw/{secretName}` endpoint. ### Sample request - + ``` curl --location --request GET 'http://localhost:8080/api/v3/secrets/raw/FOO?workspaceId=657830d579cfc8415d06ce5b&environment=dev' \ --header 'Authorization: Bearer ' ``` - + ### Sample response ``` @@ -118,11 +118,11 @@ In this brief, we'll explore how to fetch a secret back from a project on [Infis } } ``` - + Note that you can fetch a list of secrets back by making a request to the `/api/v3/secrets/raw` endpoint. See also: -- [API Reference](/api-reference/overview/introduction) \ No newline at end of file +- [API Reference](/api-reference/overview/introduction) diff --git a/docs/documentation/platform/access-controls/abac/managing-machine-identity-attributes.mdx b/docs/documentation/platform/access-controls/abac/managing-machine-identity-attributes.mdx index 5e1cc7093..b953a80bf 100644 --- a/docs/documentation/platform/access-controls/abac/managing-machine-identity-attributes.mdx +++ b/docs/documentation/platform/access-controls/abac/managing-machine-identity-attributes.mdx @@ -1,5 +1,5 @@ --- -title: "Machine identities" +title: "Machine identities" description: "Learn how to set metadata and leverage authentication attributes for machine identities." --- @@ -25,7 +25,7 @@ Machine identities can have metadata set manually, just like users. In addition, #### Accessing Attributes From Machine Identity Login -When machine identities authenticate, they may receive additional payloads/attributes from the service provider. +When machine identities authenticate, they may receive additional payloads/attributes from the service provider. For methods like OIDC, these come as claims in the token and can be made available in your policies. @@ -50,17 +50,29 @@ For methods like OIDC, these come as claims in the token and can be made availab ``` You might map: - - - **department:** to `user.department` + + - **department:** to `user.department` - **role:** to `user.role` Once configured, these attributes become available in your policies using the following format: - + ``` {{ identity.auth.oidc.claims. }} ``` + + + + For identities authenticated using Kubernetes, the service account's namespace and name are available in their policy and can be accessed as follows: + + ``` + {{ identity.auth.kubernetes.namespace }} + {{ identity.auth.kubernetes.name }} + ``` + + + At the moment we only support OIDC claims. Payloads on other authentication methods are not yet accessible. diff --git a/docs/documentation/platform/access-controls/abac/managing-user-metadata.mdx b/docs/documentation/platform/access-controls/abac/managing-user-metadata.mdx index 3f62a3b61..b6d1d691f 100644 --- a/docs/documentation/platform/access-controls/abac/managing-user-metadata.mdx +++ b/docs/documentation/platform/access-controls/abac/managing-user-metadata.mdx @@ -27,7 +27,7 @@ User identities can have metadata attributes assigned directly. These attributes #### Applying ABAC Policies with User Metadata -Attribute-based access controls are currently only available for polices defined on Secrets Manager projects. +Attribute-based access controls are currently only available for policies defined on Secrets Manager projects. You can set ABAC permissions to dynamically set access to environments, folders, secrets, and secret tags. diff --git a/docs/documentation/platform/access-controls/access-requests.mdx b/docs/documentation/platform/access-controls/access-requests.mdx index 76cc4b74e..58b21d4eb 100644 --- a/docs/documentation/platform/access-controls/access-requests.mdx +++ b/docs/documentation/platform/access-controls/access-requests.mdx @@ -3,10 +3,10 @@ title: "Access Requests" description: "Learn how to request access to sensitive resources in Infisical." --- -In certain situations, developers need to expand their access to a certain new project or a sensitive environment. For those use cases, it is helpful to utilize Infisical's **Access Requests** functionality. +In certain situations, developers need to expand their access to a certain new project or a sensitive environment. For those use cases, it is helpful to utilize Infisical's **Access Requests** functionality. -This functionality works in the following way: -1. A project administrator sets up an access policy that assigns access managers (also known as eligible approvers) to a certain sensitive folder or environment. +This functionality works in the following way: +1. A project administrator sets up an access policy that assigns access managers (also known as eligible approvers) to a certain sensitive folder or environment. ![Create Access Request Policy Modal](/images/platform/access-controls/create-access-request-policy.png) ![Access Request Policies](/images/platform/access-controls/access-request-policies.png) @@ -19,9 +19,8 @@ This functionality works in the following way: ![Access Request Bypass](/images/platform/access-controls/access-request-bypass.png) - If the access request matches with a policy that has a **Soft** enforcement level, the requester may bypass the policy and get access to the resource without full approval. + If the access request matches with a policy that allows break-glass approval bypasses, the requester may bypass the policy and get access to the resource without full approval. -5. As soon as the request is approved, developer is able to access the sought resources. +5. As soon as the request is approved, developer is able to access the sought resources. ![Access Request Dashboard](/images/platform/access-controls/access-requests-completed.png) - diff --git a/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx b/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx index 3ddf01db3..92851cd03 100644 --- a/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx +++ b/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx @@ -60,7 +60,7 @@ The Infisical AWS ElastiCache dynamic secret allows you to generate AWS ElastiCa ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) - + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-aws-elasti-cache.png) @@ -94,9 +94,33 @@ The Infisical AWS ElastiCache dynamic secret allows you to generate AWS ElastiCa - If you want to provide specific privileges for the generated dynamic credentials, you can modify the ElastiCache statement to your needs. This is useful if you want to only give access to a specific table(s). + ![Modify ElastiCache Statements Modal](/images/platform/dynamic-secrets/modify-elasticache-statement.png) + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - ![Modify ElastiCache Statements Modal](/images/platform/dynamic-secrets/modify-elasticache-statement.png) + 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 + ``` + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the ElastiCache statement to your needs. This is useful if you want to only give access to a specific resource. + After submitting the form, you will see a dynamic secret created in the dashboard. @@ -131,7 +155,7 @@ The Infisical AWS ElastiCache dynamic secret allows you to generate AWS ElastiCa ## 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 a lease before its set time to live. +This will allow you to see the expiration time of the lease or delete a lease before it's set time to live. ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) diff --git a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx index 730e2b287..aa10e4029 100644 --- a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx +++ b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx @@ -40,7 +40,7 @@ Infisical needs an initial AWS IAM user with the required permissions to create } ``` -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/\ @@ -50,102 +50,304 @@ Replace **\** with your AWS account id and **\** w ## Set up Dynamic Secrets with AWS IAM - - - Navigate to the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret to. - - - ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) - - - ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png) - - - - Name by which you want the secret to be referenced - + + + Infisical will assume the provided role in your AWS account securely, without the need to share any credentials. + + To connect your self-hosted Infisical instance with AWS, you need to set up an AWS IAM User account that can assume the configured AWS IAM Role. - - Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) - + If your instance is deployed on AWS, the aws-sdk will automatically retrieve the credentials. Ensure that you assign the provided permission policy to your deployed instance, such as ECS or EC2. - - Maximum time-to-live for a generated secret - + The following steps are for instances not deployed on AWS: + + + Navigate to [Create IAM User](https://console.aws.amazon.com/iamv2/home#/users/create) in your AWS Console. + + + Attach the following inline permission policy to the IAM User to allow it to assume any IAM Roles: + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowAssumeAnyRole", + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam::*:role/*" + } + ] + } + ``` + + + Obtain the AWS access key ID and secret access key for your IAM User by navigating to **IAM > Users > [Your User] > Security credentials > Access keys**. - - The managing AWS IAM User Access Key - + ![Access Key Step 1](/images/integrations/aws/integrations-aws-access-key-1.png) + ![Access Key Step 2](/images/integrations/aws/integrations-aws-access-key-2.png) + ![Access Key Step 3](/images/integrations/aws/integrations-aws-access-key-3.png) + + + 1. Set the access key as **DYNAMIC_SECRET_AWS_ACCESS_KEY_ID**. + 2. Set the secret key as **DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY**. + + + - - The managing AWS IAM User Secret Key - + + + 1. Navigate to the [Create IAM Role](https://console.aws.amazon.com/iamv2/home#/roles/create?step=selectEntities) page in your AWS Console. + ![IAM Role Creation](/images/integrations/aws/integration-aws-iam-assume-role.png) - - [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. - + 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. + 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. - - The AWS data center region. - + + When configuring an IAM Role that Infisical will assume, it’s highly recommended to enable the **"Require external ID"** option and specify your **Project ID**. - - 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. - + This precaution helps protect your AWS account against the [confused deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html), a potential security vulnerability where Infisical could be tricked into performing actions on your behalf by an unauthorized actor. - - 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 - + Always enable "Require external ID" and use your Project ID when setting up the IAM Role. + + + + ![Copy IAM Role ARN](/images/integrations/aws/integration-aws-iam-assume-arn.png) + + + Navigate to the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret to. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png) + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-assume-role.png) + + Name by which you want the secret to be referenced + - - The AWS IAM inline policy that should be attached to the created users. Multiple values can be provided by separating them with commas - + + Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) + - ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png) + + Maximum time-to-live for a generated secret + - - - After submitting the form, you will see a dynamic secret created in the dashboard. + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - ![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. + 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 - ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) - ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + Allowed template functions are + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value - 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. + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` + + + Select *Assume Role* method. + - ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + The ARN of the AWS Role to assume. + - - Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret in step 4. - + + [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. + - Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + + 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. + - ![Provision Lease](/images/platform/dynamic-secrets/lease-values-aws-iam.png) - - + + 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 + + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + ![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 in step 4. + + + 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-aws-iam.png) + + + + + + Infisical will use the provided **Access Key ID** and **Secret Key** to connect to your AWS instance. + + + Navigate to the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret to. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png) + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-access-key.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 + + + + Select *Access Key* method. + + + + The managing AWS IAM User Access Key + + + + 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. + + + + 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 + + + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + ![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 in step 4. + + + 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-aws-iam.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. + +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 lease details and delete the lease ahead of its expiration time. ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) ## Renew Leases + To extend the life of the generated dynamic secret lease 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 + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic + secret diff --git a/docs/documentation/platform/dynamic-secrets/cassandra.mdx b/docs/documentation/platform/dynamic-secrets/cassandra.mdx index e7ec4f69d..56b7ec336 100644 --- a/docs/documentation/platform/dynamic-secrets/cassandra.mdx +++ b/docs/documentation/platform/dynamic-secrets/cassandra.mdx @@ -7,7 +7,7 @@ The Infisical Cassandra dynamic secret allows you to generate Cassandra database ## Prerequisite -Infisical requires a Cassandra user in your instance with the necessary permissions. This user will facilitate the creation of new accounts as needed. +Infisical requires a Cassandra user in your instance with the necessary permissions. This user will facilitate the creation of new accounts as needed. Ensure the user possesses privileges for creating, dropping, and granting permissions to roles for it to be able to create dynamic secrets. @@ -19,7 +19,7 @@ authorizer: CassandraAuthorizer ``` -The above configuration allows user creation and granting permissions. +The above configuration allows user creation and granting permissions. ## Set up Dynamic Secrets with Cassandra @@ -69,31 +69,55 @@ The above configuration allows user creation and granting permissions. Keyspace name where you want to create dynamic secrets. This ensures that the user is limited to that keyspace. - + - A CA may be required if your cassandra requires it for incoming connections. + A CA may be required if your cassandra requires it for incoming connections. ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-cassandra.png) - If you want to provide specific privileges for the generated dynamic credentials, you can modify the CQL statement to your needs. This is useful if you want to only give access to a specific key-space(s). + ![Modify CQL Statements Modal](../../../images/platform/dynamic-secrets/modify-cql-statements.png) + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - ![Modify CQL Statements Modal](../../../images/platform/dynamic-secrets/modify-cql-statements.png) + 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 + ``` + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the CQL statement to your needs. This is useful if you want to only give access to a specific key-space(s). + - After submitting the form, you will see a dynamic secret created in the dashboard. + After submitting the form, you will see a dynamic secret created in the dashboard. - If this step fails, you may have to add the CA certficate. + If this step fails, you may have to add the CA certificate. ![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. + 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) @@ -108,14 +132,14 @@ The above configuration allows user creation and granting permissions. - Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + 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. +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 lease details and delete the lease ahead of its expiration time. ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) diff --git a/docs/documentation/platform/dynamic-secrets/elastic-search.mdx b/docs/documentation/platform/dynamic-secrets/elastic-search.mdx index 210122a14..06d1102e2 100644 --- a/docs/documentation/platform/dynamic-secrets/elastic-search.mdx +++ b/docs/documentation/platform/dynamic-secrets/elastic-search.mdx @@ -7,13 +7,14 @@ The Infisical Elasticsearch dynamic secret allows you to generate Elasticsearch ## Prerequisites - - 1. Create a role with at least `manage_security` and `monitor` permissions. 2. Assign the newly created role to your API key or user that you'll use later in the dynamic secret configuration. - For testing purposes, you can also use a highly privileged role like `superuser`, that will have full control over the cluster. This is not recommended in production environments following the principle of least privilege. + For testing purposes, you can also use a highly privileged role like + `superuser`, that will have full control over the cluster. This is not + recommended in production environments following the principle of least + privilege. ## Set up Dynamic Secrets with Elasticsearch @@ -33,60 +34,92 @@ The Infisical Elasticsearch dynamic secret allows you to generate Elasticsearch 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) - + + 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. - + + Maximum time-to-live for a generated secret. + - + Your Elasticsearch host. This is the endpoint that your instance runs on. _(Example: https://your-cluster-ip)_ - + - - The port that your Elasticsearch instance is running on. _(Example: 9200)_ - + - - The roles that the new user that is created when a lease is provisioned will be assigned to. This is a required field. This defaults to `superuser`, which is highly privileged. It is recommended to create a new role with the least privileges required for the lease. - +The port that your Elasticsearch instance is running on. _(Example: 9200)_ + - + + The roles that the new user that is created when a lease is provisioned will + be assigned to. This is a required field. This defaults to `superuser`, which + is highly privileged. It is recommended to create a new role with the least + privileges required for the lease. + + + Select the authentication method you want to use to connect to your Elasticsearch instance. + + + + The username of the user that will be used to provision new dynamic secret + leases. Only required if you selected the `Username/Password` authentication + method. + + + + The password of the user that will be used to provision new dynamic secret + leases. Only required if you selected the `Username/Password` authentication + method. + + + + The ID of the API key that will be used to provision new dynamic secret + leases. Only required if you selected the `API Key` authentication method. + + + + The API key that will be used to provision new dynamic secret leases. Only + required if you selected the `API Key` authentication method. + + + + A CA may be required if your DB requires it for incoming connections. This is often the case when connecting to a managed service. + + + 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 + ``` - - The username of the user that will be used to provision new dynamic secret leases. Only required if you selected the `Username/Password` authentication method. - - - - The password of the user that will be used to provision new dynamic secret leases. Only required if you selected the `Username/Password` authentication method. - - - - The ID of the API key that will be used to provision new dynamic secret leases. Only required if you selected the `API Key` authentication method. - - - - The API key that will be used to provision new dynamic secret leases. Only required if you selected the `API Key` authentication method. - - - - A CA may be required if your DB requires it for incoming connections. This is often the case when connecting to a managed service. - - - ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-input-modal-elastic-search.png) - +![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-input-modal-elastic-search.png) After submitting the form, you will see a dynamic secret created in the dashboard. - - If this step fails, you may have to add the CA certificate. - + + If this step fails, you may have to add the CA certificate. + @@ -94,34 +127,38 @@ The Infisical Elasticsearch dynamic secret allows you to generate Elasticsearch 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) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.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. + 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) + ![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. - + + Ensure that the TTL for the lease fall 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 from it will be shown to you. + Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) - ![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 a lease before its set time to live. +This will allow you to see the expiration time of the lease or delete a lease before it's set time to live. ![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 + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic + secret diff --git a/docs/documentation/platform/dynamic-secrets/kubernetes.mdx b/docs/documentation/platform/dynamic-secrets/kubernetes.mdx new file mode 100644 index 000000000..713aefae6 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/kubernetes.mdx @@ -0,0 +1,480 @@ +--- +title: "Kubernetes" +description: "Learn how to dynamically generate Kubernetes service account tokens." +--- + +The Infisical Kubernetes dynamic secret allows you to generate short-lived service account tokens on demand. + +## Overview + +The Kubernetes dynamic secret feature enables you to generate short-lived service account tokens for your Kubernetes clusters. This is particularly useful for: + +- **Secure Access Management**: Instead of using long-lived service account tokens, you can generate short-lived tokens that automatically expire, reducing the risk of token exposure. +- **Temporary Access**: Generate tokens with specific TTLs (Time To Live) for temporary access to your Kubernetes clusters. +- **Audit Trail**: Each token generation is tracked, providing better visibility into who accessed your cluster and when. +- **Integration with Private Clusters**: Seamlessly work with private Kubernetes clusters using Infisical's Gateway feature. + + + Kubernetes service account tokens cannot be revoked once issued. This is why + it's important to use short TTLs and carefully manage token generation. The + tokens will automatically expire after their TTL period. + + + + Kubernetes service account tokens are JWTs (JSON Web Tokens) with a fixed + expiration time. Once a token is generated, its lifetime cannot be extended. + If you need longer access, you'll need to generate a new token. + + +This feature is ideal for scenarios where you need to: + +- Provide temporary access to developers or CI/CD pipelines +- Rotate service account tokens frequently +- Maintain a secure audit trail of cluster access +- Manage access to multiple Kubernetes clusters + +## Set up Dynamic Secrets with Kubernetes + + + + 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/dynamic-secret-modal-kubernetes.png) + + + Before proceeding with the setup, you'll need to make two key decisions: + + 1. **Credential Type**: How you want to manage service accounts + - **Static**: Use an existing service account with predefined permissions + - **Dynamic**: Create temporary service accounts with specific role assignments + + 2. **Authentication Method**: How you want to authenticate with the cluster + - **Token (API)**: Use a service account token for direct API access + - **Gateway**: Use an Infisical Gateway deployed in your cluster + + + + Static credentials generate service account tokens for a predefined service account. This is useful when you want to: + - Generate tokens for an existing service account + - Maintain consistent permissions across token generations + - Use a service account that already has the necessary RBAC permissions + + ### Prerequisites + + - A Kubernetes cluster with a service account + - Cluster access token with permissions to create service account tokens + - (Optional) [Gateway](/documentation/platform/gateways/overview) for private cluster access + + ### Authentication Setup + + Choose your authentication method: + + + + This method uses a service account token to authenticate with the Kubernetes cluster. It's suitable when: + - You want to use a specific service account token that you've created + - You're working with a public cluster or have network access to the cluster's API server + - You want to explicitly control which service account is used for operations + + + With Token (API) authentication, Infisical uses the provided service account token + to make API calls to your Kubernetes cluster. This token must have the necessary + permissions to generate tokens for the target service account. + + + 1. Create a service account: + ```yaml infisical-service-account.yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: infisical-token-requester + namespace: default + ``` + + ```bash + kubectl apply -f infisical-service-account.yaml + ``` + + 2. Set up RBAC permissions: + ```yaml rbac.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: tokenrequest + rules: + - apiGroups: [""] + resources: + - "serviceaccounts/token" + - "serviceaccounts" + verbs: + - "create" + - "get" + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: tokenrequest + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: tokenrequest + subjects: + - kind: ServiceAccount + name: infisical-token-requester + namespace: default + ``` + + ```bash + kubectl apply -f rbac.yaml + ``` + + 3. Create and obtain the token: + ```yaml service-account-token.yaml + apiVersion: v1 + kind: Secret + type: kubernetes.io/service-account-token + metadata: + name: infisical-token-requester-token + annotations: + kubernetes.io/service-account.name: "infisical-token-requester" + ``` + + ```bash + kubectl apply -f service-account-token.yaml + kubectl patch serviceaccount infisical-token-requester -p '{"secrets": [{"name": "infisical-token-requester-token"}]}' -n default + kubectl get secret infisical-token-requester-token -n default -o=jsonpath='{.data.token}' | base64 --decode + ``` + + + This method uses an Infisical Gateway deployed in your Kubernetes cluster. It's ideal when: + - You want to avoid storing static service account tokens + - You prefer to use the Gateway's pre-configured service account + - You want centralized management of cluster operations + + + With Gateway authentication, Infisical communicates with the Gateway, which then + uses its own service account to make API calls to the Kubernetes API server. + The Gateway's service account must have the necessary permissions to generate + tokens for the target service account. + + + 1. Deploy the Infisical Gateway in your cluster + 2. Set up RBAC permissions for the Gateway's service account: + ```yaml rbac.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: tokenrequest + rules: + - apiGroups: [""] + resources: + - "serviceaccounts/token" + - "serviceaccounts" + verbs: + - "create" + - "get" + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: tokenrequest + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: tokenrequest + subjects: + - kind: ServiceAccount + name: infisical-gateway + namespace: infisical + ``` + + ```bash + kubectl apply -f rbac.yaml + ``` + + + + + + + Dynamic credentials create a temporary service account, assign it to a defined role/cluster-role, and generate a service account token. This is useful when you want to: + - Create temporary service accounts with specific permissions + - Automatically clean up service accounts after token expiration + - Assign different roles to different users or applications + - Maintain strict control over service account permissions + + ### Prerequisites + + - A Kubernetes cluster with a service account + - Cluster access token with permissions to create service accounts and manage RBAC + - (Optional) [Gateway](/documentation/platform/gateways/overview) for private cluster access + + ### Authentication Setup + + Choose your authentication method: + + + + This method uses a service account token to authenticate with the Kubernetes cluster. It's suitable when: + - You want to use a specific service account token that you've created + - You're working with a public cluster or have network access to the cluster's API server + - You want to explicitly control which service account is used for operations + + + With Token (API) authentication, Infisical uses the provided service account token + to make API calls to your Kubernetes cluster. This token must have the necessary + permissions to create and manage service accounts, their tokens, and RBAC resources. + + + 1. Create a service account: + ```yaml service-account.yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: infisical-token-requester + namespace: default + --- + apiVersion: v1 + kind: Secret + type: kubernetes.io/service-account-token + metadata: + name: infisical-token-requester-token + annotations: + kubernetes.io/service-account.name: "infisical-token-requester" + ``` + + ```bash + kubectl apply -f service-account.yaml + ``` + + 2. Set up RBAC permissions: + ```yaml rbac.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: tokenrequest + rules: + - apiGroups: [""] + resources: + - "serviceaccounts/token" + - "serviceaccounts" + verbs: + - "create" + - "get" + - "delete" + - apiGroups: ["rbac.authorization.k8s.io"] + resources: + - "rolebindings" + - "clusterrolebindings" + verbs: + - "create" + - "delete" + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: tokenrequest + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: tokenrequest + subjects: + - kind: ServiceAccount + name: infisical-token-requester + namespace: default + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-dynamic-role-binding-sa + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: infisical-dynamic-role + subjects: + - kind: ServiceAccount + name: infisical-token-requester + namespace: default + ``` + + ```bash + kubectl apply -f rbac.yaml + ``` + + + This method uses an Infisical Gateway deployed in your Kubernetes cluster. It's ideal when: + - You want to avoid storing static service account tokens + - You prefer to use the Gateway's pre-configured service account + - You want centralized management of cluster operations + + + With Gateway authentication, Infisical communicates with the Gateway, which then + uses its own service account to make API calls to the Kubernetes API server. + The Gateway's service account must have the necessary permissions to create and + manage service accounts, their tokens, and RBAC resources. + + + 1. Deploy the Infisical Gateway in your cluster + 2. Set up RBAC permissions for the Gateway's service account: + ```yaml rbac.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: tokenrequest + rules: + - apiGroups: [""] + resources: + - "serviceaccounts/token" + - "serviceaccounts" + verbs: + - "create" + - "get" + - "delete" + - apiGroups: ["rbac.authorization.k8s.io"] + resources: + - "rolebindings" + - "clusterrolebindings" + verbs: + - "create" + - "delete" + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: tokenrequest + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: tokenrequest + subjects: + - kind: ServiceAccount + name: infisical-gateway + namespace: infisical + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-dynamic-role-binding-sa + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: infisical-dynamic-role + subjects: + - kind: ServiceAccount + name: infisical-gateway + namespace: infisical + ``` + + ```bash + kubectl apply -f rbac.yaml + ``` + + + + + In Kubernetes RBAC, a service account can only create role bindings for resources that it has access to. + This means that if you want to create dynamic service accounts with access to certain resources, + the service account creating these bindings (either the token requester or Gateway service account) + must also have access to those same resources. For example, if you want to create dynamic service + accounts that can access secrets, the token requester service account must also have access to secrets. + + + + + + + + + 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 + + + Select a gateway for private cluster access. If not specified, the Internet Gateway will be used. + + + Kubernetes API server URL (e.g., https://kubernetes.default.svc) + + + Whether to enable SSL verification for the Kubernetes API server connection. + + + Custom CA certificate for the Kubernetes API server. Leave blank to use the system/public CA. + + + Choose between Token (API) or Gateway authentication. If using Gateway, the Gateway must be deployed in your Kubernetes cluster. + + + Token with permissions to create service accounts and manage RBAC (required when using Token authentication) + + + Choose between Static (predefined service account) or Dynamic (temporary service accounts with role assignments) + + + Name of the service account to generate tokens for (required for Static credentials) + + + Kubernetes namespace where the service account exists or will be created + + + Type of role to assign (ClusterRole or Role) (required for Dynamic credentials) + + + Name of the role to assign to the temporary service account (required for Dynamic credentials) + + + Optional list of audiences to include in the generated token + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-1.png) + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-2.png) + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + +## Generate and Manage Tokens + +Once you've successfully configured the dynamic secret, you're ready to generate on-demand service account tokens. +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 fall 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 service account token will be shown to you. + +![Provision Lease](/images/platform/dynamic-secrets/kubernetes-lease-value.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 lease details and delete the lease ahead of its expiration time. + + + While you can delete the lease from Infisical, the actual Kubernetes service + account token cannot be revoked. The token will remain valid until its TTL + expires. This is why it's crucial to use appropriate TTL values when + generating tokens. + + +![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) diff --git a/docs/documentation/platform/dynamic-secrets/ldap.mdx b/docs/documentation/platform/dynamic-secrets/ldap.mdx index 00fb8dd56..a113ec344 100644 --- a/docs/documentation/platform/dynamic-secrets/ldap.mdx +++ b/docs/documentation/platform/dynamic-secrets/ldap.mdx @@ -123,6 +123,29 @@ The Infisical LDAP dynamic secret allows you to generate user credentials on dem changetype: delete ``` + + 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 + ``` + diff --git a/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx b/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx index 37e586dca..f167a0641 100644 --- a/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx +++ b/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx @@ -6,11 +6,10 @@ description: "Learn how to dynamically generate Mongo Atlas Database user creden The Infisical Mongo Atlas dynamic secret allows you to generate Mongo Atlas Database credentials on demand based on configured role. ## Prerequisite -Create a project scopped API Key with the required permission in your Mongo Atlas following the [official doc](https://www.mongodb.com/docs/atlas/configure-api-access/#grant-programmatic-access-to-a-project). - - The API Key must have permission to manage users in the project. - +Create a project scoped API Key with the required permission in your Mongo Atlas following the [official doc](https://www.mongodb.com/docs/atlas/configure-api-access/#grant-programmatic-access-to-a-project). + +The API Key must have permission to manage users in the project. ## Set up Dynamic Secrets with Mongo Atlas @@ -29,86 +28,120 @@ Create a project scopped API Key with the required permission in your Mongo Atla 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) - + + 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 - + + Maximum time-to-live for a generated secret + - - The public key of your generated Atlas API Key. This acts as a username. - + + The public key of your generated Atlas API Key. This acts as a username. + - - The private key of your generated Atlas API Key. This acts as a password. - + + The private key of your generated Atlas API Key. This acts as a password. + - - Unique 24-hexadecimal digit string that identifies your project. This is same as project id - + + Unique 24-hexadecimal digit string that identifies your project. This is same as project id + - - List that provides the pairings of one role with one applicable database. - - **Database Name**: Database to which the user is granted access privileges. - - **Collection**: Collection on which this role applies. - - **Role Name**: Human-readable label that identifies a group of privileges assigned to a database user. This value can either be a built-in role or a custom role. - - Enum: `atlasAdmin` `backup` `clusterMonitor` `dbAdmin` `dbAdminAnyDatabase` `enableSharding` `read` `readAnyDatabase` `readWrite` `readWriteAnyDatabase` ``. - + + List that provides the pairings of one role with one applicable database. + - **Database Name**: Database to which the user is granted access privileges. + - **Collection**: Collection on which this role applies. + - **Role Name**: Human-readable label that identifies a group of privileges assigned to a database user. This value can either be a built-in role or a custom role. + - Enum: `atlasAdmin` `backup` `clusterMonitor` `dbAdmin` `dbAdminAnyDatabase` `enableSharding` `read` `readAnyDatabase` `readWrite` `readWriteAnyDatabase` ``. + - ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-atlas.png) + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-atlas.png) - List that contains clusters, MongoDB Atlas Data Lakes, and MongoDB Atlas Streams Instances that this database user can access. If omitted, MongoDB Cloud grants the database user access to all the clusters, MongoDB Atlas Data Lakes, and MongoDB Atlas Streams Instances in the project. - ![Modify Scope Modal](../../../images/platform/dynamic-secrets/advanced-option-atlas.png) - - **Label**: Human-readable label that identifies the cluster or MongoDB Atlas Data Lake that this database user can access. - - **Type**: Category of resource that this database user can access. +![Modify Scope Modal](../../../images/platform/dynamic-secrets/advanced-option-atlas.png) + + + 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 + ``` + + + + List that contains clusters, MongoDB Atlas Data Lakes, and MongoDB Atlas Streams Instances that this database user can access. If omitted, MongoDB Cloud grants the database user access to all the clusters, MongoDB Atlas Data Lakes, and MongoDB Atlas Streams Instances in the project. + - **Label**: Human-readable label that identifies the cluster or MongoDB Atlas Data Lake that this database user can access. + - **Type**: Category of resource that this database user can access. + + + After submitting the form, you will see a dynamic secret created in the dashboard. - - If this step fails, you may have to add the CA certficate. - + + If this step fails, you may have to add the CA certificate. + + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) - ![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) + ![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. + 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) + ![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. - + + 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. + 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) - ![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 a lease before its set time to live. +This will allow you to see the expiration time of the lease or delete a lease before it's set time to live. ![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 + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic + secret diff --git a/docs/documentation/platform/dynamic-secrets/mongo-db.mdx b/docs/documentation/platform/dynamic-secrets/mongo-db.mdx index aa7f87f74..6753b8f0e 100644 --- a/docs/documentation/platform/dynamic-secrets/mongo-db.mdx +++ b/docs/documentation/platform/dynamic-secrets/mongo-db.mdx @@ -66,6 +66,29 @@ Create a user with the required permission in your MongoDB instance. This user w A CA may be required if your DB requires it for incoming connections. + + 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 + ``` + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-mongodb.png) @@ -103,7 +126,7 @@ Create a user with the required permission in your MongoDB instance. This user w ## 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 a lease before its set time to live. +This will allow you to see the expiration time of the lease or delete a lease before it's set time to live. ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) diff --git a/docs/documentation/platform/dynamic-secrets/mssql.mdx b/docs/documentation/platform/dynamic-secrets/mssql.mdx index 2d9bdf2d5..6b6cef982 100644 --- a/docs/documentation/platform/dynamic-secrets/mssql.mdx +++ b/docs/documentation/platform/dynamic-secrets/mssql.mdx @@ -9,7 +9,6 @@ The Infisical MS SQL dynamic secret allows you to generate Microsoft SQL server Create a user with the required permission in your SQL instance. This user will be used to create new accounts on-demand. - ## Set up Dynamic Secrets with MS SQL @@ -27,96 +26,123 @@ Create a user with the required permission in your SQL instance. This user will 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) - + + 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 - + + Maximum time-to-live for a generated secret + - - List of key/value metadata pairs - + + List of key/value metadata pairs + - - Choose the service you want to generate dynamic secrets for. This must be selected as **MS SQL**. - + + Choose the service you want to generate dynamic secrets for. This must be selected as **MS SQL**. + - - Database host - + + Database host + - - Database port - + + Database port + - - Username that will be used to create dynamic secrets - + + Username that will be used to create dynamic secrets + - - Password that will be used to create dynamic secrets - + + Password that will be used to create dynamic secrets + - - Name of the database for which you want to create dynamic secrets - + + Name of the database for which you want to create dynamic secrets + - - A CA may be required if your DB requires it for incoming connections. AWS RDS instances with default settings will requires a CA which can be downloaded [here](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions). - + + A CA may be required if your DB requires it for incoming connections. AWS RDS instances with default settings will requires a CA which can be downloaded [here](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions). + - ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-mssql.png) + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-mssql.png) - If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. This is useful if you want to only give access to a specific table(s). + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/modify-sql-statements-mssql.png) + + 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 + ``` + - ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/modify-sql-statements-mssql.png) After submitting the form, you will see a dynamic secret created in the dashboard. - - If this step fails, you may have to add the CA certficate. - + + If this step fails, you may have to add the CA certificate. + + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) - ![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) + ![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. + 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) + ![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. - + + Ensure that the TTL for the lease fall 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. + 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) - ![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 it's set time to live. ![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 + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic + secret diff --git a/docs/documentation/platform/dynamic-secrets/mysql.mdx b/docs/documentation/platform/dynamic-secrets/mysql.mdx index 1aac52fe6..c354dfe39 100644 --- a/docs/documentation/platform/dynamic-secrets/mysql.mdx +++ b/docs/documentation/platform/dynamic-secrets/mysql.mdx @@ -68,9 +68,30 @@ Create a user with the required permission in your SQL instance. This user will - If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. This is useful if you want to only give access to a specific table(s). + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/modify-sql-statement-mysql.png) + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - ![Modify SQL Statements Modal](/images/platform/dynamic-secrets/modify-sql-statement-mysql.png) + 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. @@ -106,7 +127,7 @@ Create a user with the required permission in your SQL instance. This user will ## 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 a lease before its set time to live. +This will allow you to see the expiration time of the lease or delete a lease before it's set time to live. ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) diff --git a/docs/documentation/platform/dynamic-secrets/oracle.mdx b/docs/documentation/platform/dynamic-secrets/oracle.mdx index 68dda5c16..2d6193abd 100644 --- a/docs/documentation/platform/dynamic-secrets/oracle.mdx +++ b/docs/documentation/platform/dynamic-secrets/oracle.mdx @@ -70,13 +70,36 @@ Create a user with the required permission in your SQL instance. This user will - If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. This is useful if you want to only give access to a specific table(s). + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/modify-sql-statement-oracle.png) + + 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, you may have to add the CA certficate. + If this step fails, you may have to add the CA certificate. ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) @@ -106,7 +129,7 @@ Create a user with the required permission in your SQL instance. This user will ## 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 a lease before its set time to live. +This will allow you to see the expiration time of the lease or delete a lease before it's set time to live. ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) diff --git a/docs/documentation/platform/dynamic-secrets/postgresql.mdx b/docs/documentation/platform/dynamic-secrets/postgresql.mdx index b73b2dbc1..1f5c79f1e 100644 --- a/docs/documentation/platform/dynamic-secrets/postgresql.mdx +++ b/docs/documentation/platform/dynamic-secrets/postgresql.mdx @@ -71,15 +71,39 @@ Create a user with the required permission in your SQL instance. This user will - If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. This is useful if you want to only give access to a specific table(s). - ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/modify-sql-statements.png) + + 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 + ``` + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. This is useful if you want to only give access to a specific table(s). + After submitting the form, you will see a dynamic secret created in the dashboard. - If this step fails, you may have to add the CA certficate. + If this step fails, you may have to add the CA certificate. ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) diff --git a/docs/documentation/platform/dynamic-secrets/rabbit-mq.mdx b/docs/documentation/platform/dynamic-secrets/rabbit-mq.mdx index ec57e78ea..be41901b7 100644 --- a/docs/documentation/platform/dynamic-secrets/rabbit-mq.mdx +++ b/docs/documentation/platform/dynamic-secrets/rabbit-mq.mdx @@ -9,7 +9,6 @@ The Infisical RabbitMQ dynamic secret allows you to generate RabbitMQ credential 1. Ensure that the `management` plugin is enabled on your RabbitMQ instance. This is required for the dynamic secret to work. - ## Set up Dynamic Secrets with RabbitMQ @@ -19,63 +18,90 @@ The Infisical RabbitMQ dynamic secret allows you to generate RabbitMQ credential ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) - - ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-rabbit-mq.png) + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-rabbit-mq-modal.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) - + + 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. - + + Maximum time-to-live for a generated secret. + - + Your RabbitMQ host. This must be in HTTP format. _(Example: http://your-cluster-ip)_ - + - - The port that the RabbitMQ management plugin is listening on. This is `15672` by default. - + - - The name of the virtual host that the user will be assigned to. This defaults to `/`. - +The port that the RabbitMQ management plugin is listening on. This is `15672` by default. + + + + The name of the virtual host that the user will be assigned to. This defaults + to `/`. + The permissions that the user will have on the virtual host. This defaults to `.*`. The three permission fields all take a regular expression _(regex)_, that should match resource names for which the user is granted read / write / configuration permissions + + + The username of the user that will be used to provision new dynamic secret + leases. + - - The username of the user that will be used to provision new dynamic secret leases. + + The password of the user that will be used to provision new dynamic secret + leases. + + + + 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 + ``` - - The password of the user that will be used to provision new dynamic secret leases. - - - - A CA may be required if your DB requires it for incoming connections. This is often the case when connecting to a managed service. - - - ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-input-modal-rabbit-mq.png) + + A CA may be required if your DB requires it for incoming connections. This is often the case when connecting to a managed service. + +![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-input-modal-rabbit-mq.png) After submitting the form, you will see a dynamic secret created in the dashboard. - - If this step fails, you may have to add the CA certificate. - + + If this step fails, you may have to add the CA certificate. + @@ -83,34 +109,38 @@ The Infisical RabbitMQ dynamic secret allows you to generate RabbitMQ credential 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) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.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. + 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) + ![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. - + + Ensure that the TTL for the lease fall 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 from it will be shown to you. + Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) - ![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 a lease before its set time to live. +This will allow you to see the expiration time of the lease or delete a lease before it's set time to live. ![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 + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic + secret diff --git a/docs/documentation/platform/dynamic-secrets/redis.mdx b/docs/documentation/platform/dynamic-secrets/redis.mdx index b9edb4314..583bd1d87 100644 --- a/docs/documentation/platform/dynamic-secrets/redis.mdx +++ b/docs/documentation/platform/dynamic-secrets/redis.mdx @@ -56,9 +56,33 @@ Create a user with the required permission in your Redis instance. This user wil - If you want to provide specific privileges for the generated dynamic credentials, you can modify the Redis statement to your needs. This is useful if you want to only give access to a specific table(s). + ![Modify Redis Statements Modal](/images/platform/dynamic-secrets/modify-redis-statement.png) + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - ![Modify Redis Statements Modal](/images/platform/dynamic-secrets/modify-redis-statement.png) + 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 + ``` + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the Redis statement to your needs. This is useful if you want to only give access to a specific table(s). + After submitting the form, you will see a dynamic secret created in the dashboard. @@ -93,7 +117,7 @@ Create a user with the required permission in your Redis instance. This user wil ## 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 a lease before its set time to live. +This will allow you to see the expiration time of the lease or delete a lease before it's set time to live. ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) diff --git a/docs/documentation/platform/dynamic-secrets/sap-ase.mdx b/docs/documentation/platform/dynamic-secrets/sap-ase.mdx index 3b7a895fb..6da572f35 100644 --- a/docs/documentation/platform/dynamic-secrets/sap-ase.mdx +++ b/docs/documentation/platform/dynamic-secrets/sap-ase.mdx @@ -62,21 +62,46 @@ The Infisical SAP ASE dynamic secret allows you to generate SAP ASE database cre ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-setup-modal.png) - - If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-statements.png) + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - - Due to SAP ASE limitations, the attached SQL statements are not executed as a transaction. - + 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 + ``` + + + +If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. + +Due to SAP ASE limitations, the attached SQL statements are not executed as a transaction. + + After submitting the form, you will see a dynamic secret created in the dashboard. - 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. + 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) diff --git a/docs/documentation/platform/dynamic-secrets/sap-hana.mdx b/docs/documentation/platform/dynamic-secrets/sap-hana.mdx index 668777549..8ccd842f2 100644 --- a/docs/documentation/platform/dynamic-secrets/sap-hana.mdx +++ b/docs/documentation/platform/dynamic-secrets/sap-hana.mdx @@ -62,14 +62,41 @@ The Infisical SAP HANA dynamic secret allows you to generate SAP HANA database c ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-sap-hana.png) - - If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. - ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/modify-sap-hana-sql-statements.png) + + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/modify-sap-hana-sql-statements.png) + + 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 + ``` + + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. Due to SAP HANA limitations, the attached SQL statements are not executed as a transaction. + + After submitting the form, you will see a dynamic secret created in the dashboard. @@ -80,8 +107,8 @@ The Infisical SAP HANA dynamic secret allows you to generate SAP HANA database c - 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. + 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) diff --git a/docs/documentation/platform/dynamic-secrets/snowflake.mdx b/docs/documentation/platform/dynamic-secrets/snowflake.mdx index 75db96c8f..f0bbaa08c 100644 --- a/docs/documentation/platform/dynamic-secrets/snowflake.mdx +++ b/docs/documentation/platform/dynamic-secrets/snowflake.mdx @@ -8,22 +8,27 @@ Infisical's Snowflake dynamic secrets allow you to generate Snowflake user crede ## Snowflake Prerequisites - Infisical requires a Snowflake user in your account with the USERADMIN role. This user will act as a service account for Infisical and facilitate the creation of new users as needed. + Infisical requires a Snowflake user in your account with the USERADMIN role. + This user will act as a service account for Infisical and facilitate the + creation of new users as needed. - - ![Snowflake User Dashboard](/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-users-page.png) - - - - Be sure to uncheck "Force user to change password on first time login" - - ![Snowflake Create Service User](/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-create-service-user.png) - - - ![Snowflake Account And Organization Identifiers](/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-identifiers.png) - + + ![Snowflake User + Dashboard](/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-users-page.png) + + + + Be sure to uncheck "Force user to change password on first time login" + + ![Snowflake Create Service + User](/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-create-service-user.png) + + + ![Snowflake Account And Organization + Identifiers](/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-identifiers.png) + ## Set up Dynamic Secrets with Snowflake @@ -71,10 +76,39 @@ Infisical's Snowflake dynamic secrets allow you to generate Snowflake user crede - If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL - statement to your needs. ![Modify SQL Statements Modal](/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-sql-statements.png) - + + 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 + ``` + + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL + statement to your needs. + + + + + After submitting the form, you will see a dynamic secret created in the dashboard. @@ -104,6 +138,7 @@ Infisical's Snowflake dynamic secrets allow you to generate Snowflake user crede ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) + ## Audit or Revoke Leases @@ -119,6 +154,6 @@ To extend the life of the generated dynamic secret lease past its initial time t ![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) - Lease renewals cannot exceed the maximum TTL set when configuring the dynamic - secret. + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic + secret. diff --git a/docs/documentation/platform/dynamic-secrets/vertica.mdx b/docs/documentation/platform/dynamic-secrets/vertica.mdx new file mode 100644 index 000000000..3d5c7b1a9 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/vertica.mdx @@ -0,0 +1,134 @@ +--- +title: "Vertica" +description: "Learn how to dynamically generate Vertica database users." +--- + +The Infisical Vertica dynamic secret allows you to generate Vertica database credentials on demand based on configured role. + +## Prerequisite + +Create a user with the required permission in your Vertica instance. This user will be used to create new accounts on-demand. + +## Set up Dynamic Secrets with Vertica + + + + 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/vertica/dynamic-secret-modal-vertica.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 + + + + Select a gateway for private cluster access. If not specified, the Internet Gateway will be used. + + + + Vertica database host + + + + Vertica database port (default: 5433) + + + + Name of the Vertica database for which you want to create dynamic secrets + + + + Username that will be used to create dynamic secrets + + + + Password that will be used to create dynamic secrets + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/vertica/dynamic-secret-setup-modal-vertica.png) + + + + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/vertica/modify-sql-statements-vertica.png) + + 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 + + + Customize the SQL statement used to create new users. Default creates a user with basic schema permissions. + + + Customize the SQL statement used to revoke users. Default revokes a user. + + + + + Length of generated passwords (1-250 characters) + + + Minimum required character counts: + - **Lowercase Count**: Minimum lowercase letters (default: 1) + - **Uppercase Count**: Minimum uppercase letters (default: 1) + - **Digit Count**: Minimum digits (default: 1) + - **Symbol Count**: Minimum symbols (default: 0) + + + Symbols allowed in generated passwords + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/vertica/dynamic-secret-vertica.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. + +![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-security.mdx b/docs/documentation/platform/gateways/gateway-security.mdx index 83490fd4d..93a7f662f 100644 --- a/docs/documentation/platform/gateways/gateway-security.mdx +++ b/docs/documentation/platform/gateways/gateway-security.mdx @@ -89,22 +89,3 @@ The relay system provides secure tunneling: - Gateways only accept connections to approved resources - Each connection requires explicit project authorization - Resources remain private to their assigned organization - -## Security Measures - -### Certificate Lifecycle -- Certificates have limited validity periods -- Automatic certificate rotation -- Immediate certificate revocation capabilities - -### Monitoring and Verification -1. **Continuous Verification**: - - Regular heartbeat checks - - Certificate chain validation - - Connection state monitoring - -2. **Security Controls**: - - Automatic connection termination on verification failure - - Audit logging of all access attempts - - Machine identity based authentication - diff --git a/docs/documentation/platform/gateways/networking.mdx b/docs/documentation/platform/gateways/networking.mdx new file mode 100644 index 000000000..6acdc1993 --- /dev/null +++ b/docs/documentation/platform/gateways/networking.mdx @@ -0,0 +1,168 @@ +--- +title: "Networking" +description: "Network configuration and firewall requirements for Infisical Gateway" +--- + +The Infisical Gateway requires outbound network connectivity to establish secure communication with Infisical's relay infrastructure. +This page outlines the required ports, protocols, and firewall configurations needed for optimal gateway usage. + +## Network Architecture + +The gateway uses a relay-based architecture to establish secure connections: + +1. **Gateway** connects outbound to **Relay Servers** using UDP/QUIC protocol +2. **Relay Servers** facilitate secure communication between Gateway and Infisical Cloud +3. All traffic is end-to-end encrypted using mutual TLS over QUIC + +## Required Network Connectivity + +### Outbound Connections (Required) + +The gateway requires the following outbound connectivity: + +| Protocol | Destination | Ports | Purpose | +|----------|-------------|-------|---------| +| UDP | Relay Servers | 49152-65535 | Allocated relay communication (TLS) | +| TCP | app.infisical.com / eu.infisical.com | 443 | API communication and relay allocation | + +### Relay Server IP Addresses + +Your firewall must allow outbound connectivity to the following Infisical relay servers on dynamically allocated ports. + + + + ``` + 54.235.197.91:49152-65535 + 18.215.196.229:49152-65535 + 3.222.120.233:49152-65535 + 34.196.115.157:49152-65535 + ``` + + + ``` + 3.125.237.40:49152-65535 + 52.28.157.98:49152-65535 + 3.125.176.90:49152-65535 + ``` + + + Please contact your Infisical account manager for dedicated relay server IP addresses. + + + + + These IP addresses are static and managed by Infisical. Any changes will be communicated with 60-day advance notice. + + +## Protocol Details + +### QUIC over UDP + +The gateway uses QUIC (Quick UDP Internet Connections) for primary communication: + +- **Port 5349**: STUN/TURN over TLS (secure relay communication) +- **Built-in features**: Connection migration, multiplexing, reduced latency +- **Encryption**: TLS 1.3 with certificate pinning + +## Understanding Firewall Behavior with UDP + +Unlike TCP connections, UDP is a stateless protocol, and depending on your organization's firewall configuration, you may need to adjust network rules accordingly. +When the gateway sends UDP packets to a relay server, the return responses need to be allowed back through the firewall. +Modern firewalls handle this through "connection tracking" (also called "stateful inspection"), but the behavior can vary depending on your firewall configuration. + + +### Connection Tracking + +Modern firewalls automatically track UDP connections and allow return responses. This is the preferred configuration as it: +- Automatically handles return responses +- Reduces firewall rule complexity +- Avoids the need for manual IP whitelisting + +In the event that your firewall does not support connection tracking, you will need to whitelist the relay IPs to explicitly define return traffic manually. + +## Common Network Scenarios + +### Corporate Firewalls + +For corporate environments with strict egress filtering: + +1. **Whitelist relay IP addresses** (listed above) +2. **Allow UDP port 5349** outbound +3. **Configure connection tracking** for UDP return traffic +4. **Allow ephemeral port range** 49152-65535 for return traffic if connection tracking is disabled + +### Cloud Environments (AWS/GCP/Azure) + +Configure security groups to allow: +- **Outbound UDP** to relay IPs on port 5349 +- **Outbound HTTPS** to app.infisical.com/eu.infisical.com on port 443 +- **Inbound UDP** on ephemeral ports (if not using stateful rules) + +## Frequently Asked Questions + + +The gateway is designed to handle network interruptions gracefully: + +- **Automatic reconnection**: The gateway will automatically attempt to reconnect to relay servers every 5 seconds if the connection is lost +- **Connection retry logic**: Built-in retry mechanisms handle temporary network outages without manual intervention +- **Multiple relay servers**: If one relay server is unavailable, the gateway can connect to alternative relay servers +- **Persistent sessions**: Existing connections are maintained where possible during brief network interruptions +- **Graceful degradation**: The gateway logs connection issues and continues attempting to restore connectivity + +No manual intervention is typically required during network interruptions. + + + +QUIC (Quick UDP Internet Connections) provides several advantages over traditional TCP for gateway communication: + +- **Faster connection establishment**: QUIC combines transport and security handshakes, reducing connection setup time +- **Built-in encryption**: TLS 1.3 is integrated into the protocol, ensuring all traffic is encrypted by default +- **Connection migration**: QUIC connections can survive IP address changes (useful for NAT rebinding) +- **Reduced head-of-line blocking**: Multiple data streams can be multiplexed without blocking each other +- **Better performance over unreliable networks**: Advanced congestion control and packet loss recovery +- **Lower latency**: Optimized for real-time communication between gateway and cloud services + +While TCP is stateful and easier for firewalls to track, QUIC's performance benefits outweigh the additional firewall configuration requirements. + + + +No inbound ports need to be opened. The gateway only makes outbound connections: + +- **Outbound UDP** to relay servers on ports 49152-65535 +- **Outbound HTTPS** to Infisical API endpoints +- **Return responses** are handled by connection tracking or explicit IP whitelisting + +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 UDP restrictions: + +1. **Work with your network team** to allow outbound UDP to the specific relay IP addresses +2. **Use explicit IP whitelisting** if connection tracking is disabled +3. **Consider network policy exceptions** for the gateway host +4. **Monitor firewall logs** to identify which specific rules are blocking traffic + +The gateway requires UDP connectivity to function - TCP-only configurations are not supported. + + + +The gateway connects to **one relay server at a time**: + +- **Single active connection**: Only one relay connection is established per gateway instance +- **Automatic failover**: If the current relay becomes unavailable, the gateway will connect to an alternative relay +- **Load distribution**: Different gateway instances may connect to different relay servers for load balancing +- **No manual selection**: The Infisical API automatically assigns the optimal relay server based on availability and proximity + +You should whitelist all relay IP addresses to ensure proper failover functionality. + + +No, relay servers cannot decrypt any traffic passing through them: + +- **End-to-end encryption**: All traffic between the gateway and Infisical Cloud is encrypted using mutual TLS with certificate pinning +- **Relay acts as a tunnel**: The relay server only forwards encrypted packets - it has no access to encryption keys +- **No data storage**: Relay servers do not store any traffic or network-identifiable information +- **Certificate isolation**: Each organization has its own private PKI system, ensuring complete tenant isolation + +The relay infrastructure is designed as a secure forwarding mechanism, similar to a VPN tunnel, where the relay provider cannot see the contents of the traffic flowing through it. + \ No newline at end of file diff --git a/docs/documentation/platform/gateways/overview.mdx b/docs/documentation/platform/gateways/overview.mdx index 7ccc098cd..127e544b7 100644 --- a/docs/documentation/platform/gateways/overview.mdx +++ b/docs/documentation/platform/gateways/overview.mdx @@ -32,7 +32,7 @@ For detailed installation instructions, refer to the Infisical [CLI Installation To function, the Gateway must authenticate with Infisical. This requires a machine identity configured with the appropriate permissions to create and manage a Gateway. Once authenticated, the Gateway establishes a secure connection with Infisical to allow your private resources to be reachable. -### Deployment process +### Get started @@ -89,18 +89,208 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t helm repo update ``` - ### Create a Kubernetes Secret with the gateway token + ### Create a Kubernetes Secret containing gateway environment variables - Create a new Kubernetes secret containing the gateway token as the `TOKEN` key. You can optionally also set the `INFISICAL_API_URL` key to your Infisical instance URL. By default, `INFISICAL_API_URL` is set to `https://app.infisical.com`. + 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. - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=TOKEN= - ``` - - - The secret name is `infisical-gateway-environment` by default. The `TOKEN` key is required, and the `INFISICAL_API_URL` key is optional. - + #### 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= + ``` + + + + 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= + ``` + + + + + #### 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 ```bash @@ -128,7 +318,7 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t - + 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 --token $(infisical login --method=universal-auth --client-id=<> --client-secret=<> --plain) @@ -158,14 +348,4 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t 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) - - - To enable Infisical features like dynamic secrets or secret rotation to access private resources through the Gateway, you need to link the Gateway to the relevant projects. - - Start by accessing the **Gateway settings** then locate the Gateway in the list, click the options menu (**:**), and select **Edit Details**. - ![Edit Gateway Option](../../../images/platform/gateways/edit-gateway.png) - In the edit modal that appears, choose the projects you want the Gateway to access and click **Save** to confirm your selections. - ![Project Assignment Modal](../../../images/platform/gateways/assign-project.png) - Once added to a project, the Gateway becomes available for use by any feature that supports Gateways within that project. - diff --git a/docs/documentation/platform/github-org-sync.mdx b/docs/documentation/platform/github-org-sync.mdx index 00c9bf4c4..519e12db8 100644 --- a/docs/documentation/platform/github-org-sync.mdx +++ b/docs/documentation/platform/github-org-sync.mdx @@ -13,7 +13,7 @@ To enable and configure GitHub Organization Synchronization, follow these steps: - 1. Navigate to **Organization Settings** and select the **Security Tab**. + 1. Navigate to the **Single Sign-On (SSO)** page and select the **Provisioning** tab. ![config](../../images/platform/external-syncs/github-org-sync-section.png) 2. Click the **Configure** button and provide the name of your GitHub Organization. ![config-modal](../../images/platform/external-syncs/github-org-sync-config-modal.png) diff --git a/docs/documentation/platform/identities/aws-auth.mdx b/docs/documentation/platform/identities/aws-auth.mdx index 494606ccd..f27d5c7bf 100644 --- a/docs/documentation/platform/identities/aws-auth.mdx +++ b/docs/documentation/platform/identities/aws-auth.mdx @@ -62,7 +62,7 @@ access the Infisical API using the AWS Auth authentication method. - To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. ![identities organization](/images/platform/identities/identities-org.png) @@ -311,7 +311,7 @@ access the Infisical API using the AWS Auth authentication method. - Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; the default TTL is `7200` seconds which can be adjusted. If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, diff --git a/docs/documentation/platform/identities/azure-auth.mdx b/docs/documentation/platform/identities/azure-auth.mdx index 03d997ffb..7a7c112ef 100644 --- a/docs/documentation/platform/identities/azure-auth.mdx +++ b/docs/documentation/platform/identities/azure-auth.mdx @@ -62,7 +62,7 @@ access the Infisical API using the Azure Auth authentication method. - To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. ![identities organization](/images/platform/identities/identities-org.png) @@ -173,7 +173,7 @@ access the Infisical API using the Azure Auth authentication method. We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using Azure Auth as they handle the authentication process including retrieving the client access token. - Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; the default TTL is `7200` seconds which can be adjusted. If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, a new access token should be obtained by performing another login operation. diff --git a/docs/documentation/platform/identities/gcp-auth.mdx b/docs/documentation/platform/identities/gcp-auth.mdx index 6573544de..8d6a1f177 100644 --- a/docs/documentation/platform/identities/gcp-auth.mdx +++ b/docs/documentation/platform/identities/gcp-auth.mdx @@ -68,7 +68,7 @@ access the Infisical API using the GCP ID Token authentication method. - To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. ![identities organization](/images/platform/identities/identities-org.png) @@ -168,7 +168,7 @@ access the Infisical API using the GCP ID Token authentication method. We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using GCP IAM Auth as they handle the authentication process including generating the signed JWT token. - Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; the default TTL is `7200` seconds which can be adjusted. If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, a new access token should be obtained by performing another login operation. @@ -179,7 +179,7 @@ access the Infisical API using the GCP ID Token authentication method. - + ## Diagram The following sequence diagram illustrates the GCP IAM Auth workflow for authenticating GCP IAM service accounts with Infisical. @@ -237,7 +237,7 @@ access the Infisical API using the GCP IAM authentication method. - To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. ![identities organization](/images/platform/identities/identities-org.png) @@ -352,7 +352,7 @@ access the Infisical API using the GCP IAM authentication method. We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using GCP IAM Auth as they handle the authentication process including generating the signed JWT token. - Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; the default TTL is `7200` seconds which can be adjusted. If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, a new access token should be obtained by performing another login operation. @@ -361,5 +361,5 @@ access the Infisical API using the GCP IAM authentication method. - + diff --git a/docs/documentation/platform/identities/jwt-auth.mdx b/docs/documentation/platform/identities/jwt-auth.mdx index 3dcf12b29..339138881 100644 --- a/docs/documentation/platform/identities/jwt-auth.mdx +++ b/docs/documentation/platform/identities/jwt-auth.mdx @@ -57,7 +57,7 @@ In the following steps, we explore how to create and use identities to access th - To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. ![identities organization](/images/platform/identities/identities-org.png) diff --git a/docs/documentation/platform/identities/kubernetes-auth.mdx b/docs/documentation/platform/identities/kubernetes-auth.mdx index 58069f09e..e357ba75e 100644 --- a/docs/documentation/platform/identities/kubernetes-auth.mdx +++ b/docs/documentation/platform/identities/kubernetes-auth.mdx @@ -52,11 +52,12 @@ Infisical is able to authenticate and interact with the TokenReview API by using In the following steps, we explore how to create and use identities for your applications in Kubernetes to access the Infisical API using the Kubernetes Auth authentication method. + + - - - + + **When to use this option**: Choose this approach when you want centralized authentication management. Only one service account needs special permissions, and your application service accounts remain unchanged. @@ -126,44 +127,94 @@ In the following steps, we explore how to create and use identities for your app ``` Keep this JWT token handy as you will need it for the **Token Reviewer JWT** field when configuring the Kubernetes Auth authentication method for the identity in step 2. + - - + + + **When to use this option**: Choose this approach to eliminate long-lived tokens. This option simplifies Infisical configuration but requires each application service account to have elevated permissions. + - - **When to use this option**: Choose this approach to eliminate long-lived tokens. This option simplifies Infisical configuration but requires each application service account to have elevated permissions. - + The self-validation method eliminates the need for a separate long-lived reviewer JWT by using the same token for both authentication and validation. Instead of creating a dedicated reviewer service account, you'll grant the necessary permissions to each application service account. - The self-validation method eliminates the need for a separate long-lived reviewer JWT by using the same token for both authentication and validation. Instead of creating a dedicated reviewer service account, you'll grant the necessary permissions to each application service account. + For each service account that needs to authenticate with Infisical, add the `system:auth-delegator` role: - For each service account that needs to authenticate with Infisical, add the `system:auth-delegator` role: + ```yaml client-role-binding.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-client-binding-[your-app-name] + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator + subjects: + - kind: ServiceAccount + name: [your-app-service-account] + namespace: [your-app-namespace] + ``` - ```yaml client-role-binding.yaml - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRoleBinding - metadata: - name: infisical-client-binding-[your-app-name] - roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: system:auth-delegator - subjects: - - kind: ServiceAccount - name: [your-app-service-account] - namespace: [your-app-namespace] - ``` + ``` + kubectl apply -f client-role-binding.yaml + ``` - ``` - kubectl apply -f client-role-binding.yaml - ``` + When configuring Kubernetes Auth in Infisical, leave the **Token Reviewer JWT** field empty. Infisical will use the client's own token for validation. + + + + **When to use this option**: Choose this approach when you have a gateway deployed in your Kubernetes Cluster and wish to eliminate long-lived tokens. This approach simplifies Infisical Kubernetes Auth configuration, and only one service account will need to have the elevated `system:auth-delegator` ClusterRole binding. + - When configuring Kubernetes Auth in Infisical, leave the **Token Reviewer JWT** field empty. Infisical will use the client's own token for validation. - - - + + **Note:** Gateway is a paid feature. - **Infisical Cloud users:** Gateway is + available under the **Enterprise Tier**. - **Self-Hosted Infisical:** Please + contact [sales@infisical.com](mailto:sales@infisical.com) to purchase an + enterprise license. + + + + + To deploy a gateway in your Kubernetes cluster, follow our [Gateway deployment guide using helm](/documentation/platform/gateways/overview). + + + + To grant the gateway the `system:auth-delegator` ClusterRole binding, you can use the following command: + + ```yaml gateway-role-binding.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-token-reviewer-role-binding + namespace: default # Replace with your namespace if not default + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator + subjects: + - kind: ServiceAccount + name: infisical-gateway # The name of the gateway service account + namespace: default # Replace with your namespace if not default + ``` + + ```bash + kubectl apply -f gateway-role-binding.yaml + ``` + + + The gateway service account name is `infisical-gateway` by default if deployed using Helm. + + + + + To configure your Kubernetes Auth method to use the gateway as the token reviewer, set the `Review Method` to "Gateway as Reviewer", and select the gateway you want to use as the token reviewer. + + ![identities organization create kubernetes auth method](/images/platform/identities/identities-kubernetes-auth-gateway-as-reviewer.png) + + + + - To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. ![identities organization](/images/platform/identities/identities-org.png) @@ -190,7 +241,7 @@ In the following steps, we explore how to create and use identities for your app Here's some more guidance on each field: - Kubernetes Host / Base Kubernetes API URL: The host string, host:port pair, or URL to the base of the Kubernetes API server. This can usually be obtained by running `kubectl cluster-info`. - - Token Reviewer JWT: A long-lived service account JWT token for Infisical to access the [TokenReview API](https://kubernetes.io/docs/reference/kubernetes-api/authentication-resources/token-review-v1/) to validate other service account JWT tokens submitted by applications/pods. This is the JWT token obtained from step 1.5(Reviewer Tab). If omitted, the client's own JWT will be used instead, which requires the client to have the `system:auth-delegator` ClusterRole binding. + - Token Reviewer JWT: A long-lived service account JWT token for Infisical to access the [TokenReview API](https://kubernetes.io/docs/reference/kubernetes-api/authentication-resources/token-review-v1/) to validate other service account JWT tokens submitted by applications/pods. This is the JWT token obtained from step 1.5(Reviewer Tab). If omitted, the client's own JWT will be used instead, which requires the client to have the `system:auth-delegator` ClusterRole binding. This is shown in step 1, option 2. - Allowed Service Account Names: A comma-separated list of trusted service account names that are allowed to authenticate with Infisical. - Allowed Namespaces: A comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical. @@ -257,7 +308,7 @@ In the following steps, we explore how to create and use identities for your app - Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; the default TTL is `7200` seconds which can be adjusted. If an identity access token exceeds its max ttl, it can no longer authenticate with the Infisical API. In this case, @@ -280,7 +331,7 @@ In the following steps, we explore how to create and use identities for your app There are a few reasons for why this might happen: - The access token has expired. -- The identity is insufficently permissioned to interact with the resources you wish to access. +- The identity is insufficiently permissioned to interact with the resources you wish to access. - The client access token is being used from an untrusted IP. diff --git a/docs/documentation/platform/identities/ldap-auth/general.mdx b/docs/documentation/platform/identities/ldap-auth/general.mdx new file mode 100644 index 000000000..7fb2798c7 --- /dev/null +++ b/docs/documentation/platform/identities/ldap-auth/general.mdx @@ -0,0 +1,87 @@ +--- +title: General +description: "Learn how to authenticate with Infisical using LDAP." +--- + +**LDAP Auth** is an LDAP based authentication method that allows you to authenticate with Infisical using a machine identity configured with an [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol) directory. + +## Guide + + + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. + + ![Create identity](/images/platform/identities/ldap/identities-org-create-identity.png) + + When creating an identity, you specify an organization level role for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![Create identity modal](/images/platform/identities/ldap/identities-org-create-identity-modal.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the Organization Roles tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be redirected to a page where you can manage the identity. + + + + 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: + + - `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. + - `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. + + ![Configure LDAP auth](/images/platform/identities/ldap/identities-org-configure-ldap.png) + + + After configuring LDAP auth for your identity, you can authenticate with the identity and obtain an access token using your LDAP credentials. + + ```bash + curl --request POST \ + --url https://app.infisical.com/api/v1/auth/ldap-auth/login \ + --header 'Content-Type: application/json' \ + --data '{ + "identityId": "", + "username": "", + "password": "" + }' + ``` + + + For EU Cloud and Self-Hosted users, make sure to replace `https://app.infisical.com` with `https://eu.infisical.com` or your self-hosted instance's URL in the request URL. + + + If successful, you'll receive an access token in the response body. + + ```json + { + "accessToken": "your-access-token", + "expiresIn": 2592000, + "accessTokenMaxTTL": 2592000, + "tokenType": "Bearer" + } + ``` + + You can read more about the login API endpoint [here](/api-reference/endpoints/ldap-auth/login). + + + diff --git a/docs/documentation/platform/identities/ldap-auth/jumpcloud.mdx b/docs/documentation/platform/identities/ldap-auth/jumpcloud.mdx new file mode 100644 index 000000000..4bd497eac --- /dev/null +++ b/docs/documentation/platform/identities/ldap-auth/jumpcloud.mdx @@ -0,0 +1,97 @@ +--- +title: JumpCloud +description: "Learn how to authenticate with Infisical using LDAP with JumpCloud." +--- + +**LDAP Auth** is an LDAP based authentication method that allows you to authenticate with Infisical using a machine identity configured with an [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol) directory. + +## Guide + + + + In JumpCloud, head to USER MANAGEMENT > Users and create a new user via the Manual user entry option. + This user will be used as a privileged service account to facilitate Infisical's ability to bind/search the LDAP directory. + + Next after creating the user, under User Security Settings and Permissions > Permission Settings, check the box next to Enable as LDAP Bind DN. + + ![User management](/images/platform/identities/ldap/jumpcloud-users-management.png) + + + + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. + + ![Create identity](/images/platform/identities/ldap/identities-org-create-identity.png) + + When creating an identity, you specify an organization level role for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![Create identity modal](/images/platform/identities/ldap/identities-org-create-identity-modal.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the Organization Roles tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be redirected to a page where you can manage the identity. + + + + 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: + + - `URL`: The LDAP server to connect to (`ldaps://ldap.jumpcloud.com:636`). + - `Bind DN`: The distinguished name of object to bind when performing the user search (`uid=,ou=Users,o=,dc=jumpcloud,dc=com`). + - `Bind Pass`: The password to use along with Bind DN when performing the user search. This is the password for the user created in the previous step. + - `Search Base / DN`: Base DN under which to perform user search (`ou=Users,o=,dc=jumpcloud,dc=com`). + - `User Search Filter`: Template used to construct the LDAP user search filter (`(uid={{username}})`). + - `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 (instructions to obtain the certificate for JumpCloud [here](https://jumpcloud.com/support/connect-to-ldap-with-tls-ssl)). + - `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 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. + + ![Configure LDAP auth](/images/platform/identities/ldap/identities-org-configure-ldap.png) + + + After configuring LDAP auth for your identity, you can authenticate with the identity and obtain an access token using your LDAP credentials. + + ```bash + curl --request POST \ + --url https://app.infisical.com/api/v1/auth/ldap-auth/login \ + --header 'Content-Type: application/json' \ + --data '{ + "identityId": "", + "username": "", + "password": "" + }' + ``` + + + For EU Cloud and Self-Hosted users, make sure to replace `https://app.infisical.com` with `https://eu.infisical.com` or your self-hosted instance's URL in the request URL. + + + If successful, you'll receive an access token in the response body. + + ```json + { + "accessToken": "your-access-token", + "expiresIn": 2592000, + "accessTokenMaxTTL": 2592000, + "tokenType": "Bearer" + } + ``` + + You can read more about the login API endpoint [here](/api-reference/endpoints/ldap-auth/login). + + + diff --git a/docs/documentation/platform/identities/oci-auth.mdx b/docs/documentation/platform/identities/oci-auth.mdx new file mode 100644 index 000000000..ef5fafa4c --- /dev/null +++ b/docs/documentation/platform/identities/oci-auth.mdx @@ -0,0 +1,212 @@ +--- +title: OCI Auth +description: "Learn how to authenticate with Infisical using OCI user accounts." +--- + +**OCI Auth** is an OCI-native authentication method that verifies Oracle Cloud Infrastructure users through signature validation, allowing secure access to Infisical resources. + +## Diagram + +The following sequence diagram illustrates the OCI Auth workflow for authenticating OCI users with Infisical. + +```mermaid +sequenceDiagram + participant Client + participant Infisical + participant OCI + + Note over Client,Client: Step 1: Sign user identity request + + Note over Client,Infisical: Step 2: Login Operation + Client->>Infisical: Send signed request details to /api/v1/auth/oci-auth/login + + Note over Infisical,OCI: Step 3: Request verification + Infisical->>OCI: Forward signed request + OCI-->>Infisical: Return user details + + Note over Infisical: Step 4: Identity property validation + Infisical->>Client: Return short-lived access token + + Note over Client,Infisical: Step 5: Access Infisical API with token + Client->>Infisical: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high level, Infisical authenticates an OCI user by verifying its identity and checking that it meets specific requirements (e.g., its username is authorized, its part of a tenancy) at the `/api/v1/auth/oci-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: +1. The client [signs](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/signingrequests.htm) a `/20160918/users/{userId}` request using an OCI user's [private key](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/apisigningkey.htm#Required_Keys_and_OCIDs); this is done using the [OCI SDK](https://infisical.com/docs/documentation/platform/identities/oci-auth#accessing-the-infisical-api-with-the-identity) or API. +2. The client sends the signed request's headers and their user OCID to Infisical at the `/api/v1/auth/oci-auth/login` endpoint. +3. Infisical reconstructs the request and sends it to OCI via the [Get User](https://docs.oracle.com/en/engineered-systems/private-cloud-appliance/3.0-latest/ceapi/op-20160918-users-user_id-get.html) endpoint for verification and obtains the identity associated with the OCI user. +4. Infisical checks the user's properties against set criteria such as **Allowed Usernames** and **Tenancy OCID**. +5. If all checks pass, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API. + +## Prerequisite + +In order to sign requests, you must have an OCI user with credentials such as the private key. If you're unaware of how to create a user and obtain the needed credentials, expand the menu below. + + + + + ![Search Domains](/images/app-connections/oci/search-domains.png) + + + Select the domain in which you want to create the Infisical user account. + + ![Select Domain](/images/app-connections/oci/select-domain.png) + + + ![Select Users](/images/app-connections/oci/select-users.png) + + + ![Click Create User](/images/app-connections/oci/click-create-user.png) + + + The name, email, and username can be anything. + + ![Create User](/images/app-connections/oci/create-user.png) + + + After you've created a user, you'll be redirected to the user's page. Navigate to 'API keys'. + + ![Select API Keys](/images/app-connections/oci/select-api-keys.png) + + + Click on 'Add API key' and then download or import the private key. After you've obtained the private key, click 'Add'. + + ![Add API Key](/images/app-connections/oci/add-api-key.png) + + + At the end of the downloaded private key file, you'll see `OCI_API_KEY`. This is not apart of the private key, and should not be included when you use the private key to sign requests. + + + + + After creating the API key, you'll be shown a modal with relevant information. Save the highlighted values (and the private key) for later steps. + + ![User Info](/images/app-connections/oci/user-info.png) + + + + +## Guide + +In the following steps, we explore how to create and use identities for your workloads and applications on OCI to +access the Infisical API using the OCI request signing authentication method. + +### Creating an identity + +To create an identity, head to your Organization Settings > Access Control > [Identities](https://app.infisical.com/organization/access-management?selectedTab=identities) and press **Create identity**. + +![identities organization](/images/platform/identities/identities-org.png) + +When creating an identity, you specify an organization-level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > [Organization Roles](https://app.infisical.com/organization/access-management?selectedTab=roles). + +![identities organization create](/images/platform/identities/identities-org-create.png) + +Input some details for your new identity: +- **Name (required):** A friendly name for the identity. +- **Role (required):** A role from the [**Organization Roles**](https://app.infisical.com/organization/access-management?selectedTab=roles) tab for the identity to assume. The organization role assigned will determine what organization-level resources this identity can have access to. + +Once you've created an identity, you'll be redirected to a page where you can manage the identity. + +![identities page](/images/platform/identities/identities-page.png) + +Since the identity has been configured with [Universal Auth](https://infisical.com/docs/documentation/platform/identities/universal-auth) by default, you should reconfigure it to use OCI Auth instead. To do this, click the cog next to **Universal Auth** and then select **Delete** in the options dropdown. + +![identities press cog](/images/platform/identities/identities-press-cog.png) + +![identities page remove default auth](/images/platform/identities/identities-page-remove-default-auth.png) + +Now create a new OCI Auth Method. + +![identities create oci auth method](/images/platform/identities/identities-org-create-oci-auth-method.png) + +Here's some information about each field: +- **Tenancy OCID:** The OCID of your tenancy. All users authenticating must be part of this Tenancy. +- **Allowed Usernames:** A comma-separated list of trusted OCI users that are allowed to authenticate with Infisical. +- **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 an infinite number of uses. +- **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. + +### Adding an identity to a project + +In order to allow an identity to access project-level resources such as secrets, you must add it to the relevant projects. + +To do this, head over to the project you want to add the identity to and navigate to Project Settings > Access Control > Machine Identities and press **Add Identity**. + +![identities project](/images/platform/identities/identities-project.png) + +Select the identity you want to add to the project and the project-level role you want it to assume. The project role given to the identity will determine what project-level resources this identity can access. + +![identities project create](/images/platform/identities/identities-project-create.png) + +### Accessing the Infisical API with the identity + +To access the Infisical API as the identity, you need to construct a signed [Get User](https://docs.oracle.com/en/engineered-systems/private-cloud-appliance/3.0-latest/ceapi/op-20160918-users-user_id-get.html) request using [OCI Signature v1](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/signingrequests.htm#Request_Signatures) and then make a request to the `/api/v1/auth/oci-auth/login` endpoint passing the signed header data and user OCID. + +Below is an example of how you can authenticate with Infisical using the `oci-sdk` for NodeJS. + +```typescript +import { common } from "oci-sdk"; + +// Change these credentials to match your OCI user +const tenancyId = "ocid1.tenancy.oc1..example"; +const userId = "ocid1.user.oc1..example"; +const fingerprint = "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00"; +const region = "us-ashburn-1"; +const privateKey = "..."; // Must be PEM format + +const provider = new common.SimpleAuthenticationDetailsProvider( + tenancyId, + userId, + fingerprint, + privateKey, + null, + common.Region.fromRegionId(region), +); + +// Build request +const headers = new Headers({ + host: `identity.${region}.oraclecloud.com`, +}); + +const request: common.HttpRequest = { + method: "GET", + uri: `/20160918/users/${userId}`, + headers, + body: null, +}; + +// Sign request +const signer = new common.DefaultRequestSigner(provider); +await signer.signHttpRequest(request); + +// Forward signed request to Infisical +const requestAsJson = { + identityId: "2dd11664-68e3-471d-b366-907206ab1bff", + userOcid: userId, + headers: Object.fromEntries(request.headers.entries()), +}; + +const res = await fetch("https://app.infisical.com/api/v1/auth/oci-auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestAsJson), +}); + +const json = await res.json(); + +console.log("Infisical Response:", json); +``` + + + Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; the default TTL is `7200` seconds, which can be adjusted. + + If an identity access token expires, it can no longer access the Infisical API. A new access token should be obtained by performing another login operation. + diff --git a/docs/documentation/platform/identities/oidc-auth/azure.mdx b/docs/documentation/platform/identities/oidc-auth/azure.mdx new file mode 100644 index 000000000..a9f244794 --- /dev/null +++ b/docs/documentation/platform/identities/oidc-auth/azure.mdx @@ -0,0 +1,157 @@ +--- +title: Azure +description: "Learn how to authenticate Azure pipelines with Infisical using OpenID Connect (OIDC)." +--- + +**OIDC Auth** is a platform-agnostic JWT-based authentication method that can be used to authenticate from any platform or environment using an identity provider with OpenID Connect. + +## Diagram + +The following sequence diagram illustrates the OIDC Auth workflow for authenticating Azure pipelines with Infisical. + +```mermaid +sequenceDiagram + participant Client as Azure Pipeline + participant Idp as Identity Provider + participant Infis as Infisical + + Client->>Idp: Step 1: Request identity token + Idp-->>Client: Return JWT with verifiable claims + + Note over Client,Infis: Step 2: Login Operation + Client->>Infis: Send signed JWT to /api/v1/auth/oidc-auth/login + + Note over Infis,Idp: Step 3: Query verification + Infis->>Idp: Request JWT public key using OIDC Discovery + Idp-->>Infis: Return public key + + Note over Infis: Step 4: JWT validation + Infis->>Client: Return short-lived access token + + Note over Client,Infis: Step 5: Access Infisical API with Token + Client->>Infis: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high-level, Infisical authenticates a client by verifying the JWT and checking that it meets specific requirements (e.g. it is issued by a trusted identity provider) at the `/api/v1/auth/oidc-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The Azure pipeline requests an identity token from Azure's identity provider. +2. The fetched identity token is sent to Infisical at the `/api/v1/auth/oidc-auth/login` endpoint. +3. Infisical fetches the public key that was used to sign the identity token from Azure's identity provider using OIDC Discovery. +4. Infisical validates the JWT using the public key provided by the identity provider and checks that the subject, audience, and claims of the token matches with the set criteria. +5. If all is well, Infisical returns a short-lived access token that the Azure pipeline can use to make authenticated requests to the Infisical API. + + + Infisical needs network-level access to Azure's identity provider endpoints. + + +## Guide + +In the following steps, we explore how to create and use identities to access the Infisical API using the OIDC Auth authentication method. + + + + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be redirected to a page where you can manage the identity. + + ![identities page](/images/platform/identities/identities-page.png) + + Since the identity has been configured with Universal Auth by default, you should re-configure it to use OIDC Auth instead. To do this, press to edit the **Authentication** section, + remove the existing Universal Auth configuration, and add a new OIDC Auth configuration onto the identity. + + ![identities page remove default auth](/images/platform/identities/identities-page-remove-default-auth.png) + + ![identities create oidc auth method](/images/platform/identities/identities-org-create-oidc-auth-method.png) + + Restrict access by configuring the Subject, Audiences, and Claims fields + + Here's some more guidance on each field: + -
**OIDC Discovery URL**: The URL used to retrieve the OpenID Connect configuration from the identity provider. This is used to fetch the public keys needed to verify the JWT. For Azure, set this to `https://login.microsoftonline.com/{tenant-id}/v2.0` (replace `{tenant-id}` with your Azure AD tenant ID).
+ -
**Issuer**: The value of the `iss` claim that the token must match. For Azure, this should be `https://login.microsoftonline.com/{tenant-id}/v2.0`.
+ - **Subject**: This must match the `sub` claim in the JWT. + - **Audiences**: Values that must match the `aud` claim. + - **Claims**: Additional claims that must be present. Refer to [Azure DevOps docs](https://learn.microsoft.com/en-us/azure/devops/pipelines/library/connect-to-azure?view=azure-devops#workload-identity-federation) for available claims. + - **Access Token TTL**: Lifetime of the issued token (in seconds), e.g., `2592000` (30 days) + - **Access Token Max TTL**: Maximum allowed lifetime of the token + - **Access Token Max Number of Uses**: Max times the token can be used (`0` = unlimited) + - **Access Token Trusted IPs**: List of allowed IP ranges (defaults to `0.0.0.0/0`) + + If you are unsure about what to configure for the subject, audience, and claims fields, you can inspect the JWT token from your Azure DevOps pipeline by adding a debug step that outputs the token claims. + The `subject`, `audiences`, and `claims` fields support glob pattern matching; however, we highly recommend using hardcoded values whenever possible. +
+ + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + In Azure DevOps, to authenticate with Infisical using OIDC, you must configure a service connection that enables workload identity federation. + + Once set up, the OIDC token can be fetched automatically within the pipeline job context. Here's an example: + + ```yaml + trigger: + - main + + pool: + vmImage: ubuntu-latest + + steps: + - task: AzureCLI@2 + displayName: 'Retrieve secrets from Infisical using OIDC' + inputs: + azureSubscription: 'your-azure-service-connection-name' + scriptType: 'bash' + scriptLocation: 'inlineScript' + addSpnToEnvironment: true + inlineScript: | + # Get OIDC access token + OIDC_TOKEN=$(az account get-access-token --resource "api://AzureADTokenExchange" --query accessToken -o tsv) + + [ -z "$OIDC_TOKEN" ] && { echo "Failed to get access token"; exit 1; } + + # Exchange for Infisical access token + ACCESS_TOKEN=$(curl -s -X POST "/api/v1/auth/oidc-auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"identityId\":\"{your-identity-id}\",\"jwt\":\"$OIDC_TOKEN\"}" \ + | jq -r '.accessToken') + + # Fetch secrets + curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \ + "/api/v3/secrets/raw?environment={your-environment-slug}&workspaceSlug={your-workspace-slug}" + ``` + + Make sure the service connection is properly configured for workload identity federation and linked to your Azure AD app registration with appropriate claims. + + + Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; + the default TTL is `7200` seconds which can be adjusted. + + If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation. + + + +
diff --git a/docs/documentation/platform/identities/oidc-auth/circleci.mdx b/docs/documentation/platform/identities/oidc-auth/circleci.mdx index ddf74e3fa..bb5999f55 100644 --- a/docs/documentation/platform/identities/oidc-auth/circleci.mdx +++ b/docs/documentation/platform/identities/oidc-auth/circleci.mdx @@ -52,7 +52,7 @@ In the following steps, we explore how to create and use identities to access th - To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. ![identities organization](/images/platform/identities/identities-org.png) @@ -163,7 +163,7 @@ In the following steps, we explore how to create and use identities to access th } ``` - Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; the default TTL is `7200` seconds which can be adjusted. If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, diff --git a/docs/documentation/platform/identities/oidc-auth/general.mdx b/docs/documentation/platform/identities/oidc-auth/general.mdx index 776d175a4..f847f51fe 100644 --- a/docs/documentation/platform/identities/oidc-auth/general.mdx +++ b/docs/documentation/platform/identities/oidc-auth/general.mdx @@ -56,7 +56,7 @@ In the following steps, we explore how to create and use identities to access th - To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. ![identities organization](/images/platform/identities/identities-org.png) @@ -159,7 +159,7 @@ In the following steps, we explore how to create and use identities to access th - Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; the default TTL is `7200` seconds which can be adjusted. If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, diff --git a/docs/documentation/platform/identities/oidc-auth/github.mdx b/docs/documentation/platform/identities/oidc-auth/github.mdx index 47352a339..567f38d05 100644 --- a/docs/documentation/platform/identities/oidc-auth/github.mdx +++ b/docs/documentation/platform/identities/oidc-auth/github.mdx @@ -55,7 +55,7 @@ In the following steps, we explore how to create and use identities to access th - To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. ![identities organization](/images/platform/identities/identities-org.png) @@ -159,7 +159,7 @@ In the following steps, we explore how to create and use identities to access th - Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; the default TTL is `7200` seconds which can be adjusted. If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, diff --git a/docs/documentation/platform/identities/oidc-auth/gitlab.mdx b/docs/documentation/platform/identities/oidc-auth/gitlab.mdx index 228392aa6..b52d2f894 100644 --- a/docs/documentation/platform/identities/oidc-auth/gitlab.mdx +++ b/docs/documentation/platform/identities/oidc-auth/gitlab.mdx @@ -55,7 +55,7 @@ In the following steps, we explore how to create and use identities to access th - To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. ![identities organization](/images/platform/identities/identities-org.png) diff --git a/docs/documentation/platform/identities/oidc-auth/spire.mdx b/docs/documentation/platform/identities/oidc-auth/spire.mdx new file mode 100644 index 000000000..b402a1d10 --- /dev/null +++ b/docs/documentation/platform/identities/oidc-auth/spire.mdx @@ -0,0 +1,177 @@ +--- +title: SPIFFE/SPIRE +description: "Learn how to authenticate SPIRE workloads with Infisical using OpenID Connect (OIDC)." +--- + +**OIDC Auth** is a platform-agnostic JWT-based authentication method that can be used to authenticate from any platform or environment using an identity provider with OpenID Connect. + +## Diagram + +The following sequence diagram illustrates the OIDC Auth workflow for authenticating SPIRE workloads with Infisical. + +```mermaid +sequenceDiagram + participant Client as SPIRE Workload + participant Agent as SPIRE Agent + participant Server as SPIRE Server + participant Infis as Infisical + + Client->>Agent: Step 1: Request JWT-SVID + Agent->>Server: Validate workload and fetch signing key + Server-->>Agent: Return signing material + Agent-->>Client: Return JWT-SVID with verifiable claims + + Note over Client,Infis: Step 2: Login Operation + Client->>Infis: Send JWT-SVID to /api/v1/auth/oidc-auth/login + + Note over Infis,Server: Step 3: Query verification + Infis->>Server: Request JWT public key using OIDC Discovery + Server-->>Infis: Return public key + + Note over Infis: Step 4: JWT validation + Infis->>Client: Return short-lived access token + + Note over Client,Infis: Step 5: Access Infisical API with Token + Client->>Infis: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high-level, Infisical authenticates a SPIRE workload by verifying the JWT-SVID and checking that it meets specific requirements (e.g. it is issued by a trusted SPIRE server) at the `/api/v1/auth/oidc-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The SPIRE workload requests a JWT-SVID from the local SPIRE Agent. +2. The SPIRE Agent validates the workload's identity and requests signing material from the SPIRE Server. +3. The SPIRE Agent returns a JWT-SVID containing the workload's SPIFFE ID and other claims. +4. The JWT-SVID is sent to Infisical at the `/api/v1/auth/oidc-auth/login` endpoint. +5. Infisical fetches the public key that was used to sign the JWT-SVID from the SPIRE Server using OIDC Discovery. +6. Infisical validates the JWT-SVID using the public key provided by the SPIRE Server and checks that the subject, audience, and claims of the token matches with the set criteria. +7. If all is well, Infisical returns a short-lived access token that the workload can use to make authenticated requests to the Infisical API. + +Infisical needs network-level access to the SPIRE Server's OIDC Discovery endpoint. + +## Prerequisites + +Before following this guide, ensure you have: + +- A running SPIRE deployment with both SPIRE Server and SPIRE Agent configured +- OIDC Discovery Provider deployed alongside your SPIRE Server +- Workload registration entries created in SPIRE for the workloads that need to access Infisical +- Network connectivity between Infisical and your OIDC Discovery Provider endpoint + +For detailed SPIRE setup instructions, refer to the [SPIRE documentation](https://spiffe.io/docs/latest/spire-about/). + +## OIDC Discovery Provider Setup + +To enable JWT-SVID verification with Infisical, you need to deploy the OIDC Discovery Provider alongside your SPIRE Server. The OIDC Discovery Provider runs as a separate service that exposes the necessary OIDC endpoints. + +In Kubernetes deployments, this is typically done by adding an `oidc-discovery-provider` container to your SPIRE Server StatefulSet: + +```yaml +- name: spire-oidc + image: ghcr.io/spiffe/oidc-discovery-provider:1.12.2 + args: + - -config + - /run/spire/oidc/config/oidc-discovery-provider.conf + ports: + - containerPort: 443 + name: spire-oidc-port +``` + +The OIDC Discovery Provider will expose the OIDC Discovery endpoint at `https:///.well-known/openid_configuration`, which Infisical will use to fetch the public keys for JWT-SVID verification. + +For detailed setup instructions, refer to the [SPIRE OIDC Discovery Provider documentation](https://github.com/spiffe/spire/tree/main/support/oidc-discovery-provider). + +## Guide + +In the following steps, we explore how to create and use identities to access the Infisical API using the OIDC Auth authentication method with SPIFFE/SPIRE. + + + + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be redirected to a page where you can manage the identity. + + ![identities page](/images/platform/identities/identities-page.png) + + Since the identity has been configured with Universal Auth by default, you should re-configure it to use OIDC Auth instead. To do this, press to edit the **Authentication** section, + remove the existing Universal Auth configuration, and add a new OIDC Auth configuration onto the identity. + + ![identities page remove default auth](/images/platform/identities/identities-page-remove-default-auth.png) + + ![identities create oidc auth method](/images/platform/identities/identities-org-create-oidc-auth-method.png) + + Restrict access by configuring the Subject, Audiences, and Claims fields + + Here's some more guidance on each field: + - OIDC Discovery URL: The URL used to retrieve the OpenID Connect configuration from the SPIRE Server. This will be used to fetch the public key needed for verifying the provided JWT-SVID. This should be set to your SPIRE Server's OIDC Discovery endpoint, typically `https://:/.well-known/openid_configuration` + - Issuer: The unique identifier of the SPIRE Server issuing the JWT-SVID. This value is used to verify the iss (issuer) claim in the JWT-SVID to ensure the token is issued by a trusted SPIRE Server. This should match your SPIRE Server's configured issuer, typically `https://:` + - CA Certificate: The PEM-encoded CA certificate for establishing secure communication with the SPIRE Server endpoints. This should contain the CA certificate that signed your SPIRE Server's TLS certificate. + - Subject: The expected SPIFFE ID that is the subject of the JWT-SVID. The format of the sub field for SPIRE JWT-SVIDs follows the SPIFFE ID format: `spiffe:///`. For example: `spiffe://example.org/workload/api-server` + - Audiences: A list of intended recipients for the JWT-SVID. This value is checked against the aud (audience) claim in the token. When workloads request JWT-SVIDs from SPIRE, they specify an audience (e.g., `infisical` or your service name). Configure this to match what your workloads use. + - Claims: Additional information or attributes that should be present in the JWT-SVID for it to be valid. Standard SPIRE JWT-SVID claims include `sub` (SPIFFE ID), `aud` (audience), `exp` (expiration), and `iat` (issued at). You can also configure custom claims if your SPIRE Server includes additional metadata. + - 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 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. + SPIRE JWT-SVIDs contain standard claims like `sub` (SPIFFE ID), `aud` (audience), `exp`, and `iat`. The audience is typically specified when requesting the JWT-SVID (e.g., `spire-agent api fetch jwt -audience infisical`). + The `subject`, `audiences`, and `claims` fields support glob pattern matching; however, we highly recommend using hardcoded SPIFFE IDs whenever possible for better security. + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + Here's an example of how a workload can use its JWT-SVID to authenticate with Infisical and retrieve secrets: + + ```bash + #!/bin/bash + + # Obtain JWT-SVID from SPIRE Agent + JWT_SVID=$(spire-agent api fetch jwt -audience infisical -socketPath /run/spire/sockets/agent.sock | grep -A1 "token(" | tail -1) + + # Authenticate with Infisical using the JWT-SVID + ACCESS_TOKEN=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + -d "{\"identityId\":\"\",\"jwt\":\"$JWT_SVID\"}" \ + https://app.infisical.com/api/v1/auth/oidc-auth/login | jq -r '.accessToken') + + # Use the access token to retrieve secrets + curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://app.infisical.com/api/v3/secrets/raw?workspaceSlug=&environment=&secretPath=/" + ``` + + + Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; + the default TTL is `7200` seconds which can be adjusted. + + If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation. + + + + JWT-SVIDs from SPIRE have their own expiration time (typically short-lived). Ensure your application handles both JWT-SVID renewal from SPIRE and access token renewal from Infisical appropriately. + + + + \ No newline at end of file diff --git a/docs/documentation/platform/identities/token-auth.mdx b/docs/documentation/platform/identities/token-auth.mdx index 59c5f9abf..f31e86517 100644 --- a/docs/documentation/platform/identities/token-auth.mdx +++ b/docs/documentation/platform/identities/token-auth.mdx @@ -38,7 +38,7 @@ using the Token Auth authentication method. - To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. ![identities organization](/images/platform/identities/identities-org.png) @@ -77,9 +77,9 @@ using the Token Auth authentication method. - In order to use the identity with Token Auth, you'll need to create an (access) token; you can think of this token akin + In order to use the identity with Token Auth, you'll need to create an (access) token; you can think of this token akin to an API Key used to authenticate with the Infisical API. With that, press **Create Token**. - + ![identities client secret create](/images/platform/identities/identities-token-auth-create-1.png) ![identities client secret create](/images/platform/identities/identities-token-auth-create-2.png) @@ -106,7 +106,7 @@ using the Token Auth authentication method. to authenticate with the [Infisical API](/api-reference/overview/introduction). - Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; the default TTL is `7200` seconds which can be adjusted in the Token Auth configuration. If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, @@ -121,14 +121,14 @@ using the Token Auth authentication method. There are a few reasons for why this might happen: - + - The access token has expired. If this is the case, you should obtain a new access token or consider extending the token's TTL. - - The identity is insufficently permissioned to interact with the resources you wish to access. + - The identity is insufficiently permissioned to interact with the resources you wish to access. - The access token is being used from an untrusted IP. A identity access token can have a time-to-live (TTL) or incremental lifetime after which it expires. - + In certain cases, you may want to extend the lifespan of an access token; to do so, you must set a max TTL parameter. 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. diff --git a/docs/documentation/platform/identities/universal-auth.mdx b/docs/documentation/platform/identities/universal-auth.mdx index 4d66e30b4..51721f7bf 100644 --- a/docs/documentation/platform/identities/universal-auth.mdx +++ b/docs/documentation/platform/identities/universal-auth.mdx @@ -4,6 +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). ## Diagram @@ -42,7 +43,7 @@ using the Universal Auth authentication method. - To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. ![identities organization](/images/platform/identities/identities-org.png) @@ -64,7 +65,8 @@ 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. - ![identities organization create universal auth method](/images/platform/identities/identities-org-create-universal-auth-method.png) + ![identities organization create universal auth method 1](/images/platform/identities/identities-org-create-universal-auth-method-1.png) + ![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: @@ -73,29 +75,31 @@ using the Universal Auth authentication method. - 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. - If you’re using Infisical Cloud, then it is available under the Pro Tier. If you’re self-hosting Infisical, then you should contact sales@infisical.com to purchase an enterprise license to use it. + If you're using Infisical Cloud, then it is available under the Pro Tier. If you're self-hosting Infisical, then you should contact sales@infisical.com to purchase an enterprise license to use it. In order to use the identity, you'll need the non-sensitive **Client ID** of the identity and a **Client Secret** for it; you can think of these credentials akin to a username - and password used to authenticate with the Infisical API. + and password used to authenticate with the Infisical API. With that, press **Create Client Secret**. - + ![identities client secret create](/images/platform/identities/identities-universal-auth-create-1.png) ![identities client secret create](/images/platform/identities/identities-universal-auth-create-2.png) ![identities client secret create](/images/platform/identities/identities-universal-auth-create-3.png) - + Feel free to input any (optional) details for the **Client Secret** configuration: - + - Description: A description for the **Client Secret**. - TTL (default is `0`): The time-to-live for the **Client Secret**. By default, the TTL will be set to 0 which implies that the **Client Secret** will never expire; a value of `0` implies an infinite lifetime. - Max Number of Uses (default is `0`): The maximum number of times that the **Client Secret** can be used together with the **Client ID** to get back an access token; a value of `0` implies infinite number of uses. + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. @@ -113,10 +117,10 @@ using the Universal Auth authentication method. To access the Infisical API as the identity, you should first perform a login operation that is to exchange the **Client ID** and **Client Secret** of the identity for an access token by making a request to the `/api/v1/auth/universal-auth/login` endpoint. - + Choose the correct base URL based on your region: - + - For Infisical Cloud US users: `https://app.infisical.com` - For Infisical Cloud EU users: `https://eu.infisical.com` @@ -144,7 +148,7 @@ using the Universal Auth authentication method. Next, you can use the access token to authenticate with the [Infisical API](/api-reference/overview/introduction) - Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; the default TTL is `7200` seconds which can be adjusted in the Universal Auth configuration. If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, @@ -154,23 +158,50 @@ using the Universal Auth authentication method. +## Solving Secret Zero with Periodic Tokens + +In many automated, cloud-native, or ephemeral environments (such as VMs, containers, or serverless functions), it is often unsafe or impractical to hard-code long-lived credentials for bootstrapping access to secrets management systems. The "secret zero" problem refers to the challenge of securely providing a workload with its initial credential, without manual intervention or static secrets that could be leaked or reused. Periodic tokens in Universal Auth are designed to solve this problem by enabling secure, automated bootstrapping and ongoing access renewal, even in dynamic or short-lived environments. + +A common challenge in cloud-native and automated environments is the "secret zero" problem: how to securely bootstrap a workload (such as a VM, container, or serverless function) with its first credential, without hard-coding static secrets or requiring manual intervention. + +**Periodic tokens** in Universal Auth solve this by allowing you to issue an access token that can be continuously renewed by your workload before it expires (i.e., a client-initiated rotation mechanism): + +- When you set the **Access Token Period** in the Universal Auth configuration, the issued access token can be renewed by your workload for the specified period (in seconds). +- The token can be renewed any number of times, each time for the same period, with no maximum lifetime (unless you set a use limit). +- As long as the token is renewed before its period expires, it remains valid, so you do not need to re-issue static credentials. +- TTL and Max TTL are ignored when Access Token Period is set. + +### Example: Bootstrapping with a Periodic Token + +1. Configure Universal Auth for your identity and set **Access Token Period** (e.g., `3600` for 1 hour). +2. For improved security, configure the Client Secret with a low number of uses (e.g., `1`) or a short TTL. This ensures that after the initial login, the Client Secret cannot be reused, and any disruption in token renewal will require manual intervention. +3. Deploy your workload with the **Client Secret** and **Client ID**. +4. The workload authenticates with Infisical using the Client Secret and Client ID to obtain the initial access token (JWT). +5. The workload uses the access token to authenticate and continuously renews it before expiration: + +```bash +curl --location --request POST 'https://app.infisical.com/api/v1/auth/universal-auth/renew' \ + --header 'Authorization: Bearer ' +``` + +This approach allows your workload to securely bootstrap and maintain access to Infisical without hard-coded secrets, solving the secret zero problem. + **FAQ** - - There are a few reasons for why this might happen: - - - The client secret or access token has expired. - - The identity is insufficently permissioned to interact with the resources you wish to access. - - The client secret/access token is being used from an untrusted IP. - - - A identity access token can have a time-to-live (TTL) or incremental lifetime after which it expires. - - In certain cases, you may want to extend the lifespan of an access token; to do so, you must set a max TTL parameter. + + There are a few reasons for why this might happen: + - The client secret or access token has expired. + - The identity is insufficiently permissioned to interact with the resources you wish to access. + - The client secret/access token is being used from an untrusted IP. + + + A identity access token can have a time-to-live (TTL) or incremental lifetime after which it expires. -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. + In certain cases, you may want to extend the lifespan of an access token; to do so, you must set a max TTL parameter. - + 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. + + diff --git a/docs/documentation/platform/kms/hsm-integration.mdx b/docs/documentation/platform/kms/hsm-integration.mdx index a9ab2c832..c7d4d32fa 100644 --- a/docs/documentation/platform/kms/hsm-integration.mdx +++ b/docs/documentation/platform/kms/hsm-integration.mdx @@ -29,7 +29,6 @@ Using a hardware security module comes with the added benefit of having a secure Enabling HSM encryption has a set of key benefits: 1. **Root Key Wrapping**: The root KMS encryption key that is used to secure your Infisical instance will be encrypted using the HSM device rather than the standard software-protected key. -2. **FIPS 140-2/3 Compliance**: Using an HSM device ensures that your Infisical instance is FIPS 140-2 or FIPS 140-3 compliant. For FIPS 140-3, ensure that your HSM is FIPS 140-3 validated. #### Caveats - **Performance**: Using an HSM device can have a performance impact on your Infisical instance. This is due to the additional latency introduced by the HSM device. This is however only noticeable when your instance(s) start up or when the encryption strategy is changed. @@ -38,29 +37,22 @@ Enabling HSM encryption has a set of key benefits: ### Requirements - An Infisical instance with a version number that is equal to or greater than `v0.91.0`. - If you are using Docker, your instance must be using the `infisical/infisical-fips` image. -- An HSM device from a provider such as [Thales Luna HSM](https://cpl.thalesgroup.com/encryption/data-protection-on-demand/services/luna-cloud-hsm), [AWS CloudHSM](https://aws.amazon.com/cloudhsm/), or others. +- An HSM device from a provider such as [Thales Luna HSM](https://cpl.thalesgroup.com/encryption/data-protection-on-demand/services/luna-cloud-hsm), [AWS CloudHSM](https://aws.amazon.com/cloudhsm/), [Fortanix HSM](https://www.fortanix.com/platform/data-security-manager), or others. -### FIPS Compliance -FIPS, also known as the Federal Information Processing Standard, is a set of standards that are used to accredit cryptographic modules. FIPS 140-2 and FIPS 140-3 are the two most common standards used for cryptographic modules. If your HSM uses FIPS 140-3 validated hardware, Infisical will automatically be FIPS 140-3 compliant. If your HSM uses FIPS 140-2 validated hardware, Infisical will be FIPS 140-2 compliant. - -HSM devices are especially useful for organizations that operate in regulated industries such as healthcare, finance, and government, where data security and compliance are of the utmost importance. - -For organizations that work with US government agencies, FIPS compliance is almost always a requirement when dealing with sensitive information. FIPS compliance ensures that the cryptographic modules used by the organization meet the security requirements set by the US government. - ## Setup Instructions - To set up HSM encryption, you need to configure an HSM provider and HSM key. The HSM provider is used to connect to the HSM device, and the HSM key is used to encrypt Infisical's KMS keys. We recommend using a Cloud HSM provider such as [Thales Luna HSM](https://cpl.thalesgroup.com/encryption/data-protection-on-demand/services/luna-cloud-hsm) or [AWS CloudHSM](https://aws.amazon.com/cloudhsm/). + To set up HSM encryption, you need to configure an HSM provider and HSM key. The HSM provider is used to connect to the HSM device, and the HSM key is used to encrypt Infisical's KMS keys. We recommend using a Cloud HSM provider such as [Thales Luna HSM](https://cpl.thalesgroup.com/encryption/data-protection-on-demand/services/luna-cloud-hsm), [AWS CloudHSM](https://aws.amazon.com/cloudhsm/), or [Fortanix HSM](https://www.fortanix.com/platform/data-security-manager). You need to follow the instructions provided by the HSM provider to set up the HSM device. Once the HSM device is set up, the HSM device can be used within Infisical. After setting up the HSM from your provider, you will have a set of files that you can use to access the HSM. These files need to be present on the machine where Infisical is running. If you are using containers, you will need to mount the folder where these files are stored as a volume in the container. - The setup process for an HSM device varies depending on the provider. We have created a guide for Thales Luna Cloud HSM, which you can find below. + The setup process for an HSM device varies depending on the provider. We have created guides for Thales Luna Cloud HSM and Fortanix HSM, which you can find below. @@ -255,6 +247,78 @@ For organizations that work with US government agencies, FIPS compliance is almo After following these steps, your Docker setup will be ready to use HSM encryption. + + + + To use Fortanix HSM with Infisical, you need to: + + 1. Create an App in Fortanix: + - Set Interface value to be PKCS#11 + - Select API key as authentication method + - Assign app to a group + + ![Fortanix HSM Setup](/images/platform/kms/hsm/fortanix-hsm-setup.png) + + 2. Take note of the domain (e.g., apac.smartkey.io). You will need this to set up the configuration file for the Fortanix client. + + + + The easiest approach would be to download the `.so` file for Linux directly from the [Fortanix PKCS#11 installation page](https://fortanix.zendesk.com/hc/en-us/sections/4408769080724-PKCS-11). + + Create a configuration file named `pkcs11.conf` with the following content: + + ``` + api_endpoint = "https://apac.smartkey.io" + prevent_duplicate_opaque_objects = true + retry_timeout_millis = 60000 + ``` + + Note: Replace `apac.smartkey.io` with your actual Fortanix domain if different. For more details about the configuration file format and additional options, refer to the [Fortanix PKCS#11 Configuration File Documentation](https://support.fortanix.com/docs/clients-pkcs11-library#511-configuration-file-format). + + + + Create a directory to store the Fortanix library and configuration file: + + ```bash + mkdir -p /etc/fortanix-hsm + ``` + + Copy the downloaded `.so` file and the `pkcs11.conf` file to this directory: + + ```bash + cp /path/to/fortanix_pkcs11_4.37.2554.so /etc/fortanix-hsm/ + cp /path/to/pkcs11.conf /etc/fortanix-hsm/ + ``` + + + + Run Docker with Fortanix HSM by mounting the directory and setting the required environment variables: + + ```bash + docker run -p 80:8080 \ + -v /etc/fortanix-hsm:/etc/fortanix-hsm \ + -e HSM_LIB_PATH="/etc/fortanix-hsm/fortanix_pkcs11_4.37.2554.so" \ # Path to the PKCS#11 library + -e HSM_PIN="MDE3YWUxO..." \ # Your Fortanix app API key used for authentication + -e HSM_SLOT=0 \ # Slot value (arbitrary for Fortanix HSM) + -e HSM_KEY_LABEL="hsm-key-label" \ # Label to identify the encryption key in the HSM + -e FORTANIX_PKCS11_CONFIG_PATH="/etc/fortanix-hsm/pkcs11.conf" \ # Path to Fortanix configuration file + + # The rest are unrelated to HSM setup... + -e ENCRYPTION_KEY="<>" \ + -e AUTH_SECRET="<>" \ + -e DB_CONNECTION_URI="<>" \ + -e REDIS_URL="<>" \ + -e SITE_URL="<>" \ + infisical/infisical-fips: # Replace with the version you want to use + ``` + + + Note: Fortanix HSM integration only works for AMD64 CPU architectures. + + + + After following these steps, your Docker setup will be ready to use Fortanix HSM encryption. + @@ -569,6 +633,173 @@ For organizations that work with US government agencies, FIPS compliance is almo After following these steps, your Kubernetes setup will be ready to use HSM encryption. + + + + First, you need to set up Fortanix HSM by: + + 1. Creating an App in Fortanix: + - Set Interface value to be PKCS#11 + - Select API key as authentication method + - Assign app to a group + + ![Fortanix HSM Setup](/images/platform/kms/hsm/fortanix-hsm-setup.png) + + 2. Take note of the domain (e.g., apac.smartkey.io). You will need this when setting up the configuration file. + + + + Create a directory to store the Fortanix configuration files: + + ```bash + mkdir -p /etc/fortanix-hsm + ``` + + Download the Fortanix PKCS#11 library for Linux from the [Fortanix PKCS#11 installation page](https://fortanix.zendesk.com/hc/en-us/sections/4408769080724-PKCS-11). + + Create a configuration file named `pkcs11.conf` with the following content: + + ``` + api_endpoint = "https://apac.smartkey.io" + prevent_duplicate_opaque_objects = true + retry_timeout_millis = 60000 + ``` + + Note: Replace `apac.smartkey.io` with your actual Fortanix domain if different. + + + + Create a Persistent Volume Claim to store the Fortanix files: + + ```bash + kubectl apply -f - < + + + Update your Kubernetes secret with the Fortanix HSM environment variables: + + ```yaml + apiVersion: v1 + kind: Secret + metadata: + name: infisical-secrets + type: Opaque + stringData: + # ... Other environment variables ... + HSM_LIB_PATH: "/etc/fortanix-hsm/fortanix_pkcs11_4.37.2554.so" # Path to the PKCS#11 library in the container + HSM_PIN: "" # Your Fortanix app API key used for authentication + HSM_SLOT: "0" # Slot value (can be set to 0 for Fortanix HSM as it's arbitrary) + HSM_KEY_LABEL: "hsm-key-label" # Label to identify the encryption key in the HSM + FORTANIX_PKCS11_CONFIG_PATH: "/etc/fortanix-hsm/pkcs11.conf" # Path to Fortanix configuration file + ``` + + Apply the updated secret: + + ```bash + kubectl apply -f ./secret-file-name.yaml + ``` + + + + Update your Helm values to use the FIPS-compliant image and mount the Fortanix HSM files: + + ```yaml + # ... The rest of the values.yaml file ... + + image: + repository: infisical/infisical-fips # Must use "infisical/infisical-fips" + tag: "v0.117.1-postgres" + pullPolicy: IfNotPresent + + extraVolumeMounts: + - name: fortanix-data + mountPath: /etc/fortanix-hsm # The path where Fortanix files will be available + + extraVolumes: + - name: fortanix-data + persistentVolumeClaim: + claimName: fortanix-hsm-pvc + + # ... The rest of the values.yaml file ... + ``` + + + Note: Fortanix HSM integration only works for AMD64 CPU architectures. + + + + + Upgrade the Helm chart with the new values: + + ```bash + helm upgrade --install infisical infisical-helm-charts/infisical-standalone --values /path/to/values.yaml + ``` + + Restart the deployment: + + ```bash + kubectl rollout restart deployment/infisical-infisical + ``` + + + After following these steps, your Kubernetes setup will be ready to use Fortanix HSM encryption. + diff --git a/docs/documentation/platform/ldap/general.mdx b/docs/documentation/platform/ldap/general.mdx index 939eaa727..c1d062ef5 100644 --- a/docs/documentation/platform/ldap/general.mdx +++ b/docs/documentation/platform/ldap/general.mdx @@ -18,7 +18,9 @@ Prerequisites: - In Infisical, head to your Organization Settings > Security > LDAP and select **Manage**. + In Infisical, head to the **Single Sign-On (SSO)** page and select the **General** tab. Select **Connect** for **LDAP**. + + ![LDAP SSO Connect](../../../images/sso/connect-ldap.png) Next, input your LDAP server settings. diff --git a/docs/documentation/platform/ldap/jumpcloud.mdx b/docs/documentation/platform/ldap/jumpcloud.mdx index 39579b785..d520598d1 100644 --- a/docs/documentation/platform/ldap/jumpcloud.mdx +++ b/docs/documentation/platform/ldap/jumpcloud.mdx @@ -27,7 +27,9 @@ Prerequisites: ![LDAP JumpCloud](/images/platform/ldap/jumpcloud/ldap-jumpcloud-enable-bind-dn.png) - In Infisical, head to your Organization Settings > Security > LDAP and select **Manage**. + In Infisical, head to the **Single Sign-On (SSO)** page and select the **General** tab. Select **Connect** for **LDAP**. + + ![LDAP SSO Connect](../../../images/sso/connect-ldap.png) Next, input your JumpCloud LDAP server settings. diff --git a/docs/documentation/platform/organization.mdx b/docs/documentation/platform/organization.mdx index 3a53484fb..6c3b218ab 100644 --- a/docs/documentation/platform/organization.mdx +++ b/docs/documentation/platform/organization.mdx @@ -20,6 +20,7 @@ The **Settings** page lets you manage information about your organization includ - **Slug**: The slug of your organization. - **Default Organization Member Role**: The role assigned to users when joining your organization unless otherwise specified. - **Incident Contacts**: Emails that should be alerted if anything abnormal is detected within the organization. +- **Enabled Products**: Products which are enabled for your organization. This setting strictly affects the sidebar UI; disabling a product does not disable its API or routes. ![organization settings general](../../images/platform/organization/organization-settings-general.png) @@ -43,7 +44,7 @@ In the **Organization Roles** tab, you can edit current or create new custom rol Note that Role-Based Access Management (RBAC) is partly a paid feature. - + Infisical provides immutable roles like `admin`, `member`, etc. at the organization and project level for free. diff --git a/docs/documentation/platform/pki/acme-ca.mdx b/docs/documentation/platform/pki/acme-ca.mdx new file mode 100644 index 000000000..495d20917 --- /dev/null +++ b/docs/documentation/platform/pki/acme-ca.mdx @@ -0,0 +1,299 @@ +--- +title: "Certificates with ACME CA" +description: "Learn how to automatically provision and manage TLS certificates using ACME Certificate Authorities like Let's Encrypt with Infisical PKI" +--- + +## Concept + +The Infisical ACME integration allows you to connect with ACME (Automatic Certificate Management Environment) Certificate Authorities to automatically issue and manage publicly trusted TLS certificates for your [subscribers](/documentation/platform/pki/subscribers). This integration enables you to leverage established public CA infrastructure like Let's Encrypt while centralizing your certificate management within Infisical. + +ACME is a protocol that automates the process of certificate issuance and renewal through domain validation challenges. The integration is perfect for obtaining trusted X.509 certificates for public-facing services and is capable of automatically renewing certificates as needed. + +
+ +```mermaid +graph TD + A[ACME CA Provider
e.g., Let's Encrypt] <-->|ACME v2 Protocol| B[Infisical] + B -->|Creates TXT Records
via Route53| C[DNS Validation] + B -->|Manages Certificates| D[Subscribers] +``` + +
+ +As part of the workflow, you configure DNS provider credentials, register an ACME CA provider with Infisical, and create subscribers to represent the certificates you wish to issue. Each issued certificate is automatically managed through its lifecycle, including renewal before expiration. + +We recommend reading about [ACME protocol](https://tools.ietf.org/html/rfc8555) and [DNS-01 challenges](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge) for a fuller understanding of the underlying technology. + +## Workflow + +A typical workflow for using Infisical with ACME Certificate Authorities consists of the following steps: + +1. Setting up AWS Route53 credentials with appropriate DNS permissions. +2. Creating an AWS connection in Infisical to store the Route53 credentials. +3. Registering an ACME Certificate Authority (like Let's Encrypt) with Infisical. +4. Creating subscribers that use the ACME CA as their issuing authority. +5. Managing certificate lifecycle events such as issuance, renewal, and revocation through Infisical. + +## Understanding ACME DNS-01 Challenge + +The DNS-01 challenge is the method used by ACME CA providers to verify that you control a domain before issuing a certificate. Here's how Infisical handles this process: + +1. **Challenge Request**: When you request a certificate, the ACME provider (like Let's Encrypt) issues a challenge token. + +2. **DNS Record Creation**: Infisical creates a TXT record at `_acme-challenge.` with a value derived from the challenge token. + +3. **DNS Propagation**: The TXT record must propagate through the DNS system (usually takes a few minutes, depending on TTL settings). + +4. **Validation**: The ACME provider checks for the existence of this TXT record to verify domain control. + +5. **Cleanup**: After validation completes successfully, Infisical automatically removes the TXT record from your DNS. + +This automated process eliminates the need for manual intervention in domain validation, streamlining certificate issuance. + +## Guide + +In the following steps, we explore how to set up ACME Certificate Authority integration with Infisical using Let's Encrypt as an example. + + + + Before proceeding with the ACME CA registration, you need to set up an AWS connection with the appropriate permissions for DNS validation: + + 1. Navigate to your Organization Settings > App Connections and create a new AWS connection. + + 2. Ensure your AWS connection has the following minimum permissions for Route53 DNS validation: + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "route53:GetChange", + "Resource": "arn:aws:route53:::change/*" + }, + { + "Effect": "Allow", + "Action": "route53:ListHostedZonesByName", + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "route53:ListResourceRecordSets" + ], + "Resource": [ + "arn:aws:route53:::hostedzone/YOUR_HOSTED_ZONE_ID" + ] + }, + { + "Effect": "Allow", + "Action": [ + "route53:ChangeResourceRecordSets" + ], + "Resource": [ + "arn:aws:route53:::hostedzone/YOUR_HOSTED_ZONE_ID" + ], + "Condition": { + "ForAllValues:StringEquals": { + "route53:ChangeResourceRecordSetsRecordTypes": [ + "TXT" + ] + } + } + } + ] + } + ``` + + Replace `YOUR_HOSTED_ZONE_ID` with your actual Route53 hosted zone ID. + + For detailed instructions on setting up an AWS connection, see the [AWS Connection](/integrations/app-connections/aws) documentation. + + + + + + + To register an ACME CA, head to your Project > Internal PKI > Certificate Authorities and press the **+** button in the External Certificate Authorities section. + + ![pki register external ca](/images/platform/pki/ca/external-ca/create-external-ca-button.png) + + Fill out the details for the ACME CA registration: + + ![pki register external ca details](/images/platform/pki/ca/external-ca/create-external-ca-form.png) + + Here's guidance on each field: + + - **Type**: Select "ACME" as the External CA type. + - **Name**: Enter a name for the ACME CA (e.g., "lets-encrypt-production"). + - **DNS App Connection**: Select from available DNS app connections or configure a new one. This connection provides Infisical with the credentials needed to create and remove DNS records for ACME validation. + - **Hosted Zone ID**: Enter your Route53 hosted zone ID (e.g., Z04044I124N1GOOMCOYX1) for the domain(s) you'll be requesting certificates for. + - **Directory URL**: Enter the ACME v2 directory URL for your chosen CA provider (e.g., `https://acme-v02.api.letsencrypt.org/directory` for Let's Encrypt). + - **Account Email**: Email address to associate with your ACME account. This email will receive important notifications about your certificates. + - **Enable Direct Issuance**: Toggle on to allow direct certificate issuance without requiring subscribers. + + Finally, press **Create** to register the ACME CA with Infisical. + + + Once registered, your ACME CA will appear in the External Certificate Authorities section. + + ![pki external ca list](/images/platform/pki/ca/external-ca/external-ca-list.png) + + From here, you can: + + - View the status of the ACME CA registration + - Edit the configuration settings + - Disable or re-enable the ACME CA + - Delete the ACME CA registration if no longer needed + + You can now use this ACME CA to issue certificates for your subscribers. + + + + + To register an ACME CA with Infisical using the API, make a request to the Create External CA endpoint: + + ### Sample request + + ```bash Request + curl 'https://app.infisical.com/api/v1/pki/ca/acme' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + --data-raw '{ + "projectId": "0fccb6ee-1381-4ff1-8d5f-0cb93c6cc4d6", + "name": "lets-encrypt-production", + "type": "acme", + "status": "active", + "enableDirectIssuance": true, + "configuration": { + "dnsAppConnection": { + "id": "1e5f8c0d-09d2-492c-9b28-469acd8e841b", + "name": "acme-dns-test-connection" + }, + "dnsProviderConfig": { + "provider": "route53", + "hostedZoneId": "Z040441124N1GOOMCQYX1" + }, + "directoryUrl": "https://acme-v02.api.letsencrypt.org/directory", + "accountEmail": "admin@example.com", + "dnsAppConnectionId": "1e5f8c0d-09d2-492c-9b28-469acd8e841b" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "id": "c48b701e-a20c-4a9a-8119-68f54e5fbb05", + "name": "lets-encrypt-production", + "type": "acme", + "status": "active", + "projectId": "0fccb6ee-1381-4ff1-8d5f-0cb93c6cc4d6", + "enableDirectIssuance": true, + "configuration": { + "accountEmail": "admin@example.com", + "directoryUrl": "https://acme-v02.api.letsencrypt.org/directory", + "dnsAppConnection": { + "id": "1e5f8c0d-09d2-492c-9b28-469acd8e841b", + "name": "acme-dns-test-connection" + }, + "dnsAppConnectionId": "1e5f8c0d-09d2-492c-9b28-469acd8e841b", + "dnsProviderConfig": { + "provider": "route53", + "hostedZoneId": "Z040441124N1GOOMCQYX1" + } + } + } + ``` + + + + + Next, create a subscriber that uses your ACME CA for certificate issuance. Navigate to your Project > Subscribers and create a new subscriber. + + Configure the subscriber with: + - **Issuing CA**: Select your registered ACME CA + - **Common Name**: The domain for which you want to issue certificates (e.g., `example.com`) + - **Alternative Names**: Additional domains to include in the certificate + + Check out the [Subscribers](/documentation/platform/pki/subscribers) page for detailed instructions on creating and managing subscribers. + + + Once your subscriber is configured, you can issue certificates either through the Infisical UI or programmatically via the API. + + When you request a certificate: + 1. Infisical generates a key pair for the certificate + 2. Sends a Certificate Signing Request (CSR) to the ACME CA + 3. Receives a DNS-01 challenge from the ACME provider + 4. Creates a TXT record in Route53 to satisfy the challenge + 5. Notifies the ACME provider that the challenge is ready for validation + 6. Once validated, the ACME provider issues the certificate + 7. Infisical stores and manages the certificate for your subscriber + + The certificate will be automatically renewed before expiration according to your subscriber configuration. + + + The issued certificate and private key are now available through Infisical and can be: + + - Downloaded directly from the Infisical UI + - Retrieved via the Infisical API for programmatic access using the [latest certificate bundle endpoint](/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle) + + + +## Example: Let's Encrypt Integration + +Let's Encrypt is a free, automated, and open Certificate Authority that provides domain-validated SSL/TLS certificates. Here's how the integration works with Infisical: + +### Production Environment +- **Directory URL**: `https://acme-v02.api.letsencrypt.org/directory` +- **Rate Limits**: 50 certificates per registered domain per week +- **Certificate Validity**: 90 days with automatic renewal +- **Trusted By**: All major browsers and operating systems + +### Staging Environment (for testing) +- **Directory URL**: `https://acme-staging-v02.api.letsencrypt.org/directory` +- **Rate Limits**: Much higher limits for testing +- **Certificate Validity**: 90 days (not trusted by browsers) +- **Use Case**: Testing your ACME integration without hitting production rate limits + + + Always test your ACME integration using Let's Encrypt's staging environment first. This allows you to verify your DNS configuration and certificate issuance process without consuming your production rate limits. + + +## FAQ + + + + Currently, Infisical supports DNS-01 validation through AWS Route53. The DNS-01 challenge method is preferred for ACME integrations because it: + + - Works with wildcard certificates + - Doesn't require your servers to be publicly accessible + - Can be fully automated without manual intervention + + Support for additional DNS providers is planned for future releases. + + + Yes! ACME CAs like Let's Encrypt support wildcard certificates (e.g., `*.example.com`) when using DNS-01 validation. Simply specify the wildcard domain in your subscriber configuration. + + Note that wildcard certificates still require DNS-01 validation - HTTP-01 validation cannot be used for wildcard certificates. + + + Most ACME providers issue certificates with 90-day validity periods. This shorter validity period is designed to: + + - Encourage automation of certificate management + - Reduce the impact of compromised certificates + - Ensure systems stay up-to-date with certificate management practices + + When configured, Infisical automatically handles certificate renewal for subscribers. + + + Yes! You can register multiple ACME CAs in the same project: + + - Different providers for different domains or use cases + - Staging and production environments for the same provider + - Backup providers for redundancy + + Each subscriber can be configured to use a specific ACME CA based on your requirements. + + \ No newline at end of file diff --git a/docs/documentation/platform/pki/certificates.mdx b/docs/documentation/platform/pki/certificates.mdx index 4976b5e18..f1c434e9c 100644 --- a/docs/documentation/platform/pki/certificates.mdx +++ b/docs/documentation/platform/pki/certificates.mdx @@ -75,8 +75,8 @@ In the following steps, we explore how to issue a X.509 certificate under a CA. Here's some guidance on each field: - Friendly Name: A friendly name for the certificate; this is only for display and defaults to the common name of the certificate if left empty. - - Common Name (CN): The (common) name for the certificate like `service.acme.com`. - - Alternative Names (SANs): A comma-delimited list of Subject Alternative Names (SANs) for the certificate; these can be host names or email addresses like `app1.acme.com, app2.acme.com`. + - Common Name (CN): The common name for the certificate like `service.acme.com`. + - Alternative Names (SANs): A comma-delimited list of Subject Alternative Names (SANs) for the certificate; these can be hostnames or email addresses like `app1.acme.com, app2.acme.com`. - TTL: The lifetime of the certificate in seconds. - Key Usage: The key usage extension of the certificate. - Extended Key Usage: The extended key usage extension of the certificate. @@ -240,7 +240,7 @@ openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem ``` Note that you can also obtain the CRL from the certificate itself by -referencing the CRL distribution point extension on the certificate itself. +referencing the CRL distribution point extension on the certificate. To check a certificate against the CRL distribution point specified within it with OpenSSL, you can use the following command: diff --git a/docs/documentation/platform/pki/external-ca.mdx b/docs/documentation/platform/pki/external-ca.mdx new file mode 100644 index 000000000..02285cac6 --- /dev/null +++ b/docs/documentation/platform/pki/external-ca.mdx @@ -0,0 +1,192 @@ +--- +title: "External CA" +sidebarTitle: "External CA" +description: "Learn how to connect External Certificate Authorities with Infisical." +--- + +## Concept + +In addition to creating a Private CA hierarchy, Infisical allows you to integrate with External Certificate Authorities (CAs) to issue digital certificates for your [subscribers](/documentation/platform/pki/subscribers). This integration enables you to leverage established certificate authority infrastructure while centralizing your certificate management within Infisical. + +
+ +```mermaid +graph TD + B[Infisical] -->|Manages Certificates| D[Subscribers] + + A1[Public CAs
Let's Encrypt, ZeroSSL] -->|ACME Protocol| B + A2[Enterprise CAs
Vault PKI, Step CA] -->|ACME Protocol| B + A3[Cloud CAs
ACME-compatible services] -->|ACME Protocol| B + + A4[Future: Enterprise CAs] -.->|EST/SCEP Protocols| B + A5[Future: Cloud CAs] -.->|REST APIs| B +``` + +
+ +When you integrate an External CA with Infisical, you benefit from: + +1. **Trust by Default**: Certificates issued by public CAs are trusted by default in browsers and operating systems. +2. **Unified Management**: Manage all certificates—both internally and externally issued—from a single platform. +3. **Automation**: Leverage Infisical's automation capabilities for certificate lifecycle management. +4. **Compliance**: Meet requirements for publicly trusted certificates, especially for public-facing services. +5. **Flexibility**: Choose the most appropriate CA for different use cases while maintaining consistent management. + +## General Workflow + +A typical workflow for integrating an External CA with Infisical consists of the following steps: + +1. **Select External CA Type**: Choose the appropriate external CA based on your requirements and supported protocols. +2. **Configure Prerequisites**: Set up any required credentials, connections, or configurations specific to your chosen CA type. +3. **Register External CA**: Add the External CA configuration to your Infisical project. +4. **Create Subscribers**: Set up subscribers that use the External CA as their issuing authority. +5. **Manage Certificate Lifecycle**: Handle certificate issuance, renewal, and revocation through Infisical's unified interface. + +The specific steps and requirements vary depending on the External CA type you choose to integrate. + +## Supported Integration Methods + +Infisical currently supports integration with External Certificate Authorities through the following protocol: + +### ACME Protocol Integration + +ACME (Automatic Certificate Management Environment) is a widely adopted protocol for automated certificate issuance and management. Infisical can integrate with any CA that supports the ACME protocol, including: + +**Public Certificate Authorities:** +- Let's Encrypt - Free, automated SSL/TLS certificates +- ZeroSSL - Free and premium SSL certificates +- Buypass - Norwegian CA with free ACME certificates + +**Enterprise Certificate Authorities:** +- HashiCorp Vault PKI - Enterprise secret management with ACME support +- Step CA - Open-source certificate authority with ACME + +**Cloud Certificate Authorities:** +- Some managed certificate services that support ACME protocol + +[Learn more about ACME integration →](/documentation/platform/pki/acme-ca) + +## Use Cases + +External CA integration is ideal for various scenarios: + +### Public-Facing Services +Use publicly trusted CAs for websites and services that need browser compatibility: +- Web applications and APIs +- Load balancers and CDNs +- Public-facing microservices + +### Compliance Requirements +Meet specific compliance standards that require certificates from accredited CAs: +- PCI DSS compliance +- SOC 2 requirements +- Industry-specific regulations + +### Hybrid Infrastructure +Combine internal and external CAs for different use cases: +- Internal services with Private CAs +- Public services with External CAs +- Development vs. production environments + +### Legacy System Integration +Integrate with existing enterprise PKI infrastructure: +- Windows Active Directory Certificate Services +- Network device management +- IoT device provisioning + +## Benefits of Centralized Management + +Managing External CAs through Infisical provides several advantages over direct CA management: + +### Unified Certificate Inventory +- Single dashboard for all certificates +- Centralized expiration tracking +- Cross-CA certificate analytics + +### Automated Lifecycle Management +- Automatic certificate reissuance before expiration +- Proactive expiration alerts +- Standardized certificate management processes + +### Enhanced Security +- Centralized access controls +- Audit trails for all certificate operations +- Policy enforcement across CAs + +### Operational Efficiency +- Reduced manual certificate management +- Consistent deployment workflows +- API-driven automation +- Integration with existing tools + +## Available Integration Guides + +Get started with External CA integration: + + + + Set up automated certificate issuance with any ACME-compatible CA + + + Custom CA integrations via REST APIs (Coming Soon) + + + +## FAQ + + + + Currently, Infisical supports any Certificate Authority that implements the ACME protocol, including: + + - **Public CAs**: Let's Encrypt, ZeroSSL, Buypass + - **Enterprise CAs**: HashiCorp Vault PKI, Step CA + - **Cloud CAs**: ACME-compatible managed services + + Integration uses DNS-01 validation through Route53. Learn more about [supported DNS validation methods](/documentation/platform/pki/acme-ca#what-dns-validation-methods-are-supported). + + Support for additional integration protocols (EST, SCEP, direct APIs) is planned for future releases. + + + Yes. You can have both Private CAs (root and intermediate) and External CAs in the same project, allowing you flexibility in how you issue certificates for different use cases. This hybrid approach enables you to: + + - Use Private CAs for internal services and applications + - Use External CAs for public-facing services + - Apply consistent management practices across all certificate types + - Implement appropriate security controls based on certificate usage + + + The types of certificates you can issue depend on the External CA provider and type: + + - **Public CAs**: Typically support Domain Validation (DV) certificates, with some offering Organization Validation (OV) + - **Enterprise CAs**: Support internal certificates, device certificates, and custom certificate types + - **Cloud CAs**: Support various certificate types depending on the service + + Certificate capabilities vary by provider and integration method. + + + Certificate reissuance is handled automatically by Infisical based on the CA type: + + - **Public CAs**: Automatic reissuance using ACME protocol with the same certificate extensions before expiration + - **Other CA types**: Certificate management methods depend on the specific integration (when available) + + All certificate lifecycle events are tracked and managed through Infisical's unified interface, ensuring continuous certificate validity. + + + Authentication methods vary by CA type: + + - **Public CAs**: ACME account registration with email and account keys + - **Enterprise CAs**: Client certificates, username/password, or domain authentication (when available) + - **Cloud CAs**: API keys, OAuth tokens, or service account authentication (when available) + + Infisical securely stores and manages all authentication credentials. + + + Yes, Infisical provides policy enforcement capabilities: + + - Certificate template constraints + - Monitoring and alerting policies + - Access controls for certificate operations + + These policies ensure consistent governance across both internal and external certificate sources. + + \ No newline at end of file diff --git a/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx b/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx new file mode 100644 index 000000000..2a04f5e3a --- /dev/null +++ b/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx @@ -0,0 +1,39 @@ +--- +title: "Gloo Mesh Integration" +description: "Learn how to automatically provision and manage Istio intermediate CA certificates for Gloo Mesh using Infisical PKI" +--- + +This guide will provide a high level overview on how you can use Infisical PKI and cert-manager to issue Istio intermediate CA certificates for your Gloo Mesh workload clusters. For more background about Istio certificates, see the [Istio CA overview](https://istio.io/latest/docs/concepts/security/#pki). + +## Overview + +In this setup, we will use Infisical PKI to generate and store your root CA and subordinate CAs that are used to generate Istio intermediate CAs for your Gloo Mesh workload clusters. +To manage the lifecycle of Istio intermediate CA certificates, you'll also install [cert-manager](https://cert-manager.io/). +Cert-manager is a Kubernetes controller that helps you automate the process of obtaining and renewing certificates from various PKI providers. + +With this approach, you get the following benefits: + +- Securely store your root CA certificates and private keys. +- Leverage Infisical subordinate CAs for an extra layer of protection beneath your root CA. +- Use cert-manager to automatically issue and renew Istio intermediate CA certificates from the same root, ensuring cross-cluster workload communication. +- Increased auditability of private key infrastructure. + + +## General Setup +The certificate provisioning workflow begins with setting up your PKI hierarchy in Infisical, where you create root and subordinate certificate authorities. +When you deploy a `Certificate` CRD in your workload cluster, `cert-manager` uses the Infisical PKI Issuer controller to authenticate with Infisical using machine identity credentials and request an intermediate CA certificate. +Infisical verifies the request against your certificate templates and returns the signed certificate. +From there, Istio's control plane will automatically use this intermediate CA to sign leaf certificates for workloads in the service mesh, enabling secure mTLS communication across your entire Gloo Mesh infrastructure. + +Follow the [Infisical PKI Issuer guide](/documentation/platform/pki/pki-issuer) for detailed instructions on how to set up the Infisical PKI Issuer and cert-manager for your Istio intermediate CA certificates in Gloo Mesh clusters. + +For Gloo Mesh-specific configuration, ensure that: + +- The Certificate resource targets the `istio-system` namespace with `secretName: cacerts` +- Certificate templates in Infisical PKI are configured for intermediate CA usage with appropriate key usage and constraints +- Multiple workload clusters use the same Infisical PKI root to enable cross-cluster mTLS communication + +## Using the certificates + +Once the `cacerts` Kubernetes secret is created in the `istio-system` namespace, Istio automatically uses the custom CA certificate instead of the default self-signed certificate. +When you deploy applications to your Gloo Mesh service mesh, the workloads will receive leaf certificates signed by your Infisical PKI intermediate CA, enabling secure mTLS communication across your entire mesh infrastructure. \ No newline at end of file diff --git a/docs/documentation/platform/pki/overview.mdx b/docs/documentation/platform/pki/overview.mdx index 259f15a5d..8ee9b113d 100644 --- a/docs/documentation/platform/pki/overview.mdx +++ b/docs/documentation/platform/pki/overview.mdx @@ -4,9 +4,10 @@ sidebarTitle: "Overview" description: "Learn how to create a Private CA hierarchy and issue X.509 certificates." --- -Infisical can be used to create a Private Certificate Authority (CA) hierarchy and issue X.509 certificates for internal use. This allows you to manage your own PKI infrastructure and issue digital certificates for services, applications, and devices. +Infisical can be used to create a Private Certificate Authority (CA) hierarchy and issue X.509 certificates for internal use. This allows you to manage your own PKI infrastructure and issue digital certificates for subscribers such as services, applications, and devices. -Infisical's internal PKI offering is split into two modules: +Infisical's PKI offering is split into three components: -- [Private CA](/documentation/platform/pki/private-ca): Infisical lets you create private CAs, including root and intermediary CAs. -- [Certificates](/documentation/platform/pki/certificates): Infisical allows you to issue X.509 certificates using the private CAs you create. +- [Certificate Authorities](/documentation/platform/pki/private-ca): Create and manage private CAs, including root and intermediate CAs. +- [Subscribers](/documentation/platform/pki/subscribers): Define and manage entities that will request X.509 certificates from CAs. This module provides a centralized view of all subscribers, enabling you to issue certificates and monitor their status. +- [Certificates](/documentation/platform/pki/certificates): Track and monitor issued X.509 certificates, maintaining a comprehensive inventory of all active and expired certificates. diff --git a/docs/documentation/platform/pki/pki-issuer.mdx b/docs/documentation/platform/pki/pki-issuer.mdx index c02e477c6..c46c1f35e 100644 --- a/docs/documentation/platform/pki/pki-issuer.mdx +++ b/docs/documentation/platform/pki/pki-issuer.mdx @@ -1,7 +1,6 @@ --- -title: "Kubernetes Issuer" -sidebarTitle: "Certificates for Kubernetes" -description: "Learn how to automatically provision and manage TLS certificates for in Kubernetes using Infisical PKI" +title: "Cert Manager Issuer" +description: "Learn how to automatically provision and manage TLS certificates in Kubernetes using Infisical PKI" --- ## Concept @@ -21,20 +20,21 @@ A typical workflow for using the Infisical PKI Issuer to issue certificates for 3. Installing `cert-manager` into your Kubernetes cluster. 4. Installing the Infisical PKI Issuer controller into your Kubernetes cluster. 5. Creating an `Issuer` or `ClusterIssuer` resource in your Kubernetes cluster to represent the Infisical PKI issuer you wish to use. -6. Creating a `Certificate` resource in your Kubernetes cluster to represent a certificate you wish to issue. As part of this step, you specify the Kubernetes `Secret` to create and store the issued certificate and private key. -7. Consuming the issued certificate across your Kubernetes resources from the specified Kubernetes `Secret`. +6. Create the approver policy to accept certificate request. +7. Creating a `Certificate` resource in your Kubernetes cluster to represent a certificate you wish to issue. As part of this step, you specify the Kubernetes `Secret` to create and store the issued certificate and private key. +8. Consuming the issued certificate across your Kubernetes resources from the specified Kubernetes `Secret`. ## Guide -In the following steps, we explore how to install the Infisical PKI Issuer using [kubectl](https://github.com/kubernetes/kubectl) and use it to obtain certificates for your Kubernetes resources. +In the following steps, we explore how to install the Infisical PKI Issuer using [kubectl](https://github.com/kubernetes/kubectl) and use it to obtain certificates for your Kubernetes resources. - + Follow the instructions [here](/documentation/platform/identities/universal-auth) to configure a [machine identity](/documentation/platform/identities/machine-identities) in Infisical with Universal Auth. - + By the end of this step, you should have a **Client ID** and **Client Secret** on hand as part of the Universal Auth configuration for the Infisical PKI Issuer to authenticate with Infisical; this will be useful in steps 4 and 5. - + Currently, the Infisical PKI Issuer only supports authenticating with Infisical via the [Universal Auth](/documentation/platform/identities/universal-auth) authentication method. @@ -43,14 +43,14 @@ In the following steps, we explore how to install the Infisical PKI Issuer using Install `cert-manager` into your Kubernetes cluster by following the instructions [here](https://cert-manager.io/docs/installation/) or by running the following command: - + ```bash kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.15.3/cert-manager.yaml ``` Install the Infisical PKI Issuer controller into your Kubernetes cluster by running the following command: - + ```bash kubectl apply -f https://raw.githubusercontent.com/Infisical/infisical-issuer/main/build/install.yaml ``` @@ -76,7 +76,7 @@ In the following steps, we explore how to install the Infisical PKI Issuer using data: clientSecret: ``` - + ```bash kubectl apply -f secret-issuer.yaml ``` @@ -84,7 +84,7 @@ In the following steps, we explore how to install the Infisical PKI Issuer using - Next, create the Infisical PKI Issuer by filling out `url`, `clientId`, either `caId` or `certificateTemplateId`, and applying the following configuration file for the `Issuer` resource. + Next, create the Infisical PKI Issuer by filling out `url`, `clientId`, `projectId` or `certificateTemplateName`, and applying the following configuration file for the `Issuer` resource. This configuration file specifies the connection details to your Infisical PKI CA to be used for issuing certificates. ```yaml infisical-issuer.yaml @@ -95,8 +95,8 @@ In the following steps, we explore how to install the Infisical PKI Issuer using namespace: spec: url: "https://app.infisical.com" # the URL of your Infisical instance - caId: # the ID of the CA you want to use to issue certificates - certificateTemplateId: # the ID of the certificate template you want to use to issue certificates against + projectId: # the ID of the project you want to use to issue certificates + certificateTemplateName: # the name of the certificate template you want to use to issue certificates against authentication: universalAuth: clientId: # the Client ID from step 1 @@ -104,20 +104,11 @@ In the following steps, we explore how to install the Infisical PKI Issuer using name: "issuer-infisical-client-secret" key: "clientSecret" ``` - + ``` kubectl apply -f infisical-issuer.yaml ``` - - - The Infisical PKI Issuer supports issuing certificates against a specific CA or a specific certificate template. - - For this reason, you should only fill in the `caId` or the `certificateTemplateId` field but not both. - - We recommend using the `certificateTemplateId` field to issue certificates against a specific [certificate template](/documentation/platform/pki/certificate-templates) - since templates let you enforce constraints on issued certificates and may have alerting policies bound to them. - - + You can check that the issuer was created successfully by running the following command: ```bash @@ -128,16 +119,60 @@ In the following steps, we explore how to install the Infisical PKI Issuer using NAME AGE issuer-infisical 21h ``` - + An `Issuer` is a namespaced resource, and it is not possible to issue certificates from an `Issuer` in a different namespace. This means you will need to create an `Issuer` in each namespace you wish to obtain `Certificates` in. If you want to create a single `Issuer` that can be consumed in multiple namespaces, you should consider creating a `ClusterIssuer` resource. This is almost identical to the `Issuer` resource, however is non-namespaced so it can be used to issue `Certificates` across all namespaces. - + You can read more about the `Issuer` and `ClusterIssuer` resources [here](https://cert-manager.io/docs/configuration/). + + If you create a `CertificateRequest` now, you'll notice it's neither approved nor denied. This is expected because by default cert-manager approver controller requires an approver-policy. + + To enable approval, create the following YAML file and apply it: + + ```yaml infisical-approver-policy.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: infisical-issuer-approver + rules: + # Permission to approve or deny CertificateRequests for signers in cert-manager.io API group + - apiGroups: ['cert-manager.io'] + resources: ['signers'] + verbs: ['approve'] + resourceNames: + # Grant approval permissions for namespaced issuers + - "issuers.infisical-issuer.infisical.com/default.issuer-infisical" + # Grant approval permissions for cluster-scoped issuers + - "clusterissuers.infisical-issuer.infisical.com/clusterissuer-infisical" + --- + # Bind the cert-manager service account to the new role + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-issuer-approver-binding + subjects: + - kind: ServiceAccount + name: cert-manager + namespace: cert-manager + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: infisical-issuer-approver + ``` + + ``` + kubectl apply -f infisical-approver-policy.yaml + ``` + + This configuration creates a `ClusterRole` named `infisical-issuer-approver` that grants approval permissions for specific Infisical issuer types. It then binds this role to the cert-manager service account, allowing it to approve certificate requests from your Infisical issuers. + + For information, check out [cert manager approval policy doc](https://cert-manager.io/docs/policy/approval/approver-policy/). + Finally, create a `Certificate` by applying the following configuration file. @@ -162,7 +197,7 @@ In the following steps, we explore how to install the Infisical PKI Issuer using duration: 48h # the ttl for the certificate renewBefore: 12h # the time before the certificate expiry that the certificate should be automatically renewed ``` - + The above sample configuration file specifies a certificate to be issued with the common name `certificate-by-issuer.example.com` and ECDSA private key using the P-256 curve, valid for 48 hours; the certificate will be automatically renewed by `cert-manager` 12 hours before expiry. The certificate is issued by the issuer `issuer-infisical` created in the previous step and the resulting certificate and private key will be stored in a secret named `certificate-by-issuer`. @@ -181,7 +216,7 @@ In the following steps, we explore how to install the Infisical PKI Issuer using Since the actual certificate and private key are stored in a Kubernetes secret, we can check that the secret was created successfully by running the following command: - + ```bash kubectl get secret certificate-by-issuer -n ``` @@ -190,9 +225,9 @@ In the following steps, we explore how to install the Infisical PKI Issuer using NAME TYPE DATA AGE certificate-by-issuer kubernetes.io/tls 2 26h ``` - + We can `describe` the secret to get more information about it: - + ```bash kubectl describe secret certificate-by-issuer -n default ``` @@ -201,14 +236,14 @@ In the following steps, we explore how to install the Infisical PKI Issuer using Name: certificate-by-issuer Namespace: default Labels: controller.cert-manager.io/fao=true - Annotations: cert-manager.io/alt-names: + Annotations: cert-manager.io/alt-names: cert-manager.io/certificate-name: certificate-by-issuer cert-manager.io/common-name: certificate-by-issuer.example.com - cert-manager.io/ip-sans: + cert-manager.io/ip-sans: cert-manager.io/issuer-group: infisical-issuer.infisical.com cert-manager.io/issuer-kind: Issuer cert-manager.io/issuer-name: issuer-infisical - cert-manager.io/uri-sans: + cert-manager.io/uri-sans: Type: kubernetes.io/tls @@ -218,17 +253,18 @@ In the following steps, we explore how to install the Infisical PKI Issuer using tls.crt: 2380 bytes tls.key: 227 bytes ``` - + Here, `ca.crt` is the Root CA certificate, `tls.crt` is the requested certificate followed by the certificate chain, and `tls.key` is the private key for the certificate. - + We can decode the certificate and print it out using `openssl`: ```bash kubectl get secret certificate-by-issuer -n default -o jsonpath='{.data.tls\.crt}' | base64 --decode | openssl x509 -text -noout ``` - + In any case, the certificate is ready to be used as Kubernetes Secret by your Kubernetes resources. + ## FAQ @@ -236,15 +272,24 @@ In the following steps, we explore how to install the Infisical PKI Issuer using The full list of the fields supported on the `Certificate` resource can be found in the API reference documentation [here](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.CertificateSpec). - + Currently, not all fields are supported by the Infisical PKI Issuer. + Yes. `cert-manager` will automatically renew certificates according to the `renewBefore` threshold of expiry as specified in the corresponding `Certificate` resource. - + You can read more about the `renewBefore` field [here](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.CertificateSpec). + - \ No newline at end of file + + If you see log messages similar to: + ``` + "CertificateRequest has not been approved yet. Ignoring.","controller":"certificaterequest","controllerGroup":"cert-manager.io","controllerKind":"CertificateRequest","CertificateRequest":{"name":"skynet-infisical-rta-rsa2048-1","namespace":"infisical-system"},"namespace":"infisical-system","name":"skynet-infisical-rta-rsa2048-1","reconcileID":"bfb7cad9-d867-45b5-b3a3-0139e731b7a6"} + ``` + This indicates that the `CertificateRequest` has been created, but `cert-manager` has not yet approved it. This typically occurs because a necessary approver policy is missing. Refer to the documentation above to create an approver policy. + + diff --git a/docs/documentation/platform/pki/private-ca.mdx b/docs/documentation/platform/pki/private-ca.mdx index d7f3f896c..7d7ee1220 100644 --- a/docs/documentation/platform/pki/private-ca.mdx +++ b/docs/documentation/platform/pki/private-ca.mdx @@ -7,7 +7,7 @@ description: "Learn how to create a Private CA hierarchy with Infisical." ## Concept The first step to creating your Internal PKI is to create a Private Certificate Authority (CA) hierarchy that is a structure of entities -used to issue digital certificates for services, applications, and devices. +used to issue digital certificates for your [subscribers](/documentation/platform/pki/subscribers).
@@ -24,7 +24,7 @@ graph TD A typical workflow for setting up a Private CA hierarchy consists of the following steps: -1. Configuring an Infisical root CA with details like name, validity period, and path length — This step is optional if you wish to use an external root CA. +1. Configuring an Infisical root CA with details like name, validity period, and path length — This step is optional if you wish to use an external root CA with Infisical only serving the intermediate CAs. 2. Configuring and chaining intermediate CA(s) with details like name, validity period, path length, and imported certificate to your Root CA. 3. Managing the CA lifecycle events such as CA succession. @@ -99,7 +99,7 @@ consisting of an (optional) root CA and an intermediate CA. ![pki cas](/images/platform/pki/ca/cas.png) Great! You've successfully created a Private CA hierarchy with a root CA and an intermediate CA. - Now check out the [Certificates](/documentation/platform/pki/certificates) page to learn more about how to issue X.509 certificates using the intermediate CA. + Now check out the [Subscribers](/documentation/platform/pki/subscribers) page to learn more about how to issue X.509 certificates using the intermediate CA. 2.3b. If you have an external root CA, select **External CA** for the **Parent CA Type** field. @@ -110,7 +110,7 @@ consisting of an (optional) root CA and an intermediate CA. Finally, press **Install** to import the certificate and certificate chain as part of the installation step for the intermediate CA Great! You've successfully created a Private CA hierarchy with an intermediate CA chained to an external root CA. - Now check out the [Certificates](/documentation/platform/pki/certificates) page to learn more about how to issue X.509 certificates using the intermediate CA. + Now check out the [Subscribers](/documentation/platform/pki/subscribers) page to learn more about how to issue X.509 certificates using the intermediate CA. @@ -255,7 +255,7 @@ consisting of an (optional) root CA and an intermediate CA. } ``` - Great! You’ve successfully created a Private CA hierarchy with a root CA and an intermediate CA. Now check out the Certificates page to learn more about how to issue X.509 certificates using the intermediate CA. + Great! You’ve successfully created a Private CA hierarchy with a root CA and an intermediate CA. Now check out the [Subscribers](/documentation/platform/pki/subscribers) page to learn more about how to issue X.509 certificates using the intermediate CA. diff --git a/docs/documentation/platform/pki/subscribers.mdx b/docs/documentation/platform/pki/subscribers.mdx new file mode 100644 index 000000000..1903d246a --- /dev/null +++ b/docs/documentation/platform/pki/subscribers.mdx @@ -0,0 +1,154 @@ +--- +title: "Subscribers" +sidebarTitle: "Subscribers" +description: "Learn how to manage PKI subscribers and issue X.509 certificates for them." +--- + +## Concept + +In Infisical PKI, subscribers are logical representations of entities such as devices, servers, applications that request and receive certificates from Certificate Authorities (CAs). + +
+ +```mermaid +graph TD +A[Issuing CA] --> C1[Certificate] + C1 --> S1[Subscriber] + A --> C2[Certificate] + C2 --> S2[Subscriber] +``` + +
+ +## Workflow + +The typical workflow for managing subscribers consists of the following steps: + +1. Creating a subscriber and defining which (issuing) CA will issue X.509 certificates for it as well as attributes to be included on the certificates including common name, subject alternative names, TTL, etc. You can also optionally configure automatic certificate renewal. +2. Requesting for a certificate against the subscriber with or without a certificate signing request (CSR). +3. Managing certificate lifecycle events such as certificate renewal and revocation. As part of the certificate revocation flow, + you can also query for a Certificate Revocation List [CRL](https://en.wikipedia.org/wiki/Certificate_revocation_list), a time-stamped, signed + data structure issued by a CA containing a list of revoked certificates to check if a certificate has been revoked. + + + Note that this workflow can be executed via the Infisical UI or manually such + as via API. + + +## Guide to Issuing Certificates with Subscribers + +In the following steps, we explore how to issue a X.509 certificate for a subscriber. + + + + A subscriber is the logical representation of an entity that requests and + receives certificates from a CA. With a subscriber, you can specify the + attributes that must be present on the X.509 certificates issued for it. + + Head to your Infisical PKI Project > Subscribers to create a subscriber. + + ![pki create subscriber](/images/platform/pki/subscriber/subscriber-create.png) + + + + The **PKI Subscriber** modal is organized into two tabs: + + ### Configuration Tab + + ![pki create subscriber 2](/images/platform/pki/subscriber/subscriber-create-2.png) + + This tab contains the core certificate attributes and settings: + + - **Subscriber Name**: A slug-friendly name for the subscriber such as `web-service`. + - **Issuing CA**: The Certificate Authority (CA) that will issue X.509 certificates for the subscriber. + - **Common Name (CN)**: The common name to be included on certificates to be issued to the subscriber. + - **Subject Alternative Names (SANs)**: A comma-delimited list of Subject Alternative Names (SANs) to be included on certificates; these can be hostnames or email addresses like `app1.acme.com, app2.acme.com`. + - **TTL**: The lifetime of the certificate. + - **Key Usage**: The key usage extension of the certificate. + - **Extended Key Usage**: The extended key usage extension of the certificate. + + ### Advanced Tab + + ![pki create subscriber 3](/images/platform/pki/subscriber/subscriber-create-3.png) + + This tab contains optional advanced features: + + - **Certificate Auto Renewal**: Toggle to enable automatic certificate renewal for this subscriber. + - **Renewal Before Expiry**: When auto renewal is enabled, specify how many days before certificate expiry the system should automatically issue a new certificate (e.g., 7 days). + + + It's possible to issue certificates for a subscriber with or without a certificate signing request (CSR). + - If requesting without a CSR, the attributes specified on the subscriber will be used to issue a certificate for the subscriber. + - If requesting with a CSR, the attributes on it will be validated against the attributes specified on the subscriber + and a certificate is only issued if they comply. + + + + When Certificate Auto Renewal is enabled, the system will automatically issue new certificates before the current ones expire, ensuring continuous certificate availability without manual intervention. + + + + + Once you have created a subscriber from step 1, you can issue a certificate for it. + + Press on the subscriber you want to issue a certificate for and click on the **Issue Certificate** button on that subscriber's page. + + ![pki issue subscriber certificate](/images/platform/pki/subscriber/subscriber-issue-cert.png) + + ![pki issue subscriber certificate 2](/images/platform/pki/subscriber/subscriber-issue-cert-2.png) + + + + +## Guide to Revoking Certificates + +In the following steps, we explore how to revoke a X.509 certificate and obtain a Certificate Revocation List (CRL) for a CA. + + + + Assuming that you've issued a certificate for a subscriber, you can revoke it by + selecting the **Revoke Certificate** option on the certificate you wish to revoke + on the subscriber's page. + + ![pki revoke subscriber certificate](/images/platform/pki/subscriber/subscriber-revoke-cert.png) + + + + In order to check the revocation status of a certificate, you can check it + against the CRL of a CA by heading to its Issuing CA and downloading the CRL. + + ![pki view crl](/images/platform/pki/subscriber/subscriber-ca-crl.png) + + To verify a certificate against the + downloaded CRL with OpenSSL, you can use the following command: + +```bash +openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem +``` + +Note that you can also obtain the CRL from the certificate itself by +referencing the CRL distribution point extension on the certificate. + +To check a certificate against the CRL distribution point specified within it with OpenSSL, you can use the following command: + +```bash +openssl verify -verbose -crl_check -crl_download -CAfile chain.pem cert.pem +``` + + + + +## FAQ + + + + To renew a certificate, you have two options: + + **Manual Renewal**: Issue a new certificate for the same subscriber. The original certificate will continue to be valid through its original TTL unless explicitly revoked. + + **Automatic Renewal**: If Certificate Auto Renewal is enabled for the subscriber, the system will automatically issue new certificates before the current ones expire based on the configured renewal period. + + + When Certificate Auto Renewal is enabled for a subscriber, the system monitors certificate expiration dates and automatically issues new certificates before they expire. You can configure how many days before expiry the renewal should occur (e.g., 7 days before expiration). This ensures continuous certificate availability without manual intervention. + + \ No newline at end of file diff --git a/docs/documentation/platform/pr-workflows.mdx b/docs/documentation/platform/pr-workflows.mdx index 187bae5d4..c248c4dfa 100644 --- a/docs/documentation/platform/pr-workflows.mdx +++ b/docs/documentation/platform/pr-workflows.mdx @@ -5,23 +5,23 @@ description: "Learn how to enable a set of policies to manage changes to sensiti Approval Workflows is a paid feature. - - If you're using Infisical Cloud, then it is available under the **Pro Tier** and **Enterprise Tire**. + + If you're using Infisical Cloud, then it is available under the **Pro Tier** and **Enterprise Tier**. If you're self-hosting Infisical, then you should contact sales@infisical.com to purchase an enterprise license to use it. ## Problem at hand -Updating secrets in high-stakes environments (e.g., production) can have a number of problematic issues: -- Most developers should not have access to secrets in production environments. Yet, they are the ones who often need to add new secrets or change the existing ones. Many organizations have in-house policies with regards to what person should be contacted in the case of needing to make changes to secrets. This slows down software development lifecycle and distracts engineers from working on things that matter the most. -- As a general rule, before making changes in production environments, those changes have to be looked over by at least another person. An extra pair of eyes can help reduce the risk of human error and make sure that the change will not affect the application in an unintended way. -- After making updates to secrets, the corresponding applications need to be redeployed with the right set of secrets and configurations. This process is often not automated and hence prone to human error. +Updating secrets in high-stakes environments (e.g., production) can have a number of problematic issues: +- Most developers should not have access to secrets in production environments. Yet, they are the ones who often need to add new secrets or change the existing ones. Many organizations have in-house policies with regards to what person should be contacted in the case of needing to make changes to secrets. This slows down software development lifecycle and distracts engineers from working on things that matter the most. +- As a general rule, before making changes in production environments, those changes have to be looked over by at least another person. An extra pair of eyes can help reduce the risk of human error and make sure that the change will not affect the application in an unintended way. +- After making updates to secrets, the corresponding applications need to be redeployed with the right set of secrets and configurations. This process is often not automated and hence prone to human error. ## Solution -As a wide-spread software engineering practice, developers have to submit their code as a PR that needs to be approved before the code is merged into the main branch. +As a wide-spread software engineering practice, developers have to submit their code as a PR that needs to be approved before the code is merged into the main branch. -In a similar way, to solve the above-mentioned issues, Infisical provides a feature called `Approval Workflows` for secret management. This is a set of policies and workflows that help advance access controls, compliance procedures, and stability of a particular environment. In other words, **Approval Workflows** help you secure, stabilize, and streamline the change of secrets in high-stakes environments. +In a similar way, to solve the above-mentioned issues, Infisical provides a feature called `Approval Workflows` for secret management. This is a set of policies and workflows that help advance access controls, compliance procedures, and stability of a particular environment. In other words, **Approval Workflows** help you secure, stabilize, and streamline the change of secrets in high-stakes environments. ### Setting a policy @@ -33,6 +33,18 @@ First, you would need to create a set of policies for a certain environment. In The enforcement level determines how strict the policy is. A **Hard** enforcement level means that any change that matches the policy will need full approval prior merging. A **Soft** enforcement level allows for break glass functionality on the request. If a change request is bypassed, the approvers will be notified via email. + + Enabling the "Bypass Approvals" toggle during policy creation will create a **Soft** enforcement level. Disabling the toggle makes the enforcement level **Hard**. + + +If you choose to allow approval bypasses (Soft Enforcement), you may select specific users or groups that can perform the bypass for that specific policy. Not choosing users or groups will allow anyone to bypass the policy. + +A policy bypasser cannot bypass requests from others; the bypass action can only be performed by the request creator. + +### Self approvals + +If the **Self Approvals** option is enabled, users who are designated as approvers on the policy can approve requests that they themselves have submitted. + ### Example of creating a change policy When creating a policy, you can choose the type of policy you want to create. In this case, we will be creating a `Change Policy`. Other types of policies include `Access Policy` that creates policies for **[Access Requests](/documentation/platform/access-controls/access-requests)**. @@ -41,10 +53,18 @@ When creating a policy, you can choose the type of policy you want to create. In ### Example of updating secrets with Approval workflows -When a user submits a change to an enviropnment that is under a particular policy, a corresponsing change request will go to a predefined approver (or multiple approvers). +When a user submits a change to an environment that is under a particular policy, a corresponding change request will go to a predefined approver (or multiple approvers). ![secret update change requests](../../images/platform/pr-workflows/secret-update-request.png) Approvers are notified by email and/or Slack as soon as the request is initiated. In the Infisical Dashboard, they will be able to `approve` and `merge` (or `deny`) a request for a change in a particular environment. After that, depending on the workflows setup, the change will be automatically propagated to the right applications (e.g., using [Infisical Kubernetes Operator](https://infisical.com/docs/integrations/platforms/kubernetes)). ![secrets update pull request](../../images/platform/pr-workflows/secret-update-pr.png) + +## FAQ + + + + Yes, if you'd like to require an approval from an approver other than the one who created the request, then you can disable the **Self Approvals** feature inside of your target policy. + + diff --git a/docs/documentation/platform/project-templates.mdx b/docs/documentation/platform/project-templates.mdx index d84c6bdc1..7dd5ceb50 100644 --- a/docs/documentation/platform/project-templates.mdx +++ b/docs/documentation/platform/project-templates.mdx @@ -33,7 +33,7 @@ In the following steps, we'll explore how to set up a project template. - Navigate to the Project Templates tab on the Organization Settings page and tap on the **Add Template** button. + Navigate to the **Project Templates** tab on the Feature Settings page for the project type you want to create a template for and tap on the **Add Template** button. ![project template add button](/images/platform/project-templates/project-template-add-button.png) Specify your template details. Here's some guidance on each field: @@ -67,6 +67,7 @@ In the following steps, we'll explore how to set up a project template. --header 'Content-Type: application/json' \ --data '{ "name": "my-project-template", + "type": "secret-manager", "description": "...", "environments": "[...]", "roles": "[...]", diff --git a/docs/documentation/platform/scim/azure.mdx b/docs/documentation/platform/scim/azure.mdx index 0e86f6149..e755f8750 100644 --- a/docs/documentation/platform/scim/azure.mdx +++ b/docs/documentation/platform/scim/azure.mdx @@ -15,7 +15,7 @@ Prerequisites: - In Infisical, head to your Organization Settings > Security > SCIM Configuration and + In Infisical, head to the **Single Sign-On (SSO)** page and select the **Provisioning** tab. Under SCIM Configuration, press the **Enable SCIM provisioning** toggle to allow Azure to provision/deprovision users for your organization. ![SCIM enable provisioning](/images/platform/scim/scim-enable-provisioning.png) diff --git a/docs/documentation/platform/scim/jumpcloud.mdx b/docs/documentation/platform/scim/jumpcloud.mdx index 42d33247a..be4caf738 100644 --- a/docs/documentation/platform/scim/jumpcloud.mdx +++ b/docs/documentation/platform/scim/jumpcloud.mdx @@ -15,7 +15,7 @@ Prerequisites: - In Infisical, head to your Organization Settings > Security > SCIM Configuration and + In Infisical, head to the **Single Sign-On (SSO)** page and select the **Provisioning** tab. Under SCIM Configuration, press the **Enable SCIM provisioning** toggle to allow JumpCloud to provision/deprovision users and user groups for your organization. ![SCIM enable provisioning](/images/platform/scim/scim-enable-provisioning.png) diff --git a/docs/documentation/platform/scim/okta.mdx b/docs/documentation/platform/scim/okta.mdx index d33bd242d..cf2c17724 100644 --- a/docs/documentation/platform/scim/okta.mdx +++ b/docs/documentation/platform/scim/okta.mdx @@ -15,7 +15,7 @@ Prerequisites: - In Infisical, head to your Organization Settings > Security > SCIM Configuration and + In Infisical, head to the **Single Sign-On (SSO)** page and select the **Provisioning** tab. Under SCIM Configuration, press the **Enable SCIM provisioning** toggle to allow Okta to provision/deprovision users and user groups for your organization. ![SCIM enable provisioning](/images/platform/scim/scim-enable-provisioning.png) diff --git a/docs/documentation/platform/secret-rotation/aws-iam-user-secret.mdx b/docs/documentation/platform/secret-rotation/aws-iam-user-secret.mdx index 1e8eb3950..c44d06d4a 100644 --- a/docs/documentation/platform/secret-rotation/aws-iam-user-secret.mdx +++ b/docs/documentation/platform/secret-rotation/aws-iam-user-secret.mdx @@ -182,10 +182,10 @@ In the following steps, we explore the end-to-end workflow for setting up this s - There are a few reasons for why this might happen: + There are a few reasons for why this might happen: - The strategy configuration is invalid (e.g. the managing IAM user's credentials are incorrect, the target AWS region is incorrect, etc.) - - The managing IAM user is insufficently permissioned to rotate the credentials of the target IAM user. For instance, you may have setup + - The managing IAM user is insufficiently permissioned to rotate the credentials of the target IAM user. For instance, you may have setup [paths](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) for the managing IAM user and the policy does not have the necessary - permissions to rotate the credentials. + permissions to rotate the credentials. diff --git a/docs/documentation/platform/secret-rotation/ldap-password.mdx b/docs/documentation/platform/secret-rotation/ldap-password.mdx index 103fe4656..feb3a664d 100644 --- a/docs/documentation/platform/secret-rotation/ldap-password.mdx +++ b/docs/documentation/platform/secret-rotation/ldap-password.mdx @@ -28,7 +28,7 @@ description: "Learn how to automatically rotate LDAP passwords." 3. Select the **LDAP Connection** to use and configure the rotation behavior. Then click **Next**. ![Rotation Configuration](/images/secret-rotations-v2/ldap-password/ldap-password-configuration.png) - - **LDAP Connection** - the connection that will perform the rotation of the configured DN's password. + - **LDAP Connection** - the connection that will perform the rotation of the configured principal's password. LDAP Password Rotations require an LDAP Connection that uses ldaps:// protocol. @@ -40,13 +40,20 @@ description: "Learn how to automatically rotate LDAP passwords." - 4. Specify the Distinguished Name (DN) of the principal whose password you want to rotate and configure the password requirements. Then click **Next**. + 4. Configure the required Parameters for your rotation. Then click **Next**. ![Rotation Parameters](/images/secret-rotations-v2/ldap-password/ldap-password-parameters.png) + - **Rotation Method** - The method to use when rotating the target principal's password. + - **Connection Principal** - Infisical will use the LDAP Connection's binding principal to rotate the target principal's password. + - **Target Principal** - Infisical will bind with the target Principal to rotate their own password. + - **DN/UPN** - The Distinguished Name (DN), or User Principal Name (UPN) if supported, of the principal whose password you want to rotate. + - **Password** - The target principal's password (if **Rotation Method** is set to **Target Principal**). + - **Password Requirements** - The constraints to apply when generating new passwords. + 5. Specify the secret names that the client credentials should be mapped to. Then click **Next**. ![Rotation Secrets Mapping](/images/secret-rotations-v2/ldap-password/ldap-password-secrets-mapping.png) - - **DN** - the name of the secret that the principal's Distinguished Name (DN) will be mapped to. + - **DN/UPN** - the name of the secret that the principal's Distinguished Name (DN) or User Principal Name (UPN) will be mapped to. - **Password** - the name of the secret that the rotated password will be mapped to. 6. Give your rotation a name and description (optional). Then click **Next**. @@ -85,6 +92,7 @@ description: "Learn how to automatically rotate LDAP passwords." "minutes": 0 }, "parameters": { + "rotationMethod": "connection-principal", "dn": "CN=John,CN=Users,DC=example,DC=com", "passwordRequirements": { "length": 48, @@ -154,6 +162,7 @@ description: "Learn how to automatically rotate LDAP passwords." "lastRotationMessage": null, "type": "ldap-password", "parameters": { + "rotationMethod": "connection-principal", "dn": "CN=John,CN=Users,DC=example,DC=com", "passwordRequirements": { "length": 48, diff --git a/docs/documentation/platform/secret-rotation/mysql-credentials.mdx b/docs/documentation/platform/secret-rotation/mysql-credentials.mdx new file mode 100644 index 000000000..d0088a29e --- /dev/null +++ b/docs/documentation/platform/secret-rotation/mysql-credentials.mdx @@ -0,0 +1,158 @@ +--- +title: "MySQL Credentials Rotation" +description: "Learn how to automatically rotate MySQL credentials." +--- + +## Prerequisites + +1. Create a [MySQL Connection](/integrations/app-connections/mysql) with the required **Secret Rotation** permissions +2. Create two designated database users for Infisical to rotate the credentials for. Be sure to grant each user login permissions for the desired database with the necessary privileges their use case will require. + + An example creation statement might look like: + ```SQL + -- create user roles + CREATE USER 'infisical_user_1'@'%' IDENTIFIED BY 'temporary_password'; + CREATE USER 'infisical_user_2'@'%' IDENTIFIED BY 'temporary_password'; + + -- grant all privileges + GRANT ALL PRIVILEGES ON my_database.* TO 'infisical_user_1'@'%'; + GRANT ALL PRIVILEGES ON my_database.* TO 'infisical_user_2'@'%'; + + -- apply the privilege changes + FLUSH PRIVILEGES; + ``` + + + To learn more about the MySQL permission system, please visit their [documentation](https://dev.mysql.com/doc/refman/8.4/en/grant.html). + + + +## Create a MySQL 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 **MySQL Credentials** option. + ![Select MySQL Credentials](/images/secret-rotations-v2/mysql-credentials/select-mysql-credentials-option.png) + + 3. Select the **MySQL Connection** to use and configure the rotation behavior. Then click **Next**. + ![Rotation Configuration](/images/secret-rotations-v2/mysql-credentials/mysql-credentials-configuration.png) + + - **MySQL 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 usernames of the database users created above that will be used for rotation. Then click **Next**. + ![Rotation Parameters](/images/secret-rotations-v2/mysql-credentials/mysql-credentials-parameters.png) + + - **Database Username 1** - the username of the first user that will be used for rotation. + - **Database Username 2** - the username of the second user that will be used for rotation. + + 5. Specify the secret names that the active credentials should be mapped to. Then click **Next**. + ![Rotation Secrets Mapping](/images/secret-rotations-v2/mysql-credentials/mysql-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/mysql-credentials/mysql-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/mysql-credentials/mysql-credentials-confirm.png) + + 8. Your **MySQL Credentials** are now available for use via the mapped secrets. + ![Rotation Created](/images/secret-rotations-v2/mysql-credentials/mysql-credentials-created.png) + + + To create a MySQL Credentials Rotation, make an API request to the [Create MySQL Credentials Rotation](/api-reference/endpoints/secret-rotations/mysql-credentials/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-rotations/mysql-credentials \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-mysql-rotation", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "my database credentials rotation", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/", + "isAutoRotationEnabled": true, + "rotationInterval": 30, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "parameters": { + "username1": "infisical_user_1", + "username2": "infisical_user_2" + }, + "secretsMapping": { + "username": "MYSQL_USERNAME", + "password": "MYSQL_PASSWORD" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretRotation": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-mysql-rotation", + "description": "my database credentials rotation", + "secretsMapping": { + "username": "MYSQL_USERNAME", + "password": "MYSQL_PASSWORD" + }, + "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": "mysql", + "name": "my-mysql-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": "mysql-credentials", + "parameters": { + "username1": "infisical_user_1", + "username2": "infisical_user_2" + } + } + } + ``` + + diff --git a/docs/documentation/platform/secret-scanning.mdx b/docs/documentation/platform/secret-scanning.mdx deleted file mode 100644 index da28bfa55..000000000 --- a/docs/documentation/platform/secret-scanning.mdx +++ /dev/null @@ -1,175 +0,0 @@ ---- -title: 'Secret Scanning' -description: "Scan and prevent secret leaks in your code repositories" ---- - -The Infisical Secret Scanner allows you to keep an overview and stay alert of exposed secrets across your entire GitHub organization and repositories. - -To further enhance security, we recommend you also use our [CLI Secret Scanner](/cli/scanning-overview#automatically-scan-changes-before-you-commit) to scan for exposed secrets prior to pushing your changes. - - - - - To setup secret scanning on your own instance of Infisical, you can follow the steps below. - - - - Create a new GitHub app in your GitHub organization or personal [Developer Settings](https://github.com/settings/apps). - - ![Create GitHub App](/images/platform/secret-scanning/github-create-app.png) - - ### Configure the GitHub App - To configure the GitHub app to work with Infisical, you'll need to modify the following settings: - - **Homepage URL**: Required to be set. Set it to the URL of your Infisical instance. (e.g. `https://app.infisical.com`) - - **Setup URL**: Set this to `https:///organization/secret-scanning` - - **Webhook URL**: Set this to `https:///api/v1/secret-scanning/webhook` - - **Webhook Secret**: Set this to a random string. This is used to verify the webhook request from Infisical. Use `openssl rand -base64 32` in your terminal to generate a random secret. - - - Remember to save the webhook secret as you will need it in the next step. - - - ![GitHub App Settings](/images/platform/secret-scanning/github-configure-app.png) - - ### Configure the GitHub App Permissions - The GitHub app needs the following permissions: - - Repository permissions: - - `Checks`: Read and Write - - `Contents`: Read-only - - `Issues`: Read and Write - - `Pull Requests`: Read and Write - - `Metadata`: Read-only (enabled by default) - - ![Github App Repository Permissions](/images/platform/secret-scanning/github-repo-permissions.png) - - Subscribed events: - - `Check run` - - `Pull request` - - `Push` - - ![Github App Subscribed Events](/images/platform/secret-scanning/github-subscribed-events.png) - - - ### Create the GitHub App - Now you can create the GitHub app by clicking on the "Create GitHub App" button. - - - If you want other Github users to be able to install the app, you need to tick the "Any account" option under "Where can this GitHub App be installed?" - - - ![Create GitHub App](/images/platform/secret-scanning/github-create-app-button.png) - - - - After clicking the "Create GitHub App" button, you will be redirected to the GitHub settings page. Here you can copy the "App ID" and save it for later when you need to configure your environment variables for your Infisical instance. - - ![Github App ID](/images/platform/secret-scanning/github-app-copy-app-id.png) - - - - The GitHub App slug is the name of the app you created in a slug friendly format. You can find the slug in the URL of the app you created. - - ![Github App Slug](/images/platform/secret-scanning/github-app-copy-slug.png) - - - - Create a new app private key by clicking on the "Generate a private key" button under the "Private keys" section. - - Once you click the "Generate a private key" button, the private key will be downloaded to your computer. Save this file for later as you will need the private key when configuring Infisical. - - ![Github App Private Key](/images/platform/secret-scanning/github-app-create-private-key.png) - - - Remember to save the private key as you will need it in the next step. - - - - - - - Now you can configure your Infisical instance by setting the following environment variables: - - - `SECRET_SCANNING_GIT_APP_ID`: The App ID of your GitHub App. - - `SECRET_SCANNING_GIT_APP_SLUG`: The slug of your GitHub App. - - `SECRET_SCANNING_PRIVATE_KEY`: The private key of your GitHub App that you created in a previous step. - - `SECRET_SCANNING_WEBHOOK_SECRET`: The webhook secret of your GitHub App that you created in a previous step. - - - - After restarting your Infisical instance, you should be able to use the secret scanning feature within your organization. Follow the steps below to add the GitHub App to your Infisical organization. - - -## Install the Infisical Radar GitHub App - -To install the GitHub App, press the "Integrate With GitHub" button in the top right corner of your Infisical Secret Scanning dashboard. - -![Integrate With GitHub](/images/platform/secret-scanning/infisical-connect-secret-scanner.png) - -Next, you'll be prompted to select which organization you'd like to install the app into. Select the organization you'd like to install the app into by clicking the organization in the menu. - -![Select Organization](/images/platform/secret-scanning/github-select-org-2.png) - -Select the repositories you'd like to scan for secrets and press the "Install" button. - -![Select Repositories](/images/platform/secret-scanning/github-select-repos.png) - -## Code Scanning - -![Scanning Overview](/images/platform/secret-scanning/overview.png) - -Secret scans are built on event-driven architecture. This means that every time a push is made to one of your selected repositories, Infisical will scan the modified files for any exposed secrets. - -If one or more exposed secrets are detected, it will be displayed in your Infisical dashboard. An exposed secret is known as a **"Risk"**. Each risk has the following data associated with it: -- **Date**: When the risk was first detected. -- **Secret Type**: Which type of secret was detected. -- **Info**: Information about the secret, such as the repository, file name, and the committer who made the change. - -Once an exposed secret is detected, all organization admins will be sent an e-mail notification containing details about the exposed secret. - - - Each risk also contains a "View Exposed Secret" button, which will take you directly to the GitHub commit and to the line where the secret was exposed. - - - - -![Exposed Secret](/images/platform/secret-scanning/exposed-secret.png) - - -## Responding to Exposed Secrets - -After an exposed secret is detected, it will be marked as `Needs Attention`. When there are risks marked as needs attention, it's important to address them as soon as possible. - -You can mark the risk as `Resolved` by changing the status to one of the following states: -- **This Is a False Positive**: The secret was not exposed, but was detected by the scanner. -- **I Have Rotated The Secret**: The secret was exposed, but it has now been removed. -- **No Rotation Needed**: You are choosing to ignore this risk. You may choose to do this if the risk is non-sensitive or otherwise not a security risk. - -![Needs Attention](/images/platform/secret-scanning/needs-attention.png) - - - - -## Ignoring Known Secrets -If you're intentionally committing a test secret that the secret scanner might flag, you can instruct Infisical to overlook that secret with the methods listed below. - -### infisical-scan:ignore - -To ignore a secret contained in line of code, simply add `infisical-scan:ignore ` at the end of the line as comment in the given programming. - -```js example.js -function helloWorld() { - console.log("8dyfuiRyq=vVc3RRr_edRk-fK__JItpZ"); // infisical-scan:ignore -} -``` - -### .infisicalignore -An alternative method to exclude specific findings involves creating a .infisicalignore file at your repository's root. -You can then add the fingerprints of the findings you wish to exclude. The [Infisical scan](/cli/scanning-overview) report provides a unique Fingerprint for each secret found. -By incorporating these Fingerprints into the .infisicalignore file, Infisical will skip the corresponding secret findings in subsequent scans. - -```.ignore .infisicalignore -bea0ff6e05a4de73a5db625d4ae181a015b50855:frontend/components/utilities/attemptLogin.js:stripe-access-token:147 -bea0ff6e05a4de73a5db625d4ae181a015b50855:backend/src/json/integrations.json:generic-api-key:5 -1961b92340e5d2613acae528b886c842427ce5d0:frontend/components/utilities/attemptLogin.js:stripe-access-token:148 -``` diff --git a/docs/documentation/platform/secret-scanning/github.mdx b/docs/documentation/platform/secret-scanning/github.mdx new file mode 100644 index 000000000..9fa766ed6 --- /dev/null +++ b/docs/documentation/platform/secret-scanning/github.mdx @@ -0,0 +1,96 @@ +--- +title: "GitHub Secret Scanning" +sidebarTitle: "GitHub" +description: "Learn how to configure secret scanning for GitHub." +--- + +## Prerequisites + +- Create a [GitHub Radar Connection](/integrations/app-connections/github-radar) + +## Create a GitHub Data Source in Infisical + + + + 1. Navigate to your Secret Scanning Project's Dashboard and click the **Add Data Source** button. + ![Secret Scanning Dashboard](/images/platform/secret-scanning/github/github-data-source-step-1.png) + + 2. Select the **GitHub** option. + ![Select GitHub Option](/images/platform/secret-scanning/github/github-data-source-step-2.png) + + 3. Select the **GitHub Radar Connection** to use and configure which repositories you would like to scan. Then click **Next**. + ![Data Source Configuration](/images/platform/secret-scanning/github/github-data-source-step-3.png) + + - **GitHub Radar Connection** - the connection that has access to the repositories you want to scan. + - **Scan Repositories** - select which repositories you would like to scan. + - **All Repositories** - Infisical will scan all repositories associated with your connection. + - **Select Repositories** - Infisical will scan the selected repositories. + - **Auto-Scan Enabled** - whether Infisical should automatically perform a scan when a push is made to configured repositories. + + 4. Give your data source a name and description (optional). Then click **Next**. + ![Data Source Details](/images/platform/secret-scanning/github/github-data-source-step-4.png) + + - **Name** - the name of the data source. Must be slug-friendly. + - **Description** (optional) - a description of this data source. + + 5. Review your data source, then click **Create Data Source**. + ![Data Source Review](/images/platform/secret-scanning/github/github-data-source-step-5.png) + + 6. Your **GitHub Data Source** is now available and will begin a full scan if **Auto-Scan** is enabled. + ![Data Source Created](/images/platform/secret-scanning/github/github-data-source-step-6.png) + + 7. You can view repositories and scan results by clicking on your data source. + ![Data Source Page](/images/platform/secret-scanning/github/github-data-source-step-7.png) + + 8. In addition, you can review any findings from the **Findings Page**. + ![Findings Page](/images/platform/secret-scanning/github/github-data-source-step-8.png) + + + To create a GitHub Data Source, make an API request to the [Create GitHub Data Source](/api-reference/endpoints/secret-scanning/data-sources/github/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-scanning/data-sources/github \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-github-source", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "my github data source", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "isAutoScanEnabled": true, + "config": { + "includeRepos": ["*"] + } + }' + ``` + + ### Sample response + + ```bash Response + { + "dataSource": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "externalId": "1234567890", + "name": "my-github-source", + "description": "my github data source", + "isAutoScanEnabled": true, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "type": "github", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "github-radar", + "name": "my-radar-app", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "config": { + "includeRepos": ["*"] + } + } + } + ``` + + diff --git a/docs/documentation/platform/secret-scanning/overview.mdx b/docs/documentation/platform/secret-scanning/overview.mdx new file mode 100644 index 000000000..69bf5a783 --- /dev/null +++ b/docs/documentation/platform/secret-scanning/overview.mdx @@ -0,0 +1,230 @@ +--- +title: "Secret Scanning" +sidebarTitle: "Overview" +description: "Scan and prevent secret leaks in your code repositories" +--- + +## Introduction + +Monitor and detect exposed secrets across your data sources, including code repositories, with Infisical Secret Scanning. + +For additional security, we recommend using our [CLI Secret Scanner](/cli/scanning-overview#automatically-scan-changes-before-you-commit) to check for exposed secrets before pushing your code changes. + + + Secret Scanning is a paid feature. + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + +## How Secret Scanning Works + +Secret Scanning consists of several components that enable you to quickly respond to secret leaks: + +- **Scanner Engine**: The core component that analyzes your code and detects potential secrets using pattern matching and entropy analysis +- **Real-time Monitoring**: Provides continuous surveillance of your repositories for immediate detection of exposed secrets +- **Alert System**: Notifies organization admins via email when secrets are detected +- **Risk Management**: Allows tracking and managing detected secrets with different status options +- **Data Sources**: Integrates with various data sources and version control systems +- **Customizable Rules**: Supports ignore patterns and custom configurations to reduce false positives + +These components work together to provide comprehensive secret detection and incident response capabilities. + +### Data Sources + +Data sources are configured integrations with external platforms, such as a GitHub organization or a GitLab group, that establish secure connections for scanning purposes using [App Connections](/integrations/app-connections/overview). + +A data source acts as a secure intermediary between the external system and the scanner engine. It manages a collection of scannable resources (such as repositories) and handles the authentication and communication required for scanning operations. + +![data sources](/images/platform/secret-scanning/secret-scanning-data-sources.png) + +### Resources + +Resources are the atomic, scannable units, such as a repository, that can be monitored for secret exposure. Resources are added automatically when a data source is scanned and updated when scanning events are triggered, such as when a user pushes changes to GitHub. + +Each resource maintains its own scanning history and status, allowing for granular monitoring and management of secret scanning across your organization. + +![resources](/images/platform/secret-scanning/secret-scanning-resources.png) + +### Scans + +Scans can be initiated in two ways: + +1. **Full Scan** - Manually triggered scan that comprehensively checks either all resources associated with a data source or a single selected resource. + +2. **Diff Scan** - Automatically executed when **Auto-Scan** is enabled on a data source. This scan type specifically focuses on updates to existing resources. + +All scan activities can be monitored in real-time through the Infisical UI, which displays: +- Current scan status +- Timestamp of the scan +- Resource(s) being scanned +- Detection results (whether any secrets were found) + +![scans](/images/platform/secret-scanning/secret-scanning-scans.png) + +### Findings + +Findings are automatically generated when secret leaks are detected during scanning operations. Each finding contains comprehensive information including: +- The specific scanning rule that identified the leak +- File location and line number where the secret was found +- Resource-specific details (e.g., commit hash and author for Git repositories) + +Findings are initially marked as **Unresolved** and can be updated to one of the following statuses with additional remarks: +- **Resolved** - The issue has been addressed +- **False Positive** - The detection was incorrect +- **Ignore** - The finding can be safely disregarded + +These status options help teams effectively track and manage the lifecycle of detected secret leaks. + +![findings](/images/platform/secret-scanning/secret-scanning-findings.png) + +### Configuration + +You can configure custom scanning rules and exceptions by updating your project's scanning configuration via the UI or API. + +The configuration options allow you to: +- Define custom scanning patterns and rules +- Set up ignore patterns to reduce false positives +- Specify file path exclusions +- Configure entropy thresholds for secret detection +- Add allowlists for known safe patterns + +For detailed configuration options, expand the example configuration below. + + + ```toml + # Title for the configuration file + title = "Some title" + + + # This configuration is the foundation that can be expanded. If there are any overlapping rules + # between this base and the expanded configuration, the rules in this base will take priority. + # Another aspect of extending configurations is the ability to link multiple files, up to a depth of 2. + # "Allowlist" arrays get appended and may have repeated elements. + # "useDefault" and "path" cannot be used simultaneously. Please choose one. + [extend] + # useDefault will extend the base configuration with the default config: + # https://raw.githubusercontent.com/Infisical/infisical/main/cli/config/infisical-scan.toml + useDefault = true + # or you can supply a path to a configuration. Path is relative to where infisical cli + # was invoked, not the location of the base config. + path = "common_config.toml" + + # An array of tables that contain information that define instructions + # on how to detect secrets + [[rules]] + + # Unique identifier for this rule + id = "some-identifier-for-rule" + + # Short human readable description of the rule. + description = "awesome rule 1" + + # Golang regular expression used to detect secrets. Note Golang's regex engine + # does not support lookaheads. + regex = '''one-go-style-regex-for-this-rule''' + + # Golang regular expression used to match paths. This can be used as a standalone rule or it can be used + # in conjunction with a valid `regex` entry. + path = '''a-file-path-regex''' + + # Array of strings used for metadata and reporting purposes. + tags = ["tag","another tag"] + + # A regex match may have many groups, this allows you to specify the group that should be used as (which group the secret is contained in) + # its entropy checked if `entropy` is set. + secretGroup = 3 + + # Float representing the minimum shannon entropy a regex group must have to be considered a secret. + # Shannon entropy measures how random a data is. Since secrets are usually composed of many random characters, they typically have high entropy + entropy = 3.5 + + # Keywords are used for pre-regex check filtering. + # If rule has keywords but the text fragment being scanned doesn't have at least one of it's keywords, it will be skipped for processing further. + # Ideally these values should either be part of the identifier or unique strings specific to the rule's regex + # (introduced in v8.6.0) + keywords = [ + "auth", + "password", + "token", + ] + + # You can include an allowlist table for a single rule to reduce false positives or ignore commits + # with known/rotated secrets + [rules.allowlist] + description = "ignore commit A" + commits = [ "commit-A", "commit-B"] + paths = [ + '''go\.mod''', + '''go\.sum''' + ] + # note: (rule) regexTarget defaults to check the _Secret_ in the finding. + # if regexTarget is not specified then _Secret_ will be used. + # Acceptable values for regexTarget are "match" and "line" + regexTarget = "match" + regexes = [ + '''process''', + '''getenv''', + ] + # note: stopwords targets the extracted secret, not the entire regex match + # if the extracted secret is found in the stopwords list, the finding will be skipped (i.e not included in report) + stopwords = [ + '''client''', + '''endpoint''', + ] + + + # This is a global allowlist which has a higher order of precedence than rule-specific allowlists. + # If a commit listed in the `commits` field below is encountered then that commit will be skipped and no + # secrets will be detected for said commit. The same logic applies for regexes and paths. + [allowlist] + description = "global allow list" + commits = [ "commit-A", "commit-B", "commit-C"] + paths = [ + '''gitleaks\.toml''', + '''(.*?)(jpg|gif|doc)''' + ] + + # note: (global) regexTarget defaults to check the _Secret_ in the finding. + # if regexTarget is not specified then _Secret_ will be used. + # Acceptable values for regexTarget are "match" and "line" + regexTarget = "match" + + regexes = [ + '''219-09-9999''', + '''078-05-1120''', + '''(9[0-9]{2}|666)-\d{2}-\d{4}''', + ] + # note: stopwords targets the extracted secret, not the entire regex match + # if the extracted secret is found in the stopwords list, the finding will be skipped (i.e not included in report) + stopwords = [ + '''client''', + '''endpoint''', + ] + ``` + + +![config](/images/platform/secret-scanning/secret-scanning-config.png) + +## Ignoring Known Secrets +If you're intentionally committing a test secret that the secret scanner might flag, you can instruct Infisical to overlook that secret with the methods listed below. + +### infisical-scan:ignore + +To ignore a secret contained in line of code, simply add `infisical-scan:ignore ` at the end of the line as comment in the given programming. + +```js example.js +function helloWorld() { + console.log("8dyfuiRyq=vVc3RRr_edRk-fK__JItpZ"); // infisical-scan:ignore +} +``` + +### .infisicalignore +An alternative method to exclude specific findings involves creating a .infisicalignore file at your repository's root. +You can then add the fingerprints of the findings you wish to exclude. The [Infisical scan](/cli/scanning-overview) report provides a unique Fingerprint for each secret found. +By incorporating these Fingerprints into the .infisicalignore file, Infisical will skip the corresponding secret findings in subsequent scans. + +```.ignore .infisicalignore +bea0ff6e05a4de73a5db625d4ae181a015b50855:frontend/components/utilities/attemptLogin.js:stripe-access-token:147 +bea0ff6e05a4de73a5db625d4ae181a015b50855:backend/src/json/integrations.json:generic-api-key:5 +1961b92340e5d2613acae528b886c842427ce5d0:frontend/components/utilities/attemptLogin.js:stripe-access-token:148 +``` diff --git a/docs/documentation/platform/secret-sharing.mdx b/docs/documentation/platform/secret-sharing.mdx index 4ff3a326b..6c78359cd 100644 --- a/docs/documentation/platform/secret-sharing.mdx +++ b/docs/documentation/platform/secret-sharing.mdx @@ -5,42 +5,53 @@ description: "Learn how to share time & view-count bound secrets securely with a --- Developers frequently need to share secrets with team members, contractors, or other third parties, which can be risky due to potential leaks or misuse. -Infisical offers a secure solution for sharing secrets over the internet in a time and view count bound manner. It is possible to share secrets without signing up via [share.infisical.com](https://share.infisical.com) or via Infisical Dashboard (which has more advanced funcitonality). +Infisical offers a secure solution for sharing secrets over the internet in a time and view-count bound manner. It is possible to share secrets without signing up via [share.infisical.com](https://share.infisical.com) or via Infisical Dashboard (which has more advanced functionality). -With its zero-knowledge architecture, secrets shared via Infisical remain unreadable even to Infisical itself. +## Sharing a Secret -## Share a Secret + + + ![Secret Sharing](../../images/platform/secret-sharing/overview.png) + + + ![Configure Secret](../../images/platform/secret-sharing/create-new-secret.png) -1. Navigate to the **Organization** page. -2. Click on the **Secret Sharing** tab from the sidebar. + - **Name (optional):** A friendly name for the shared secret. + - **Your Secret:** The secret content. + - **Password (optional):** A password which will be required when viewing the secret. -![Secret Sharing](../../images/platform/secret-sharing/overview.png) + - **Limit access to people within organization:** Only lets people within your organization view the secret. Enabling this feature requires secret viewers to log into Infisical. + - **Expires In:** The time it'll take for the secret to expire. + - **Max Views:** How many times the secret can be viewed before it's destroyed. - - Infisical does not have access to the shared secrets. This is a part of our - zero knowledge architecture. - + - **Authorized Emails (optional):** Emails which are authorized to view this secret. Enabling this feature requires secret viewers to log into Infisical. Each email will receive the shared secret link in their inbox after creation. + + + After creating the shared secret, its link will be displayed. Share this with the intended recipients. -3. Click on the **Share Secret** button. Set the secret, its expiration time and specify if the secret can be viewed only once. It expires as soon as any of the conditions are met. -Also, specify if the secret can be accessed by anyone or only people within your organization. + + If no organization or email restrictions are set, anyone with this link can view the secret before it expires. + - ![Add View-Bound Sharing Secret](../../images/platform/secret-sharing/create-new-secret.png) + ![Copy URL](../../images/platform/secret-sharing/copy-url.png) + + + Visiting the secret link will display its contents. - - Secret once set cannot be changed. This is to ensure that the secret is not - tampered with. - + ![Access Shared Secret](../../images/platform/secret-sharing/public-view.png) + + -5. Copy the link and share it with the intended recipient. Anyone with the link can access the secret before its expiration condition. Hence, it is recommended to share the link only with the intended recipient. +## Deleting a Shared Secret -![Copy URL](../../images/platform/secret-sharing/copy-url.png) +To delete a shared secret, click the **Trash Can** icon on the relevant shared secret row in the [**Secret Sharing**](https://app.infisical.com/organization/secret-sharing?selectedTab=share-secret) page. -## Access a Shared Secret +![Delete Secret](../../images/platform/secret-sharing/delete-secret.png) -Just click on the link you received to access the secret. The secret will be displayed on the screen & for how long it is valid. +## FAQ -![Access Shared Secret](../../images/platform/secret-sharing/public-view.png) - -## Delete a Shared Secret - -In the **Secret Sharing** tab, click on the **Delete** button next to the secret you want to delete. This will delete the secret immediately & the link will no longer be accessible. + + + No, secrets cannot be changed after they've been created. This is to ensure that secrets are not tampered with. + + diff --git a/docs/documentation/platform/ssh/overview.mdx b/docs/documentation/platform/ssh/overview.mdx index e71eeabe1..a252e1f71 100644 --- a/docs/documentation/platform/ssh/overview.mdx +++ b/docs/documentation/platform/ssh/overview.mdx @@ -31,16 +31,9 @@ we will register a remote host with Infisical through a [machine identity](/docu - 1.1. Start by creating a new Infisical SSH project in Infisical. + Start by creating a new Infisical SSH project in Infisical. ![ssh project create](/images/platform/ssh/v2/ssh-create-project.png) - - 1.2. Create a custom role in the project under Access Control > Project Roles to grant the machine identity that we will create in step 2 the ability to **Create** and **Issue Host Certificates** on the **SSH Host** resource; this will enable the linked machine identity to bootstrap a remote host with Infisical - and establish the necessary configuration on it. - - ![ssh custom role bootstrap 1](/images/platform/ssh/v2/ssh-add-bootstrap-role-1.png) - - ![ssh custom role bootstrap 2](/images/platform/ssh/v2/ssh-add-bootstrap-role-2.png) 2.1. Follow the instructions [here](/documentation/platform/identities/universal-auth) to configure a [machine identity](/documentation/platform/identities/machine-identities) in Infisical with Universal Auth. @@ -52,7 +45,14 @@ we will register a remote host with Infisical through a [machine identity](/docu You may use other authentication methods as suitable (e.g. [AWS Auth](/documentation/platform/identities/aws-auth), [Azure Auth](/documentation/platform/identities/azure-auth), [GCP Auth](/documentation/platform/identities/gcp-auth), etc.) as part of the machine identity configuration but, to keep this example simple, we will be using Universal Auth. - 2.2. Add the machine identity to the Infisical SSH project you created in the previous step and assign it the custom role you created in step 1.2. + 2.2. Add the machine identity to the Infisical SSH project you created in the previous step and assign it the **SSH Host Bootstrapper** role. + + This role grants the ability to **Create** and **Issue Host Certificates** on the **SSH Host** resource; this will enable the linked machine identity to bootstrap a remote host with Infisical + and establish the necessary configuration on it. + + + If you plan to use a custom role to bootstrap SSH hosts, ensure the role has the **Create** and **Issue Host Certificates** on the **SSH Host** resource. + ![ssh add identity to project](/images/platform/ssh/v2/ssh-add-identity-to-project.png) diff --git a/docs/documentation/platform/sso/auth0-oidc.mdx b/docs/documentation/platform/sso/auth0-oidc.mdx index e8b532c1c..4b54c053d 100644 --- a/docs/documentation/platform/sso/auth0-oidc.mdx +++ b/docs/documentation/platform/sso/auth0-oidc.mdx @@ -14,7 +14,7 @@ description: "Learn how to configure Auth0 OIDC for Infisical SSO." 1.1. From the Application's Page, navigate to the settings tab of the Auth0 application you want to integrate with Infisical. ![OIDC auth0 list of applications](../../../images/sso/auth0-oidc/application-settings.png) - + 1.2. In the Application URIs section, set the **Application Login URI** and **Allowed Web Origins** fields to `https://app.infisical.com` and the **Allowed Callback URL** field to `https://app.infisical.com/api/v1/sso/oidc/callback`. ![OIDC auth0 create application uris](../../../images/sso/auth0-oidc/application-uris.png) ![OIDC auth0 create application origin](../../../images/sso/auth0-oidc/application-origin.png) @@ -39,8 +39,8 @@ description: "Learn how to configure Auth0 OIDC for Infisical SSO." - 3.1. Back in Infisical, in the Organization settings > Security > OIDC, click **Connect**. - ![OIDC auth0 manage org Infisical](../../../images/sso/auth0-oidc/org-oidc-overview.png) + 3.1. Back in Infisical, head to the **Single Sign-On (SSO)** page and select the **General** tab. Click **Connect** for **OIDC**. + ![OIDC SSO Connect](../../../images/sso/connect-oidc.png) 3.2. For configuration type, select **Discovery URL**. Then, set **Discovery Document URL**, **JWT Signature Algorithm**, **Client ID**, and **Client Secret** from step 2.1 and 2.2. ![OIDC auth0 paste values into Infisical](../../../images/sso/auth0-oidc/org-update-oidc.png) @@ -70,7 +70,7 @@ description: "Learn how to configure Auth0 OIDC for Infisical SSO." prior to enforcing OIDC SSO to prevent any unintended issues. - In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + In case of a lockout, an organization admin can use the [Admin Login Portal](https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal) in the `/login/admin` path e.g. https://app.infisical.com/login/admin. diff --git a/docs/documentation/platform/sso/auth0-saml.mdx b/docs/documentation/platform/sso/auth0-saml.mdx index b426d1aae..22ef00c89 100644 --- a/docs/documentation/platform/sso/auth0-saml.mdx +++ b/docs/documentation/platform/sso/auth0-saml.mdx @@ -12,7 +12,9 @@ description: "Learn how to configure Auth0 SAML for Infisical SSO." - In Infisical, head to Organization Settings > Security and click **Connect** for SAML under the Connect to an Identity Provider section. Select Auth0, then click **Connect** again. + In Infisical, head to the **Single Sign-On (SSO)** page and select the **General** tab. Click **Connect** for **SAML** under the Connect to an Identity Provider section. Select **Auth0**, then click **Connect** again. + + ![SSO connect section](../../../images/sso/connect-saml.png) Next, note the **Application Callback URL** and **Audience** to use when configuring the Auth0 SAML application. @@ -21,30 +23,30 @@ description: "Learn how to configure Auth0 SAML for Infisical SSO." 2.1. In your Auth0 account, head to Applications and create an application. - + ![Auth0 SAML app creation](../../../images/sso/auth0-saml/create-application.png) - + Select **Regular Web Application** and press **Create**. - + ![Auth0 SAML app creation](../../../images/sso/auth0-saml/create-application-2.png) - + 2.2. In the Application head to Settings > Application URIs and add the **Application Callback URL** from step 1 into the **Allowed Callback URLs** field. - + ![Auth0 SAML allowed callback URLs](../../../images/sso/auth0-saml/auth0-config.png) - + 2.3. In the Application head to Addons > SAML2 Web App and copy the **Issuer**, **Identity Provider Login URL**, and **Identity Provider Certificate** from the **Usage** tab. - + ![Auth0 SAML config](../../../images/sso/auth0-saml/auth0-config-2.png) - + 2.4. Back in Infisical, set **Issuer**, **Identity Provider Login URL**, and **Certificate** to the corresponding items from step 2.3. - + ![Auth0 SAML Infisical config](../../../images/sso/auth0-saml/infisical-config.png) - + 2.5. Back in Auth0, in the **Settings** tab, set the **Application Callback URL** to the **Application Callback URL** from step 1 and update the **Settings** field with the JSON under the picture below (replacing `` with the **Audience** from step 1). - + ![Auth0 SAML config](../../../images/sso/auth0-saml/auth0-config-3.png) - + ```json { "audience": "", @@ -74,7 +76,7 @@ description: "Learn how to configure Auth0 SAML for Infisical SSO." Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO. - In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + In case of a lockout, an organization admin can use the [Admin Login Portal](https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal) in the `/login/admin` path e.g. https://app.infisical.com/login/admin. @@ -94,4 +96,4 @@ description: "Learn how to configure Auth0 SAML for Infisical SSO." 32`.
- `SITE_URL`: The absolute URL of your self-hosted instance of Infisical including the protocol (e.g. https://app.infisical.com) - \ No newline at end of file + diff --git a/docs/documentation/platform/sso/azure.mdx b/docs/documentation/platform/sso/azure.mdx index 282cddae5..0957dc4d1 100644 --- a/docs/documentation/platform/sso/azure.mdx +++ b/docs/documentation/platform/sso/azure.mdx @@ -5,14 +5,16 @@ description: "Learn how to configure Microsoft Entra ID for Infisical SSO." Azure SAML SSO is a paid feature. - + If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, then you should contact sales@infisical.com to purchase an enterprise license to use it. - In Infisical, head to Organization Settings > Security and click **Connect** for SAML under the Connect to an Identity Provider section. Select Azure / Entra, then click **Connect** again. + In Infisical, head to the **Single Sign-On (SSO)** page and select the **General** tab. Click **Connect** for **SAML** under the Connect to an Identity Provider section. Select **Azure / Entra**, then click **Connect** again. + + ![SSO connect section](../../../images/sso/connect-saml.png) Next, copy the **Reply URL (Assertion Consumer Service URL)** and **Identifier (Entity ID)** to use when configuring the Azure SAML application. @@ -24,7 +26,7 @@ description: "Learn how to configure Microsoft Entra ID for Infisical SSO." ![Azure SAML enterprise applications](../../../images/sso/azure/enterprise-applications.png) ![Azure SAML new application](../../../images/sso/azure/new-application.png) - + On the next screen, press the **+ Create your own application** button. Give the application a unique name like Infisical; choose the "Integrate any other application you don't find in the gallery (Non-gallery)" option and hit the **Create** button. @@ -87,9 +89,9 @@ description: "Learn how to configure Microsoft Entra ID for Infisical SSO." Back in Azure, navigate to the **Users and groups** tab and select **+ Add user/group** to assign access to the login with SSO application on a user or group-level. - + ![Azure SAML assignment](../../../images/sso/azure/assignment.png) - + Enabling SAML SSO allows members in your organization to log into Infisical via Azure. @@ -107,7 +109,7 @@ description: "Learn how to configure Microsoft Entra ID for Infisical SSO." prior to enforcing SAML SSO to prevent any unintended issues. - In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + In case of a lockout, an organization admin can use the [Admin Login Portal](https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal) in the `/login/admin` path e.g. https://app.infisical.com/login/admin. diff --git a/docs/documentation/platform/sso/general-oidc/group-membership-mapping.mdx b/docs/documentation/platform/sso/general-oidc/group-membership-mapping.mdx new file mode 100644 index 000000000..fd405fdfc --- /dev/null +++ b/docs/documentation/platform/sso/general-oidc/group-membership-mapping.mdx @@ -0,0 +1,55 @@ +--- +title: "General OIDC Group Membership Mapping" +sidebarTitle: "Group Membership Mapping" +description: "Learn how to sync OIDC group members to matching groups in Infisical." +--- + +You can have Infisical automatically sync group +memberships between your OIDC provider and Infisical by configuring a `groups` claim on your provider tokens. +When a user logs in via OIDC, they will be added to Infisical groups that are present in their OIDC `groups` claim, +and removed from any Infisical groups not present in the claim. + + + When enabled, manual + management of Infisical group memberships will be disabled. + + + + Group membership changes in your OIDC provider only sync with Infisical when a + user logs in via OIDC. For example, if you remove a user from a group in your OIDC provider, + this change will not be reflected in Infisical until their next OIDC login. + To ensure this behavior, Infisical recommends enabling Enforce OIDC SSO in the OIDC settings. + + + + + + To enable OIDC Group Membership Mapping, you must configure a `groups` claim in your OIDC provider. + + Add a `groups` property with a list of the user's OIDC group names to your token. + + Example of expected token payload: + ```json + { + // "email": "john@provider.com", + // "given_name": "John", + // ...other claims + "groups": ["Billing Group", "Sales Group"] + } + ``` + + + Setup varies between OIDC providers. Please refer to your OIDC provider's documentation for more information. + + + + 2.1. In Infisical, create any groups you would like to sync users to. Make sure the name of the Infisical group is an exact match of the OIDC group name. + ![OIDC general infisical group](/images/sso/keycloak-oidc/group-membership-mapping/create-infisical-group.png) + + 2.2. Next, enable **OIDC Group Membership Mapping** on the **Single Sign-On (SSO)** page under the **General** tab. + ![OIDC general enable group membership mapping](/images/sso/keycloak-oidc/group-membership-mapping/enable-group-membership-mapping.png) + + 2.3. The next time a user logs in they will be synced to their matching OIDC groups. + ![OIDC general synced users](/images/sso/keycloak-oidc/group-membership-mapping/synced-users.png) + + \ No newline at end of file diff --git a/docs/documentation/platform/sso/general-oidc.mdx b/docs/documentation/platform/sso/general-oidc/overview.mdx similarity index 85% rename from docs/documentation/platform/sso/general-oidc.mdx rename to docs/documentation/platform/sso/general-oidc/overview.mdx index 76e364b2f..07ddaaedd 100644 --- a/docs/documentation/platform/sso/general-oidc.mdx +++ b/docs/documentation/platform/sso/general-oidc/overview.mdx @@ -1,5 +1,6 @@ --- title: "General OIDC" +sidebarTitle: "Overview" description: "Learn how to configure OIDC for Infisical SSO with any OIDC-compliant identity provider" --- @@ -28,8 +29,8 @@ Prerequisites: 1.4. Access the IdP’s OIDC discovery document (usually located at `https:///.well-known/openid-configuration`). This document contains important endpoints such as authorization, token, userinfo, and keys. - 2.1. Back in Infisical, in the Organization settings > Security > OIDC, click Connect. - ![OIDC general manage org Infisical](../../../images/sso/general-oidc/org-oidc-manage.png) + 2.1. Back in Infisical, head to the **Single Sign-On (SSO)** page and select the **General** tab. Select **Connect** for **OIDC**. + ![OIDC SSO Connect](../../../../images/sso/connect-oidc.png) 2.2. You can configure OIDC either through the Discovery URL (Recommended) or by inputting custom endpoints. @@ -39,10 +40,10 @@ Prerequisites: Note that the Discovery Document URL typically takes the form: `https:///.well-known/openid-configuration`. - ![OIDC general discovery config](../../../images/sso/general-oidc/discovery-oidc-form.png) + ![OIDC general discovery config](../../../../images/sso/general-oidc/discovery-oidc-form.png) To configure OIDC via the custom endpoints, set the **Configuration Type** field to **Custom** and input the required endpoint fields. - ![OIDC general custom config](../../../images/sso/general-oidc/custom-oidc-form.png) + ![OIDC general custom config](../../../../images/sso/general-oidc/custom-oidc-form.png) 2.3. Select the appropriate JWT signature algorithm for your IdP. Currently, the supported options are RS256, RS512, HS256, and EdDSA. @@ -55,7 +56,7 @@ Prerequisites: Enabling OIDC SSO allows members in your organization to log into Infisical via the configured Identity Provider - ![OIDC general enable OIDC](../../../images/sso/general-oidc/org-oidc-enable.png) + ![OIDC general enable OIDC](../../../../images/sso/general-oidc/org-oidc-enable.png) @@ -69,7 +70,7 @@ Prerequisites: We recommend ensuring that your account is provisioned using the identity provider prior to enforcing OIDC SSO to prevent any unintended issues. - In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + In case of a lockout, an organization admin can use the [Admin Login Portal](https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal) in the `/login/admin` path e.g. https://app.infisical.com/login/admin. diff --git a/docs/documentation/platform/sso/google-saml.mdx b/docs/documentation/platform/sso/google-saml.mdx index 87ffa8412..84888b2f9 100644 --- a/docs/documentation/platform/sso/google-saml.mdx +++ b/docs/documentation/platform/sso/google-saml.mdx @@ -12,7 +12,9 @@ description: "Learn how to configure Google SAML for Infisical SSO." - In Infisical, head to Organization Settings > Security and click **Connect** for SAML under the Connect to an Identity Provider section. Select Google, then click **Connect** again. + In Infisical, head to the **Single Sign-On (SSO)** page and select the **General** tab. Click **Connect** for **SAML** under the Connect to an Identity Provider section. Select **Google**, then click **Connect** again. + + ![SSO connect section](../../../images/sso/connect-saml.png) Next, note the **ACS URL** and **SP Entity ID** to use when configuring the Google SAML application. @@ -22,21 +24,21 @@ description: "Learn how to configure Google SAML for Infisical SSO." 2.1. In your [Google Admin console](https://support.google.com/a/answer/182076), head to Menu > Apps > Web and mobile apps and create a **custom SAML app**. - + ![Google SAML app creation](../../../images/sso/google-saml/create-custom-saml-app.png) - + 2.2. In the **App details** tab, give the application a unique name like Infisical. - + ![Google SAML app naming](../../../images/sso/google-saml/name-custom-saml-app.png) - + 2.3. In the **Google Identity Provider details** tab, copy the **SSO URL**, **Entity ID** and **Certificate**. - + ![Google SAML custom app details](../../../images/sso/google-saml/custom-saml-app-config.png) - + 2.4. Back in Infisical, set **SSO URL** and **Certificate** to the corresponding items from step 2.3. - + ![Google SAML Infisical config](../../../images/sso/google-saml/infisical-config.png) - + 2.5. Back in the Google Admin console, in the **Service provider details** tab, set the **ACS URL** and **Entity ID** to the corresponding items from step 1. Also, check the **Signed response** checkbox. @@ -82,7 +84,7 @@ description: "Learn how to configure Google SAML for Infisical SSO." prior to enforcing SAML SSO to prevent any unintended issues. - In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + In case of a lockout, an organization admin can use the [Admin Login Portal](https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal) in the `/login/admin` path e.g. https://app.infisical.com/login/admin. diff --git a/docs/documentation/platform/sso/jumpcloud.mdx b/docs/documentation/platform/sso/jumpcloud.mdx index 6ca20c752..3cad22247 100644 --- a/docs/documentation/platform/sso/jumpcloud.mdx +++ b/docs/documentation/platform/sso/jumpcloud.mdx @@ -5,14 +5,16 @@ description: "Learn how to configure JumpCloud SAML for Infisical SSO." JumpCloud SAML SSO is a paid feature. - + If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, then you should contact sales@infisical.com to purchase an enterprise license to use it. - In Infisical, head to Organization Settings > Security and click **Connect** for SAML under the Connect to an Identity Provider section. Select JumpCloud, then click **Connect** again. + In Infisical, head to the **Single Sign-On (SSO)** page and select the **General** tab. Click **Connect** for **SAML** under the Connect to an Identity Provider section. Select **JumpCloud**, then click **Connect** again. + + ![SSO connect section](../../../images/sso/connect-saml.png) Next, copy the **ACS URL** and **SP Entity ID** to use when configuring the JumpCloud SAML application. @@ -81,13 +83,12 @@ description: "Learn how to configure JumpCloud SAML for Infisical SSO." To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one JumpCloud user with Infisical; Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO. - + - We recommend ensuring that your account is provisioned the application in JumpCloud - prior to enforcing SAML SSO to prevent any unintended issues. + We recommend ensuring that your account is provisioned in the application in JumpCloud prior to enforcing SAML SSO to prevent any unintended issues. - In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + In case of a lockout, an organization admin can use the [Admin Login Portal](https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal) in the `/login/admin` path e.g. https://app.infisical.com/login/admin. diff --git a/docs/documentation/platform/sso/keycloak-oidc/group-membership-mapping.mdx b/docs/documentation/platform/sso/keycloak-oidc/group-membership-mapping.mdx index c423bac5a..29bca5a8d 100644 --- a/docs/documentation/platform/sso/keycloak-oidc/group-membership-mapping.mdx +++ b/docs/documentation/platform/sso/keycloak-oidc/group-membership-mapping.mdx @@ -53,7 +53,7 @@ Infisical groups not present in their groups claim. 2.1. In Infisical, create any groups you would like to sync users to. Make sure the name of the Infisical group is an exact match of the Keycloak group name. ![OIDC keycloak infisical group](/images/sso/keycloak-oidc/group-membership-mapping/create-infisical-group.png) - 2.2. Next, enable **OIDC Group Membership Mapping** in Organization Settings > Security. + 2.2. Next, enable **OIDC Group Membership Mapping** on the **Single Sign-On (SSO)** page under the **General** tab. ![OIDC keycloak enable group membership mapping](/images/sso/keycloak-oidc/group-membership-mapping/enable-group-membership-mapping.png) 2.3. The next time a user logs in they will be synced to their matching Keycloak groups. diff --git a/docs/documentation/platform/sso/keycloak-oidc/overview.mdx b/docs/documentation/platform/sso/keycloak-oidc/overview.mdx index 803818a0e..2c75fc6fe 100644 --- a/docs/documentation/platform/sso/keycloak-oidc/overview.mdx +++ b/docs/documentation/platform/sso/keycloak-oidc/overview.mdx @@ -66,8 +66,8 @@ description: "Learn how to configure Keycloak OIDC for Infisical SSO." - 3.1. Back in Infisical, in the Organization settings > Security > OIDC, click Connect. - ![OIDC keycloak manage org Infisical](/images/sso/keycloak-oidc/manage-org-oidc.png) + 3.1. Back in Infisical, head to the **Single Sign-On (SSO)** page and select the **General** tab. Click **Connect** for **OIDC**. + ![OIDC SSO Connect](../../../../images/sso/connect-oidc.png) 3.2. For configuration type, select Discovery URL. Then, set the appropriate values for **Discovery Document URL**, **JWT Signature Algorithm**, **Client ID**, and **Client Secret**. ![OIDC keycloak paste values into Infisical](/images/sso/keycloak-oidc/create-oidc.png) @@ -97,7 +97,7 @@ description: "Learn how to configure Keycloak OIDC for Infisical SSO." prior to enforcing OIDC SSO to prevent any unintended issues. - In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + In case of a lockout, an organization admin can use the [Admin Login Portal](https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal) in the `/login/admin` path e.g. https://app.infisical.com/login/admin. diff --git a/docs/documentation/platform/sso/keycloak-saml.mdx b/docs/documentation/platform/sso/keycloak-saml.mdx index 7e4004122..daca360b4 100644 --- a/docs/documentation/platform/sso/keycloak-saml.mdx +++ b/docs/documentation/platform/sso/keycloak-saml.mdx @@ -5,44 +5,44 @@ description: "Learn how to configure Keycloak SAML for Infisical SSO." Keycloak SAML SSO is a paid feature. - + If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, then you should contact sales@infisical.com to purchase an enterprise license to use it. - In Infisical, head to Organization Settings > Security and click **Connect** for SAML under the Connect to an Identity Provider section. Select Keycloak, then click **Connect** again. - - ![Keycloak SAML organization security section](../../../images/sso/keycloak/org-security-section.png) - + In Infisical, head to the **Single Sign-On (SSO)** page and select the **General** tab. Click **Connect** for **SAML** under the Connect to an Identity Provider section. Select **Keycloak**, then click **Connect** again. + + ![SSO connect section](../../../images/sso/connect-saml.png) + Next, copy the **Valid redirect URI** and **SP Entity ID** to use when configuring the Keycloak SAML application. - + ![Keycloak SAML initial configuration](../../../images/sso/keycloak/init-config.png) 2.1. In your realm, navigate to the **Clients** tab and click **Create client** to create a new client application. - + ![SAML keycloak list of clients](../../../images/sso/keycloak/clients-list.png) - + You don’t typically need to make a realm dedicated to Infisical. We recommend adding Infisical as a client to your primary realm. - + In the General Settings step, set **Client type** to **SAML**, the **Client ID** field to `https://app.infisical.com`, and the **Name** field to a friendly name like **Infisical**. - + ![SAML keycloak create client general settings](../../../images/sso/keycloak/create-client-general-settings.png) - + If you’re self-hosting Infisical, then you will want to replace https://app.infisical.com with your own domain. - + Next, in the Login Settings step, set both the **Home URL** field and **Valid redirect URIs** field to the **Valid redirect URI** from step 1 and press **Save**. - + ![SAML keycloak create client login settings](../../../images/sso/keycloak/create-client-login-settings.png) - + 2.2. Once you've created the client, under its **Settings** tab, make sure to set the following values: - + - Under **SAML Capabilities**: - Name ID format: email (or username). - Force name ID format: On. @@ -54,59 +54,59 @@ description: "Learn how to configure Keycloak SAML for Infisical SSO." - Signature algorithm: RSA_SHA256. ![SAML keycloak client SAML capabilities](../../../images/sso/keycloak/client-saml-capabilities.png) - + ![SAML keycloak client signature encryption](../../../images/sso/keycloak/client-signature-encryption.png) - + 2.3. Next, navigate to the **Client scopes** tab select the client's dedicated scope. - + ![SAML keycloak client scopes list](../../../images/sso/keycloak/client-scopes-list.png) - + Next click **Add predefined mapper**. - + ![SAML keycloak client mappers empty](../../../images/sso/keycloak/client-mappers-empty.png) - + Select the **X500 email**, **X500 givenName**, and **X500 surname** attributes and click **Add**. - + ![SAML keycloak client mappers predefined](../../../images/sso/keycloak/client-mappers-predefined.png) - - Now click on the **X500 email** mapper and set the **SAML Attribute Name** field to **email**. + + Now click on the **X500 email** mapper and set the **SAML Attribute Name** field to **email**. ![SAML keycloak client mappers email](../../../images/sso/keycloak/client-mappers-email.png) - + Repeat the same for **X500 givenName** and **X500 surname** mappers, setting the **SAML Attribute Name** field to **firstName** and **lastName** respectively. - + Next, back in the client scope's **Mappers**, click **Add mapper** and select **by configuration**. - + ![SAML keycloak client mappers by configuration](../../../images/sso/keycloak/client-mappers-by-configuration.png) - + Select **User Property**. - + ![SAML keycloak client mappers user property](../../../images/sso/keycloak/client-mappers-user-property.png) - Set the the **Name** field to **Username**, the **Property** field to **username**, and the **SAML Attribtue Name** to **username**. - + Set the the **Name** field to **Username**, the **Property** field to **username**, and the **SAML Attribute Name** to **username**. + ![SAML keycloak client mappers username](../../../images/sso/keycloak/client-mappers-username.png) - + Repeat the same for the `id` attribute, setting the **Name** field to **ID**, the **Property** field to **id**, and the **SAML Attribute Name** to **id**. - + ![SAML keycloak client mappers id](../../../images/sso/keycloak/client-mappers-id.png) - + Once you've completed the above steps, the list of mappers should look like this: - + ![SAML keycloak client mappers completed](../../../images/sso/keycloak/client-mappers-completed.png) Back in Keycloak, navigate to Configure > Realm settings > General tab > Endpoints > SAML 2.0 Identity Provider Metadata and copy the IDP URL. This should appear in various places and take the form: `https://keycloak-mysite.com/realms/myrealm/protocol/saml`. - + ![SAML keycloak realm SAML metadata](../../../images/sso/keycloak/realm-saml-metadata.png) - + Also, in the **Keys** tab, locate the RS256 key and copy the certificate to use when finishing configuring Keycloak SAML in Infisical. - + ![SAML keycloak realm settings keys](../../../images/sso/keycloak/realm-settings-keys.png) Back in Infisical, set **IDP URL** and **Certificate** to the items from step 3. Also, set the **Client ID** to the `https://app.infisical.com`. - + Once you've done that, press **Update** to complete the required configuration. ![SAML Okta paste values into Infisical](../../../images/sso/keycloak/idp-values.png) @@ -119,7 +119,7 @@ description: "Learn how to configure Keycloak SAML for Infisical SSO." Enforcing SAML SSO ensures that members in your organization can only access Infisical by logging into the organization via Keycloak. - + To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one Keycloak user with Infisical; Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO. @@ -128,7 +128,7 @@ description: "Learn how to configure Keycloak SAML for Infisical SSO." prior to enforcing SAML SSO to prevent any unintended issues. - In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + In case of a lockout, an organization admin can use the [Admin Login Portal](https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal) in the `/login/admin` path e.g. https://app.infisical.com/login/admin. @@ -147,4 +147,4 @@ description: "Learn how to configure Keycloak SAML for Infisical SSO." 32`.
- `SITE_URL`: The absolute URL of your self-hosted instance of Infisical including the protocol (e.g. https://app.infisical.com) - \ No newline at end of file + diff --git a/docs/documentation/platform/sso/okta.mdx b/docs/documentation/platform/sso/okta.mdx index 1abd03d6f..ecdf6ca39 100644 --- a/docs/documentation/platform/sso/okta.mdx +++ b/docs/documentation/platform/sso/okta.mdx @@ -12,8 +12,10 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." - In Infisical, head to Organization Settings > Security and click **Connect** for SAML under the Connect to an Identity Provider section. Select Okta, then click **Connect** again. - + In Infisical, head to the **Single Sign-On (SSO)** page and select the **General** tab. Click **Connect** for **SAML** under the Connect to an Identity Provider section. Select **Okta**, then click **Connect** again. + + ![SSO connect section](../../../images/sso/connect-saml.png) + Next, copy the **Single sign-on URL** and **Audience URI (SP Entity ID)** to use when configuring the Okta SAML 2.0 application. ![Okta SAML initial configuration](../../../images/sso/okta/init-config.png) @@ -91,13 +93,12 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO. - We recommend ensuring that your account is provisioned the application in Okta - prior to enforcing SAML SSO to prevent any unintended issues. + We recommend ensuring that your account is provisioned for the application in Okta prior to enforcing SAML SSO to prevent any unintended issues. - - In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. - + + In case of a lockout, an organization admin can use the [Admin Login Portal](https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal) in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + diff --git a/docs/documentation/platform/sso/overview.mdx b/docs/documentation/platform/sso/overview.mdx index e5d5e5c16..66243f7d8 100644 --- a/docs/documentation/platform/sso/overview.mdx +++ b/docs/documentation/platform/sso/overview.mdx @@ -39,18 +39,30 @@ If your required identity provider is not shown in the list above, please reach For enhanced security, Infisical enforces PKCE (Proof Key for Code Exchange) with the OAuth 2.0-based SSO providers and OIDC. This provides additional protection against authorization code interception attacks and strengthens your authentication flow security. +## SSO Break Glass + +In the event your SSO provider experiences downtime, and you need to access Infisical, Organization Admins can utilize the Admin Login Portal to bypass SSO enforcement. + +This portal is accessible at `/login/admin` (e.g., https://app.infisical.com/login/admin). + + + To bypass SSO for an organization, you must be an **Organization Admin** for that specific organization. This **Organization Admin** role is independent of **Server Admin** status. Being a **Server Admin** alone does not grant permission to use this bypass feature. + + ## FAQ - - By default, Infisical Cloud is configured to not trust emails from external - identity providers to prevent any malicious account takeover attempts via - email spoofing. Accordingly, Infisical creates a new user for anyone provisioned - through an external identity provider and requires an additional email - verification step upon their first login. + + By default, Infisical Cloud is configured to not trust emails from external + identity providers to prevent any malicious account takeover attempts via + email spoofing. Accordingly, Infisical creates a new user for anyone provisioned + through an external identity provider and requires an additional email + verification step upon their first login. - If you're running a self-hosted instance of Infisical and would like it to trust emails from external identity providers, - you can configure this behavior in the Server Admin Console. - - + If you're running a self-hosted instance of Infisical and would like it to trust emails from external identity providers, + you can configure this behavior in the Server Admin Console. + + + You are likely being redirected because you do not have email authentication mode enabled, or you're not an **Organization Admin**. This portal requires **Organization Admin** status and direct credential login (email and password). **Server Admin** status alone is insufficient. + diff --git a/docs/documentation/setup/networking.mdx b/docs/documentation/setup/networking.mdx index 4a666b73c..6de27c3c0 100644 --- a/docs/documentation/setup/networking.mdx +++ b/docs/documentation/setup/networking.mdx @@ -4,33 +4,36 @@ sidebarTitle: "Networking" description: "Network configuration details for Infisical Cloud" --- -## Overview - When integrating your infrastructure with Infisical Cloud, you may need to configure network access controls. This page provides the IP addresses that Infisical uses to communicate with your services. -## Egress IP Addresses +## Infisical IP Addresses -Infisical Cloud operates from two regions: US and EU. If your infrastructure has strict network policies, you may need to allow traffic from Infisical by adding the following IP addresses to your ingress rules. These are the egress IPs Infisical uses when making outbound requests to your services. +Infisical Cloud operates from multiple regions. If your infrastructure has strict network policies, you may need to allow traffic from Infisical by adding the following IP addresses to your ingress rules. These are the IP addresses that Infisical uses when making outbound requests to your services. -### US Region + + + ``` + 3.213.63.16 + 54.164.68.7 + ``` + + + + ``` + 3.77.89.19 + 3.125.209.189 + ``` + + + + For dedicated Infisical deployments, please contact your account manager for the specific IP addresses used in your dedicated environment. + + -To allow connections from Infisical US, add these IP addresses to your ingress rules: + +These IP addresses are static and managed by Infisical. Any changes will be communicated with 60-day advance notice. + -- `3.213.63.16` -- `54.164.68.7` +## What These IP Addresses Are Used For -### EU Region - -To allow connections from Infisical EU, add these IP addresses to your ingress rules: - -- `3.77.89.19` -- `3.125.209.189` - -## Common Use Cases - -You may need to allow Infisical’s egress IPs if your services require inbound connections for: - -- Secret rotation - When Infisical needs to send requests to your systems to automatically rotate credentials -- Dynamic secrets - When Infisical generates and manages temporary credentials for your cloud services -- Secret integrations - When syncing secrets with third-party services like Azure Key Vault -- Native authentication with machine identities - When using methods like Kubernetes authentication +These IP addresses represent the source IPs you'll see when Infisical Cloud makes connections to your infrastructure. All outbound traffic from Infisical Cloud originates from these IP addresses, ensuring predictable source IP addresses for your firewall rules. diff --git a/docs/images/app-connections/1password/app-connection-created.png b/docs/images/app-connections/1password/app-connection-created.png new file mode 100644 index 000000000..adfd1b260 Binary files /dev/null and b/docs/images/app-connections/1password/app-connection-created.png differ diff --git a/docs/images/app-connections/1password/app-connection-modal.png b/docs/images/app-connections/1password/app-connection-modal.png new file mode 100644 index 000000000..cf828de3c Binary files /dev/null and b/docs/images/app-connections/1password/app-connection-modal.png differ diff --git a/docs/images/app-connections/1password/app-connection-option.png b/docs/images/app-connections/1password/app-connection-option.png new file mode 100644 index 000000000..bd07c0a80 Binary files /dev/null and b/docs/images/app-connections/1password/app-connection-option.png differ diff --git a/docs/images/app-connections/1password/click-connect-server.png b/docs/images/app-connections/1password/click-connect-server.png new file mode 100644 index 000000000..f3720c2d4 Binary files /dev/null and b/docs/images/app-connections/1password/click-connect-server.png differ diff --git a/docs/images/app-connections/1password/configure-connect-server.png b/docs/images/app-connections/1password/configure-connect-server.png new file mode 100644 index 000000000..89015d499 Binary files /dev/null and b/docs/images/app-connections/1password/configure-connect-server.png differ diff --git a/docs/images/app-connections/1password/deploy-server.png b/docs/images/app-connections/1password/deploy-server.png new file mode 100644 index 000000000..cf29ea2e4 Binary files /dev/null and b/docs/images/app-connections/1password/deploy-server.png differ diff --git a/docs/images/app-connections/1password/developer-page.png b/docs/images/app-connections/1password/developer-page.png new file mode 100644 index 000000000..7df91dfcf Binary files /dev/null and b/docs/images/app-connections/1password/developer-page.png differ diff --git a/docs/images/app-connections/1password/set-up-access-token.png b/docs/images/app-connections/1password/set-up-access-token.png new file mode 100644 index 000000000..c0730d5c3 Binary files /dev/null and b/docs/images/app-connections/1password/set-up-access-token.png differ diff --git a/docs/images/app-connections/github-radar/create-github-radar-app-method.png b/docs/images/app-connections/github-radar/create-github-radar-app-method.png new file mode 100644 index 000000000..2aa403dbc Binary files /dev/null and b/docs/images/app-connections/github-radar/create-github-radar-app-method.png differ diff --git a/docs/images/app-connections/github-radar/github-radar-app-created.png b/docs/images/app-connections/github-radar/github-radar-app-created.png new file mode 100644 index 000000000..d4b9a9374 Binary files /dev/null and b/docs/images/app-connections/github-radar/github-radar-app-created.png differ diff --git a/docs/images/app-connections/github-radar/github-radar-authorize.png b/docs/images/app-connections/github-radar/github-radar-authorize.png new file mode 100644 index 000000000..a113b761c Binary files /dev/null and b/docs/images/app-connections/github-radar/github-radar-authorize.png differ diff --git a/docs/images/app-connections/github-radar/select-github-radar-connection.png b/docs/images/app-connections/github-radar/select-github-radar-connection.png new file mode 100644 index 000000000..35a9e574d Binary files /dev/null and b/docs/images/app-connections/github-radar/select-github-radar-connection.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-1.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-1.png new file mode 100644 index 000000000..4c55482e2 Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-1.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-10.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-10.png new file mode 100644 index 000000000..50959a5c2 Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-10.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-2.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-2.png new file mode 100644 index 000000000..5b9aa6ebc Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-2.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-3.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-3.png new file mode 100644 index 000000000..b9fae23ed Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-3.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-4.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-4.png new file mode 100644 index 000000000..52e27cec1 Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-4.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-5.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-5.png new file mode 100644 index 000000000..aed2940ca Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-5.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-6.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-6.png new file mode 100644 index 000000000..1ed7a8173 Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-6.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-7.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-7.png new file mode 100644 index 000000000..f7646855d Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-7.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-8.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-8.png new file mode 100644 index 000000000..012e09796 Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-8.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-9.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-9.png new file mode 100644 index 000000000..d53849e6b Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-9.png differ diff --git a/docs/images/app-connections/gitlab/create-gitlab-access-token-connection.png b/docs/images/app-connections/gitlab/create-gitlab-access-token-connection.png new file mode 100644 index 000000000..379d6e0e1 Binary files /dev/null and b/docs/images/app-connections/gitlab/create-gitlab-access-token-connection.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-access-token-connection-created.png b/docs/images/app-connections/gitlab/gitlab-access-token-connection-created.png new file mode 100644 index 000000000..85da8a929 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-access-token-connection-created.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-add-access-token.png b/docs/images/app-connections/gitlab/gitlab-add-access-token.png new file mode 100644 index 000000000..b1307f774 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-add-access-token.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-copy-token.png b/docs/images/app-connections/gitlab/gitlab-copy-token.png new file mode 100644 index 000000000..e425ac3ef Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-copy-token.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-secret-scanning-token.png b/docs/images/app-connections/gitlab/gitlab-secret-scanning-token.png new file mode 100644 index 000000000..69575afe7 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-secret-scanning-token.png differ diff --git a/docs/images/app-connections/gitlab/select-gitlab-connection.png b/docs/images/app-connections/gitlab/select-gitlab-connection.png new file mode 100644 index 000000000..0f559477d Binary files /dev/null and b/docs/images/app-connections/gitlab/select-gitlab-connection.png differ diff --git a/docs/images/app-connections/mysql/create-username-and-password-method.png b/docs/images/app-connections/mysql/create-username-and-password-method.png new file mode 100644 index 000000000..0efd58241 Binary files /dev/null and b/docs/images/app-connections/mysql/create-username-and-password-method.png differ diff --git a/docs/images/app-connections/mysql/select-mysql-connection.png b/docs/images/app-connections/mysql/select-mysql-connection.png new file mode 100644 index 000000000..8d6e6312c Binary files /dev/null and b/docs/images/app-connections/mysql/select-mysql-connection.png differ diff --git a/docs/images/app-connections/mysql/username-and-password-connection.png b/docs/images/app-connections/mysql/username-and-password-connection.png new file mode 100644 index 000000000..1d1b2fae6 Binary files /dev/null and b/docs/images/app-connections/mysql/username-and-password-connection.png differ diff --git a/docs/images/app-connections/oci/add-api-key.png b/docs/images/app-connections/oci/add-api-key.png new file mode 100644 index 000000000..049ea4c87 Binary files /dev/null and b/docs/images/app-connections/oci/add-api-key.png differ diff --git a/docs/images/app-connections/oci/app-connection-created.png b/docs/images/app-connections/oci/app-connection-created.png new file mode 100644 index 000000000..73edfa441 Binary files /dev/null and b/docs/images/app-connections/oci/app-connection-created.png differ diff --git a/docs/images/app-connections/oci/app-connection-modal.png b/docs/images/app-connections/oci/app-connection-modal.png new file mode 100644 index 000000000..c4ca6c0fb Binary files /dev/null and b/docs/images/app-connections/oci/app-connection-modal.png differ diff --git a/docs/images/app-connections/oci/app-connection-option.png b/docs/images/app-connections/oci/app-connection-option.png new file mode 100644 index 000000000..1651316c6 Binary files /dev/null and b/docs/images/app-connections/oci/app-connection-option.png differ diff --git a/docs/images/app-connections/oci/click-create-policy.png b/docs/images/app-connections/oci/click-create-policy.png new file mode 100644 index 000000000..edc5a74e9 Binary files /dev/null and b/docs/images/app-connections/oci/click-create-policy.png differ diff --git a/docs/images/app-connections/oci/click-create-user.png b/docs/images/app-connections/oci/click-create-user.png new file mode 100644 index 000000000..d4422b1a4 Binary files /dev/null and b/docs/images/app-connections/oci/click-create-user.png differ diff --git a/docs/images/app-connections/oci/create-group.png b/docs/images/app-connections/oci/create-group.png new file mode 100644 index 000000000..9063ed737 Binary files /dev/null and b/docs/images/app-connections/oci/create-group.png differ diff --git a/docs/images/app-connections/oci/create-policy.png b/docs/images/app-connections/oci/create-policy.png new file mode 100644 index 000000000..ea666e09e Binary files /dev/null and b/docs/images/app-connections/oci/create-policy.png differ diff --git a/docs/images/app-connections/oci/create-user.png b/docs/images/app-connections/oci/create-user.png new file mode 100644 index 000000000..f10488544 Binary files /dev/null and b/docs/images/app-connections/oci/create-user.png differ diff --git a/docs/images/app-connections/oci/search-domains.png b/docs/images/app-connections/oci/search-domains.png new file mode 100644 index 000000000..b56f85350 Binary files /dev/null and b/docs/images/app-connections/oci/search-domains.png differ diff --git a/docs/images/app-connections/oci/search-policies.png b/docs/images/app-connections/oci/search-policies.png new file mode 100644 index 000000000..d541fdbbe Binary files /dev/null and b/docs/images/app-connections/oci/search-policies.png differ diff --git a/docs/images/app-connections/oci/select-api-keys.png b/docs/images/app-connections/oci/select-api-keys.png new file mode 100644 index 000000000..7c63e0919 Binary files /dev/null and b/docs/images/app-connections/oci/select-api-keys.png differ diff --git a/docs/images/app-connections/oci/select-domain.png b/docs/images/app-connections/oci/select-domain.png new file mode 100644 index 000000000..9190de801 Binary files /dev/null and b/docs/images/app-connections/oci/select-domain.png differ diff --git a/docs/images/app-connections/oci/select-groups.png b/docs/images/app-connections/oci/select-groups.png new file mode 100644 index 000000000..d958900a3 Binary files /dev/null and b/docs/images/app-connections/oci/select-groups.png differ diff --git a/docs/images/app-connections/oci/select-users.png b/docs/images/app-connections/oci/select-users.png new file mode 100644 index 000000000..392fd7000 Binary files /dev/null and b/docs/images/app-connections/oci/select-users.png differ diff --git a/docs/images/app-connections/oci/user-info.png b/docs/images/app-connections/oci/user-info.png new file mode 100644 index 000000000..24688d084 Binary files /dev/null and b/docs/images/app-connections/oci/user-info.png differ diff --git a/docs/images/platform/access-controls/abac-policy-k8s-format.png b/docs/images/platform/access-controls/abac-policy-k8s-format.png new file mode 100644 index 000000000..0aff7830a Binary files /dev/null and b/docs/images/platform/access-controls/abac-policy-k8s-format.png differ diff --git a/docs/images/platform/dynamic-secrets/advanced-option-atlas.png b/docs/images/platform/dynamic-secrets/advanced-option-atlas.png index 50c9f89bd..5ddf3920e 100644 Binary files a/docs/images/platform/dynamic-secrets/advanced-option-atlas.png and b/docs/images/platform/dynamic-secrets/advanced-option-atlas.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-input-modal-elastic-search.png b/docs/images/platform/dynamic-secrets/dynamic-secret-input-modal-elastic-search.png index 14d2d48b2..fd7834302 100644 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-input-modal-elastic-search.png and b/docs/images/platform/dynamic-secrets/dynamic-secret-input-modal-elastic-search.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-modal-kubernetes.png b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-kubernetes.png new file mode 100644 index 000000000..b53f52a1a Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-kubernetes.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-mongodb.png b/docs/images/platform/dynamic-secrets/dynamic-secret-mongodb.png index d3a804f8f..e978c7d30 100644 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-mongodb.png and b/docs/images/platform/dynamic-secrets/dynamic-secret-mongodb.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-access-key.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-access-key.png new file mode 100644 index 000000000..439208d83 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-access-key.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-assume-role.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-assume-role.png new file mode 100644 index 000000000..e3be4b5f1 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-assume-role.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png deleted file mode 100644 index d412109fa..000000000 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png and /dev/null differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-1.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-1.png new file mode 100644 index 000000000..dffbedad3 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-1.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-2.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-2.png new file mode 100644 index 000000000..cc5e56001 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-2.png differ diff --git a/docs/images/platform/dynamic-secrets/kubernetes-lease-value.png b/docs/images/platform/dynamic-secrets/kubernetes-lease-value.png new file mode 100644 index 000000000..a8d22f088 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/kubernetes-lease-value.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-cql-statements.png b/docs/images/platform/dynamic-secrets/modify-cql-statements.png index d1e1b9b98..6bbb44780 100644 Binary files a/docs/images/platform/dynamic-secrets/modify-cql-statements.png and b/docs/images/platform/dynamic-secrets/modify-cql-statements.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-elasticache-statement.png b/docs/images/platform/dynamic-secrets/modify-elasticache-statement.png index c8cd662d0..d9675849d 100644 Binary files a/docs/images/platform/dynamic-secrets/modify-elasticache-statement.png and b/docs/images/platform/dynamic-secrets/modify-elasticache-statement.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-redis-statement.png b/docs/images/platform/dynamic-secrets/modify-redis-statement.png index c9726f752..d37cf9bd0 100644 Binary files a/docs/images/platform/dynamic-secrets/modify-redis-statement.png and b/docs/images/platform/dynamic-secrets/modify-redis-statement.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-sap-hana-sql-statements.png b/docs/images/platform/dynamic-secrets/modify-sap-hana-sql-statements.png index 973fcf731..bfff5baa0 100644 Binary files a/docs/images/platform/dynamic-secrets/modify-sap-hana-sql-statements.png and b/docs/images/platform/dynamic-secrets/modify-sap-hana-sql-statements.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-sql-statement-mysql.png b/docs/images/platform/dynamic-secrets/modify-sql-statement-mysql.png index 8ad9fc0e3..312a63d6c 100644 Binary files a/docs/images/platform/dynamic-secrets/modify-sql-statement-mysql.png and b/docs/images/platform/dynamic-secrets/modify-sql-statement-mysql.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-sql-statement-oracle.png b/docs/images/platform/dynamic-secrets/modify-sql-statement-oracle.png index 0874aa23d..800ef05b1 100644 Binary files a/docs/images/platform/dynamic-secrets/modify-sql-statement-oracle.png and b/docs/images/platform/dynamic-secrets/modify-sql-statement-oracle.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-sql-statements-mssql.png b/docs/images/platform/dynamic-secrets/modify-sql-statements-mssql.png index e399db47d..58c33e655 100644 Binary files a/docs/images/platform/dynamic-secrets/modify-sql-statements-mssql.png and b/docs/images/platform/dynamic-secrets/modify-sql-statements-mssql.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-sql-statements.png b/docs/images/platform/dynamic-secrets/modify-sql-statements.png index d0f3b09da..feda34830 100644 Binary files a/docs/images/platform/dynamic-secrets/modify-sql-statements.png and b/docs/images/platform/dynamic-secrets/modify-sql-statements.png differ diff --git a/docs/images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-statements.png b/docs/images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-statements.png index 9ac56f456..c133505b7 100644 Binary files a/docs/images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-statements.png and b/docs/images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-statements.png differ diff --git a/docs/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-sql-statements.png b/docs/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-sql-statements.png index 44c41bd52..fc7e9f663 100644 Binary files a/docs/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-sql-statements.png and b/docs/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-sql-statements.png differ diff --git a/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-modal-vertica.png b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-modal-vertica.png new file mode 100644 index 000000000..0424286d9 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-modal-vertica.png differ diff --git a/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-setup-modal-vertica.png b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-setup-modal-vertica.png new file mode 100644 index 000000000..a270cb214 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-setup-modal-vertica.png differ diff --git a/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-vertica.png b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-vertica.png new file mode 100644 index 000000000..6effe4574 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-vertica.png differ diff --git a/docs/images/platform/dynamic-secrets/vertica/modify-sql-statements-vertica.png b/docs/images/platform/dynamic-secrets/vertica/modify-sql-statements-vertica.png new file mode 100644 index 000000000..dd97ccc4e Binary files /dev/null and b/docs/images/platform/dynamic-secrets/vertica/modify-sql-statements-vertica.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-active.png b/docs/images/platform/external-syncs/github-org-sync-active.png index bb5ce1ca3..1137d7601 100644 Binary files a/docs/images/platform/external-syncs/github-org-sync-active.png and b/docs/images/platform/external-syncs/github-org-sync-active.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-config-modal.png b/docs/images/platform/external-syncs/github-org-sync-config-modal.png index b856048e3..d02cd4589 100644 Binary files a/docs/images/platform/external-syncs/github-org-sync-config-modal.png and b/docs/images/platform/external-syncs/github-org-sync-config-modal.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-section.png b/docs/images/platform/external-syncs/github-org-sync-section.png index dad1fa425..870b9d055 100644 Binary files a/docs/images/platform/external-syncs/github-org-sync-section.png and b/docs/images/platform/external-syncs/github-org-sync-section.png differ diff --git a/docs/images/platform/identities/identities-kubernetes-auth-gateway-as-reviewer.png b/docs/images/platform/identities/identities-kubernetes-auth-gateway-as-reviewer.png new file mode 100644 index 000000000..30ac12545 Binary files /dev/null and b/docs/images/platform/identities/identities-kubernetes-auth-gateway-as-reviewer.png differ diff --git a/docs/images/platform/identities/identities-org-create-oci-auth-method.png b/docs/images/platform/identities/identities-org-create-oci-auth-method.png new file mode 100644 index 000000000..6d08b4ee9 Binary files /dev/null and b/docs/images/platform/identities/identities-org-create-oci-auth-method.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 new file mode 100644 index 000000000..d3fe6fbe0 Binary files /dev/null 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 new file mode 100644 index 000000000..ea0fc9671 Binary files /dev/null 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.png b/docs/images/platform/identities/identities-org-create-universal-auth-method.png deleted file mode 100644 index a3a0a8d9c..000000000 Binary files a/docs/images/platform/identities/identities-org-create-universal-auth-method.png and /dev/null differ diff --git a/docs/images/platform/identities/identities-org-create.png b/docs/images/platform/identities/identities-org-create.png index 06a1ef496..cf5b4c3a5 100644 Binary files a/docs/images/platform/identities/identities-org-create.png and b/docs/images/platform/identities/identities-org-create.png differ diff --git a/docs/images/platform/identities/identities-org.png b/docs/images/platform/identities/identities-org.png index ad75b3dd1..8d396ca84 100644 Binary files a/docs/images/platform/identities/identities-org.png and b/docs/images/platform/identities/identities-org.png differ diff --git a/docs/images/platform/identities/identities-page-remove-default-auth.png b/docs/images/platform/identities/identities-page-remove-default-auth.png index 5b8f22fa2..55c2fbf80 100644 Binary files a/docs/images/platform/identities/identities-page-remove-default-auth.png and b/docs/images/platform/identities/identities-page-remove-default-auth.png differ diff --git a/docs/images/platform/identities/identities-page.png b/docs/images/platform/identities/identities-page.png index 35b8af658..43692ea5d 100644 Binary files a/docs/images/platform/identities/identities-page.png and b/docs/images/platform/identities/identities-page.png differ diff --git a/docs/images/platform/identities/identities-press-cog.png b/docs/images/platform/identities/identities-press-cog.png new file mode 100644 index 000000000..08cd381af Binary files /dev/null and b/docs/images/platform/identities/identities-press-cog.png differ diff --git a/docs/images/platform/identities/identities-project-create.png b/docs/images/platform/identities/identities-project-create.png index d7a2cc5e1..49094fcac 100644 Binary files a/docs/images/platform/identities/identities-project-create.png and b/docs/images/platform/identities/identities-project-create.png differ diff --git a/docs/images/platform/identities/identities-project.png b/docs/images/platform/identities/identities-project.png index b02b7cfca..c561dc342 100644 Binary files a/docs/images/platform/identities/identities-project.png and b/docs/images/platform/identities/identities-project.png differ diff --git a/docs/images/platform/identities/ldap/identities-org-add-auth-method-modal.png b/docs/images/platform/identities/ldap/identities-org-add-auth-method-modal.png new file mode 100644 index 000000000..e9a5f276c Binary files /dev/null and b/docs/images/platform/identities/ldap/identities-org-add-auth-method-modal.png differ diff --git a/docs/images/platform/identities/ldap/identities-org-add-auth-method.png b/docs/images/platform/identities/ldap/identities-org-add-auth-method.png new file mode 100644 index 000000000..95d301010 Binary files /dev/null and b/docs/images/platform/identities/ldap/identities-org-add-auth-method.png differ diff --git a/docs/images/platform/identities/ldap/identities-org-configure-ldap.png b/docs/images/platform/identities/ldap/identities-org-configure-ldap.png new file mode 100644 index 000000000..c9dfb4950 Binary files /dev/null and b/docs/images/platform/identities/ldap/identities-org-configure-ldap.png differ diff --git a/docs/images/platform/identities/ldap/identities-org-create-identity-modal.png b/docs/images/platform/identities/ldap/identities-org-create-identity-modal.png new file mode 100644 index 000000000..3ac6555e4 Binary files /dev/null and b/docs/images/platform/identities/ldap/identities-org-create-identity-modal.png differ diff --git a/docs/images/platform/identities/ldap/identities-org-create-identity.png b/docs/images/platform/identities/ldap/identities-org-create-identity.png new file mode 100644 index 000000000..1086f6521 Binary files /dev/null and b/docs/images/platform/identities/ldap/identities-org-create-identity.png differ diff --git a/docs/images/platform/identities/ldap/jumpcloud-users-management.png b/docs/images/platform/identities/ldap/jumpcloud-users-management.png new file mode 100644 index 000000000..cc5dc13ca Binary files /dev/null and b/docs/images/platform/identities/ldap/jumpcloud-users-management.png differ diff --git a/docs/images/platform/kms/hsm/fortanix-hsm-setup.png b/docs/images/platform/kms/hsm/fortanix-hsm-setup.png new file mode 100644 index 000000000..7465e1296 Binary files /dev/null and b/docs/images/platform/kms/hsm/fortanix-hsm-setup.png differ diff --git a/docs/images/platform/organization/organization-settings-general.png b/docs/images/platform/organization/organization-settings-general.png index affcf32ff..9467b6005 100644 Binary files a/docs/images/platform/organization/organization-settings-general.png and b/docs/images/platform/organization/organization-settings-general.png differ diff --git a/docs/images/platform/pki/ca/external-ca/create-external-ca-button.png b/docs/images/platform/pki/ca/external-ca/create-external-ca-button.png new file mode 100644 index 000000000..bda7822a0 Binary files /dev/null and b/docs/images/platform/pki/ca/external-ca/create-external-ca-button.png differ diff --git a/docs/images/platform/pki/ca/external-ca/create-external-ca-form.png b/docs/images/platform/pki/ca/external-ca/create-external-ca-form.png new file mode 100644 index 000000000..ef572bfa7 Binary files /dev/null and b/docs/images/platform/pki/ca/external-ca/create-external-ca-form.png differ diff --git a/docs/images/platform/pki/ca/external-ca/external-ca-list.png b/docs/images/platform/pki/ca/external-ca/external-ca-list.png new file mode 100644 index 000000000..4ef05c059 Binary files /dev/null and b/docs/images/platform/pki/ca/external-ca/external-ca-list.png differ diff --git a/docs/images/platform/pki/subscriber/subscriber-ca-crl.png b/docs/images/platform/pki/subscriber/subscriber-ca-crl.png new file mode 100644 index 000000000..35f7dad65 Binary files /dev/null and b/docs/images/platform/pki/subscriber/subscriber-ca-crl.png differ diff --git a/docs/images/platform/pki/subscriber/subscriber-create-2.png b/docs/images/platform/pki/subscriber/subscriber-create-2.png new file mode 100644 index 000000000..25ff2c190 Binary files /dev/null and b/docs/images/platform/pki/subscriber/subscriber-create-2.png differ diff --git a/docs/images/platform/pki/subscriber/subscriber-create-3.png b/docs/images/platform/pki/subscriber/subscriber-create-3.png new file mode 100644 index 000000000..c9068ac26 Binary files /dev/null and b/docs/images/platform/pki/subscriber/subscriber-create-3.png differ diff --git a/docs/images/platform/pki/subscriber/subscriber-create.png b/docs/images/platform/pki/subscriber/subscriber-create.png new file mode 100644 index 000000000..8a4709ea3 Binary files /dev/null and b/docs/images/platform/pki/subscriber/subscriber-create.png differ diff --git a/docs/images/platform/pki/subscriber/subscriber-issue-cert-2.png b/docs/images/platform/pki/subscriber/subscriber-issue-cert-2.png new file mode 100644 index 000000000..916c5aab9 Binary files /dev/null and b/docs/images/platform/pki/subscriber/subscriber-issue-cert-2.png differ diff --git a/docs/images/platform/pki/subscriber/subscriber-issue-cert.png b/docs/images/platform/pki/subscriber/subscriber-issue-cert.png new file mode 100644 index 000000000..f96c7db28 Binary files /dev/null and b/docs/images/platform/pki/subscriber/subscriber-issue-cert.png differ diff --git a/docs/images/platform/pki/subscriber/subscriber-revoke-cert.png b/docs/images/platform/pki/subscriber/subscriber-revoke-cert.png new file mode 100644 index 000000000..4601991c8 Binary files /dev/null and b/docs/images/platform/pki/subscriber/subscriber-revoke-cert.png differ diff --git a/docs/images/platform/pr-workflows/create-change-policy.png b/docs/images/platform/pr-workflows/create-change-policy.png index 4ff1ad884..fabcb6716 100644 Binary files a/docs/images/platform/pr-workflows/create-change-policy.png and b/docs/images/platform/pr-workflows/create-change-policy.png differ diff --git a/docs/images/platform/project-templates/project-template-add-button.png b/docs/images/platform/project-templates/project-template-add-button.png index 965de1e9a..c71c5c938 100644 Binary files a/docs/images/platform/project-templates/project-template-add-button.png and b/docs/images/platform/project-templates/project-template-add-button.png differ diff --git a/docs/images/platform/project-templates/project-template-apply.png b/docs/images/platform/project-templates/project-template-apply.png index 1ec49cb43..0ed2d320c 100644 Binary files a/docs/images/platform/project-templates/project-template-apply.png and b/docs/images/platform/project-templates/project-template-apply.png differ diff --git a/docs/images/platform/project-templates/project-template-create.png b/docs/images/platform/project-templates/project-template-create.png index 6cd109049..6c485b4cc 100644 Binary files a/docs/images/platform/project-templates/project-template-create.png and b/docs/images/platform/project-templates/project-template-create.png differ diff --git a/docs/images/platform/project-templates/project-template-customized.png b/docs/images/platform/project-templates/project-template-customized.png index f21717326..182e669c2 100644 Binary files a/docs/images/platform/project-templates/project-template-customized.png and b/docs/images/platform/project-templates/project-template-customized.png differ diff --git a/docs/images/platform/project-templates/project-template-edit-form.png b/docs/images/platform/project-templates/project-template-edit-form.png index c4e29297f..72085468f 100644 Binary files a/docs/images/platform/project-templates/project-template-edit-form.png and b/docs/images/platform/project-templates/project-template-edit-form.png differ diff --git a/docs/images/platform/scim/scim-enable-provisioning.png b/docs/images/platform/scim/scim-enable-provisioning.png index a4385244f..37fc658b5 100644 Binary files a/docs/images/platform/scim/scim-enable-provisioning.png and b/docs/images/platform/scim/scim-enable-provisioning.png differ diff --git a/docs/images/platform/scim/scim-group-mapping.png b/docs/images/platform/scim/scim-group-mapping.png index 76baa8d8d..37bfcf45a 100644 Binary files a/docs/images/platform/scim/scim-group-mapping.png and b/docs/images/platform/scim/scim-group-mapping.png differ diff --git a/docs/images/platform/secret-scanning/exposed-secret.png b/docs/images/platform/secret-scanning/exposed-secret.png deleted file mode 100644 index 727765292..000000000 Binary files a/docs/images/platform/secret-scanning/exposed-secret.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-app-copy-app-id.png b/docs/images/platform/secret-scanning/github-app-copy-app-id.png deleted file mode 100644 index a94cb5ece..000000000 Binary files a/docs/images/platform/secret-scanning/github-app-copy-app-id.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-app-copy-slug.png b/docs/images/platform/secret-scanning/github-app-copy-slug.png deleted file mode 100644 index c555dcd41..000000000 Binary files a/docs/images/platform/secret-scanning/github-app-copy-slug.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-app-create-private-key.png b/docs/images/platform/secret-scanning/github-app-create-private-key.png deleted file mode 100644 index 50f602a36..000000000 Binary files a/docs/images/platform/secret-scanning/github-app-create-private-key.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-configure-app.png b/docs/images/platform/secret-scanning/github-configure-app.png deleted file mode 100644 index df64eeb18..000000000 Binary files a/docs/images/platform/secret-scanning/github-configure-app.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-create-app-button.png b/docs/images/platform/secret-scanning/github-create-app-button.png deleted file mode 100644 index 3ea4b2d38..000000000 Binary files a/docs/images/platform/secret-scanning/github-create-app-button.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-create-app.png b/docs/images/platform/secret-scanning/github-create-app.png deleted file mode 100644 index f4d1cdb8c..000000000 Binary files a/docs/images/platform/secret-scanning/github-create-app.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-register-app.png b/docs/images/platform/secret-scanning/github-register-app.png deleted file mode 100644 index 904c07bf2..000000000 Binary files a/docs/images/platform/secret-scanning/github-register-app.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-repo-permissions.png b/docs/images/platform/secret-scanning/github-repo-permissions.png deleted file mode 100644 index 53eae9a41..000000000 Binary files a/docs/images/platform/secret-scanning/github-repo-permissions.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-select-org-2.png b/docs/images/platform/secret-scanning/github-select-org-2.png deleted file mode 100644 index 55b945c18..000000000 Binary files a/docs/images/platform/secret-scanning/github-select-org-2.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-select-org.png b/docs/images/platform/secret-scanning/github-select-org.png deleted file mode 100644 index 7d6e5abc5..000000000 Binary files a/docs/images/platform/secret-scanning/github-select-org.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-select-repos.png b/docs/images/platform/secret-scanning/github-select-repos.png deleted file mode 100644 index 51a6648d2..000000000 Binary files a/docs/images/platform/secret-scanning/github-select-repos.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-subscribed-events.png b/docs/images/platform/secret-scanning/github-subscribed-events.png deleted file mode 100644 index 7aa6b431f..000000000 Binary files a/docs/images/platform/secret-scanning/github-subscribed-events.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-1.png b/docs/images/platform/secret-scanning/github/github-data-source-step-1.png new file mode 100644 index 000000000..62fc84459 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-1.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-2.png b/docs/images/platform/secret-scanning/github/github-data-source-step-2.png new file mode 100644 index 000000000..5962406e4 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-2.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-3.png b/docs/images/platform/secret-scanning/github/github-data-source-step-3.png new file mode 100644 index 000000000..d1a0e81a0 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-3.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-4.png b/docs/images/platform/secret-scanning/github/github-data-source-step-4.png new file mode 100644 index 000000000..f46045250 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-4.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-5.png b/docs/images/platform/secret-scanning/github/github-data-source-step-5.png new file mode 100644 index 000000000..9f0891888 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-5.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-6.png b/docs/images/platform/secret-scanning/github/github-data-source-step-6.png new file mode 100644 index 000000000..886c0ca45 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-6.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-7.png b/docs/images/platform/secret-scanning/github/github-data-source-step-7.png new file mode 100644 index 000000000..bc34c5fdb Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-7.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-8.png b/docs/images/platform/secret-scanning/github/github-data-source-step-8.png new file mode 100644 index 000000000..75b9f8175 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-8.png differ diff --git a/docs/images/platform/secret-scanning/infisical-connect-secret-scanner.png b/docs/images/platform/secret-scanning/infisical-connect-secret-scanner.png deleted file mode 100644 index 11f24fd74..000000000 Binary files a/docs/images/platform/secret-scanning/infisical-connect-secret-scanner.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/needs-attention.png b/docs/images/platform/secret-scanning/needs-attention.png deleted file mode 100644 index 6ac664ead..000000000 Binary files a/docs/images/platform/secret-scanning/needs-attention.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/overview.png b/docs/images/platform/secret-scanning/overview.png deleted file mode 100644 index 19981fa11..000000000 Binary files a/docs/images/platform/secret-scanning/overview.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/secret-scanning-config.png b/docs/images/platform/secret-scanning/secret-scanning-config.png new file mode 100644 index 000000000..b844f711f Binary files /dev/null and b/docs/images/platform/secret-scanning/secret-scanning-config.png differ diff --git a/docs/images/platform/secret-scanning/secret-scanning-data-sources.png b/docs/images/platform/secret-scanning/secret-scanning-data-sources.png new file mode 100644 index 000000000..344178f54 Binary files /dev/null and b/docs/images/platform/secret-scanning/secret-scanning-data-sources.png differ diff --git a/docs/images/platform/secret-scanning/secret-scanning-findings.png b/docs/images/platform/secret-scanning/secret-scanning-findings.png new file mode 100644 index 000000000..2f0dde504 Binary files /dev/null and b/docs/images/platform/secret-scanning/secret-scanning-findings.png differ diff --git a/docs/images/platform/secret-scanning/secret-scanning-resources.png b/docs/images/platform/secret-scanning/secret-scanning-resources.png new file mode 100644 index 000000000..c47b252fc Binary files /dev/null and b/docs/images/platform/secret-scanning/secret-scanning-resources.png differ diff --git a/docs/images/platform/secret-scanning/secret-scanning-scans.png b/docs/images/platform/secret-scanning/secret-scanning-scans.png new file mode 100644 index 000000000..f710dbc20 Binary files /dev/null and b/docs/images/platform/secret-scanning/secret-scanning-scans.png differ diff --git a/docs/images/platform/secret-sharing/copy-url.png b/docs/images/platform/secret-sharing/copy-url.png index 89d86ede4..4e945ff3a 100644 Binary files a/docs/images/platform/secret-sharing/copy-url.png and b/docs/images/platform/secret-sharing/copy-url.png differ diff --git a/docs/images/platform/secret-sharing/create-new-secret.png b/docs/images/platform/secret-sharing/create-new-secret.png index 03a34e19d..f862af3de 100644 Binary files a/docs/images/platform/secret-sharing/create-new-secret.png and b/docs/images/platform/secret-sharing/create-new-secret.png differ diff --git a/docs/images/platform/secret-sharing/delete-secret.png b/docs/images/platform/secret-sharing/delete-secret.png new file mode 100644 index 000000000..f26b3ce8e Binary files /dev/null and b/docs/images/platform/secret-sharing/delete-secret.png differ diff --git a/docs/images/platform/secret-sharing/overview.png b/docs/images/platform/secret-sharing/overview.png index 428110517..863850a7a 100644 Binary files a/docs/images/platform/secret-sharing/overview.png and b/docs/images/platform/secret-sharing/overview.png differ diff --git a/docs/images/platform/secret-sharing/public-view.png b/docs/images/platform/secret-sharing/public-view.png index 9673fcd37..e49caf8a8 100644 Binary files a/docs/images/platform/secret-sharing/public-view.png and b/docs/images/platform/secret-sharing/public-view.png differ diff --git a/docs/images/platform/ssh/v2/ssh-add-bootstrap-role-1.png b/docs/images/platform/ssh/v2/ssh-add-bootstrap-role-1.png deleted file mode 100644 index 8acc1efe9..000000000 Binary files a/docs/images/platform/ssh/v2/ssh-add-bootstrap-role-1.png and /dev/null differ diff --git a/docs/images/platform/ssh/v2/ssh-add-bootstrap-role-2.png b/docs/images/platform/ssh/v2/ssh-add-bootstrap-role-2.png deleted file mode 100644 index 2ad9804d4..000000000 Binary files a/docs/images/platform/ssh/v2/ssh-add-bootstrap-role-2.png and /dev/null differ diff --git a/docs/images/platform/ssh/v2/ssh-add-identity-to-project.png b/docs/images/platform/ssh/v2/ssh-add-identity-to-project.png index 83bd3c984..d921cc8d0 100644 Binary files a/docs/images/platform/ssh/v2/ssh-add-identity-to-project.png and b/docs/images/platform/ssh/v2/ssh-add-identity-to-project.png differ diff --git a/docs/images/secret-rotations-v2/ldap-password/ldap-password-confirm.png b/docs/images/secret-rotations-v2/ldap-password/ldap-password-confirm.png index 1725c4355..707736095 100644 Binary files a/docs/images/secret-rotations-v2/ldap-password/ldap-password-confirm.png and b/docs/images/secret-rotations-v2/ldap-password/ldap-password-confirm.png differ diff --git a/docs/images/secret-rotations-v2/ldap-password/ldap-password-parameters.png b/docs/images/secret-rotations-v2/ldap-password/ldap-password-parameters.png index dfe723b06..8dbfb8ddf 100644 Binary files a/docs/images/secret-rotations-v2/ldap-password/ldap-password-parameters.png and b/docs/images/secret-rotations-v2/ldap-password/ldap-password-parameters.png differ diff --git a/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-configuration.png b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-configuration.png new file mode 100644 index 000000000..4e797ba67 Binary files /dev/null and b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-configuration.png differ diff --git a/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-confirm.png b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-confirm.png new file mode 100644 index 000000000..4a59fc218 Binary files /dev/null and b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-confirm.png differ diff --git a/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-created.png b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-created.png new file mode 100644 index 000000000..582502e0d Binary files /dev/null and b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-created.png differ diff --git a/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-details.png b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-details.png new file mode 100644 index 000000000..143568bb0 Binary files /dev/null and b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-details.png differ diff --git a/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-parameters.png b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-parameters.png new file mode 100644 index 000000000..c889e047a Binary files /dev/null and b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-parameters.png differ diff --git a/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-secrets-mapping.png b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-secrets-mapping.png new file mode 100644 index 000000000..4903118c8 Binary files /dev/null and b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-secrets-mapping.png differ diff --git a/docs/images/secret-rotations-v2/mysql-credentials/select-mysql-credentials-option.png b/docs/images/secret-rotations-v2/mysql-credentials/select-mysql-credentials-option.png new file mode 100644 index 000000000..78cc8c61a Binary files /dev/null and b/docs/images/secret-rotations-v2/mysql-credentials/select-mysql-credentials-option.png differ diff --git a/docs/images/secret-syncs/1password/configure-destination.png b/docs/images/secret-syncs/1password/configure-destination.png new file mode 100644 index 000000000..af5191486 Binary files /dev/null and b/docs/images/secret-syncs/1password/configure-destination.png differ diff --git a/docs/images/secret-syncs/1password/configure-details.png b/docs/images/secret-syncs/1password/configure-details.png new file mode 100644 index 000000000..69ce333e3 Binary files /dev/null and b/docs/images/secret-syncs/1password/configure-details.png differ diff --git a/docs/images/secret-syncs/1password/configure-source.png b/docs/images/secret-syncs/1password/configure-source.png new file mode 100644 index 000000000..ee08db72b Binary files /dev/null and b/docs/images/secret-syncs/1password/configure-source.png differ diff --git a/docs/images/secret-syncs/1password/configure-sync-options.png b/docs/images/secret-syncs/1password/configure-sync-options.png new file mode 100644 index 000000000..f0b3488e2 Binary files /dev/null and b/docs/images/secret-syncs/1password/configure-sync-options.png differ diff --git a/docs/images/secret-syncs/1password/review-configuration.png b/docs/images/secret-syncs/1password/review-configuration.png new file mode 100644 index 000000000..5663e7da2 Binary files /dev/null and b/docs/images/secret-syncs/1password/review-configuration.png differ diff --git a/docs/images/secret-syncs/1password/select-option.png b/docs/images/secret-syncs/1password/select-option.png new file mode 100644 index 000000000..a19b8189d Binary files /dev/null and b/docs/images/secret-syncs/1password/select-option.png differ diff --git a/docs/images/secret-syncs/1password/sync-created.png b/docs/images/secret-syncs/1password/sync-created.png new file mode 100644 index 000000000..fbe8c90d6 Binary files /dev/null and b/docs/images/secret-syncs/1password/sync-created.png differ diff --git a/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-options.png b/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-options.png index 6a4a68f2c..30a74eac0 100644 Binary files a/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-options.png and b/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-options.png differ diff --git a/docs/images/secret-syncs/aws-secrets-manager/aws-secrets-manager-options.png b/docs/images/secret-syncs/aws-secrets-manager/aws-secrets-manager-options.png index 89ec35e4d..7e3cd5ea9 100644 Binary files a/docs/images/secret-syncs/aws-secrets-manager/aws-secrets-manager-options.png and b/docs/images/secret-syncs/aws-secrets-manager/aws-secrets-manager-options.png differ diff --git a/docs/images/secret-syncs/gcp-secret-manager/gcp-secret-manager-destination.png b/docs/images/secret-syncs/gcp-secret-manager/gcp-secret-manager-destination.png index c50b6232a..1841b4b6d 100644 Binary files a/docs/images/secret-syncs/gcp-secret-manager/gcp-secret-manager-destination.png and b/docs/images/secret-syncs/gcp-secret-manager/gcp-secret-manager-destination.png differ diff --git a/docs/images/secret-syncs/oci-vault/configure-destination.png b/docs/images/secret-syncs/oci-vault/configure-destination.png new file mode 100644 index 000000000..553380635 Binary files /dev/null and b/docs/images/secret-syncs/oci-vault/configure-destination.png differ diff --git a/docs/images/secret-syncs/oci-vault/configure-details.png b/docs/images/secret-syncs/oci-vault/configure-details.png new file mode 100644 index 000000000..27cf890e8 Binary files /dev/null and b/docs/images/secret-syncs/oci-vault/configure-details.png differ diff --git a/docs/images/secret-syncs/oci-vault/configure-source.png b/docs/images/secret-syncs/oci-vault/configure-source.png new file mode 100644 index 000000000..0953466fc Binary files /dev/null and b/docs/images/secret-syncs/oci-vault/configure-source.png differ diff --git a/docs/images/secret-syncs/oci-vault/configure-sync-options.png b/docs/images/secret-syncs/oci-vault/configure-sync-options.png new file mode 100644 index 000000000..6f40e0dbb Binary files /dev/null and b/docs/images/secret-syncs/oci-vault/configure-sync-options.png differ diff --git a/docs/images/secret-syncs/oci-vault/copy-compartment-ocid.png b/docs/images/secret-syncs/oci-vault/copy-compartment-ocid.png new file mode 100644 index 000000000..fb4355807 Binary files /dev/null and b/docs/images/secret-syncs/oci-vault/copy-compartment-ocid.png differ diff --git a/docs/images/secret-syncs/oci-vault/review-configuration.png b/docs/images/secret-syncs/oci-vault/review-configuration.png new file mode 100644 index 000000000..2abe7820f Binary files /dev/null and b/docs/images/secret-syncs/oci-vault/review-configuration.png differ diff --git a/docs/images/secret-syncs/oci-vault/search-compartment.png b/docs/images/secret-syncs/oci-vault/search-compartment.png new file mode 100644 index 000000000..005f06ecd Binary files /dev/null and b/docs/images/secret-syncs/oci-vault/search-compartment.png differ diff --git a/docs/images/secret-syncs/oci-vault/select-compartment.png b/docs/images/secret-syncs/oci-vault/select-compartment.png new file mode 100644 index 000000000..3eae44c32 Binary files /dev/null and b/docs/images/secret-syncs/oci-vault/select-compartment.png differ diff --git a/docs/images/secret-syncs/oci-vault/select-option.png b/docs/images/secret-syncs/oci-vault/select-option.png new file mode 100644 index 000000000..49a61ccae Binary files /dev/null and b/docs/images/secret-syncs/oci-vault/select-option.png differ diff --git a/docs/images/secret-syncs/oci-vault/sync-created.png b/docs/images/secret-syncs/oci-vault/sync-created.png new file mode 100644 index 000000000..c68fedc68 Binary files /dev/null and b/docs/images/secret-syncs/oci-vault/sync-created.png differ diff --git a/docs/images/sso/auth0-oidc/org-oidc-overview.png b/docs/images/sso/auth0-oidc/org-oidc-overview.png deleted file mode 100644 index f5778b97a..000000000 Binary files a/docs/images/sso/auth0-oidc/org-oidc-overview.png and /dev/null differ diff --git a/docs/images/sso/connect-ldap.png b/docs/images/sso/connect-ldap.png new file mode 100644 index 000000000..419d6f8b7 Binary files /dev/null and b/docs/images/sso/connect-ldap.png differ diff --git a/docs/images/sso/connect-oidc.png b/docs/images/sso/connect-oidc.png new file mode 100644 index 000000000..43da1bb0a Binary files /dev/null and b/docs/images/sso/connect-oidc.png differ diff --git a/docs/images/sso/connect-saml.png b/docs/images/sso/connect-saml.png new file mode 100644 index 000000000..40de3a0d2 Binary files /dev/null and b/docs/images/sso/connect-saml.png differ diff --git a/docs/images/sso/general-oidc/org-oidc-manage.png b/docs/images/sso/general-oidc/org-oidc-manage.png deleted file mode 100644 index f5778b97a..000000000 Binary files a/docs/images/sso/general-oidc/org-oidc-manage.png and /dev/null differ diff --git a/docs/images/sso/keycloak-oidc/group-membership-mapping/enable-group-membership-mapping.png b/docs/images/sso/keycloak-oidc/group-membership-mapping/enable-group-membership-mapping.png index 199a7432a..d3b38c762 100644 Binary files a/docs/images/sso/keycloak-oidc/group-membership-mapping/enable-group-membership-mapping.png and b/docs/images/sso/keycloak-oidc/group-membership-mapping/enable-group-membership-mapping.png differ diff --git a/docs/images/sso/keycloak-oidc/manage-org-oidc.png b/docs/images/sso/keycloak-oidc/manage-org-oidc.png deleted file mode 100644 index f5778b97a..000000000 Binary files a/docs/images/sso/keycloak-oidc/manage-org-oidc.png and /dev/null differ diff --git a/docs/integrations/app-connections/1password.mdx b/docs/integrations/app-connections/1password.mdx new file mode 100644 index 000000000..0c3926a1b --- /dev/null +++ b/docs/integrations/app-connections/1password.mdx @@ -0,0 +1,123 @@ +--- +title: "1Password Connection" +description: "Learn how to configure a 1Password Connection for Infisical." +--- + +Infisical supports the use of [Service Accounts](https://developer.1password.com/docs/service-accounts) to connect with 1Password. + +## Setup 1Password Connect Server + + + If you already have a Connect Server for your vault you may skip this step. + + + + + ![Developer Page](/images/app-connections/1password/developer-page.png) + + + ![Click Connect Server](/images/app-connections/1password/click-connect-server.png) + + + 1. Input a name for your Connect Server + 2. Click "Choose Vaults" and select the vaults you want to connect + 3. For each selected vault, click **Edit Access** and **Enable All** + 4. Click "Add Environment" + + ![Configure Connect Server](/images/app-connections/1password/configure-connect-server.png) + + + 1. Input a name and expiration for the token + 2. Click "Choose Vaults" and select the vaults you want to connect + 3. For each selected vault, click **Edit Access** and **Enable All** + 4. Click "Issue Token" + + ![Set Up Access Token](/images/app-connections/1password/set-up-access-token.png) + + + Download the Credentials File and set up your Connect Server. + + + Follow [this guide](https://developer.1password.com/docs/connect/get-started#step-2-deploy-1password-connect-server) to deploy a Connect Server. + + + Make sure to save the **Access Token** for later use. + + ![Deploy Server](/images/app-connections/1password/deploy-server.png) + + + +## Create 1Password Connection in Infisical + + + + + + In your Infisical dashboard, go to **Organization Settings** and select the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click the **+ Add Connection** button and select the **1Password Connection** option from the available integrations. + + ![Select 1Password Connection](/images/app-connections/1password/app-connection-option.png) + + + Complete the 1Password Connection form by entering: + - A descriptive name for the connection + - An optional description for future reference + - The URL at which your 1Password Connect Server instance is hosted + - The Access Token from earlier steps + + ![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. + + ![1Password Connection Created](/images/app-connections/1password/app-connection-created.png) + + + + + To create an 1Password Connection, make an API request to the [Create 1Password Connection](/api-reference/endpoints/app-connections/1password/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/1password \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-1password-connection", + "method": "api-token", + "credentials": { + "instanceUrl": "https://1pass.example.com", + "apiToken": "[PRIVATE TOKEN]" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", + "name": "my-1password-connection", + "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": "1password", + "method": "api-token", + "credentials": { + "instanceUrl": "https://1pass.example.com" + } + } + } + ``` + + diff --git a/docs/integrations/app-connections/github-radar.mdx b/docs/integrations/app-connections/github-radar.mdx new file mode 100644 index 000000000..376973efd --- /dev/null +++ b/docs/integrations/app-connections/github-radar.mdx @@ -0,0 +1,121 @@ +--- +title: "GitHub Radar Connection" +description: "Learn how to configure a GitHub Radar Connection for Infisical." +--- + +Infisical supports GitHub App installation for creating a GitHub Radar Connection. + + + GitHub Radar Connections are specifically configured for [Secret Scanning](/documentation/platform/secret-scanning/overview) and require specific permissions and webhook configuration. + + Check out our [GitHub Connection](/integrations/app-connections/github) for secret management features such as [Secret Syncs](/integrations/secret-syncs/overview). + + + + Using a GitHub Radar Connection with app authentication on a self-hosted instance of Infisical requires configuring an application on GitHub + and registering your instance with it. + + + + Navigate to the GitHub App Settings [here](https://github.com/settings/apps). Click **New GitHub App**. + + + If you have a GitHub organization, you can create an application under it + in your organization Settings > Developer settings > GitHub Apps > New GitHub App. + + + ![create github radar app](/images/app-connections/github-radar/self-hosted-github-radar-step-1.png) + + Configure the following fields: + + 1. **Name** - give your app a name + 2. **Homepage URL** - your self-hosted domain (i.e. `https://your-domain.com`) + 3. **Callback URL** - the callback URL for your domain (i.e. `https://your-domain.com/organization/app-connections/github-radar/oauth/callback`) + 4. **User Authorization** - enable request user authorization on app installation + + ![github radar app details](/images/app-connections/github-radar/self-hosted-github-radar-step-2.png) + + Enable and configure the Webhook fields: + + - **Webhook URL** - the webhook URL for your domain (i.e. `https://your-domain.com/secret-scanning/webhooks/github`) + - **Webhook Secret** - a strong, generated secret to verify webhook payloads + - **SSL Verification** - enable SSL verification + + ![github radar app webhook](/images/app-connections/github-radar/self-hosted-github-radar-step-3.png) + + Set the following repository permissions: + - **Contents**: `Read-only` + - **Metadata**: `Read-only` + + ![github radar app permissions 1](/images/app-connections/github-radar/self-hosted-github-radar-step-4.png) + ![github radar app permissions 2](/images/app-connections/github-radar/self-hosted-github-radar-step-5.png) + + Subscribe to the following events: + - **Push** + + ![github radar app events](/images/app-connections/github-radar/self-hosted-github-radar-step-6.png) + + Create the Github application. + ![github radar app complete](/images/app-connections/github-radar/self-hosted-github-radar-step-7.png) + + + Generate a new **Client Secret** for your GitHub application. + ![github radar app client secret](/images/app-connections/github-radar/self-hosted-github-radar-step-8.png) + + Generate a new **Private Key** for your Github application. + + You will need to copy the contents of the .pem file downloaded + + ![github radar app private key](/images/app-connections/github-radar/self-hosted-github-radar-step-9.png) + + Obtain the following credentials: + + 1. **Slug** - the slug of your application found in the URL + 2. **App ID** - the ID of your application + 3. **Client ID** - the client ID of your application + 4. **Client Secret** - the client secret generated above + 5. **Private Key** - the contents of the private key .pem file generated above + 6. **Webhook Secret** - the secret generated in the previous step when configuring the webhook + + ![github radar app credentials](/images/app-connections/github-radar/self-hosted-github-radar-step-10.png) + + Back in your Infisical instance, add the six new environment variables for the credentials of your GitHub Radar application: + + - `INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID`: The **Client ID** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET`: The **Client Secret** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG`: The **Slug** of your GitHub application. This is the one found in the URL. + - `INF_APP_CONNECTION_GITHUB_RADAR_APP_ID`: The **App ID** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY`: The **Private Key** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET`: The **Webhook Secret** of your GitHub application. + + Once added, restart your Infisical instance and use the GitHub integration via app authentication. + + + + +## Setup GitHub Radar Connection in Infisical + + + + Navigate to the **App Connections** tab on the **Organization Settings** page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Select the **GitHub Radar Connection** option from the connection options modal. + ![Select GitHub Radar Connection](/images/app-connections/github-radar/select-github-radar-connection.png) + + + Select the **GitHub App** method and click **Connect to GitHub**. + ![Connect via GitHub App](/images/app-connections/github-radar/create-github-radar-app-method.png) + + + You will then be redirected to the GitHub App installation page. + + Install and authorize the GitHub application. This will redirect you back to Infisical's App Connections page. + ![Install GitHub App](/images/app-connections/github-radar/github-radar-authorize.png) + + + Your **GitHub Radar Connection** is now available for use. + ![GitHub Radar Connection](/images/app-connections/github-radar/github-radar-app-created.png) + + \ No newline at end of file diff --git a/docs/integrations/app-connections/ldap.mdx b/docs/integrations/app-connections/ldap.mdx index 63c4bfed1..db0b596ce 100644 --- a/docs/integrations/app-connections/ldap.mdx +++ b/docs/integrations/app-connections/ldap.mdx @@ -10,7 +10,7 @@ Infisical supports the use of [Simple Binding](https://ldap.com/the-ldap-bind-op You will need the following information to establish an LDAP connection: - **LDAP URL** - The LDAP/LDAPS URL to connect to (e.g., ldap://domain-or-ip:389 or ldaps://domain-or-ip:636) -- **Binding DN** - The Distinguished Name (DN) of the principal to bind with (e.g., 'CN=John,CN=Users,DC=example,DC=com') +- **Binding DN/UPN** - The Distinguished Name (DN), or User Principal Name (UPN) if supported, of the principal to bind with (e.g., 'CN=John,CN=Users,DC=example,DC=com') - **Binding Password** - The password to bind with for authentication - **CA Certificate** - The SSL certificate (PEM format) to use for secure connection when using ldaps:// with a self-signed certificate diff --git a/docs/integrations/app-connections/mysql.mdx b/docs/integrations/app-connections/mysql.mdx new file mode 100644 index 000000000..38a8a4e97 --- /dev/null +++ b/docs/integrations/app-connections/mysql.mdx @@ -0,0 +1,129 @@ +--- +title: "MySQL Connection" +description: "Learn how to configure a MySQL Connection for Infisical." +--- + +Infisical supports connecting to MySQL using a database role. + +## Configure a MySQL Role for Infisical + + + + Infisical recommends creating a designated role in your MySQL database for your connection. + ```SQL + -- create user role + CREATE USER 'infisical_role'@'%' IDENTIFIED BY 'my-password'; + ``` + + + Depending on how you intend to use your MySQL connection, you'll need to grant one or more of the following permissions. + + To learn more about MySQL's permission system, please visit their [documentation](https://dev.mysql.com/doc/refman/8.4/en/grant.html). + + + + For Secret Rotations, your Infisical user will require the ability to alter other users' passwords: + ```SQL + -- enable permissions to alter login credentials + GRANT CREATE USER ON *.* TO 'infisical_role'@'%'; + + -- Apply changes + FLUSH PRIVILEGES; + ``` + + + + + You'll need the following information to create your MySQL connection: + - `host` - The hostname or IP address of your MySQL server + - `port` - The port number your MySQL server is listening on (default: 3306) + - `database` - The name of the specific database you want to connect to + - `username` - The role name of the login created in the steps above + - `password` - The role password of the login created in the steps above + - `sslCertificate` (optional) - The SSL certificate required for connection (if configured) + + + If you are self-hosting Infisical and intend to connect to an internal/private IP address, be sure to set the `ALLOW_INTERNAL_IP_CONNECTIONS` environment variable to `true`. + + + + +## Create Connection in Infisical + + + + 1. Navigate to the App Connections tab on the Organization Settings page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + 2. Select the **MySQL Connection** option. + ![Select MySQL Connection](/images/app-connections/mysql/select-mysql-connection.png) + + 3. Select the **Username & Password** method option and provide the details obtained from the previous section and press **Connect to MySQL**. + + + Optionally, if you'd like Infisical to manage the credentials of this connection, you can enable the Platform Managed Credentials option. + If enabled, Infisical will update the password of the connection on creation to prevent external access to this database role. + + + ![Create MySQL Connection](/images/app-connections/mysql/create-username-and-password-method.png) + + 4. Your **MySQL Connection** is now available for use. + ![Assume Role MySQL Connection](/images/app-connections/mysql/username-and-password-connection.png) + + + To create a MySQL Connection, make an API request to the [Create MySQL Connection](/api-reference/endpoints/app-connections/mysql/create) API endpoint. + + + Optionally, if you'd like Infisical to manage the credentials of this connection, you can set the `isPlatformManagedCredentials` option to `true`. + If enabled, Infisical will update the password of the connection on creation to prevent external access to this database role. + + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/mysql \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-mysql-connection", + "method": "username-and-password", + "isPlatformManagedCredentials": true, + "credentials": { + "host": "123.4.5.6", + "port": 3306, + "database": "default", + "username": "infisical_role", + "password": "my-password", + "sslEnabled": true, + "sslRejectUnauthorized": true + }, + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-mysql-connection", + "version": 1, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "app": "mysql", + "method": "username-and-password", + "isPlatformManagedCredentials": true, + "credentials": { + "host": "123.4.5.6", + "port": 3306, + "database": "default", + "username": "infisical_role", + "sslEnabled": true, + "sslRejectUnauthorized": true + } + } + } + ``` + + diff --git a/docs/integrations/app-connections/oci.mdx b/docs/integrations/app-connections/oci.mdx new file mode 100644 index 000000000..58fb3c1d3 --- /dev/null +++ b/docs/integrations/app-connections/oci.mdx @@ -0,0 +1,196 @@ +--- +title: "OCI Connection" +description: "Learn how to configure an Oracle Cloud Infrastructure Connection for Infisical." +--- + + + OCI App Connection is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + +Infisical supports the use of [API Signing Key Authentication](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/apisigningkey.htm) to connect with OCI. + +## Create OCI User + + + + ![Search Domains](/images/app-connections/oci/search-domains.png) + + + Select the domain in which you want to create the Infisical user account. + + ![Select Domain](/images/app-connections/oci/select-domain.png) + + + ![Select Users](/images/app-connections/oci/select-users.png) + + + ![Click Create User](/images/app-connections/oci/click-create-user.png) + + + The name, email, and username can be anything. + + ![Create User](/images/app-connections/oci/create-user.png) + + + After you've created a user, you'll be redirected to the user's page. Navigate to 'API keys'. + + ![Select API Keys](/images/app-connections/oci/select-api-keys.png) + + + Click on 'Add API key' and then download or import the private key. After you've obtained the private key, click 'Add'. + + ![Add API Key](/images/app-connections/oci/add-api-key.png) + + + After creating the API key, you'll be shown a modal with relevant information. Save the highlighted values (and the private key) for later steps. + + ![User Info](/images/app-connections/oci/user-info.png) + + + +## Create OCI Group + + + + ![Search Domains](/images/app-connections/oci/search-domains.png) + + + Select the domain in which you want to create the Infisical user account. + + ![Select Domain](/images/app-connections/oci/select-domain.png) + + + ![Select Groups](/images/app-connections/oci/select-groups.png) + + + The name and description can be anything. **Ensure that you assign the user created in earlier steps to this group**. + + ![Create Group](/images/app-connections/oci/create-group.png) + + + After creating the group, take note of its name. It will be used in later steps. + + + +## Create OCI Policy + + + + ![Search Policies](/images/app-connections/oci/search-policies.png) + + + ![Click Create Policy](/images/app-connections/oci/click-create-policy.png) + + + The name and description can be anything. Click 'Show manual editor' and paste in the policy rules relevant to your task: + + + + ``` + Allow group to manage secret-family in compartment + Allow group to use keys in compartment + Allow group to use vaults in compartment + Allow group to inspect compartments in tenancy + ``` + + - **Group Name:** The name of the group you created in earlier steps. + - **Compartment Name:** The name of the compartment which has your secrets vault. + + If you'd like to grant Infisical access to all compartments, replace instances of `compartment ` with `tenancy`. + + + + ![Create Policy](/images/app-connections/oci/create-policy.png) + + + **You must create this policy on the root compartment**, otherwise some functionality may not work. + + + + +## Create OCI Connection in Infisical + + + + + + In your Infisical dashboard, go to **Organization Settings** and select the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click the **+ Add Connection** button and select the **OCI Connection** option from the available integrations. + + ![Select OCI Connection](/images/app-connections/oci/app-connection-option.png) + + + Complete the OCI Connection form by entering: + - A descriptive name for the connection + - An optional description for future reference + - The User OCID from [earlier steps](https://infisical.com/docs/integrations/app-connections/oci#create-oci-user) + - The Tenancy OCID from [earlier steps](https://infisical.com/docs/integrations/app-connections/oci#create-oci-user) + - The Region from [earlier steps](https://infisical.com/docs/integrations/app-connections/oci#create-oci-user) + - The Fingerprint from [earlier steps](https://infisical.com/docs/integrations/app-connections/oci#create-oci-user) + - The Private Key PEM from [earlier steps](https://infisical.com/docs/integrations/app-connections/oci#create-oci-user) + + ![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. + + ![OCI Connection Created](/images/app-connections/oci/app-connection-created.png) + + + + + To create an OCI Connection, make an API request to the [Create OCI Connection](/api-reference/endpoints/app-connections/oci/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/oci \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-oci-connection", + "method": "access-key", + "credentials": { + "userOcid": "ocid1.user.oc1..aaaaaaaagrp35tbkvvad4y2j7sug7xonua7dl2gfp4at2u5i5xj4ghnitg3a", + "tenancyOcid": "ocid1.tenancy.oc1..aaaaaaaaotfma465m4zumfe2ua64mj2m5dwmlw2llh4g4dnfttnakiifonta", + "region": "us-ashburn-1", + "fingerprint": "9c:f6:18:23:92:73:f8:e1:85:2c:6a:e3:2c:7d:ec:8f", + "privateKey": "[PRIVATE KEY PEM]" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", + "name": "my-oci-connection", + "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": "oci", + "method": "access-key", + "credentials": { + "userOcid": "ocid1.user.oc1..aaaaaaaagrp35tbkvvad4y2j7sug7xonua7dl2gfp4at2u5i5xj4ghnitg3a", + "tenancyOcid": "ocid1.tenancy.oc1..aaaaaaaaotfma465m4zumfe2ua64mj2m5dwmlw2llh4g4dnfttnakiifonta", + "region": "us-ashburn-1", + "fingerprint": "9c:f6:18:23:92:73:f8:e1:85:2c:6a:e3:2c:7d:ec:8f" + } + } + } + ``` + + diff --git a/docs/integrations/app-connections/teamcity.mdx b/docs/integrations/app-connections/teamcity.mdx index 1ffafe637..889355954 100644 --- a/docs/integrations/app-connections/teamcity.mdx +++ b/docs/integrations/app-connections/teamcity.mdx @@ -3,7 +3,7 @@ title: "TeamCity Connection" description: "Learn how to configure a TeamCity Connection for Infisical." --- -Infisical supports connecting to TeamCity using an Access Token to securely sync your secrets to TeamCity. +Infisical supports connecting to TeamCity using Access Tokens. ## Setup TeamCity Connection in Infisical diff --git a/docs/integrations/app-connections/vercel.mdx b/docs/integrations/app-connections/vercel.mdx index 8ef4a5647..7ab7bea1b 100644 --- a/docs/integrations/app-connections/vercel.mdx +++ b/docs/integrations/app-connections/vercel.mdx @@ -3,7 +3,7 @@ title: "Vercel Connection" description: "Learn how to configure a Vercel Connection for Infisical." --- -Infisical supports connecting to Vercel using an API Token to securely sync your secrets to Vercel. +Infisical supports connecting to Vercel using API Tokens. ## Setup Vercel Connection in Infisical diff --git a/docs/integrations/app-connections/windmill.mdx b/docs/integrations/app-connections/windmill.mdx index ca4aa7da4..5cab9fa38 100644 --- a/docs/integrations/app-connections/windmill.mdx +++ b/docs/integrations/app-connections/windmill.mdx @@ -3,7 +3,7 @@ title: "Windmill Connection" description: "Learn how to configure a Windmill Connection for Infisical." --- -Infisical supports connecting to Windmill using an **Access Token** to securely sync your secrets to Windmill. +Infisical supports connecting to Windmill using Access Tokens. ## Get a Windmill Access Token diff --git a/docs/integrations/cloud/heroku.mdx b/docs/integrations/cloud/heroku.mdx index a63c3f381..75cf8c106 100644 --- a/docs/integrations/cloud/heroku.mdx +++ b/docs/integrations/cloud/heroku.mdx @@ -22,11 +22,11 @@ description: "How to sync secrets from Infisical to Heroku" Select which Infisical environment secrets you want to sync to which Heroku app and press create integration to start syncing secrets to Heroku. - + ![integrations heroku](../../images/integrations/heroku/integrations-heroku-create.png) Here's some guidance on each field: - + - Project Environment: The environment in the current Infisical project from which you want to sync secrets from. - Secrets Path: The path in the current Infisical project from which you want to sync secrets from such as `/` (for secrets that do not reside in a folder) or `/foo/bar` (for secrets nested in a folder, in this case a folder called `bar` in another folder called `foo`). - Heroku App: The application in Heroku that you want to sync secrets to. @@ -34,7 +34,7 @@ description: "How to sync secrets from Infisical to Heroku" - **No Import - Overwrite all values in Heroku**: Sync secrets and overwrite any existing secrets in Heroku. - **Import - Prefer values from Infisical**: Import secrets from Heroku to Infisical; if a secret with the same name already exists in Infisical, do nothing. Afterwards, sync secrets to Heroku. - **Import - Prefer values from Heroku**: Import secrets from Heroku to Infisical; if a secret with the same name already exists in Infisical, replace its value with the one from Heroku. Afterwards, sync secrets to Heroku. - + ![integrations heroku](../../images/integrations/heroku/integrations-heroku.png) @@ -46,27 +46,26 @@ description: "How to sync secrets from Infisical to Heroku" Navigate to your user Account settings > Applications to create a new API client. - ![integrations Heroku config](../../images/integrations/heroku/integrations-heroku-config-settings.png) - ![integrations Heroku config](../../images/integrations/heroku/integrations-heroku-config-applications.png) - ![integrations Heroku config](../../images/integrations/heroku/integrations-heroku-config-new-app.png) - + ![integrations Heroku config](../../images/integrations/heroku/integrations-heroku-config-settings.png) + ![integrations Heroku config](../../images/integrations/heroku/integrations-heroku-config-applications.png) + ![integrations Heroku config](../../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`. - ![integrations Heroku config](../../images/integrations/heroku/integrations-heroku-config-new-app-form.png) + ![integrations Heroku config](../../images/integrations/heroku/integrations-heroku-config-new-app-form.png) Obtain the **Client ID** and **Client Secret** for your Heroku API client. - - ![integrations Heroku config](../../images/integrations/heroku/integrations-heroku-config-credentials.png) - + + ![integrations Heroku config](../../images/integrations/heroku/integrations-heroku-config-credentials.png) + 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. - + Once added, restart your Infisical instance and use the Heroku integration. - diff --git a/docs/integrations/frameworks/pulumi.mdx b/docs/integrations/frameworks/pulumi.mdx new file mode 100644 index 000000000..11a8e0cb7 --- /dev/null +++ b/docs/integrations/frameworks/pulumi.mdx @@ -0,0 +1,14 @@ +--- +title: "Pulumi" +description: "Using Infisical with Pulumi via the Terraform Bridge" +--- + +Infisical can be integrated with Pulumi by leveraging Pulumi’s [Terraform Bridge](https://www.pulumi.com/blog/any-terraform-provider/), +which allows Terraform providers to be used seamlessly within Pulumi projects. This enables infrastructure and platform teams to manage Infisical secrets and resources +using Pulumi’s familiar programming languages (including TypeScript, Python, Go, and C#), without any change to existing workflows. + +The Terraform Bridge wraps the [Infisical Terraform provider](/integrations/frameworks/terraform) and exposes its resources (such as `infisical_secret`, `infisical_project`, and `infisical_service_token`) +in a Pulumi-compatible interface. This makes it easy to integrate secret management directly into Pulumi-based IaC pipelines, ensuring secrets stay in sync with +the rest of your cloud infrastructure. Authentication is handled through the same methods as Terraform: using environment variables such as `INFISICAL_TOKEN` and `INFISICAL_SITE_URL`. + +By bridging the Infisical provider, teams using Pulumi can adopt secure, centralized secrets management without compromising on their toolchain or language preferences. \ No newline at end of file diff --git a/docs/integrations/platforms/kubernetes-csi.mdx b/docs/integrations/platforms/kubernetes-csi.mdx index 88df9585c..da1d4d019 100644 --- a/docs/integrations/platforms/kubernetes-csi.mdx +++ b/docs/integrations/platforms/kubernetes-csi.mdx @@ -1,6 +1,6 @@ --- title: "Kubernetes CSI" -description: "How to use Infisical to inject secrets directly into Kubernetes pods." +description: "How to use the Infisical Kubernetes CSI provider to inject secrets directly into Kubernetes pods." --- ## Overview @@ -15,9 +15,9 @@ flowchart LR CSP --> CSD(Secrets Store CSI Driver) end - subgraph Application + subgraph Pod CSD --> V(Volume) - V <--> P(Pod) + V <--> P(Application) end ``` diff --git a/docs/integrations/platforms/kubernetes-injector.mdx b/docs/integrations/platforms/kubernetes-injector.mdx new file mode 100644 index 000000000..aacdcfbbb --- /dev/null +++ b/docs/integrations/platforms/kubernetes-injector.mdx @@ -0,0 +1,317 @@ +--- +title: "Kubernetes Agent Injector" +description: "How to use the Infisical Kubernetes Agent Injector to inject secrets directly into Kubernetes pods." +--- + +## Overview + +The Infisical Kubernetes Agent Injector allows you to inject secrets directly into your Kubernetes pods. The Injector will create a [Infisical Agent](/integrations/platforms/infisical-agent) container within your pod that syncs secrets from Infisical into a shared volume mount within your pod. + + +The Infisical Agent Injector will patch and modify your pod's deployment to contain an [Infisical Agent](/integrations/platforms/infisical-agent) container which renders your Infisical secrets into a shared volume mount within your pod. + +The Infisical Agent Injector is built on [Kubernetes Mutating Admission Webhooks](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers), and will watch for `CREATE` and `UPDATE` events on pods in your cluster. +The injector is namespace-agnostic, and will watch for pods in any namespace, but will only patch pods that have the `org.infisical.com/inject` annotation set to `true`. + + +```mermaid +flowchart LR + subgraph Secrets Management + SS(Infisical) --> INJ(Infisical Injector) + end + + subgraph Pod + INJ --> INIT(Agent Init Container) + INIT --> V(Volume) + V <--> P(Application) + end + +``` + +## Install the Infisical Agent Injector + +To install the Infisical Agent Injector, you will need to install our helm charts using [Helm](https://helm.sh/). + +```bash +helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' +helm repo update +helm install --generate-name infisical-helm-charts/infisical-agent-injector +``` + +After installing the helm chart you can verify that the injector is running and working as intended by checking the logs of the injector pod. +```bash +$ kubectl logs deployment/infisical-agent-injector +2025/05/19 14:20:05 Starting infisical-agent-injector... +2025/05/19 14:20:05 Generating self-signed certificate... +2025/05/19 14:20:06 Creating directory: /tmp/tls +2025/05/19 14:20:06 Writing cert to: /tmp/tls/tls.crt +2025/05/19 14:20:06 Writing key to: /tmp/tls/tls.key +2025/05/19 14:20:06 Starting HTTPS server on port 8585... +2025/05/19 14:20:06 Attempting to update webhook config (attempt 1)... +2025/05/19 14:20:06 Successfully updated webhook configuration with CA bundle +``` + +## Supported annotations + +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. + + - `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. + + + 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. + + +## ConfigMap Configuration + +### Supported Fields + +When you are configuring a pod to use the injector, you must create a config map in the same namespace as the pod you want to inject secrets into. +The entire config needs to be of string format and needs to be assigned to the `config.yaml` key in the config map. You can find a full example of the config at the end of this section. + + + The address of your Infisical instance. This field is optional and will default to `https://app.infisical.com` if not provided. + + + + The authentication type to use to connect to Infisical. Currently only the `kubernetes` authentication type is supported. + You can refer to our [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) documentation for more information on how to create a machine identity for Kubernetes Auth. + Please note that the pod's default service account will be used to authenticate with Infisical. + + + + The ID of the machine identity to use to connect to Infisical. This field is required if the `infisical.auth.type` is set to `kubernetes`. + + + +The templates hold an array of templates that will be rendered and injected into the pod. + + + + The path to inject the secrets into within the pod. + If not specified, this will default to `/shared/infisical-secrets`. If you have multiple templates and don't provide a destination path, the destination paths will default to `/shared/infisical-secrets-1`, `/shared/infisical-secrets-2`, etc. + + + + The content of the template to render. + This will be rendered as a [Go Template](https://pkg.go.dev/text/template) and will have access to the following variables. + It follows the templating format and supports the same functions as the [Infisical Agent](/integrations/platforms/infisical-agent#quick-start-infisical-agent) + + + +### Authentication +The Infisical Agent Injector only supports Machine Identity [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) authentication at the moment. + +To configure Kubernetes Auth, you need to set the `auth.type` field to `kubernetes` and set the `auth.config.identity-id` to the ID of the machine identity you wish to use for authentication. + +```yaml +auth: + type: "kubernetes" + config: + identity-id: "" +``` + +### Example ConfigMap +```yaml config-map.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: demo-config-map +data: + config.yaml: | + infisical: + address: "https://app.infisical.com" + auth: + type: "kubernetes" + config: + identity-id: "" + templates: + - destination-path: "/path/to/save/secrets/file.txt" + template-content: | + {{- with secret "" "dev" "/" }} + {{- range . }} + {{ .Key }}={{ .Value }} + {{- end }} + {{- end }} +``` + +```bash +kubectl apply -f config-map.yaml +``` + +To use the config map in your pod, you will need to add the `org.infisical.com/agent-config-map` annotation to your pod's deployment. The value of the annotation is the name of the config map you created above. +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: demo + labels: + 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/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: + # ... +``` + + +## Quick Start +In this section we'll walk through a full example of how to inject secrets into a pod using the Infisical Agent Injector. +In this example we'll create a basic nginx deployment and print a Infisical secret called `API_KEY` to the container logs. + +### Create secrets in Infisical +First you'll need to create the secret in Infisical. + +- `API_KEY`: The API key to use for the nginx deployment. + +Once you've created the secret, save your project ID, environment slug, and secret path, as these will be used in the next step. + +### Configuration +To use the injector you must create a config map in the same namespace as the pod you want to inject secrets into. In this example we'll create a config map in the `test-namespace` namespace. + +The agent injector will authenticate with Infisical using a [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) machine identity. Please follow the [instructions](/documentation/platform/identities/kubernetes-auth) to create a machine identity configured for Kubernetes Auth. +The agent injector will use the service account token of the pod to authenticate with Infisical. + +The `template-content` will be rendered as a [Go Template](https://pkg.go.dev/text/template) and will have access to the following variables. It follows the templating format and supports the same functions as the [Infisical Agent](/integrations/platforms/infisical-agent#quick-start-infisical-agent) +The `destination-path` refers to the path within the pod that the secrets will be injected into. In this case we're injecting the secrets into a file called `/infisical/secrets`. + + +Replace the ``, ``, with your project ID and the environment slug of where you created your secrets in Infisical. Replace `` with the ID of your machine identity configured for Kubernetes Auth. +```yaml config-map.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: nginx-infisical-config-map + namespace: test-namespace +data: + config.yaml: | + infisical: + address: "https://app.infisical.com" + auth: + type: "kubernetes" + config: + identity-id: "" + templates: + - destination-path: "/infisical/secrets" + template-content: | + {{- with secret "" "" "/" }} + {{- range . }} + {{ .Key }}={{ .Value }} + {{- end }} + {{- end }} +``` + +Now apply the config map: +```bash +kubectl apply -f config-map.yaml +``` + +### Injecting secrets into your pod + +To inject secrets into your pod, you will need to add the `org.infisical.com/inject: "true"` annotation to your pod's deployment. + +The `org.infisical.com/agent-config-map` annotation will point to the config map we created in the previous step. It's important that the config map is in the same namespace as the pod. + +We are creating a nginx deployment with a PVC to store the database data. + +```yaml nginx.yaml +--- +apiVersion: v1 +kind: Pod +metadata: + name: nginx-pod + namespace: test-namespace + labels: + app: nginx + annotations: + org.infisical.com/inject: "true" + org.infisical.com/inject-mode: "init" + org.infisical.com/agent-config-map: "nginx-infisical-config-map" +spec: + containers: + - name: simple-app-demo + image: nginx:alpine + command: ["/bin/sh", "-c"] + args: + - | + export $(cat /infisical/secrets | xargs) + echo "API_KEY is set to: $API_KEY" + nginx -g "daemon off;" +``` + +### Applying the deployment + +To apply the deployment, you can use the following command: + +```bash +kubectl apply -f nginx.yaml +``` +It may take a few minutes for the pod to be ready and for the Infisical secrets to be injected. You can check the status of the pod by running: + +```bash +kubectl get pods -n test-namespace +``` + +### Verifying the secrets are injected + +To verify the secrets are injected, you can check the pod's logs: + +```bash +$ kubectl exec -it pod/nginx-pod -n test-namespace -- cat /infisical/secrets + +Defaulted container "simple-app-demo" out of: simple-app-demo, infisical-agent-init (init) + +API_KEY=sk_api_... # The secret you created in Infisical +``` + +Additionally you can now check that the `API_KEY` secret is being logged to the nginx container logs: +```bash +$ kubectl logs pod/nginx-pod -n test-namespace +Defaulted container "simple-app-demo" out of: simple-app-demo, infisical-agent-init (init) +API_KEY is set to: sk_api_... # The secret you created in Infisical +``` + + +## Troubleshooting + + + + If the pod is stuck in `Init` state, it means the Agent init container is failing to start or is stuck in a restart loop. + This could be due to a number of reasons, such as the machine identity not having the correct permissions, or trying to fetch secrets from a non-existent project/environment. + + You can check the logs of the infisical init container by running: + ```bash + # For deployments + kubectl logs deployment/your-deployment-name -c infisical-agent-init -n "" + + # For pods + kubectl logs pod/your-pod-name -c infisical-agent-init -n "" + ``` + + You can also check the logs of the pod by running: + ```bash + kubectl logs deployment/postgres-deployment -n test-namespace + ``` + + When checking the logs of the agent init container, you may see something like the following: + ```bash + Starting infisical agent... + 11:10AM INF starting Infisical agent... + 11:10AM INF Infisical instance address set to https://daniel1.tunn.dev + 11:10AM INF template engine started for template 1... + 11:10AM INF attempting to authenticate... + 11:10AM INF new access token saved to file at path '/home/infisical/config/identity-access-token' + 11:10AM ERR unable to process template because template: literalTemplate:1:9: executing "literalTemplate" at : error calling secret: CallGetRawSecretsV3: Unsuccessful response [GET https://daniel1.tunn.dev/api/v3/secrets/raw?environment=dev&expandSecretReferences=true&include_imports=true&secretPath=%2F&workspaceId=3c0d3ff6-165c-4dc9-b52c-ff3ffaedfce311111] [status-code=404] [response={"reqId":"req-ljqNq567jchFrK","statusCode":404,"message":"Project with ID '3c0d3ff6-165c-4dc9-b52c-ff3ffaedfce311111' not found during bot lookup. Are you sure you are using the correct project ID?","error":"NotFound"}] + + echo 'Agent failed with exit code 1' + + exit 1 + Agent failed with exit code 1 + ``` + + In the above error, the project ID was invalid in the config map. + \ No newline at end of file diff --git a/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx index 21f54994a..5962e4c10 100644 --- a/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx @@ -165,7 +165,7 @@ spec: - Creation polices allow you to control whether or not owner references should be added to the managed Kubernetes secret that is generated by the Infisical operator. + Creation policies allow you to control whether or not owner references should be added to the managed Kubernetes secret that is generated by the Infisical operator. This is useful for tools such as ArgoCD, where every resource requires an owner reference; otherwise, it will be pruned automatically. #### Available options diff --git a/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx index 50f07bb76..d87648bbf 100644 --- a/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx @@ -34,7 +34,7 @@ Before applying the InfisicalPushSecret CRD, you need to create a Kubernetes sec metadata: name: infisical-push-secret-demo spec: - resyncInterval: 1m + resyncInterval: 1m # Remove this field to disable automatic reconciliation of the InfisicalPushSecret CRD. hostAPI: https://app.infisical.com/api # Optional, defaults to no replacement. @@ -124,7 +124,9 @@ After applying the InfisicalPushSecret CRD, you should notice that the secrets y - The `resyncInterval` is a string-formatted duration that defines the time between each resync. + The `resyncInterval` is a string-formatted duration that defines the time between each resync. The field is optional, and will default to no automatic resync if not defined. + + If you don't want to automatically reconcile the InfisicalPushSecret CRD on an interval, you can remove the `resyncInterval` field entirely from your InfisicalPushSecret CRD. The format of the field is `[duration][unit]` where `duration` is a number and `unit` is a string representing the unit of time. @@ -239,7 +241,21 @@ After applying the InfisicalPushSecret CRD, you should notice that the secrets y DATABASE_URL: postgres://127.0.0.1:5432 ENCRYPTION_KEY: fabcc12-a22-facbaa4-11aa568aab ``` + + + The `generators[]` field is used to define the generators you want to use for your InfisicalPushSecret CRD. + You can follow the guide for [using generators to push secrets](#using-generators-to-push-secrets) for more information. + Example: + + ```yaml + push: + generators: + - destinationSecretName: password-generator-test + generatorRef: + kind: Password + name: password-generator + ``` @@ -459,6 +475,148 @@ Using Go templates, you can format, combine, and create new key-value pairs of s Please refer to the [templating functions documentation](/integrations/platforms/kubernetes/overview#available-helper-functions) for more information. +## Using generators to push secrets + +Generators allow secrets to be dynamically generated during each reconciliation cycle and then pushed to Infisical. They are useful for use cases where a new secret value is needed on every sync, such as ephemeral credentials or one-time-use tokens. + +A generator is defined as a custom resource (`ClusterGenerator`) within the cluster, which specifies the logic for generating secret values. Generators are stateless, each invocation triggers the creation of a new set of values, with no tracking or persistence of previously generated data. + +Because of this behavior, you may want to disable automatic syncing for the `InfisicalPushSecret` resource to avoid continuous regeneration of secrets. This can be done by omitting the `resyncInterval` field from the InfisicalPushSecret CRD. + +### Example usage +```yaml + push: + secret: + secretName: push-secret-source-secret + secretNamespace: dev + generators: + - destinationSecretName: password-generator # Name of the secret that will be created in Infisical + generatorRef: + kind: Password # Kind of the resource, must match the generator kind. + name: custom-generator # Name of the generator resource +``` + +To use a generator, you must specify at least one generator in the `push.generators[]` field. + + + + This field holds an array of the generators you want to use for your InfisicalPushSecret CRD. + + + + The name of the secret that will be created in Infisical. + + + + The reference to the generator resource. + + Valid fields: + - `kind`: The kind of the generator resource, must match the generator kind. + - `name`: The name of the generator resource. + + + + The kind of the generator resource, must match the generator kind. + + Valid values: + - `Password` + - `UUID` + + + + The name of the generator resource. + + +### Supported Generators +Below are the currently supported generators for the InfisicalPushSecret CRD. Each generator is a `ClusterGenerator` custom resource that can be used to customize the generated secret. + + + ### Password Generator + + The Password generator is a custom resource that is installed on the cluster that defines the logic for generating a password. + - `kind`: The kind of the generator resource, must match the generator kind. For the Password generator, the kind is `Password`. + - `generator.passwordSpec`: The spec of the password generator. + + + The `generator.kind` field must match the kind of the generator resource. For the Password generator, the kind should always be set to `Password`. + + + - `length`: The length of the password. + - `digits`: The number of digits in the password. + - `symbols`: The number of symbols in the password. + - `symbolCharacters`: The characters to use for the symbols in the password. + - `noUpper`: Whether to include uppercase letters in the password. + - `allowRepeat`: Whether to allow repeating characters in the password. + + + ```yaml password-cluster-generator.yaml + apiVersion: secrets.infisical.com/v1alpha1 + kind: ClusterGenerator + metadata: + name: password-generator + spec: + kind: Password + generator: + passwordSpec: + length: 10 + digits: 5 + symbols: 5 + symbolCharacters: "-_$@" + noUpper: false + allowRepeat: true + ``` + + Example InfisicalPushSecret CRD using the Password generator: + ```yaml infisical-push-secret-crd.yaml + push: + generators: + - destinationSecretName: password-generator-test + generatorRef: + kind: Password + name: password-generator + ``` + + + ### UUID Generator + + The UUID generator is a custom resource that is installed on the cluster that defines the logic for generating a UUID. + - `kind`: The kind of the generator resource, must match the generator kind. For the UUID generator, the kind is `UUID`. + - `generator.uuidSpec`: The spec of the UUID generator. For UUID's, this can be left empty. + + + The `generator.kind` field must match the kind of the generator resource. For the UUID generator, the kind should always be set to `UUID`. + + + + The spec of the UUID generator. For UUID's, this can be left empty. + + + ```yaml uuid-cluster-generator.yaml + apiVersion: secrets.infisical.com/v1alpha1 + kind: ClusterGenerator + metadata: + name: uuid-generator + spec: + kind: UUID + generator: + uuidSpec: + ``` + + Example InfisicalPushSecret CRD using the UUID generator: + + ```yaml infisical-push-secret-crd.yaml + push: + generators: + - destinationSecretName: uuid-generator-test + generatorRef: + kind: UUID + name: uuid-generator + ``` + + + + + ## Applying the InfisicalPushSecret CRD to your cluster Once you have configured the `InfisicalPushSecret` CRD with the required fields, you can apply it to your cluster. diff --git a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx index a79a8d0a5..145737e96 100644 --- a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx @@ -232,7 +232,7 @@ spec: - To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. ![identities organization](/images/platform/identities/identities-org.png) @@ -407,7 +407,7 @@ spec: - To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. ![identities organization](/images/platform/identities/identities-org.png) @@ -832,7 +832,7 @@ The namespace of the managed Kubernetes secret to be created. Override the default Opaque type for managed secrets with this field. Useful for creating kubernetes.io/dockerconfigjson secrets. -Creation polices allow you to control whether or not owner references should be added to the managed Kubernetes secret that is generated by the Infisical operator. +Creation policies allow you to control whether or not owner references should be added to the managed Kubernetes secret that is generated by the Infisical operator. This is useful for tools such as ArgoCD, where every resource requires an owner reference; otherwise, it will be pruned automatically. #### Available options @@ -940,7 +940,7 @@ The Infisical operator will automatically create the Kubernetes config map in th The namespace of the managed Kubernetes config map that your Infisical data will be stored in. - Creation polices allow you to control whether or not owner references should be added to the managed Kubernetes config map that is generated by the Infisical operator. + Creation policies allow you to control whether or not owner references should be added to the managed Kubernetes config map that is generated by the Infisical operator. This is useful for tools such as ArgoCD, where every resource requires an owner reference; otherwise, it will be pruned automatically. #### Available options diff --git a/docs/integrations/secret-syncs/1password.mdx b/docs/integrations/secret-syncs/1password.mdx new file mode 100644 index 000000000..6e2b96b4a --- /dev/null +++ b/docs/integrations/secret-syncs/1password.mdx @@ -0,0 +1,163 @@ +--- +title: "1Password Sync" +description: "Learn how to configure a 1Password Sync for Infisical." +--- + +**Prerequisites:** +- Create an [1Password Connection](/integrations/app-connections/1password) + + + + + + Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + + ![Select 1Password](/images/secret-syncs/1password/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/1password/configure-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + + Configure the **Destination** to where secrets should be deployed, then click **Next**. + + ![Configure Destination](/images/secret-syncs/1password/configure-destination.png) + + - **1Password Connection**: The 1Password Connection to authenticate with. + - **Vault**: The 1Password vault to sync secrets to. + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Sync Options](/images/secret-syncs/1password/configure-sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over 1Password when keys conflict. + - **Import Secrets (Prioritize 1Password)**: Imports secrets from the destination endpoint before syncing, prioritizing values from 1Password over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + + Configure the **Details** of your 1Password Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/1password/configure-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your 1Password Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/1password/review-configuration.png) + + + If enabled, your 1Password Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/1password/sync-created.png) + + + + + To create an **1Password Sync**, make an API request to the [Create 1Password Sync](/api-reference/endpoints/secret-syncs/1password/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/1password \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-1password-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "destinationConfig": { + "vaultId": "..." + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-1password-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "1password", + "name": "my-1password-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "1password", + "destinationConfig": { + "vaultId": "..." + } + } + } + ``` + + + +## FAQ + + + + Infisical can only perform CRUD operations on the following item types: + - API Credentials + + diff --git a/docs/integrations/secret-syncs/aws-parameter-store.mdx b/docs/integrations/secret-syncs/aws-parameter-store.mdx index fad37265a..abc52d971 100644 --- a/docs/integrations/secret-syncs/aws-parameter-store.mdx +++ b/docs/integrations/secret-syncs/aws-parameter-store.mdx @@ -40,6 +40,10 @@ description: "Learn how to configure an AWS Parameter Store Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Parameter Store when keys conflict. - **Import Secrets (Prioritize AWS Parameter Store)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Parameter Store over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + - **KMS Key**: The AWS KMS key ID or alias to encrypt parameters with. - **Tags**: Optional resource tags to add to parameters synced by Infisical. - **Sync Secret Metadata as Resource Tags**: If enabled, metadata attached to secrets will be added as resource tags to parameters synced by Infisical. diff --git a/docs/integrations/secret-syncs/aws-secrets-manager.mdx b/docs/integrations/secret-syncs/aws-secrets-manager.mdx index 8ed85be25..91c606b0a 100644 --- a/docs/integrations/secret-syncs/aws-secrets-manager.mdx +++ b/docs/integrations/secret-syncs/aws-secrets-manager.mdx @@ -43,6 +43,10 @@ description: "Learn how to configure an AWS Secrets Manager Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Secrets Manager when keys conflict. - **Import Secrets (Prioritize AWS Secrets Manager)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Secrets Manager over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + - **KMS Key**: The AWS KMS key ID or alias to encrypt secrets with. - **Tags**: Optional tags to add to secrets synced by Infisical. - **Sync Secret Metadata as Tags**: If enabled, metadata attached to secrets will be added as tags to secrets synced by Infisical. diff --git a/docs/integrations/secret-syncs/azure-app-configuration.mdx b/docs/integrations/secret-syncs/azure-app-configuration.mdx index 35a577872..f4aaa7edd 100644 --- a/docs/integrations/secret-syncs/azure-app-configuration.mdx +++ b/docs/integrations/secret-syncs/azure-app-configuration.mdx @@ -48,7 +48,10 @@ description: "Learn how to configure an Azure App Configuration Sync for Infisic - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Secrets Manager when keys conflict. - **Import Secrets (Prioritize Azure App Configuration)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Secrets Manager over Infisical when keys conflict. - + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. diff --git a/docs/integrations/secret-syncs/azure-key-vault.mdx b/docs/integrations/secret-syncs/azure-key-vault.mdx index 5f55a73ae..d19a0162e 100644 --- a/docs/integrations/secret-syncs/azure-key-vault.mdx +++ b/docs/integrations/secret-syncs/azure-key-vault.mdx @@ -51,6 +51,10 @@ description: "Learn how to configure a Azure Key Vault Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Secrets Manager when keys conflict. - **Import Secrets (Prioritize Azure Key Vault)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Secrets Manager over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. diff --git a/docs/integrations/secret-syncs/camunda.mdx b/docs/integrations/secret-syncs/camunda.mdx index 5ed2cd9ae..0e977aa27 100644 --- a/docs/integrations/secret-syncs/camunda.mdx +++ b/docs/integrations/secret-syncs/camunda.mdx @@ -39,6 +39,10 @@ description: "Learn how to configure a Camunda Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Camunda when keys conflict. - **Import Secrets (Prioritize Camunda)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Camunda over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. diff --git a/docs/integrations/secret-syncs/databricks.mdx b/docs/integrations/secret-syncs/databricks.mdx index c9db5f88a..e11537420 100644 --- a/docs/integrations/secret-syncs/databricks.mdx +++ b/docs/integrations/secret-syncs/databricks.mdx @@ -46,6 +46,10 @@ description: "Learn how to configure a Databricks Sync for Infisical." Databricks does not support importing secrets. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. diff --git a/docs/integrations/secret-syncs/gcp-secret-manager.mdx b/docs/integrations/secret-syncs/gcp-secret-manager.mdx index 72c932116..0be08a9a9 100644 --- a/docs/integrations/secret-syncs/gcp-secret-manager.mdx +++ b/docs/integrations/secret-syncs/gcp-secret-manager.mdx @@ -34,6 +34,9 @@ description: "Learn how to configure a GCP Secret Manager Sync for Infisical." - **GCP Connection**: The GCP Connection to authenticate with. - **Project**: The GCP project to sync with. + - **Scope**: The GCP project scope that secrets should be synced to: + - **Global**: Secrets will be synced globally; available to all project regions. + - **Region**: Secrets will be synced to the specified region. 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. ![Configure Options](/images/secret-syncs/gcp-secret-manager/gcp-secret-manager-options.png) @@ -42,6 +45,10 @@ description: "Learn how to configure a GCP Secret Manager Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over GCP Secret Manager when keys conflict. - **Import Secrets (Prioritize GCP Secret Manager)**: Imports secrets from the destination endpoint before syncing, prioritizing values from GCP Secret Manager over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. diff --git a/docs/integrations/secret-syncs/github.mdx b/docs/integrations/secret-syncs/github.mdx index d55ec3d0b..14b2d9a7f 100644 --- a/docs/integrations/secret-syncs/github.mdx +++ b/docs/integrations/secret-syncs/github.mdx @@ -62,6 +62,10 @@ description: "Learn how to configure a GitHub Sync for Infisical." GitHub does not support importing secrets. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. diff --git a/docs/integrations/secret-syncs/hashicorp-vault.mdx b/docs/integrations/secret-syncs/hashicorp-vault.mdx index 0d6c0d644..fae2e0962 100644 --- a/docs/integrations/secret-syncs/hashicorp-vault.mdx +++ b/docs/integrations/secret-syncs/hashicorp-vault.mdx @@ -54,6 +54,10 @@ description: "Learn how to configure a Hashicorp Vault Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Hashicorp Vault when keys conflict. - **Import Secrets (Prioritize Hashicorp Vault)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Hashicorp Vault over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. diff --git a/docs/integrations/secret-syncs/humanitec.mdx b/docs/integrations/secret-syncs/humanitec.mdx index e8cd7eafc..f252724fb 100644 --- a/docs/integrations/secret-syncs/humanitec.mdx +++ b/docs/integrations/secret-syncs/humanitec.mdx @@ -55,6 +55,10 @@ description: "Learn how to configure a Humanitec Sync for Infisical." Humanitec does not support importing secrets. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. diff --git a/docs/integrations/secret-syncs/oci-vault.mdx b/docs/integrations/secret-syncs/oci-vault.mdx new file mode 100644 index 000000000..396b4d13f --- /dev/null +++ b/docs/integrations/secret-syncs/oci-vault.mdx @@ -0,0 +1,187 @@ +--- +title: "OCI Vault Sync" +description: "Learn how to configure an Oracle Cloud Infrastructure Vault Sync for Infisical." +--- + + + OCI Vault Sync is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + +**Prerequisites:** +- Create an [OCI Connection](/integrations/app-connections/oci) with the required **Secret Sync** permissions +- [Create](https://docs.oracle.com/en-us/iaas/Content/Identity/compartments/To_create_a_compartment.htm) or use an existing OCI Compartment (which the OCI Connection is authorized to access) +- [Create](https://docs.oracle.com/en-us/iaas/Content/KeyManagement/Tasks/managingvaults_topic-To_create_a_new_vault.htm#createnewvault) or use an existing OCI Vault + + + + + + Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + + ![Select OCI Vault](/images/secret-syncs/oci-vault/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/oci-vault/configure-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + + Configure the **Destination** to where secrets should be deployed, then click **Next**. + + ![Configure Destination](/images/secret-syncs/oci-vault/configure-destination.png) + + - **OCI Connection**: The OCI Connection to authenticate with. + - **Compartment**: The compartment where the vault is located. + - **Vault**: The vault to sync secrets to. + - **Encryption Key**: The encryption key to use when creating secrets in the vault. + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Sync Options](/images/secret-syncs/oci-vault/configure-sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over OCI Vault when keys conflict. + - **Import Secrets (Prioritize OCI Vault)**: Imports secrets from the destination endpoint before syncing, prioritizing values from OCI Vault over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + + Configure the **Details** of your OCI Vault Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/oci-vault/configure-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your OCI Vault Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/oci-vault/review-configuration.png) + + + If enabled, your OCI Vault Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/oci-vault/sync-created.png) + + + + + To create an **OCI Vault Sync**, make an API request to the [Create OCI Vault Sync](/api-reference/endpoints/secret-syncs/oci-vault/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/oci-vault \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-oci-vault-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "destinationConfig": { + "compartmentOcid": "...", + "vaultOcid": "...", + "keyOcid": "..." + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-oci-vault-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "oci", + "name": "my-oci-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "oci-vault", + "destinationConfig": { + "compartmentOcid": "...", + "vaultOcid": "...", + "keyOcid": "..." + } + } + } + ``` + + + +## FAQ + + + + When Infisical attempts to sync secrets, the sync will fail and attempt to re-sync if **any secret** has one of the following lifecycle states: + - SchedulingDeletion + - CancellingDeletion + - Deleting + - Creating + - Updating + + We do this to prevent any desync issues. + + + In the case that a variable is created or updated while it's scheduled for deletion in OCI Vault, we cancel the deletion and update the variable. This action may take up to a minute since Infisical must wait for OCI to completely cancel the deletion and then update the variable. + + diff --git a/docs/integrations/secret-syncs/overview.mdx b/docs/integrations/secret-syncs/overview.mdx index 0df04cbb7..937c8d826 100644 --- a/docs/integrations/secret-syncs/overview.mdx +++ b/docs/integrations/secret-syncs/overview.mdx @@ -93,4 +93,32 @@ via the UI or API for the third-party service you intend to sync secrets to. Infisical is continuously expanding it's Secret Sync third-party service support. If the service you need isn't available, you can still use our Native Integrations in the interim, or contact us at team@infisical.com to make a request . - \ No newline at end of file + + +## Key Schemas + +Key Schemas transform your secret keys by applying a prefix, suffix, or format pattern during sync to external destinations. This makes it clear which secrets are managed by Infisical and prevents accidental changes to unrelated secrets. + +Any destination secrets which do not match the schema will not get deleted or updated by Infisical. + +Key Schemas use handlebars syntax to define dynamic values. Here's a full list of available variables: +- `{{secretKey}}` - The key of the secret +- `{{environment}}` - The environment which the secret is in (e.g. dev, staging, prod) + +**Example:** +- Infisical key: `SECRET_1` +- Schema: `INFISICAL_{{secretKey}}` +- Synced key: `INFISICAL_SECRET_1` + +
+ ```mermaid + graph LR + A[Infisical: **SECRET_1**] -->|Apply Schema| B[Destination: **INFISICAL_SECRET_1**] + 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 + ``` +
+ + + When importing secrets from the destination into Infisical, the schema is stripped from imported secret keys. + diff --git a/docs/integrations/secret-syncs/teamcity.mdx b/docs/integrations/secret-syncs/teamcity.mdx index af4c8d76a..52f2c1bac 100644 --- a/docs/integrations/secret-syncs/teamcity.mdx +++ b/docs/integrations/secret-syncs/teamcity.mdx @@ -48,7 +48,10 @@ description: "Learn how to configure a TeamCity Sync for Infisical." Infisical only syncs secrets from within the target scope; inherited secrets will not be imported. - + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. diff --git a/docs/integrations/secret-syncs/terraform-cloud.mdx b/docs/integrations/secret-syncs/terraform-cloud.mdx index 80a087d2b..c48b87609 100644 --- a/docs/integrations/secret-syncs/terraform-cloud.mdx +++ b/docs/integrations/secret-syncs/terraform-cloud.mdx @@ -56,6 +56,10 @@ description: "Learn how to configure a Terraform Cloud Sync for Infisical." Terraform Cloud does not support importing secrets. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. diff --git a/docs/integrations/secret-syncs/vercel.mdx b/docs/integrations/secret-syncs/vercel.mdx index 593874dee..74cffbc11 100644 --- a/docs/integrations/secret-syncs/vercel.mdx +++ b/docs/integrations/secret-syncs/vercel.mdx @@ -43,6 +43,10 @@ description: "Learn how to configure a Vercel Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Vercel when keys conflict. - **Import Secrets (Prioritize Vercel)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Vercel over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. diff --git a/docs/integrations/secret-syncs/windmill.mdx b/docs/integrations/secret-syncs/windmill.mdx index 90d35f8b8..c0757ef37 100644 --- a/docs/integrations/secret-syncs/windmill.mdx +++ b/docs/integrations/secret-syncs/windmill.mdx @@ -44,6 +44,10 @@ description: "Learn how to configure a Windmill Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Windmill when keys conflict. - **Import Secrets (Prioritize Windmill)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Windmill over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. diff --git a/docs/internals/bug-bounty.mdx b/docs/internals/bug-bounty.mdx deleted file mode 100644 index e45de05bf..000000000 --- a/docs/internals/bug-bounty.mdx +++ /dev/null @@ -1,80 +0,0 @@ ---- -title: "Bug bounty program" -description: " Learn about our bug bounty program and how to report vulnerabilities." ---- - -The Infisical Bug Bounty Program is our way of recognizing and rewarding the work of security researchers who help keep our platform secure. By reporting vulnerabilities or potential risks, you help us protect secrets, infrastructure, and the organizations who rely on us. - -We value reports that help identify vulnerabilities that affect the integrity of secrets, prevent unauthorized access to environments, or expose flaws in our authentication or authorization flows. - -### How to Report - -- Send reports to **security@infisical.com** with clear steps to reproduce, impact, and (if possible) a proof-of-concept. -- We will acknowledge receipt within 3 business days. -- We'll provide an initial assessment or next steps within 5 business days. - -### What's in Scope? - -- Vulnerabilities in our cloud-hosted platform (e.g., `app.infisical.com`, `eu.infisical.com`) -- Security issues in the open source Infisical codebase, as maintained in our official GitHub repository -- Authentication bypass, privilege escalation, or access to secrets/data without authorization - -### Reward Guidelines - -Bounties are based on severity, impact, and exploitability, as well as whether the report introduces a new vulnerability class or helps improve an existing fix. - -| Severity | Examples | Typical Reward (USD currency) | -| --- | --- | --- | -| **Critical** | Full unauthorized access to secrets, authentication bypass, cross-tenant access, RCE, full compromise, etc | $2,000 - $5,000 | -| **High** | Privilege escalation, project-level access without authorization, persistent DoS | $750 - $2,000 | -| **Medium** | Info disclosure, scoped DoS (e.g. ReDoS with auth), or minor access control issues | $250 - $1,000 | -| **Low / Informational** | Missing headers, CSP warnings, theoretical flaws, self-hosting misconfigurations | Recognition only | - - -We may award lower amounts for: -- Duplicate class vulnerabilities already under review -- Patch bypasses of previously rewarded issues -- Vulnerabilities requiring unrealistic attacker conditions - -All final reward amounts are determined at Infisical's discretion based on impact, report quality, and how actionable the issue is. - - -### Out of Scope - -- Social engineering or phishing (including email hyperlink injection without code execution) -- Rate limiting issues on non-sensitive endpoints -- Denial-of-service attacks that require authentication and don't impact core service availability -- Findings based on outdated or forked code not maintained by the Infisical team -- Vulnerabilities in third-party dependencies unless they result in a direct risk to Infisical users - - -### Responsible Disclosure - -We ask that researchers: - -- Avoid accessing data that isn't yours -- Do not publicly disclose without coordination -- Use testing accounts where possible -- Give us a reasonable window to investigate and patch before going public - -Researchers can also spin up our [self-hosted version of Infisical](/self-hosting/overview) to test for vulnerabilities locally. - -### Program Conduct and Enforcement - -We value professional and collaborative interaction with security researchers. To maintain the integrity of our bug bounty program, we expect all participants to adhere to the following guidelines: - -- Maintain professional communication in all interactions -- Do not threaten public disclosure of vulnerabilities before we've had reasonable time to investigate and address the issue -- Do not attempt to extort or coerce compensation through threats -- Follow the responsible disclosure process outlined in this document -- Do not use automated scanning tools without prior permission - -Violations of these guidelines may result in: - -1. **Warning**: For minor violations, we may issue a warning explaining the violation and requesting compliance with program guidelines. -2. **Temporary Ban**: Repeated minor violations or more serious violations may result in a temporary suspension from the program. -3. **Permanent Ban**: Severe violations such as threats, extortion attempts, or unauthorized public disclosure will result in permanent removal from the Infisical Bug Bounty Program. - -We reserve the right to reject reports, withhold bounties, and remove participants from the program at our discretion for conduct that undermines the collaborative spirit of security research. - -Infisical is committed to working respectfully with security researchers who follow these guidelines, and we strive to recognize and reward valuable contributions that help protect our platform and users. diff --git a/docs/internals/permissions/organization-permissions.mdx b/docs/internals/permissions/organization-permissions.mdx index c68d845e2..80c843851 100644 --- a/docs/internals/permissions/organization-permissions.mdx +++ b/docs/internals/permissions/organization-permissions.mdx @@ -142,12 +142,10 @@ Below is a comprehensive list of all available organization-level subjects and t #### Subject: `billing` -| Action | Description | -| -------- | ------------------------------------------------ | -| `read` | View billing information and subscription status | -| `create` | Set up new payment methods or subscriptions | -| `edit` | Modify billing details or subscription plans | -| `delete` | Remove payment methods or cancel subscriptions | +| Action | Description | +| ---------------- | ------------------------------------------------ | +| `read` | View billing information and subscription status | +| `manage-billing` | Manage billing details and subscription plans | ### Templates & Automation @@ -218,3 +216,4 @@ Supports conditions and permission inversion | `create-gateways` | Add new gateways to organization | | `edit-gateways` | Modify existing gateway settings | | `delete-gateways` | Remove gateways from organization | +| `attach-gateways` | Attach gateways to resources | diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index acf95485b..98f3bfeb2 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -178,12 +178,14 @@ Supports conditions and permission inversion #### Subject: `secret-approval` -| Action | Description | -| -------- | ----------------------------------- | -| `read` | View approval policies and requests | -| `create` | Create new approval policies | -| `edit` | Modify approval policies | -| `delete` | Remove approval policies | +| Action | Description | +| --------------------- | ----------------------------------------------------------------------------------- | +| `read` | View approval policies and requests | +| `create` | Create new approval policies | +| `edit` | Modify approval policies | +| `delete` | Remove approval policies | +| `allow-change-bypass` | Allow request creators to merge changes without approval in break-glass situations | +| `allow-access-bypass` | Allow request creators to access secrets without approval in break-glass situations | #### Subject: `secret-rotation` @@ -314,3 +316,32 @@ Supports conditions and permission inversion | `create` | Create new SSH certificate templates | | `edit` | Modify SSH template configurations | | `delete` | Remove SSH certificate templates | + +### Secret Scanning + +#### Subject: `secret-scanning-data-sources` + +| Action | Description | +| -------- | ---------------------------------------------------- | +| `read-data-sources` | View Data Sources | +| `create-data-sources` | Create new Data Sources | +| `edit-data-sources` | Modify Data Sources | +| `delete-data-sources` | Remove Data Sources | +| `read-data-source-resources` | View Data Source Resources | +| `read-data-source-scans` | View Data Source Scans | +| `trigger-data-source-scans` | Trigger Data Source Secret Scans | + +#### Subject: `secret-scanning-findings` + +| Action | Description | +| -------- | --------------------------------- | +| `read-findings` | View Secret Scanning Findings | +| `update-findings` | Update Secret Scanning Findings | + + +#### Subject: `secret-scanning-configs` + +| Action | Description | +| ---------------- | ------------------------------------------------ | +| `read-configs` | View Secret Scanning Project Configuration | +| `update-configs` | Update Secret Scanning Project Configuration | diff --git a/docs/internals/security.mdx b/docs/internals/security.mdx index 219c32287..85138be9c 100644 --- a/docs/internals/security.mdx +++ b/docs/internals/security.mdx @@ -117,7 +117,3 @@ Whether or not Infisical or your employees can access data in the Infisical inst It should be noted that, even on Infisical Cloud, it is physically impossible for employees of Infisical to view the values of secrets if users have not explicitly granted Infisical access to their project (i.e. opted out of zero-knowledge). Please email security@infisical.com if you have any specific inquiries about employee data and security policies. - -## Bug Bounty Program -We run a [Bug Bounty Program](/internals/bug-bounty) to recognize and reward security researchers who help make Infisical more secure. -If you've found a vulnerability, please review the program details for scope, disclosure guidelines, and reward tiers. \ No newline at end of file diff --git a/docs/mint.json b/docs/mint.json index 52171243b..f124e14b1 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -112,10 +112,19 @@ "pages": [ "documentation/platform/pki/overview", "documentation/platform/pki/private-ca", + "documentation/platform/pki/external-ca", + "documentation/platform/pki/subscribers", "documentation/platform/pki/certificates", - "documentation/platform/pki/pki-issuer", + "documentation/platform/pki/acme-ca", "documentation/platform/pki/est", - "documentation/platform/pki/alerting" + "documentation/platform/pki/alerting", + { + "group": "Integrations", + "pages": [ + "documentation/platform/pki/pki-issuer", + "documentation/platform/pki/integration-guides/gloo-mesh" + ] + } ] }, { @@ -190,6 +199,7 @@ "documentation/platform/secret-rotation/azure-client-secret", "documentation/platform/secret-rotation/ldap-password", "documentation/platform/secret-rotation/mssql-credentials", + "documentation/platform/secret-rotation/mysql-credentials", "documentation/platform/secret-rotation/postgres-credentials" ] }, @@ -215,14 +225,17 @@ "documentation/platform/dynamic-secrets/sap-ase", "documentation/platform/dynamic-secrets/sap-hana", "documentation/platform/dynamic-secrets/snowflake", - "documentation/platform/dynamic-secrets/totp" + "documentation/platform/dynamic-secrets/totp", + "documentation/platform/dynamic-secrets/kubernetes", + "documentation/platform/dynamic-secrets/vertica" ] }, { "group": "Gateway", "pages": [ "documentation/platform/gateways/overview", - "documentation/platform/gateways/gateway-security" + "documentation/platform/gateways/gateway-security", + "documentation/platform/gateways/networking" ] }, "documentation/platform/project-templates", @@ -242,74 +255,113 @@ ] }, "documentation/platform/secret-sharing", - "documentation/platform/secret-scanning" + { + "group": "Secret Scanning", + "pages": [ + "documentation/platform/secret-scanning/overview", + "documentation/platform/secret-scanning/github" + ] + } ] }, { "group": "Authentication Methods", "pages": [ - "documentation/platform/auth-methods/email-password", - "documentation/platform/token", - "documentation/platform/identities/token-auth", - "documentation/platform/identities/universal-auth", - "documentation/platform/identities/kubernetes-auth", - "documentation/platform/identities/gcp-auth", - "documentation/platform/identities/azure-auth", - "documentation/platform/identities/aws-auth", - "documentation/platform/identities/jwt-auth", { - "group": "OIDC Auth", + "group": "User Authentication", "pages": [ - "documentation/platform/identities/oidc-auth/general", - "documentation/platform/identities/oidc-auth/github", - "documentation/platform/identities/oidc-auth/circleci", - "documentation/platform/identities/oidc-auth/gitlab", - "documentation/platform/identities/oidc-auth/terraform-cloud" - ] - }, - "documentation/platform/mfa", - { - "group": "SSO", - "pages": [ - "documentation/platform/sso/overview", - "documentation/platform/sso/google", - "documentation/platform/sso/github", - "documentation/platform/sso/gitlab", - "documentation/platform/sso/okta", - "documentation/platform/sso/azure", - "documentation/platform/sso/jumpcloud", - "documentation/platform/sso/keycloak-saml", - "documentation/platform/sso/google-saml", - "documentation/platform/sso/auth0-saml", + "documentation/platform/auth-methods/email-password", { - "group": "Keycloak OIDC", + "group": "SSO", "pages": [ - "documentation/platform/sso/keycloak-oidc/overview", - "documentation/platform/sso/keycloak-oidc/group-membership-mapping" + "documentation/platform/sso/overview", + "documentation/platform/sso/google", + "documentation/platform/sso/github", + "documentation/platform/sso/gitlab", + "documentation/platform/sso/okta", + "documentation/platform/sso/azure", + "documentation/platform/sso/jumpcloud", + "documentation/platform/sso/keycloak-saml", + "documentation/platform/sso/google-saml", + "documentation/platform/sso/auth0-saml", + { + "group": "OIDC", + "pages": [ + { + "group": "Keycloak OIDC", + "pages": [ + "documentation/platform/sso/keycloak-oidc/overview", + "documentation/platform/sso/keycloak-oidc/group-membership-mapping" + ] + }, + "documentation/platform/sso/auth0-oidc", + { + "group": "General OIDC", + "pages": [ + "documentation/platform/sso/general-oidc/overview", + "documentation/platform/sso/general-oidc/group-membership-mapping" + ] + } + ] + } ] }, - "documentation/platform/sso/auth0-oidc", - "documentation/platform/sso/general-oidc" + { + "group": "LDAP", + "pages": [ + "documentation/platform/ldap/overview", + "documentation/platform/ldap/jumpcloud", + "documentation/platform/ldap/general" + ] + }, + { + "group": "SCIM", + "pages": [ + "documentation/platform/scim/overview", + "documentation/platform/scim/okta", + "documentation/platform/scim/azure", + "documentation/platform/scim/jumpcloud", + "documentation/platform/scim/group-mappings" + ] + } ] }, + { - "group": "LDAP", + "group": "Machine Identities", "pages": [ - "documentation/platform/ldap/overview", - "documentation/platform/ldap/jumpcloud", - "documentation/platform/ldap/general" - ] - }, - { - "group": "SCIM", - "pages": [ - "documentation/platform/scim/overview", - "documentation/platform/scim/okta", - "documentation/platform/scim/azure", - "documentation/platform/scim/jumpcloud", - "documentation/platform/scim/group-mappings" + "documentation/platform/identities/aws-auth", + "documentation/platform/identities/azure-auth", + "documentation/platform/identities/gcp-auth", + "documentation/platform/identities/jwt-auth", + "documentation/platform/identities/kubernetes-auth", + "documentation/platform/identities/oci-auth", + "documentation/platform/identities/token-auth", + "documentation/platform/identities/universal-auth", + { + "group": "OIDC Auth", + "pages": [ + "documentation/platform/identities/oidc-auth/general", + "documentation/platform/identities/oidc-auth/azure", + "documentation/platform/identities/oidc-auth/github", + "documentation/platform/identities/oidc-auth/circleci", + "documentation/platform/identities/oidc-auth/gitlab", + "documentation/platform/identities/oidc-auth/terraform-cloud", + "documentation/platform/identities/oidc-auth/spire" + ] + }, + + { + "group": "LDAP Auth", + "pages": [ + "documentation/platform/identities/ldap-auth/general", + "documentation/platform/identities/ldap-auth/jumpcloud" + ] + } ] }, + "documentation/platform/token", + "documentation/platform/mfa", "documentation/platform/github-org-sync" ] }, @@ -330,7 +382,8 @@ "group": "Linux Package", "pages": [ "self-hosting/deployment-options/native/linux-package/installation", - "self-hosting/deployment-options/native/linux-package/commands-configuration" + "self-hosting/deployment-options/native/linux-package/commands-configuration", + "self-hosting/deployment-options/linux-upgrade" ] }, "self-hosting/guides/upgrading-infisical", @@ -409,6 +462,7 @@ "integrations/platforms/kubernetes/infisical-dynamic-secret-crd" ] }, + "integrations/platforms/kubernetes-injector", "integrations/platforms/kubernetes-csi", "integrations/platforms/docker-swarm-with-agent", "integrations/platforms/ecs-with-agent" @@ -425,6 +479,7 @@ ] }, "integrations/frameworks/terraform", + "integrations/frameworks/pulumi", "integrations/platforms/ansible", "integrations/platforms/apache-airflow" ] @@ -436,6 +491,7 @@ { "group": "Connections", "pages": [ + "integrations/app-connections/1password", "integrations/app-connections/auth0", "integrations/app-connections/aws", "integrations/app-connections/azure-app-configuration", @@ -445,10 +501,13 @@ "integrations/app-connections/databricks", "integrations/app-connections/gcp", "integrations/app-connections/github", + "integrations/app-connections/github-radar", "integrations/app-connections/hashicorp-vault", "integrations/app-connections/humanitec", "integrations/app-connections/ldap", "integrations/app-connections/mssql", + "integrations/app-connections/mysql", + "integrations/app-connections/oci", "integrations/app-connections/postgres", "integrations/app-connections/teamcity", "integrations/app-connections/terraform-cloud", @@ -465,6 +524,7 @@ { "group": "Syncs", "pages": [ + "integrations/secret-syncs/1password", "integrations/secret-syncs/aws-parameter-store", "integrations/secret-syncs/aws-secrets-manager", "integrations/secret-syncs/azure-app-configuration", @@ -475,6 +535,7 @@ "integrations/secret-syncs/github", "integrations/secret-syncs/hashicorp-vault", "integrations/secret-syncs/humanitec", + "integrations/secret-syncs/oci-vault", "integrations/secret-syncs/teamcity", "integrations/secret-syncs/terraform-cloud", "integrations/secret-syncs/vercel", @@ -676,6 +737,16 @@ "api-reference/endpoints/aws-auth/revoke" ] }, + { + "group": "OCI Auth", + "pages": [ + "api-reference/endpoints/oci-auth/login", + "api-reference/endpoints/oci-auth/attach", + "api-reference/endpoints/oci-auth/retrieve", + "api-reference/endpoints/oci-auth/update", + "api-reference/endpoints/oci-auth/revoke" + ] + }, { "group": "Azure Auth", "pages": [ @@ -716,6 +787,16 @@ "api-reference/endpoints/jwt-auth/revoke" ] }, + { + "group": "LDAP Auth", + "pages": [ + "api-reference/endpoints/ldap-auth/login", + "api-reference/endpoints/ldap-auth/attach", + "api-reference/endpoints/ldap-auth/retrieve", + "api-reference/endpoints/ldap-auth/update", + "api-reference/endpoints/ldap-auth/revoke" + ] + }, { "group": "Groups", "pages": [ @@ -938,6 +1019,19 @@ "api-reference/endpoints/secret-rotations/mssql-credentials/update" ] }, + { + "group": "MySQL Credentials", + "pages": [ + "api-reference/endpoints/secret-rotations/mysql-credentials/create", + "api-reference/endpoints/secret-rotations/mysql-credentials/delete", + "api-reference/endpoints/secret-rotations/mysql-credentials/get-by-id", + "api-reference/endpoints/secret-rotations/mysql-credentials/get-by-name", + "api-reference/endpoints/secret-rotations/mysql-credentials/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/mysql-credentials/list", + "api-reference/endpoints/secret-rotations/mysql-credentials/rotate-secrets", + "api-reference/endpoints/secret-rotations/mysql-credentials/update" + ] + }, { "group": "PostgreSQL Credentials", "pages": [ @@ -953,6 +1047,47 @@ } ] }, + { + "group": "Secret Scanning", + "pages": [ + { + "group": "Data Sources", + "pages": [ + "api-reference/endpoints/secret-scanning/data-sources/list", + "api-reference/endpoints/secret-scanning/data-sources/options", + { + "group": "GitHub", + "pages": [ + "api-reference/endpoints/secret-scanning/data-sources/github/list", + "api-reference/endpoints/secret-scanning/data-sources/github/get-by-id", + "api-reference/endpoints/secret-scanning/data-sources/github/get-by-name", + "api-reference/endpoints/secret-scanning/data-sources/github/list-resources", + "api-reference/endpoints/secret-scanning/data-sources/github/list-scans", + "api-reference/endpoints/secret-scanning/data-sources/github/create", + "api-reference/endpoints/secret-scanning/data-sources/github/update", + "api-reference/endpoints/secret-scanning/data-sources/github/delete", + "api-reference/endpoints/secret-scanning/data-sources/github/scan", + "api-reference/endpoints/secret-scanning/data-sources/github/scan-resource" + ] + } + ] + }, + { + "group": "Findings", + "pages": [ + "api-reference/endpoints/secret-scanning/findings/list", + "api-reference/endpoints/secret-scanning/findings/update" + ] + }, + { + "group": "Configuration", + "pages": [ + "api-reference/endpoints/secret-scanning/config/get-by-project-id", + "api-reference/endpoints/secret-scanning/config/update" + ] + } + ] + }, { "group": "Identity Specific Privilege", "pages": [ @@ -985,6 +1120,18 @@ "pages": [ "api-reference/endpoints/app-connections/list", "api-reference/endpoints/app-connections/options", + { + "group": "1Password", + "pages": [ + "api-reference/endpoints/app-connections/1password/list", + "api-reference/endpoints/app-connections/1password/available", + "api-reference/endpoints/app-connections/1password/get-by-id", + "api-reference/endpoints/app-connections/1password/get-by-name", + "api-reference/endpoints/app-connections/1password/create", + "api-reference/endpoints/app-connections/1password/update", + "api-reference/endpoints/app-connections/1password/delete" + ] + }, { "group": "Auth0", "pages": [ @@ -1093,6 +1240,18 @@ "api-reference/endpoints/app-connections/github/delete" ] }, + { + "group": "GitHub Radar", + "pages": [ + "api-reference/endpoints/app-connections/github-radar/list", + "api-reference/endpoints/app-connections/github-radar/available", + "api-reference/endpoints/app-connections/github-radar/get-by-id", + "api-reference/endpoints/app-connections/github-radar/get-by-name", + "api-reference/endpoints/app-connections/github-radar/create", + "api-reference/endpoints/app-connections/github-radar/update", + "api-reference/endpoints/app-connections/github-radar/delete" + ] + }, { "group": "Hashicorp Vault", "pages": [ @@ -1141,6 +1300,30 @@ "api-reference/endpoints/app-connections/mssql/delete" ] }, + { + "group": "MySQL", + "pages": [ + "api-reference/endpoints/app-connections/mysql/list", + "api-reference/endpoints/app-connections/mysql/available", + "api-reference/endpoints/app-connections/mysql/get-by-id", + "api-reference/endpoints/app-connections/mysql/get-by-name", + "api-reference/endpoints/app-connections/mysql/create", + "api-reference/endpoints/app-connections/mysql/update", + "api-reference/endpoints/app-connections/mysql/delete" + ] + }, + { + "group": "OCI", + "pages": [ + "api-reference/endpoints/app-connections/oci/list", + "api-reference/endpoints/app-connections/oci/available", + "api-reference/endpoints/app-connections/oci/get-by-id", + "api-reference/endpoints/app-connections/oci/get-by-name", + "api-reference/endpoints/app-connections/oci/create", + "api-reference/endpoints/app-connections/oci/update", + "api-reference/endpoints/app-connections/oci/delete" + ] + }, { "group": "PostgreSQL", "pages": [ @@ -1208,6 +1391,20 @@ "pages": [ "api-reference/endpoints/secret-syncs/list", "api-reference/endpoints/secret-syncs/options", + { + "group": "1Password", + "pages": [ + "api-reference/endpoints/secret-syncs/1password/list", + "api-reference/endpoints/secret-syncs/1password/get-by-id", + "api-reference/endpoints/secret-syncs/1password/get-by-name", + "api-reference/endpoints/secret-syncs/1password/create", + "api-reference/endpoints/secret-syncs/1password/update", + "api-reference/endpoints/secret-syncs/1password/delete", + "api-reference/endpoints/secret-syncs/1password/sync-secrets", + "api-reference/endpoints/secret-syncs/1password/import-secrets", + "api-reference/endpoints/secret-syncs/1password/remove-secrets" + ] + }, { "group": "AWS Parameter Store", "pages": [ @@ -1344,6 +1541,20 @@ "api-reference/endpoints/secret-syncs/humanitec/remove-secrets" ] }, + { + "group": "OCI", + "pages": [ + "api-reference/endpoints/secret-syncs/oci-vault/list", + "api-reference/endpoints/secret-syncs/oci-vault/get-by-id", + "api-reference/endpoints/secret-syncs/oci-vault/get-by-name", + "api-reference/endpoints/secret-syncs/oci-vault/create", + "api-reference/endpoints/secret-syncs/oci-vault/update", + "api-reference/endpoints/secret-syncs/oci-vault/delete", + "api-reference/endpoints/secret-syncs/oci-vault/sync-secrets", + "api-reference/endpoints/secret-syncs/oci-vault/import-secrets", + "api-reference/endpoints/secret-syncs/oci-vault/remove-secrets" + ] + }, { "group": "TeamCity", "pages": [ @@ -1428,9 +1639,43 @@ { "group": "Infisical PKI", "pages": [ + { + "group": "Subscribers", + "pages": [ + "api-reference/endpoints/pki/subscribers/list-certs", + "api-reference/endpoints/pki/subscribers/create", + "api-reference/endpoints/pki/subscribers/read", + "api-reference/endpoints/pki/subscribers/update", + "api-reference/endpoints/pki/subscribers/delete", + "api-reference/endpoints/pki/subscribers/issue-cert", + "api-reference/endpoints/pki/subscribers/sign-cert", + "api-reference/endpoints/pki/subscribers/order-cert", + "api-reference/endpoints/pki/subscribers/get-latest-cert-bundle" + ] + }, { "group": "Certificate Authorities", "pages": [ + { + "group": "ACME", + "pages": [ + "api-reference/endpoints/certificate-authorities/acme/list", + "api-reference/endpoints/certificate-authorities/acme/create", + "api-reference/endpoints/certificate-authorities/acme/read", + "api-reference/endpoints/certificate-authorities/acme/update", + "api-reference/endpoints/certificate-authorities/acme/delete" + ] + }, + { + "group": "Internal", + "pages": [ + "api-reference/endpoints/certificate-authorities/internal/list", + "api-reference/endpoints/certificate-authorities/internal/create", + "api-reference/endpoints/certificate-authorities/internal/read", + "api-reference/endpoints/certificate-authorities/internal/update", + "api-reference/endpoints/certificate-authorities/internal/delete" + ] + }, "api-reference/endpoints/certificate-authorities/list", "api-reference/endpoints/certificate-authorities/create", "api-reference/endpoints/certificate-authorities/read", @@ -1455,6 +1700,8 @@ "api-reference/endpoints/certificates/revoke", "api-reference/endpoints/certificates/delete", "api-reference/endpoints/certificates/cert-body", + "api-reference/endpoints/certificates/bundle", + "api-reference/endpoints/certificates/private-key", "api-reference/endpoints/certificates/issue-certificate", "api-reference/endpoints/certificates/sign-certificate" ] @@ -1600,7 +1847,6 @@ }, "internals/components", "internals/security", - "internals/bug-bounty", "internals/service-tokens" ] }, diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index b63c58d3a..efce4d912 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -1,5 +1,5 @@ --- -title: "Configurations" +title: "Environment Variables" description: "Read how to configure environment variables for self-hosted Infisical." --- @@ -32,14 +32,16 @@ Used to configure platform-specific security and operational settings Specifies the network interface Infisical will bind to when accepting incoming connections. - By default, Infisical binds to `localhost`, which restricts access to connections from the same machine. + By default, Infisical binds to `localhost`, which restricts access to connections from the same machine. - To make the application accessible externally (e.g., for self-hosted deployments), set this to `0.0.0.0`, which tells the server to listen on all network interfaces. +To make the application accessible externally (e.g., for self-hosted deployments), set this to `0.0.0.0`, which tells the server to listen on all network interfaces. + +Example values: + +- `localhost` (default, same as `127.0.0.1`) +- `0.0.0.0` (all interfaces, accessible externally) +- `192.168.1.100` (specific interface IP) - Example values: - - `localhost` (default, same as `127.0.0.1`) - - `0.0.0.0` (all interfaces, accessible externally) - - `192.168.1.100` (specific interface IP) @@ -86,8 +88,9 @@ The platform utilizes Postgres to persist all of its data and Redis for caching ### PostgreSQL - Please note that the database user you create must be granted all privileges on the Infisical database. - This includes the ability to create new schemas, create, update, delete, modify tables and indexes, etc. + Please note that the database user you create must be granted all privileges + on the Infisical database. This includes the ability to create new schemas, + create, update, delete, modify tables and indexes, etc. @@ -95,9 +98,8 @@ The platform utilizes Postgres to persist all of its data and Redis for caching - Configure the SSL certificate for securing a Postgres connection by first encoding it in base64. - Use the command below to encode your certificate: - `echo "" | base64` + Configure the SSL certificate for securing a Postgres connection by first encoding it in base64. + Use the following command to encode your certificate: `echo "" | base64` @@ -111,20 +113,51 @@ DB_READ_REPLICAS=[{"DB_CONNECTION_URI":""}] Configure the SSL certificate for securing a Postgres replica connection by first encoding it in base64. - Use the command below to encode your certificate: - `echo "" | base64` + Use the following command to encode your certificate: `echo "" | base64` - If not provided it will use master SSL certificate. + If not provided it will use master SSL certificate. ### Redis +Redis is used for caching and background tasks. You can use either a standalone Redis instance or a Redis Sentinel setup. - - Redis connection string. - + + + + Redis connection string. + + + + + Comma-separated list of Sentinel host:port pairs. ``` + 192.168.65.254:26379,192.168.65.254:26380 ``` + + + The name of the Redis master set monitored by Sentinel + + + Whether to use TLS/SSL for Redis Sentinel connection + + + Authentication username for Redis Sentinel + + + Authentication password for Redis Sentinel + + + ## Email Service @@ -169,6 +202,16 @@ Without email configuration, Infisical's core functions like sign-up/login and s If this is `true`, Infisical will validate the server's SSL/TLS certificate and reject the connection if the certificate is invalid or not trusted. If set to `false`, the client will accept the server's certificate regardless of its validity, which can be useful in development or testing environments but is not recommended for production use. + + + If your SMTP server uses a certificate signed by a custom Certificate Authority, you should set this variable so that Infisical can trust the custom CA. + + This variable **must be a base64 encoded PEM certificate**. Use the following command to encode your certificate: `echo "" | base64` + + Infisical highly encourages the following variables be used alongside this one for maximum security: + - `SMTP_REQUIRE_TLS=true` + - `SMTP_TLS_REJECT_UNAUTHORIZED=true` + @@ -222,7 +265,7 @@ SMTP_FROM_NAME=Infisical This will be used to verify the email you are sending from. ![Create SES identity](../../images/self-hosting/configuration/email/ses-create-identity.png) - If you AWS SES is under sandbox mode, you will only be able to send emails to verified identies. + If you AWS SES is under sandbox mode, you will only be able to send emails to verified identies. @@ -388,9 +431,9 @@ SMTP_FROM_NAME=Infisical - + 1. Create an account and configure [SMTP2Go](https://www.smtp2go.com/) to send emails. -2. Turn on SMTP authentication +2. Turn on SMTP authentication ``` SMTP_HOST=mail.smtp2go.com SMTP_PORT=You can use one of the following ports: 2525, 80, 25, 8025, or 587 @@ -401,7 +444,7 @@ SMTP_FROM_NAME=Infisical ``` {" "} - + Optional (for TLS/SSL): TLS: Available on the same ports (2525, 80, 25, 8025, or 587) @@ -510,6 +553,32 @@ You can configure third-party app connections for re-use across Infisical Projec + + + The ID of the GitHub Radar App + + + + The slug of the GitHub Radar App + + + + The client ID for the GitHub Radar App + + + + The client secret for the GitHub Radar App + + + + The private key for the GitHub Radar App + + + + The webhook secret configured for payload verification in the GitHub Radar App + + + The OAuth2 client ID for GitHub OAuth Connection @@ -632,13 +701,27 @@ To help you sync secrets from Infisical to services such as Github and Gitlab, I The App ID of your GitHub App. - - The slug of your GitHub App. - +{" "} - - A private key for your GitHub App. - + + The slug of your GitHub App. + + +{" "} + + + A private key for your GitHub App. + The webhook secret of your GitHub App. diff --git a/docs/self-hosting/deployment-options/linux-upgrade.mdx b/docs/self-hosting/deployment-options/linux-upgrade.mdx new file mode 100644 index 000000000..6712626bd --- /dev/null +++ b/docs/self-hosting/deployment-options/linux-upgrade.mdx @@ -0,0 +1,390 @@ +--- +title: "Upgrading" +description: "How to upgrade Infisical deployment using linux package" +--- + +This guide explains how to upgrade Infisical Linux package installations to newer versions. +The Infisical Linux package includes only the Infisical service component itself, as PostgreSQL and Redis databases are managed separately. +Upgrades for PostgreSQL and Redis are not covered in this guide as they depend on your specific database deployment method. + +## Upgrade Options + +There are two primary methods to upgrade Infisical: + +1. **Standard Upgrade (with brief downtime)**: The simplest approach that briefly takes Infisical offline during the upgrade. +2. **Minimal-Downtime Upgrade**: For multi-node deployments where high availability is required. + +## Before You Begin + +### Checking Your Current Version + +Before upgrading, note your current Infisical version: + +```bash +cat /opt/infisical-core/version-manifest.txt +``` + +Look for `infisical` component. This will be the version of Infisical currently installed. + +### Prerequisites + +- Verify that your PostgreSQL and Redis instances are up and running +- Back up your PostgreSQL database before proceeding with any upgrade +- Review release notes for the version you're upgrading to + +### Creating a Database Backup + +We strongly recommend backing up your database before upgrading. +Your backup approach may look different depending on how you configured PostgreSQL and whether it's self-managed or using a managed service. +Here is a sample of how you would perform a manual backup: + +```bash +# Example PostgreSQL backup command (adjust parameters as needed) +pg_dump -U -h -d > infisical_backup.sql +``` + +### Database Migrations During Upgrade + +By default, Infisical runs database migrations automatically on startup. + +- It uses database locks to ensure only one instance runs migrations at a time +- Other instances will wait for the lock to be released before continuing startup +- This prevents race conditions and database conflicts + +## Standard Upgrade (with Downtime) + +This method is suitable for single-node deployments or situations where a brief downtime is acceptable. + + + + ```bash + infisical-ctl stop + ``` + + +To upgrade to the latest version: + + + + ```bash + sudo apt-get update && sudo apt-get install -y infisical-core + ``` + + + ```bash + sudo yum update infisical-core + ``` + + + +To upgrade to a specific version: + + + + ```bash + sudo apt-get install -y infisical-core= + ``` + + + ```bash + sudo yum install infisical-core- + ``` + + + + + + ```bash + infisical-ctl reconfigure + ``` + + + + ```bash + infisical-ctl start + ``` + + + + ```bash + infisical-ctl status + ``` + + Check the logs for any issues: + ```bash + infisical-ctl tail + ``` + + + +## Minimal-Downtime Upgrade + +For multi-node setups where you need to maintain availability during upgrades, follow this procedure. This approach requires at least two Infisical nodes behind a load balancer. + +### Understanding Traffic Draining + +"Draining" a server means gracefully removing it from the pool of active servers without disrupting existing connections. When you drain a server: + +1. The load balancer stops sending new requests to the server +2. Existing connections are allowed to complete naturally +3. Once all connections finish, the server can be safely taken offline for maintenance + +This approach ensures users/machines do not experience sudden connection errors during the upgrade process. + +### Preparing for the Upgrade + +1. **Designate a deploy node**: Choose any single node that will run migrations. This node will be upgraded first. + +2. **Configure your load balancer**: Ensure your load balancer can perform health checks against Infisical's `api/status` endpoint. + +### Upgrade Process + +#### On the deploy node: + + + + +Drain the traffic on this node gracefully. You can do this in a number of ways depending on the load balancer you have configured. +Approaches for some common load balancers are provided below: + + + + If using NGINX as a load balancer, you can remove the server from the upstream pool temporarily: + ```bash + # Edit your NGINX configuration to comment out or remove the server + sudo nano /path/to/your/nginx-config.conf + + # Reload NGINX to apply changes + sudo nginx -s reload + ``` + + + If using HAProxy, you can put the server in maintenance mode: + ```bash + # Using the HAProxy socket command + echo "disable server infisical_backend/infisical-node1" | socat stdio /var/lib/haproxy/stats + ``` + + + Deregister the instance from the load balancer using the AWS console or CLI + + + Follow your load balancer's documentation for instructions on draining procedure + + + + + +Verify no new traffic is arriving before proceeding with the upgrade. + + + +```bash +infisical-ctl stop +``` + + + + +To upgrade to the latest version: + + + + ```bash + sudo apt-get update && sudo apt-get install -y infisical-core + ``` + + + ```bash + sudo yum update infisical-core + ``` + + + +To upgrade to a specific version: + + + + ```bash + sudo apt-get install -y infisical-core= + ``` + + + ```bash + sudo yum install infisical-core- + ``` + + + + + +```bash +infisical-ctl reconfigure +``` + + + +```bash +infisical-ctl tail +``` +Look for successful migration messages in the logs. + + + +Re-enable the server in your load balancer using the same method you used to remove it. + + + +#### On all remaining nodes (one at a time): + + + +Follow the same draining procedure as described for the deploy node: + +- Remove the server from your load balancer's active pool +- Wait for existing connections to complete +- Verify the node is no longer receiving traffic + + + +```bash +infisical-ctl stop +``` + + + +To upgrade to the latest version: + + + + ```bash + sudo apt-get update && sudo apt-get install -y infisical-core + ``` + + + ```bash + sudo yum update infisical-core + ``` + + + +To upgrade to a specific version: + + + + ```bash + sudo apt-get install -y infisical-core= + ``` + + + ```bash + sudo yum install infisical-core- + ``` + + + + + +```bash +infisical-ctl reconfigure +``` + + + +```bash +infisical-ctl status +infisical-ctl tail +``` + + + +- Check logs to ensure the service has started successfully +- Verify it can connect to the database and Redis + + + +Re-enable the server in your load balancer using the same method you used to remove it. + + + +Check logs and monitoring to ensure traffic is flowing correctly. + + + +Repeat steps 1-7 for each remaining node, one at a time. + + + +After all nodes are upgraded, verify that the application is functioning correctly: +- Test core functionality +- Check logs for any errors + + + +## Rolling Back + +If you need to roll back to a previous version of Infisical, follow steps below. + + + +```bash +infisical-ctl stop +``` + + + +For Debian/Ubuntu: +```bash +sudo apt-get install -y infisical-core= +``` + +For RHEL/CentOS/Amazon Linux: +```bash +sudo yum downgrade infisical-core- +``` + + + +Restore your Postgres/Redis database from backup. + + + +```bash +infisical-ctl reconfigure +``` + + + +```bash +infisical-ctl status +``` + + + +## Troubleshooting + + + +If you encounter database migration issues: + +1. Check the logs: + ```bash + infisical-ctl tail + ``` + +2. Ensure the database user has sufficient privileges to create/modify tables. + +3. If migrations fail repeatedly, consider restoring from the backup you took prior to upgrading. + + + + +1. Check for configuration errors: + ```bash + infisical-ctl tail + infisical-ctl status + ``` + +2. Verify all required environment variables are set in your `/etc/infisical/infisical.rb` file. + + \ No newline at end of file diff --git a/docs/self-hosting/guides/custom-certificates.mdx b/docs/self-hosting/guides/custom-certificates.mdx index 67b258d08..41947a0d9 100644 --- a/docs/self-hosting/guides/custom-certificates.mdx +++ b/docs/self-hosting/guides/custom-certificates.mdx @@ -4,19 +4,19 @@ description: "Learn how to configure Infisical with custom certificates" --- By default, the Infisical Docker image includes certificates from well-known public certificate authorities. -However, some integrations with Infisical may need to communicate with your internal services that use private certificate authorities. +However, some integrations with Infisical may need to communicate with your internal services that use private certificate authorities. To configure trust for custom certificates, follow these steps. This is particularly useful for connecting Infisical with self-hosted services like GitLab. ## Prerequisites - Docker - Standalone [Infisical image](https://hub.docker.com/r/infisical/infisical) -- Certificate public key `.pem` files +- Certificate public key `.crt` files ## Setup -1. Place all your public key `.pem` files into a single directory. -2. Mount the directory containing the `.pem` files to the `usr/local/share/ca-certificates/` path in the Infisical container. +1. Place all your public key `.crt` files into a single directory. +2. Mount the directory containing the `.crt` files to the `/usr/local/share/ca-certificates/` path in the Infisical container. 3. Set the following environment variable on your Infisical container: ``` NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e7f57e85c..d435cf0d7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,6 +25,7 @@ "@hookform/resolvers": "^3.9.1", "@lexical/react": "^0.29.0", "@lottiefiles/dotlottie-react": "^0.12.0", + "@lottiefiles/dotlottie-web": "^0.38.2", "@octokit/rest": "^21.0.2", "@peculiar/x509": "^1.12.3", "@radix-ui/react-accordion": "^1.2.2", @@ -78,7 +79,7 @@ "react-day-picker": "^9.4.3", "react-dom": "^18.3.1", "react-helmet": "^6.1.0", - "react-hook-form": "^7.54.0", + "react-hook-form": "^7.56.3", "react-i18next": "^15.2.0", "react-icons": "^5.4.0", "react-markdown": "^10.0.1", @@ -11484,9 +11485,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.54.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.0.tgz", - "integrity": "sha512-PS05+UQy/IdSbJNojBypxAo9wllhHgGmyr8/dyGQcPoiMf3e7Dfb9PWYVRco55bLbxH9S+1yDDJeTdlYCSxO3A==", + "version": "7.56.3", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.3.tgz", + "integrity": "sha512-IK18V6GVbab4TAo1/cz3kqajxbDPGofdF0w7VHdCo0Nt8PrPlOZcuuDq9YYIV1BtjcX78x0XsldbQRQnQXWXmw==", "license": "MIT", "engines": { "node": ">=18.0.0" diff --git a/frontend/package.json b/frontend/package.json index 6225b78f0..9a2cc2ba4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "@hookform/resolvers": "^3.9.1", "@lexical/react": "^0.29.0", "@lottiefiles/dotlottie-react": "^0.12.0", + "@lottiefiles/dotlottie-web": "^0.38.2", "@octokit/rest": "^21.0.2", "@peculiar/x509": "^1.12.3", "@radix-ui/react-accordion": "^1.2.2", @@ -82,7 +83,7 @@ "react-day-picker": "^9.4.3", "react-dom": "^18.3.1", "react-helmet": "^6.1.0", - "react-hook-form": "^7.54.0", + "react-hook-form": "^7.56.3", "react-i18next": "^15.2.0", "react-icons": "^5.4.0", "react-markdown": "^10.0.1", diff --git a/frontend/public/images/integrations/1Password.png b/frontend/public/images/integrations/1Password.png new file mode 100644 index 000000000..8518b41e6 Binary files /dev/null and b/frontend/public/images/integrations/1Password.png differ diff --git a/frontend/public/images/integrations/Oracle.png b/frontend/public/images/integrations/Oracle.png new file mode 100644 index 000000000..14845d2f2 Binary files /dev/null and b/frontend/public/images/integrations/Oracle.png differ diff --git a/frontend/public/lotties/blocks.json b/frontend/public/lotties/blocks.json new file mode 100644 index 000000000..93a6ad0cb --- /dev/null +++ b/frontend/public/lotties/blocks.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":60,"w":500,"h":500,"nm":"system-regular-40-add-card","ddd":0,"assets":[{"id":"comp_1","nm":"hover-add-card","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-90,"ix":10},"p":{"a":0,"k":[354.165,145.831,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.6,"y":0},"t":1,"s":[{"i":[[11.506,0],[0,0],[0,-11.505],[0,0],[-11.506,0],[0,0],[0,11.506],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.506],[0,0],[11.506,0],[0,0],[0,-11.505]],"v":[[46.875,-67.709],[-46.875,-67.709],[-67.709,-46.875],[-67.709,46.875],[-46.875,67.709],[46.875,67.709],[67.709,46.875],[67.709,-46.875]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":16,"s":[{"i":[[11.506,0],[0,0],[0,-11.505],[0,0],[-11.506,0],[0,0],[0,11.506],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.506],[0,0],[11.506,0],[0,0],[0,-11.505]],"v":[[46.581,26.291],[-47.169,26.291],[-68.003,47.125],[-67.709,46.875],[-46.875,67.709],[46.875,67.709],[67.709,46.875],[67.415,47.125]],"c":true}]},{"t":18,"s":[{"i":[[11.506,0],[0,0],[0,0.034],[0,0],[-11.506,0],[0,0],[0,-0.034],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,-0.034],[0,0],[11.506,0],[0,0],[0,0.034]],"v":[[46.581,67.83],[-47.169,67.83],[-68.003,67.769],[-67.709,67.77],[-46.875,67.709],[46.875,67.709],[67.709,67.77],[67.415,67.769]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":18,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[0]},{"t":35,"s":[90]}],"ix":10},"p":{"a":0,"k":[354.171,354.168,0],"ix":2,"l":2},"a":{"a":0,"k":[354.171,354.168,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[67.709,0],[-67.709,0]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":8,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[43.168,0],[-43.168,0]],"c":false}]},{"t":20,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[67.709,0],[-67.709,0]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-67.709],[0,67.709]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":8,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-43.168],[0,43.168]],"c":false}]},{"t":20,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-67.709],[0,67.709]],"c":false}]}],"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[354.171,354.168],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.333,"y":0},"t":8,"s":[145.831,145.831,0],"to":[34.722,0,0],"ti":[-34.722,0,0]},{"t":42,"s":[354.165,145.831,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[11.506,0],[0,0],[0,-11.506],[0,0],[-11.506,0],[0,0],[0,11.505],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.505],[0,0],[11.506,0],[0,0],[0,-11.506]],"v":[[46.875,-67.709],[-46.875,-67.709],[-67.709,-46.875],[-67.709,46.875],[-46.875,67.709],[46.875,67.709],[67.709,46.875],[67.709,-46.875]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.333,"y":0},"t":15,"s":[145.831,354.17,0],"to":[0,-34.723,0],"ti":[0,34.723,0]},{"t":49,"s":[145.831,145.831,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[11.506,0],[0,0],[0,-11.505],[0,0],[-11.506,0],[0,0],[0,11.506],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.506],[0,0],[11.506,0],[0,0],[0,-11.505]],"v":[[46.875,-67.709],[-46.875,-67.709],[-67.709,-46.875],[-67.709,46.875],[-46.875,67.709],[46.875,67.709],[67.709,46.875],[67.709,-46.875]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[145.831,354.17,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":23,"s":[{"i":[[11.506,0],[0,0],[0,0.034],[0,0],[-11.506,0],[0,0],[0,-0.034],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,-0.034],[0,0],[11.506,0],[0,0],[0,0.034]],"v":[[46.581,67.83],[-47.169,67.83],[-68.003,67.769],[-67.709,67.77],[-46.875,67.709],[46.875,67.709],[67.709,67.77],[67.415,67.769]],"c":true}]},{"i":{"x":0,"y":1},"o":{"x":0.167,"y":0.167},"t":25,"s":[{"i":[[11.506,0],[0,0],[0,-11.505],[0,0],[-11.506,0],[0,0],[0,11.506],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.506],[0,0],[11.506,0],[0,0],[0,-11.505]],"v":[[46.581,26.291],[-47.169,26.291],[-68.003,47.125],[-67.709,46.875],[-46.875,67.709],[46.875,67.709],[67.709,46.875],[67.415,47.125]],"c":true}]},{"t":57,"s":[{"i":[[11.506,0],[0,0],[0,-11.505],[0,0],[-11.506,0],[0,0],[0,11.506],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.506],[0,0],[11.506,0],[0,0],[0,-11.505]],"v":[[46.875,-67.709],[-46.875,-67.709],[-67.709,-46.875],[-67.709,46.875],[-46.875,67.709],[46.875,67.709],[67.709,46.875],[67.709,-46.875]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":23,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.002,250.002,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[2083,2083,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.41,0],[0,0],[0,0],[0.41,0],[0,-0.41],[0,0],[0,0],[0,-0.41],[-0.41,0],[0,0],[0,0],[-0.41,0],[0,0.41],[0,0],[0,0],[0,0.41]],"o":[[0,0],[0,0],[0,-0.41],[-0.41,0],[0,0],[0,0],[-0.41,0],[0,0.41],[0,0],[0,0],[0,0.41],[0.41,0],[0,0],[0,0],[0.41,0],[0,-0.41]],"v":[[3.25,-0.75],[0.75,-0.75],[0.75,-3.25],[0,-4],[-0.75,-3.25],[-0.75,-0.75],[-3.25,-0.75],[-4,0],[-3.25,0.75],[-0.75,0.75],[-0.75,3.25],[0,4],[0.75,3.25],[0.75,0.75],[3.25,0.75],[4,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[255,255],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.14,0],[0,0],[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14]],"o":[[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14],[0,0],[0.14,0],[0,0]],"v":[[2.5,2.25],[2.25,2.5],[-2.25,2.5],[-2.5,2.25],[-2.5,-2.25],[-2.25,-2.5],[2.25,-2.5],[2.5,-2.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.96,0],[0,0],[0,-0.96],[0,0],[-0.96,0],[0,0],[0,0.96],[0,0]],"o":[[0,0],[-0.97,0],[0,0],[0,0.96],[0,0],[0.96,0],[0,0],[0,-0.96]],"v":[[2.25,-4],[-2.25,-4],[-4,-2.25],[-4,2.25],[-2.25,4],[2.25,4],[4,2.25],[4,-2.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[255,245],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.14,0],[0,0],[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14]],"o":[[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14],[0,0],[0.14,0],[0,0]],"v":[[2.5,2.25],[2.25,2.5],[-2.25,2.5],[-2.5,2.25],[-2.5,-2.25],[-2.25,-2.5],[2.25,-2.5],[2.5,-2.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.96,0],[0,0],[0,-0.96],[0,0],[-0.97,0],[0,0],[0,0.96],[0,0]],"o":[[0,0],[-0.97,0],[0,0],[0,0.96],[0,0],[0.96,0],[0,0],[0,-0.96]],"v":[[2.25,-4],[-2.25,-4],[-4,-2.25],[-4,2.25],[-2.25,4],[2.25,4],[4,2.25],[4,-2.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[245,245],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":3,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.14,0],[0,0],[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14]],"o":[[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14],[0,0],[0.14,0],[0,0]],"v":[[2.5,2.25],[2.25,2.5],[-2.25,2.5],[-2.5,2.25],[-2.5,-2.25],[-2.25,-2.5],[2.25,-2.5],[2.5,-2.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.96,0],[0,0],[0,-0.96],[0,0],[-0.97,0],[0,0],[0,0.96],[0,0]],"o":[[0,0],[-0.97,0],[0,0],[0,0.96],[0,0],[0.96,0],[0,0],[0,-0.96]],"v":[[2.25,-4],[-2.25,-4],[-4,-2.25],[-4,2.25],[-2.25,4],[2.25,4],[4,2.25],[4,-2.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[245,255],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":3,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false}],"ip":60,"op":300,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.002,250.002,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[2083,2083,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.41,0],[0,0],[0,0],[0.41,0],[0,-0.41],[0,0],[0,0],[0,-0.41],[-0.41,0],[0,0],[0,0],[-0.41,0],[0,0.41],[0,0],[0,0],[0,0.41]],"o":[[0,0],[0,0],[0,-0.41],[-0.41,0],[0,0],[0,0],[-0.41,0],[0,0.41],[0,0],[0,0],[0,0.41],[0.41,0],[0,0],[0,0],[0.41,0],[0,-0.41]],"v":[[3.25,-0.75],[0.75,-0.75],[0.75,-3.25],[0,-4],[-0.75,-3.25],[-0.75,-0.75],[-3.25,-0.75],[-4,0],[-3.25,0.75],[-0.75,0.75],[-0.75,3.25],[0,4],[0.75,3.25],[0.75,0.75],[3.25,0.75],[4,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[255,255],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.14,0],[0,0],[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14]],"o":[[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14],[0,0],[0.14,0],[0,0]],"v":[[2.5,2.25],[2.25,2.5],[-2.25,2.5],[-2.5,2.25],[-2.5,-2.25],[-2.25,-2.5],[2.25,-2.5],[2.5,-2.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.96,0],[0,0],[0,-0.96],[0,0],[-0.96,0],[0,0],[0,0.96],[0,0]],"o":[[0,0],[-0.97,0],[0,0],[0,0.96],[0,0],[0.96,0],[0,0],[0,-0.96]],"v":[[2.25,-4],[-2.25,-4],[-4,-2.25],[-4,2.25],[-2.25,4],[2.25,4],[4,2.25],[4,-2.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[255,245],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.14,0],[0,0],[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14]],"o":[[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14],[0,0],[0.14,0],[0,0]],"v":[[2.5,2.25],[2.25,2.5],[-2.25,2.5],[-2.5,2.25],[-2.5,-2.25],[-2.25,-2.5],[2.25,-2.5],[2.5,-2.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.96,0],[0,0],[0,-0.96],[0,0],[-0.97,0],[0,0],[0,0.96],[0,0]],"o":[[0,0],[-0.97,0],[0,0],[0,0.96],[0,0],[0.96,0],[0,0],[0,-0.96]],"v":[[2.25,-4],[-2.25,-4],[-4,-2.25],[-4,2.25],[-2.25,4],[2.25,4],[4,2.25],[4,-2.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[245,245],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":3,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.14,0],[0,0],[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14]],"o":[[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14],[0,0],[0.14,0],[0,0]],"v":[[2.5,2.25],[2.25,2.5],[-2.25,2.5],[-2.5,2.25],[-2.5,-2.25],[-2.25,-2.5],[2.25,-2.5],[2.5,-2.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.96,0],[0,0],[0,-0.96],[0,0],[-0.97,0],[0,0],[0,0.96],[0,0]],"o":[[0,0],[-0.97,0],[0,0],[0,0.96],[0,0],[0.96,0],[0,0],[0,-0.96]],"v":[[2.25,-4],[-2.25,-4],[-4,-2.25],[-4,2.25],[-2.25,4],[2.25,4],[4,2.25],[4,-2.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[245,255],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":3,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":1,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[0.914,0.91,0.91],"ix":1}}]}],"ip":0,"op":131,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"hover-add-card","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":500,"h":500,"ip":0,"op":70,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-add-card","dr":60}],"props":{}} \ No newline at end of file diff --git a/frontend/public/lotties/check.json b/frontend/public/lotties/check.json new file mode 100644 index 000000000..8d66090dc --- /dev/null +++ b/frontend/public/lotties/check.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":60,"w":500,"h":500,"nm":"system-regular-31-check","ddd":0,"assets":[{"id":"comp_1","nm":"hover-check","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[253.419,260.347,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[149.956,-122.947],[-31.321,57.362],[-83.54,5.208]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[100]},{"t":20,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[28.5]},{"t":20,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-31-check').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[253.419,260.347,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[149.956,-122.947],[-31.321,57.362],[-83.54,5.208]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.05],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":21,"s":[0]},{"t":60,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.05],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":21,"s":[0]},{"t":60,"s":[28.5]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-31-check').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-180,"ix":10},"p":{"a":0,"k":[250.004,250.003,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-2.572,-106.399],[106.399,-2.572],[2.572,106.399],[-106.399,2.572]],"o":[[2.572,106.399],[-106.399,2.572],[-2.572,-106.399],[106.399,-2.572]],"v":[[192.652,-4.656],[4.656,192.652],[-192.652,4.656],[-4.656,-192.652]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-31-check').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":1,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,249.974,0],"ix":2,"l":2},"a":{"a":0,"k":[250,249.999,0],"ix":1,"l":2},"s":{"a":0,"k":[2083,2083,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[1.57,-1.64],[2.27,-0.05],[1.65,1.56],[0.05,2.27],[-4.68,0.11],[-0.07,0],[-1.6,-1.52],[-0.05,-2.27]],"o":[[-1.57,1.64],[-2.28,0.06],[-1.65,-1.56],[-0.11,-4.69],[0.07,0],[2.19,0],[1.64,1.57],[0.06,2.26]],"v":[[6.15,5.861],[0.2,8.491],[-5.87,6.151],[-8.5,0.201],[-0.21,-8.499],[0,-8.499],[5.86,-6.149],[8.49,-0.199]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[2.67,-0.06],[-0.13,-5.51],[-1.93,-1.84],[-2.58,0],[-0.08,0],[-1.84,1.93],[0.06,2.67],[1.93,1.84]],"o":[[-5.51,0.14],[0.06,2.67],[1.88,1.79],[0.08,0],[2.67,-0.06],[1.84,-1.93],[-0.06,-2.67],[-1.94,-1.84]],"v":[[-0.24,-9.999],[-10,0.241],[-6.9,7.241],[-0.01,10.001],[0.24,10.001],[7.24,6.901],[10,-0.239],[6.9,-7.239]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-31-check').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[250,249.999],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.3,-0.29],[0,0],[0,0],[0.29,-0.29],[-0.29,-0.29],[0,0],[-0.19,0],[-0.15,0.15],[0,0],[0.3,0.3]],"o":[[0,0],[0,0],[-0.29,-0.29],[-0.29,0.29],[0,0],[0.15,0.15],[0.19,0],[0,0],[0.3,-0.29],[-0.29,-0.29]],"v":[[3.476,-3.286],[-1.504,1.694],[-3.484,-0.276],[-4.544,-0.276],[-4.544,0.784],[-2.034,3.284],[-1.504,3.504],[-0.974,3.284],[4.536,-2.226],[4.536,-3.286]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-31-check').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[250.164,250.496],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":60,"op":300,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,249.974,0],"ix":2,"l":2},"a":{"a":0,"k":[250,249.999,0],"ix":1,"l":2},"s":{"a":0,"k":[2083,2083,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[1.57,-1.64],[2.27,-0.05],[1.65,1.56],[0.05,2.27],[-4.68,0.11],[-0.07,0],[-1.6,-1.52],[-0.05,-2.27]],"o":[[-1.57,1.64],[-2.28,0.06],[-1.65,-1.56],[-0.11,-4.69],[0.07,0],[2.19,0],[1.64,1.57],[0.06,2.26]],"v":[[6.15,5.861],[0.2,8.491],[-5.87,6.151],[-8.5,0.201],[-0.21,-8.499],[0,-8.499],[5.86,-6.149],[8.49,-0.199]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[2.67,-0.06],[-0.13,-5.51],[-1.93,-1.84],[-2.58,0],[-0.08,0],[-1.84,1.93],[0.06,2.67],[1.93,1.84]],"o":[[-5.51,0.14],[0.06,2.67],[1.88,1.79],[0.08,0],[2.67,-0.06],[1.84,-1.93],[-0.06,-2.67],[-1.94,-1.84]],"v":[[-0.24,-9.999],[-10,0.241],[-6.9,7.241],[-0.01,10.001],[0.24,10.001],[7.24,6.901],[10,-0.239],[6.9,-7.239]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-31-check').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[250,249.999],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.3,-0.29],[0,0],[0,0],[0.29,-0.29],[-0.29,-0.29],[0,0],[-0.19,0],[-0.15,0.15],[0,0],[0.3,0.3]],"o":[[0,0],[0,0],[-0.29,-0.29],[-0.29,0.29],[0,0],[0.15,0.15],[0.19,0],[0,0],[0.3,-0.29],[-0.29,-0.29]],"v":[[3.476,-3.286],[-1.504,1.694],[-3.484,-0.276],[-4.544,-0.276],[-4.544,0.784],[-2.034,3.284],[-1.504,3.504],[-0.974,3.284],[4.536,-2.226],[4.536,-3.286]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-31-check').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[250.164,250.496],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":1,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[0.91,0.91,0.914],"ix":1}}]}],"ip":0,"op":302,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"hover-check","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":500,"h":500,"ip":0,"op":70,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-check","dr":60}],"props":{}} \ No newline at end of file diff --git a/frontend/public/lotties/pki-subscriber.json b/frontend/public/lotties/pki-subscriber.json new file mode 100644 index 000000000..f6e0ce16e --- /dev/null +++ b/frontend/public/lotties/pki-subscriber.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":89,"w":430,"h":430,"nm":"wired-outline-88-document-user","ddd":0,"assets":[{"id":"comp_1","nm":"Content-12","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"outline 4","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.41,"y":0},"t":6,"s":[0.044,-73.171,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":35,"s":[0.044,-117.966,0],"to":[0,0,0],"ti":[0,0,0]},{"t":50,"s":[0.044,-100.171,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,22.108],[22.108,0],[0,-22.108],[-22.108,0]],"o":[[0,-22.108],[-22.108,0],[0,22.108],[22.108,0]],"v":[[40.03,0],[0,-40.03],[-40.03,0],[0,40.03]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-88-document-user').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-88-document-user').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"outline 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.14,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[214.956,575.075,0],"to":[0,0,0],"ti":[0,0,0]},{"t":29,"s":[214.956,315.075,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-30.376,0],[0,0],[0,-30.376]],"o":[[0,0],[0,0],[0,-30.376],[0,0],[30.376,0],[0,0]],"v":[[80.015,33.358],[-80.015,33.358],[-80.015,21.642],[-25.015,-33.358],[25.015,-33.358],[80.015,21.642]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-88-document-user').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-88-document-user').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0}]},{"id":"comp_3","nm":"Content-36","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"outline 4","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.41,"y":0},"t":6,"s":[0.044,-73.171,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":35,"s":[0.044,-117.966,0],"to":[0,0,0],"ti":[0,0,0]},{"t":50,"s":[0.044,-100.171,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,22.108],[22.108,0],[0,-22.108],[-22.108,0]],"o":[[0,-22.108],[-22.108,0],[0,22.108],[22.108,0]],"v":[[40.03,0],[0,-40.03],[-40.03,0],[0,40.03]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-88-document-user').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-88-document-user').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"outline 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.14,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[214.956,575.075,0],"to":[0,0,0],"ti":[0,0,0]},{"t":29,"s":[214.956,315.075,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-30.376,0],[0,0],[0,-30.376]],"o":[[0,0],[0,0],[0,-30.376],[0,0],[30.376,0],[0,0]],"v":[[80.015,33.358],[-80.015,33.358],[-80.015,21.642],[-25.015,-33.358],[25.015,-33.358],[80.015,21.642]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-88-document-user').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-88-document-user').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0}]},{"id":"comp_4","nm":"hover-swipe","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Page-corner","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.001,249.76,0],"ix":2,"l":2},"a":{"a":0,"k":[250.001,249.76,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.22,"y":1},"o":{"x":0.333,"y":0},"t":42,"s":[{"i":[[0,0],[-49.694,-50.431],[0,0]],"o":[[0,0],[50.313,51.06],[0,0]],"v":[[-53.373,-53.373],[-0.373,-0.627],[53.373,53.373]],"c":false}]},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-53.373,-53.373],[-53.373,53.373],[53.373,53.373]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-88-document-user').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-88-document-user').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[330.06,116.567],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Page","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.243],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.326],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":20,"s":[9]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":47,"s":[-7]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":70,"s":[5]},{"t":89,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.243,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[317.001,368.76,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.326,"y":1},"o":{"x":0.333,"y":0},"t":20,"s":[351.001,381.76,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":42,"s":[291.751,356.51,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":65,"s":[321.001,369.26,0],"to":[0,0,0],"ti":[0,0,0]},{"t":80,"s":[317.001,368.76,0]}],"ix":2,"l":2},"a":{"a":0,"k":[352.001,403.76,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-53.373,-53.373],[-53.373,53.373],[53.373,53.373]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":20,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-53.373,-53.373],[-53.373,53.373],[53.373,53.373]],"c":false}]},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-213.237,-53.373],[-213.237,319.57],[53.373,319.57]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-88-document-user').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-88-document-user').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[330.06,116.567],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":1,"k":[{"t":20,"s":[100],"h":1},{"t":38,"s":[0],"h":1}],"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[26.69,-186.57],[-133.43,-186.57],[-133.43,186.57],[133.43,186.57],[133.43,-79.82]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-88-document-user').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-88-document-user').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[250,249.76],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"mask","parent":2,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[249.001,249.76,0],"ix":2,"l":2},"a":{"a":0,"k":[250.001,249.76,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[26.69,-186.57],[26.75,-186.57],[26.75,-79.76],[133.43,-79.76],[133.43,-79.82]],"c":true}]},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[26.69,-186.57],[-133.43,-186.57],[-133.43,186.57],[133.43,186.57],[133.43,-79.82]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[250,249.76],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":51,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Content-12","parent":2,"tt":2,"tp":3,"refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":51,"st":-50,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"Content-12","parent":2,"refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[348.631,28.403],[82.211,28.403],[82.211,401.557],[348.631,401.557]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"w":430,"h":430,"ip":37.5,"op":881.5,"st":37.5,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"stroke","np":3,"mn":"Pseudo/@@jxAy4KF1Sn6X4aYQ0vVH/w","ix":1,"en":1,"ef":[{"ty":7,"nm":"Menu","mn":"Pseudo/@@jxAy4KF1Sn6X4aYQ0vVH/w-0001","ix":1,"v":{"a":0,"k":3,"ix":1}}]},{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":2,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[1,1,1],"ix":1}}]},{"ty":5,"nm":"secondary","np":3,"mn":"ADBE Color Control","ix":3,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[1,1,1],"ix":1}}]}],"ip":0,"op":360,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"hover-swipe","refId":"comp_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":99,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-swipe","dr":89}],"props":{}} \ No newline at end of file diff --git a/frontend/public/lotties/pki-template.json b/frontend/public/lotties/pki-template.json new file mode 100644 index 000000000..0001f3d07 --- /dev/null +++ b/frontend/public/lotties/pki-template.json @@ -0,0 +1,3098 @@ +{ + "v": "5.7.5", + "fr": 100, + "ip": 0, + "op": 250, + "w": 512, + "h": 532, + "nm": "Comp 1", + "ddd": 0, + "metadata": {}, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 12345679, + "ty": 4, + "nm": "Group Layer 8", + "sr": 1, + "ks": { + "p": { "a": 0, "k": [373.76, 495.53049180327866, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [52.459016393442624, 52.459016393442624, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "gr", + "it": [ + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [220.741, 37.184], + [225.501, 35.896], + [228.749, 32.36800000000001], + [229.981, 27.216000000000008], + [228.749, 22.12], + [225.501, 18.592], + [220.741, 17.304], + [215.981, 18.592], + [212.677, 22.12], + [211.501, 27.216000000000008], + [212.677, 32.36800000000001], + [215.981, 35.896], + [220.741, 37.184], + [220.741, 37.184], + [220.741, 37.184] + ], + "i": [ + [0, 0], + [-1.380999999999972, 0.8586999999999989], + [-0.7839999999999918, 1.493299999999991], + [0, 1.903999999999996], + [0.8220000000000027, 1.493299999999991], + [1.382000000000062, 0.8586999999999989], + [1.79200000000003, 0], + [1.418999999999983, -0.8586999999999989], + [0.8220000000000027, -1.493300000000005], + [0, -1.904000000000011], + [-0.7839999999999918, -1.5307000000000102], + [-1.380999999999972, -0.8586999999999989], + [-1.754000000000019, 0], + [0, 0], + [0, 0] + ], + "o": [ + [1.79200000000003, 0], + [1.382000000000062, -0.8586999999999989], + [0.8220000000000027, -1.5307000000000102], + [0, -1.904000000000011], + [-0.7839999999999918, -1.493300000000005], + [-1.380999999999972, -0.8586999999999989], + [-1.754000000000019, 0], + [-1.380999999999972, 0.8586999999999989], + [-0.7839999999999918, 1.493299999999991], + [0, 1.903999999999996], + [0.8220000000000027, 1.493299999999991], + [1.418999999999983, 0.8586999999999989], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [221.357, 43.06400000000001], + [214.917, 41.608], + [210.49300000000005, 37.408], + [211.221, 36.232], + [211.221, 42.392], + [205.173, 42.392], + [205.173, 0], + [211.501, 0], + [211.501, 18.36800000000001], + [210.49300000000005, 16.912000000000006], + [214.973, 12.88], + [221.357, 11.424000000000007], + [229.085, 13.49600000000001], + [234.51700000000005, 19.152], + [236.533, 27.216000000000008], + [234.51700000000005, 35.28], + [229.141, 40.992], + [221.357, 43.06400000000001], + [221.357, 43.06400000000001], + [221.357, 43.06400000000001] + ], + "i": [ + [0, 0], + [1.942000000000007, 0.9706999999999937], + [1.045999999999935, 1.829300000000003], + [-0.2426666666666506, 0.3919999999999959], + [0, -2.053333333333327], + [2.015999999999963, 0], + [0, 14.13066666666667], + [-2.109333333333325, 0], + [0, -6.122666666666674], + [0.3360000000000127, 0.4853333333333296], + [-1.865999999999985, 0.9707000000000079], + [-2.38900000000001, 0], + [-2.277000000000044, -1.3813000000000102], + [-1.30600000000004, -2.389300000000006], + [0, -2.986699999999999], + [1.343999999999937, -2.389300000000006], + [2.27800000000002, -1.4187000000000012], + [2.912000000000035, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-2.351999999999975, 0], + [-1.903999999999996, -0.9707000000000079], + [0.2426666666666506, -0.3919999999999959], + [0, 2.053333333333327], + [-2.015999999999963, 0], + [0, -14.13066666666667], + [2.109333333333325, 0], + [0, 6.122666666666667], + [-0.3360000000000127, -0.4853333333333296], + [1.120000000000005, -1.717300000000009], + [1.867000000000075, -0.9706999999999937], + [2.875, 0], + [2.314999999999941, 1.381299999999996], + [1.343999999999937, 2.389300000000006], + [0, 2.986699999999999], + [-1.30600000000004, 2.389300000000006], + [-2.27699999999993, 1.381299999999996], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [181.87, 43.06400000000001], + [176.438, 42], + [172.854, 38.976], + [171.566, 34.384], + [172.63, 29.960000000000008], + [176.046, 26.656000000000006], + [181.814, 24.752], + [192.342, 23.016000000000005], + [192.342, 28], + [183.046, 29.624], + [179.35, 31.248], + [178.174, 34.16], + [179.462, 37.016000000000005], + [182.878, 38.08], + [187.35799999999995, 36.96000000000001], + [190.38199999999995, 33.992], + [191.446, 29.792], + [191.446, 22.008], + [189.766, 18.36800000000001], + [185.398, 16.912000000000006], + [180.974, 18.256], + [178.23, 21.616], + [172.966, 18.98400000000001], + [175.71, 15.064000000000007], + [180.134, 12.376], + [185.566, 11.424000000000007], + [191.894, 12.768], + [196.206, 16.52], + [197.774, 22.008], + [197.774, 42.392], + [191.726, 42.392], + [191.726, 36.904], + [193.014, 37.072], + [190.27, 40.264], + [186.518, 42.336], + [181.87, 43.06400000000001], + [181.87, 43.06400000000001], + [181.87, 43.06400000000001] + ], + "i": [ + [0, 0], + [1.567999999999984, 0.7092999999999989], + [0.8589999999999236, 1.2693000000000012], + [0, 1.7547], + [-0.7089999999999463, 1.306699999999992], + [-1.530000000000086, 0.8960000000000008], + [-2.313999999999965, 0.3733000000000004], + [-3.509333333333302, 0.5786666666666633], + [0, -1.661333333333332], + [3.098666666666645, -0.541333333333327], + [0.7839999999999918, -0.784000000000006], + [0, -1.194699999999997], + [-0.8579999999999472, -0.7467000000000041], + [-1.381000000000085, 0], + [-1.268999999999892, 0.7466999999999899], + [-0.7089999999999463, 1.2319999999999993], + [0, 1.530699999999996], + [0, 2.594666666666669], + [1.120000000000005, 0.9332999999999885], + [1.829999999999927, 0], + [1.269999999999982, -0.8960000000000008], + [0.5979999999999563, -1.381299999999996], + [1.754666666666708, 0.8773333333333255], + [-1.269000000000005, 1.11999999999999], + [-1.680000000000064, 0.6346999999999952], + [-1.903999999999996, 0], + [-1.828999999999951, -0.8960000000000008], + [-1.008000000000038, -1.6053], + [0, -2.090699999999998], + [0, -6.794666666666672], + [2.015999999999963, 0], + [0, 1.829333333333338], + [-0.4293333333333749, -0.05599999999999739], + [1.120000000000005, -0.8959999999999866], + [1.418999999999983, -0.4852999999999952], + [1.717999999999961, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-2.052999999999997, 0], + [-1.529999999999973, -0.7467000000000041], + [-0.8580000000000609, -1.306699999999992], + [0, -1.642700000000005], + [0.7469999999999573, -1.306700000000006], + [1.530999999999949, -0.8960000000000008], + [3.509333333333302, -0.5786666666666633], + [0, 1.661333333333332], + [-3.098666666666645, 0.541333333333327], + [-1.680000000000064, 0.2987000000000108], + [-0.7839999999999918, 0.7467000000000041], + [0, 1.157300000000006], + [0.8959999999999582, 0.7092999999999989], + [1.717999999999961, 0], + [1.307000000000016, -0.7467000000000041], + [0.7100000000000364, -1.2693000000000012], + [0, -2.594666666666669], + [0, -1.493299999999991], + [-1.081999999999994, -0.9707000000000079], + [-1.680000000000064, 0], + [-1.232000000000085, 0.8586999999999989], + [-1.754666666666708, -0.8773333333333255], + [0.5599999999999454, -1.493300000000005], + [1.269999999999982, -1.157300000000006], + [1.717999999999961, -0.6347000000000094], + [2.389999999999986, 0], + [1.866999999999962, 0.8960000000000008], + [1.045999999999935, 1.568000000000012], + [0, 6.794666666666672], + [-2.015999999999963, 0], + [0, -1.829333333333338], + [0.4293333333333749, 0.05599999999999739], + [-0.70900000000006, 1.2319999999999993], + [-1.081999999999994, 0.8960000000000008], + [-1.380999999999972, 0.4853000000000094], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [159.072, 42.392], + [159.072, 0], + [165.4, 0], + [165.4, 42.392], + [159.072, 42.392], + [159.072, 42.392], + [159.072, 42.392] + ], + "i": [ + [0, 0], + [0, 14.13066666666667], + [-2.109333333333325, 0], + [0, -14.13066666666667], + [2.109333333333325, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, -14.13066666666667], + [2.109333333333325, 0], + [0, 14.13066666666667], + [-2.109333333333325, 0], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [138.952, 43.06400000000001], + [130.888, 40.992], + [125.456, 35.28], + [123.496, 27.16], + [125.456, 19.040000000000006], + [130.832, 13.49600000000001], + [138.448, 11.424000000000007], + [144.552, 12.600000000000009], + [149.088, 15.848], + [151.888, 20.49600000000001], + [152.896, 26.096], + [152.84, 27.608], + [152.616, 29.064000000000007], + [128.48, 29.064000000000007], + [128.48, 24.024], + [149.032, 24.024], + [146.008, 26.320000000000007], + [145.616, 21.448000000000008], + [142.816, 18.032], + [138.448, 16.744], + [133.968, 18.032], + [130.944, 21.616], + [130.104, 27.216000000000008], + [130.944, 32.592], + [134.192, 36.176], + [139.008, 37.464], + [143.65599999999995, 36.232], + [146.736, 33.040000000000006], + [151.888, 35.56], + [149.088, 39.42400000000001], + [144.60799999999995, 42.11200000000001], + [138.952, 43.06400000000001], + [138.952, 43.06400000000001], + [138.952, 43.06400000000001] + ], + "i": [ + [0, 0], + [2.351999999999975, 1.381299999999996], + [1.307000000000016, 2.389300000000006], + [0, 2.986699999999999], + [-1.307000000000016, 2.35199999999999], + [-2.240000000000009, 1.343999999999994], + [-2.836999999999989, 0], + [-1.79200000000003, -0.784000000000006], + [-1.231999999999971, -1.381299999999996], + [-0.6349999999999909, -1.754700000000014], + [0, -1.978700000000003], + [0.03699999999992087, -0.5227000000000004], + [0.1119999999999663, -0.4480000000000075], + [8.04533333333336, 0], + [0, 1.680000000000007], + [-6.850666666666712, 0], + [1.008000000000038, -0.7653333333333308], + [0.6349999999999909, 1.4187000000000012], + [1.269000000000005, 0.8213000000000079], + [1.680000000000064, 0], + [1.307000000000016, -0.8586999999999989], + [0.70900000000006, -1.567999999999998], + [-0.1490000000000009, -2.202700000000007], + [-0.7469999999999573, -1.530699999999996], + [-1.380999999999972, -0.8586999999999989], + [-1.79200000000003, 0], + [-1.268999999999892, 0.8213000000000079], + [-0.7469999999999573, 1.306699999999992], + [-1.717333333333386, -0.8400000000000034], + [1.269000000000005, -1.157300000000006], + [1.755000000000109, -0.6720000000000113], + [2.052999999999997, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-3.024000000000001, 0], + [-2.315000000000055, -1.4187000000000012], + [-1.307000000000016, -2.426699999999997], + [0, -3.061299999999989], + [1.343999999999937, -2.352000000000004], + [2.240000000000009, -1.3813000000000102], + [2.277000000000044, 0], + [1.79200000000003, 0.7839999999999918], + [1.232000000000085, 1.344000000000008], + [0.6720000000000255, 1.7547], + [0, 0.4852999999999952], + [-0.03700000000003456, 0.5227000000000004], + [-8.04533333333336, 0], + [0, -1.680000000000007], + [6.850666666666712, 0], + [-1.008000000000038, 0.7653333333333308], + [0.3729999999999336, -1.829300000000003], + [-0.59699999999998, -1.456000000000003], + [-1.232000000000085, -0.8586999999999989], + [-1.67999999999995, 0], + [-1.306999999999903, 0.8213000000000079], + [-0.7089999999999463, 1.530699999999996], + [-0.1870000000000118, 2.053299999999993], + [0.7839999999999918, 1.53070000000001], + [1.418999999999983, 0.8586999999999989], + [1.828999999999951, 0], + [1.307000000000016, -0.8212999999999937], + [1.717333333333386, 0.8400000000000034], + [-0.59699999999998, 1.4187000000000012], + [-1.231999999999971, 1.11999999999999], + [-1.716999999999985, 0.6346999999999952], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [111.001, 7.951999999999998], + [111.001, 0.6720000000000041], + [117.329, 0.6720000000000041], + [117.329, 7.951999999999998], + [111.001, 7.951999999999998], + [111.001, 7.951999999999998], + [111.001, 7.951999999999998] + ], + "i": [ + [0, 0], + [0, 2.426666666666662], + [-2.109333333333325, 0], + [0, -2.426666666666662], + [2.109333333333325, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, -2.426666666666662], + [2.109333333333325, 0], + [0, 2.426666666666662], + [-2.109333333333325, 0], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [111.001, 42.392], + [111.001, 12.096], + [117.329, 12.096], + [117.329, 42.392], + [111.001, 42.392], + [111.001, 42.392], + [111.001, 42.392] + ], + "i": [ + [0, 0], + [0, 10.09866666666667], + [-2.109333333333325, 0], + [0, -10.09866666666667], + [2.109333333333325, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, -10.09866666666666], + [2.109333333333325, 0], + [0, 10.09866666666666], + [-2.109333333333325, 0], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [101.41, 42.72800000000001], + [94.01800000000003, 40.040000000000006], + [91.38599999999997, 32.48], + [91.38599999999997, 17.808000000000007], + [86.06600000000003, 17.808000000000007], + [86.06600000000003, 12.096], + [86.90599999999995, 12.096], + [90.21000000000004, 10.864], + [91.38599999999997, 7.504000000000005], + [91.38599999999997, 5.152000000000001], + [97.71400000000006, 5.152000000000001], + [97.71400000000006, 12.096], + [104.602, 12.096], + [104.602, 17.808000000000007], + [97.71400000000006, 17.808000000000007], + [97.71400000000006, 32.2], + [98.21799999999996, 34.888000000000005], + [99.84199999999998, 36.568], + [102.754, 37.128], + [103.76200000000006, 37.072], + [104.826, 36.96000000000001], + [104.826, 42.392], + [103.09, 42.616], + [101.41, 42.72800000000001], + [101.41, 42.72800000000001], + [101.41, 42.72800000000001] + ], + "i": [ + [0, 0], + [1.754999999999995, 1.792000000000002], + [0, 3.248000000000005], + [0, 4.890666666666661], + [1.773333333333312, 0], + [0, 1.903999999999996], + [-0.2799999999999727, 0], + [-0.7839999999999918, 0.8212999999999937], + [0, 1.4187000000000012], + [0, 0.7839999999999989], + [-2.109333333333325, 0], + [0, -2.314666666666668], + [-2.295999999999935, 0], + [0, -1.903999999999996], + [2.295999999999935, 0], + [0, -4.797333333333327], + [-0.3360000000000127, -0.7467000000000041], + [-0.7469999999999573, -0.4106999999999914], + [-1.19500000000005, 0], + [-0.3730000000000473, 0.03730000000000189], + [-0.3360000000000127, 0.03729999999998768], + [0, -1.810666666666663], + [0.6349999999999909, -0.07469999999999288], + [0.4850000000000136, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-3.173000000000002, 0], + [-1.754999999999995, -1.792000000000002], + [0, -4.890666666666661], + [-1.773333333333312, 0], + [0, -1.903999999999996], + [0.2799999999999727, 0], + [1.419000000000096, 0], + [0.7839999999999918, -0.8213000000000079], + [0, -0.7839999999999989], + [2.109333333333325, 0], + [0, 2.314666666666668], + [2.295999999999935, 0], + [0, 1.903999999999996], + [-2.295999999999935, 0], + [0, 4.797333333333327], + [0, 1.045299999999997], + [0.3360000000000127, 0.7092999999999989], + [0.7470000000000709, 0.3733000000000004], + [0.2989999999999782, 0], + [0.3729999999999336, -0.03730000000000189], + [0, 1.810666666666663], + [-0.5230000000000246, 0.0747000000000071], + [-0.6349999999999909, 0.0747000000000071], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [78.71499999999997, 42.72800000000001], + [71.32299999999998, 40.040000000000006], + [68.69100000000003, 32.48], + [68.69100000000003, 17.808000000000007], + [63.37099999999998, 17.808000000000007], + [63.37099999999998, 12.096], + [64.21100000000001, 12.096], + [67.51499999999999, 10.864], + [68.69100000000003, 7.504000000000005], + [68.69100000000003, 5.152000000000001], + [75.019, 5.152000000000001], + [75.019, 12.096], + [81.90700000000004, 12.096], + [81.90700000000004, 17.808000000000007], + [75.019, 17.808000000000007], + [75.019, 32.2], + [75.52300000000002, 34.888000000000005], + [77.14699999999999, 36.568], + [80.05900000000003, 37.128], + [81.06700000000001, 37.072], + [82.13099999999997, 36.96000000000001], + [82.13099999999997, 42.392], + [80.39499999999998, 42.616], + [78.71499999999997, 42.72800000000001], + [78.71499999999997, 42.72800000000001], + [78.71499999999997, 42.72800000000001] + ], + "i": [ + [0, 0], + [1.754000000000019, 1.792000000000002], + [0, 3.248000000000005], + [0, 4.890666666666661], + [1.773333333333369, 0], + [0, 1.903999999999996], + [-0.2800000000000296, 0], + [-0.7839999999999918, 0.8212999999999937], + [0, 1.4187000000000012], + [0, 0.7839999999999989], + [-2.109333333333325, 0], + [0, -2.314666666666668], + [-2.295999999999992, 0], + [0, -1.903999999999996], + [2.295999999999992, 0], + [0, -4.797333333333327], + [-0.3360000000000127, -0.7467000000000041], + [-0.7470000000000141, -0.4106999999999914], + [-1.19500000000005, 0], + [-0.3740000000000236, 0.03730000000000189], + [-0.3360000000000127, 0.03729999999998768], + [0, -1.810666666666663], + [0.6340000000000146, -0.07469999999999288], + [0.4850000000000136, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-3.173999999999978, 0], + [-1.754999999999995, -1.792000000000002], + [0, -4.890666666666661], + [-1.773333333333369, 0], + [0, -1.903999999999996], + [0.2800000000000296, 0], + [1.418000000000006, 0], + [0.7839999999999918, -0.8213000000000079], + [0, -0.7839999999999989], + [2.109333333333325, 0], + [0, 2.314666666666668], + [2.295999999999992, 0], + [0, 1.903999999999996], + [-2.295999999999992, 0], + [0, 4.797333333333327], + [0, 1.045299999999997], + [0.3359999999999559, 0.7092999999999989], + [0.7460000000000377, 0.3733000000000004], + [0.297999999999945, 0], + [0.3730000000000473, -0.03730000000000189], + [0, 1.810666666666663], + [-0.5230000000000246, 0.0747000000000071], + [-0.6349999999999909, 0.0747000000000071], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [44.18799999999999, 37.184], + [48.94799999999998, 35.896], + [52.19600000000003, 32.36800000000001], + [53.428, 27.216000000000008], + [52.19600000000003, 22.12], + [48.94799999999998, 18.592], + [44.18799999999999, 17.304], + [39.428, 18.592], + [36.12400000000002, 22.12], + [34.94799999999998, 27.216000000000008], + [36.12400000000002, 32.36800000000001], + [39.428, 35.896], + [44.18799999999999, 37.184], + [44.18799999999999, 37.184], + [44.18799999999999, 37.184] + ], + "i": [ + [0, 0], + [-1.381999999999948, 0.8586999999999989], + [-0.7840000000000487, 1.493299999999991], + [0, 1.903999999999996], + [0.8209999999999695, 1.493299999999991], + [1.381000000000029, 0.8586999999999989], + [1.79200000000003, 0], + [1.418000000000006, -0.8586999999999989], + [0.8209999999999695, -1.493300000000005], + [0, -1.904000000000011], + [-0.7840000000000487, -1.5307000000000102], + [-1.382000000000005, -0.8586999999999989], + [-1.754999999999995, 0], + [0, 0], + [0, 0] + ], + "o": [ + [1.79200000000003, 0], + [1.381000000000029, -0.8586999999999989], + [0.8209999999999695, -1.5307000000000102], + [0, -1.904000000000011], + [-0.7840000000000487, -1.493300000000005], + [-1.381999999999948, -0.8586999999999989], + [-1.754999999999995, 0], + [-1.382000000000005, 0.8586999999999989], + [-0.7840000000000487, 1.493299999999991], + [0, 1.903999999999996], + [0.8209999999999695, 1.493299999999991], + [1.418000000000006, 0.8586999999999989], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [44.18799999999999, 43.06400000000001], + [36.18000000000001, 40.992], + [30.46800000000002, 35.336], + [28.33999999999997, 27.216000000000008], + [30.46800000000002, 19.096], + [36.18000000000001, 13.49600000000001], + [44.18799999999999, 11.424000000000007], + [52.19600000000003, 13.49600000000001], + [57.85199999999998, 19.096], + [59.98000000000002, 27.216000000000008], + [57.85199999999998, 35.392], + [52.139999999999986, 41.048], + [44.18799999999999, 43.06400000000001], + [44.18799999999999, 43.06400000000001], + [44.18799999999999, 43.06400000000001] + ], + "i": [ + [0, 0], + [2.425999999999988, 1.381299999999996], + [1.418000000000006, 2.389300000000006], + [0, 3.024000000000001], + [-1.41900000000004, 2.352000000000004], + [-2.389999999999986, 1.343999999999994], + [-2.949999999999989, 0], + [-2.352000000000032, -1.3813000000000102], + [-1.381999999999948, -2.389300000000006], + [0, -3.061300000000003], + [1.418000000000006, -2.389299999999992], + [2.38900000000001, -1.381299999999996], + [2.912000000000035, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-2.911999999999978, 0], + [-2.389999999999986, -1.381299999999996], + [-1.41900000000004, -2.389299999999992], + [0, -3.061300000000003], + [1.418000000000006, -2.389300000000006], + [2.38900000000001, -1.3813000000000102], + [2.98599999999999, 0], + [2.388999999999953, 1.343999999999994], + [1.418000000000006, 2.352000000000004], + [0, 3.061299999999989], + [-1.418999999999983, 2.389300000000006], + [-2.389999999999986, 1.343999999999994], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [0, 42.392], + [0, 0.6720000000000041], + [6.608000000000004, 0.6720000000000041], + [6.608000000000004, 36.512], + [24.639999999999986, 36.512], + [24.639999999999986, 42.392], + [0, 42.392], + [0, 42.392], + [0, 42.392] + ], + "i": [ + [0, 0], + [0, 13.90666666666666], + [-2.202666666666687, 0], + [0, -11.94666666666666], + [-6.01066666666668, 0], + [0, -1.959999999999994], + [8.21333333333331, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, -13.90666666666667], + [2.202666666666687, 0], + [0, 11.94666666666667], + [6.01066666666668, 0], + [0, 1.959999999999994], + [-8.21333333333331, 0], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "fl", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "r": 1, + "bm": 0 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [98.08047485351562, -21.67217254638672], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [99.99999403953552, 99.99999403953552], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [246.681, 42.392], + [246.681, 0], + [253.009, 0], + [253.009, 18.032], + [252.001, 17.248], + [255.585, 12.936000000000007], + [261.297, 11.424000000000007], + [267.23299999999995, 12.88], + [271.265, 16.912000000000006], + [272.721, 22.792], + [272.721, 42.392], + [266.449, 42.392], + [266.449, 24.528000000000006], + [265.553, 20.664], + [263.201, 18.2], + [259.729, 17.304], + [256.25699999999995, 18.2], + [253.849, 20.664], + [253.009, 24.528000000000006], + [253.009, 42.392], + [246.681, 42.392], + [246.681, 42.392], + [246.681, 42.392] + ], + "i": [ + [0, 0], + [0, 14.13066666666667], + [-2.109333333333325, 0], + [0, -6.010666666666665], + [0.3360000000000127, 0.2613333333333259], + [-1.643000000000029, 0.9706999999999937], + [-2.166000000000054, 0], + [-1.717999999999961, -0.9706999999999937], + [-0.9710000000000036, -1.717300000000009], + [0, -2.202699999999993], + [0, -6.533333333333331], + [2.090666666666721, 0], + [0, 5.954666666666668], + [0.59699999999998, 1.045299999999997], + [1.007999999999925, 0.5600000000000023], + [1.305999999999926, 0], + [1.045000000000073, -0.5973000000000042], + [0.59699999999998, -1.082700000000003], + [0, -1.493300000000005], + [0, -5.954666666666668], + [2.109333333333325, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, -14.13066666666667], + [2.109333333333325, 0], + [0, 6.010666666666665], + [-0.3360000000000127, -0.2613333333333259], + [0.7459999999999809, -1.903999999999996], + [1.641999999999967, -1.0080000000000098], + [2.240000000000009, 0], + [1.717000000000098, 0.9707000000000079], + [0.9700000000000273, 1.717299999999994], + [0, 6.533333333333331], + [-2.090666666666721, 0], + [0, -5.954666666666668], + [0, -1.5307000000000102], + [-0.5599999999999454, -1.082700000000003], + [-1.008000000000038, -0.5973000000000042], + [-1.270000000000095, 0], + [-1.007999999999953, 0.5600000000000023], + [-0.5600000000000023, 1.082700000000003], + [0, 5.954666666666668], + [-2.109333333333325, 0], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-354.5325317382812, -77.50520324707031], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [237.089, 42.72800000000001], + [229.697, 40.040000000000006], + [227.065, 32.48], + [227.065, 17.808000000000007], + [221.745, 17.808000000000007], + [221.745, 12.096], + [222.585, 12.096], + [225.889, 10.864], + [227.065, 7.504000000000005], + [227.065, 5.152000000000001], + [233.393, 5.152000000000001], + [233.393, 12.096], + [240.281, 12.096], + [240.281, 17.808000000000007], + [233.393, 17.808000000000007], + [233.393, 32.2], + [233.897, 34.888000000000005], + [235.521, 36.568], + [238.433, 37.128], + [239.441, 37.072], + [240.505, 36.96000000000001], + [240.505, 42.392], + [238.769, 42.616], + [237.089, 42.72800000000001], + [237.089, 42.72800000000001], + [237.089, 42.72800000000001] + ], + "i": [ + [0, 0], + [1.755000000000052, 1.792000000000002], + [0, 3.248000000000005], + [0, 4.890666666666661], + [1.773333333333369, 0], + [0, 1.903999999999996], + [-0.2800000000000296, 0], + [-0.7839999999999918, 0.8212999999999937], + [0, 1.4187000000000012], + [0, 0.7839999999999989], + [-2.109333333333325, 0], + [0, -2.314666666666668], + [-2.295999999999992, 0], + [0, -1.903999999999996], + [2.295999999999992, 0], + [0, -4.797333333333327], + [-0.3360000000000127, -0.7467000000000041], + [-0.7459999999999809, -0.4106999999999914], + [-1.194000000000017, 0], + [-0.3729999999999905, 0.03730000000000189], + [-0.3360000000000127, 0.03729999999998768], + [0, -1.810666666666663], + [0.6350000000000477, -0.07469999999999288], + [0.48599999999999, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-3.173000000000002, 0], + [-1.753999999999962, -1.792000000000002], + [0, -4.890666666666661], + [-1.773333333333369, 0], + [0, -1.903999999999996], + [0.2800000000000296, 0], + [1.418999999999983, 0], + [0.7839999999999918, -0.8213000000000079], + [0, -0.7839999999999989], + [2.109333333333325, 0], + [0, 2.314666666666668], + [2.295999999999992, 0], + [0, 1.903999999999996], + [-2.295999999999992, 0], + [0, 4.797333333333327], + [0, 1.045299999999997], + [0.3359999999999559, 0.7092999999999989], + [0.7470000000000141, 0.3733000000000004], + [0.2989999999999782, 0], + [0.3740000000000236, -0.03730000000000189], + [0, 1.810666666666663], + [-0.5220000000000482, 0.0747000000000071], + [-0.6339999999999577, 0.0747000000000071], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-354.5325317382812, -77.50520324707031], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [210.259, 7.951999999999998], + [210.259, 0.6720000000000041], + [216.587, 0.6720000000000041], + [216.587, 7.951999999999998], + [210.259, 7.951999999999998], + [210.259, 7.951999999999998], + [210.259, 7.951999999999998] + ], + "i": [ + [0, 0], + [0, 2.426666666666662], + [-2.109333333333325, 0], + [0, -2.426666666666662], + [2.109333333333325, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, -2.426666666666662], + [2.109333333333325, 0], + [0, 2.426666666666662], + [-2.109333333333325, 0], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-354.5325317382812, -77.50520324707031], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [210.259, 42.392], + [210.259, 12.096], + [216.587, 12.096], + [216.587, 42.392], + [210.259, 42.392], + [210.259, 42.392], + [210.259, 42.392] + ], + "i": [ + [0, 0], + [0, 10.09866666666667], + [-2.109333333333325, 0], + [0, -10.09866666666667], + [2.109333333333325, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, -10.09866666666666], + [2.109333333333325, 0], + [0, 10.09866666666666], + [-2.109333333333325, 0], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-354.5325317382812, -77.50520324707031], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [169.688, 42.392], + [159.272, 12.096], + [165.992, 12.096], + [173.944, 36.232], + [171.592, 36.232], + [179.712, 12.096], + [185.48, 12.096], + [193.544, 36.232], + [191.192, 36.232], + [199.2, 12.096], + [205.92, 12.096], + [195.448, 42.392], + [189.736, 42.392], + [181.56, 17.696], + [183.632, 17.696], + [175.456, 42.392], + [169.688, 42.392], + [169.688, 42.392], + [169.688, 42.392] + ], + "i": [ + [0, 0], + [3.47199999999998, 10.09866666666667], + [-2.240000000000009, 0], + [-2.650666666666666, -8.045333333333332], + [0.7839999999999918, 0], + [-2.706666666666649, 8.045333333333332], + [-1.922666666666657, 0], + [-2.687999999999988, -8.045333333333332], + [0.7839999999999918, 0], + [-2.669333333333327, 8.045333333333332], + [-2.240000000000009, 0], + [3.490666666666641, -10.09866666666667], + [1.903999999999996, 0], + [2.725333333333367, 8.232], + [-0.6906666666666865, 0], + [2.72533333333331, -8.232], + [1.922666666666657, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-3.47199999999998, -10.09866666666666], + [2.240000000000009, 0], + [2.650666666666666, 8.045333333333332], + [-0.7839999999999918, 0], + [2.706666666666649, -8.045333333333332], + [1.922666666666657, 0], + [2.687999999999988, 8.045333333333332], + [-0.7839999999999918, 0], + [2.669333333333327, -8.045333333333332], + [2.240000000000009, 0], + [-3.490666666666641, 10.09866666666666], + [-1.903999999999996, 0], + [-2.725333333333367, -8.232], + [0.6906666666666865, 0], + [-2.72533333333331, 8.232], + [-1.922666666666657, 0], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-354.5325317382812, -77.50520324707031], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "fl", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "r": 1, + "bm": 0 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [155.86146545410156, 56.001014709472656], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [99.99999403953552, 99.99999403953552], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [132.444, 43.06400000000001], + [124.38, 40.992], + [118.948, 35.28], + [116.988, 27.16], + [118.948, 19.040000000000006], + [124.324, 13.49600000000001], + [131.94, 11.424000000000007], + [138.044, 12.600000000000009], + [142.58, 15.848], + [145.38, 20.49600000000001], + [146.388, 26.096], + [146.332, 27.608], + [146.108, 29.064000000000007], + [121.972, 29.064000000000007], + [121.972, 24.024], + [142.524, 24.024], + [139.5, 26.320000000000007], + [139.108, 21.448000000000008], + [136.308, 18.032], + [131.94, 16.744], + [127.46, 18.032], + [124.436, 21.616], + [123.596, 27.216000000000008], + [124.436, 32.592], + [127.684, 36.176], + [132.5, 37.464], + [137.148, 36.232], + [140.228, 33.040000000000006], + [145.38, 35.56], + [142.58, 39.42400000000001], + [138.1, 42.11200000000001], + [132.444, 43.06400000000001], + [132.444, 43.06400000000001], + [132.444, 43.06400000000001] + ], + "i": [ + [0, 0], + [2.351999999999975, 1.381299999999996], + [1.305999999999983, 2.389300000000006], + [0, 2.986699999999999], + [-1.307000000000016, 2.35199999999999], + [-2.240000000000009, 1.343999999999994], + [-2.838000000000022, 0], + [-1.79200000000003, -0.784000000000006], + [-1.232000000000028, -1.381299999999996], + [-0.6350000000000477, -1.754700000000014], + [0, -1.978700000000003], + [0.03699999999997772, -0.5227000000000004], + [0.1120000000000232, -0.4480000000000075], + [8.045333333333303, 0], + [0, 1.680000000000007], + [-6.850666666666655, 0], + [1.007999999999981, -0.7653333333333308], + [0.6340000000000146, 1.4187000000000012], + [1.269000000000005, 0.8213000000000079], + [1.67999999999995, 0], + [1.305999999999983, -0.8586999999999989], + [0.7090000000000032, -1.567999999999998], + [-0.1499999999999773, -2.202700000000007], + [-0.7470000000000141, -1.530699999999996], + [-1.382000000000005, -0.8586999999999989], + [-1.791999999999973, 0], + [-1.269999999999982, 0.8213000000000079], + [-0.7469999999999573, 1.306699999999992], + [-1.717333333333329, -0.8400000000000034], + [1.269000000000005, -1.157300000000006], + [1.754000000000019, -0.6720000000000113], + [2.052999999999997, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-3.024000000000001, 0], + [-2.314999999999998, -1.4187000000000012], + [-1.307000000000016, -2.426699999999997], + [0, -3.061299999999989], + [1.343999999999994, -2.352000000000004], + [2.240000000000009, -1.3813000000000102], + [2.276999999999987, 0], + [1.791999999999973, 0.7839999999999918], + [1.231999999999971, 1.344000000000008], + [0.6719999999999686, 1.7547], + [0, 0.4852999999999952], + [-0.03800000000001091, 0.5227000000000004], + [-8.045333333333303, 0], + [0, -1.680000000000007], + [6.850666666666655, 0], + [-1.007999999999981, 0.7653333333333308], + [0.3730000000000473, -1.829300000000003], + [-0.5979999999999563, -1.456000000000003], + [-1.232000000000028, -0.8586999999999989], + [-1.680000000000007, 0], + [-1.307000000000016, 0.8213000000000079], + [-0.7100000000000364, 1.530699999999996], + [-0.186999999999955, 2.053299999999993], + [0.7839999999999918, 1.53070000000001], + [1.418000000000006, 0.8586999999999989], + [1.829000000000008, 0], + [1.305999999999983, -0.8212999999999937], + [1.717333333333329, 0.8400000000000034], + [-0.5980000000000132, 1.4187000000000012], + [-1.232000000000028, 1.11999999999999], + [-1.718000000000018, 0.6346999999999952], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-211.7300415039062, -77.67320251464844], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [95.32, 37.184], + [100.024, 35.896], + [103.328, 32.36800000000001], + [104.56, 27.216000000000008], + [103.328, 22.12], + [100.024, 18.592], + [95.32, 17.304], + [90.56, 18.592], + [87.256, 22.12], + [86.08000000000001, 27.216000000000008], + [87.256, 32.36800000000001], + [90.50399999999999, 35.896], + [95.32, 37.184], + [95.32, 37.184], + [95.32, 37.184] + ], + "i": [ + [0, 0], + [-1.381, 0.8586999999999989], + [-0.7839999999999918, 1.493299999999991], + [0, 1.903999999999996], + [0.820999999999998, 1.493299999999991], + [1.419000000000011, 0.8586999999999989], + [1.754999999999995, 0], + [1.418999999999983, -0.8586999999999989], + [0.7839999999999918, -1.493300000000005], + [0, -1.904000000000011], + [-0.7839999999999918, -1.5307000000000102], + [-1.381, -0.8586999999999989], + [-1.792000000000002, 0], + [0, 0], + [0, 0] + ], + "o": [ + [1.754999999999995, 0], + [1.419000000000011, -0.8586999999999989], + [0.820999999999998, -1.5307000000000102], + [0, -1.904000000000011], + [-0.7839999999999918, -1.493300000000005], + [-1.381, -0.8586999999999989], + [-1.754999999999995, 0], + [-1.419000000000011, 0.8586999999999989], + [-0.7839999999999918, 1.493299999999991], + [0, 1.903999999999996], + [0.7839999999999918, 1.493299999999991], + [1.419000000000011, 0.8586999999999989], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-211.7300415039062, -77.67320251464844], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [94.70400000000001, 43.06400000000001], + [86.864, 40.992], + [81.43199999999999, 35.28], + [79.47200000000001, 27.216000000000008], + [81.488, 19.152], + [86.91999999999999, 13.49600000000001], + [94.648, 11.424000000000007], + [101.088, 12.88], + [105.512, 16.912000000000006], + [104.56, 18.36800000000001], + [104.56, 0], + [110.832, 0], + [110.832, 42.392], + [104.84, 42.392], + [104.84, 36.232], + [105.568, 37.408], + [101.088, 41.608], + [94.70400000000001, 43.06400000000001], + [94.70400000000001, 43.06400000000001], + [94.70400000000001, 43.06400000000001] + ], + "i": [ + [0, 0], + [2.314999999999998, 1.381299999999996], + [1.344000000000023, 2.389300000000006], + [0, 2.986699999999999], + [-1.343999999999994, 2.389300000000006], + [-2.276999999999987, 1.381299999999996], + [-2.875, 0], + [-1.8669999999999902, -0.9706999999999937], + [-1.082999999999998, -1.717300000000009], + [0.3173333333333233, -0.4853333333333296], + [0, 6.122666666666674], + [-2.090666666666664, 0], + [0, -14.13066666666667], + [1.99733333333333, 0], + [0, 2.053333333333327], + [-0.242666666666679, -0.3919999999999959], + [1.941000000000003, -0.9707000000000079], + [2.314999999999998, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-2.912000000000006, 0], + [-2.277000000000015, -1.4187000000000012], + [-1.306999999999988, -2.389300000000006], + [0, -2.986699999999999], + [1.343999999999994, -2.389300000000006], + [2.277000000000015, -1.3813000000000102], + [2.426999999999992, 0], + [1.867000000000019, 0.9707000000000079], + [-0.3173333333333233, 0.4853333333333296], + [0, -6.122666666666674], + [2.090666666666664, 0], + [0, 14.13066666666667], + [-1.99733333333333, 0], + [0, -2.053333333333327], + [0.242666666666679, 0.3919999999999959], + [-1.045000000000016, 1.829300000000003], + [-1.941000000000003, 0.9706999999999937], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-211.7300415039062, -77.67320251464844], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [57.40100000000001, 43.06400000000001], + [51.968999999999994, 42], + [48.38499999999999, 38.976], + [47.09700000000001, 34.384], + [48.161, 29.960000000000008], + [51.577, 26.656000000000006], + [57.345, 24.752], + [67.87299999999999, 23.016000000000005], + [67.87299999999999, 28], + [58.577, 29.624], + [54.881, 31.248], + [53.70500000000001, 34.16], + [54.992999999999995, 37.016000000000005], + [58.40899999999999, 38.08], + [62.88900000000001, 36.96000000000001], + [65.91300000000001, 33.992], + [66.977, 29.792], + [66.977, 22.008], + [65.297, 18.36800000000001], + [60.929, 16.912000000000006], + [56.505, 18.256], + [53.761, 21.616], + [48.496999999999986, 18.98400000000001], + [51.240999999999985, 15.064000000000007], + [55.66499999999999, 12.376], + [61.09700000000001, 11.424000000000007], + [67.42500000000001, 12.768], + [71.737, 16.52], + [73.305, 22.008], + [73.305, 42.392], + [67.257, 42.392], + [67.257, 36.904], + [68.54499999999999, 37.072], + [65.80099999999999, 40.264], + [62.04900000000001, 42.336], + [57.40100000000001, 43.06400000000001], + [57.40100000000001, 43.06400000000001], + [57.40100000000001, 43.06400000000001] + ], + "i": [ + [0, 0], + [1.568000000000012, 0.7092999999999989], + [0.8590000000000089, 1.2693000000000012], + [0, 1.7547], + [-0.7090000000000032, 1.306699999999992], + [-1.531000000000006, 0.8960000000000008], + [-2.314999999999998, 0.3733000000000004], + [-3.509333333333331, 0.5786666666666633], + [0, -1.661333333333332], + [3.098666666666674, -0.541333333333327], + [0.7839999999999918, -0.784000000000006], + [0, -1.194699999999997], + [-0.8590000000000089, -0.7467000000000041], + [-1.381, 0], + [-1.269000000000005, 0.7466999999999899], + [-0.7090000000000032, 1.2319999999999993], + [0, 1.530699999999996], + [0, 2.594666666666669], + [1.120000000000005, 0.9332999999999885], + [1.829000000000008, 0], + [1.269000000000005, -0.8960000000000008], + [0.5970000000000084, -1.381299999999996], + [1.754666666666679, 0.8773333333333255], + [-1.268999999999977, 1.11999999999999], + [-1.680000000000007, 0.6346999999999952], + [-1.903999999999996, 0], + [-1.829000000000008, -0.8960000000000008], + [-1.0080000000000098, -1.6053], + [0, -2.090699999999998], + [0, -6.794666666666672], + [2.015999999999991, 0], + [0, 1.829333333333338], + [-0.429333333333318, -0.05599999999999739], + [1.120000000000005, -0.8959999999999866], + [1.418999999999983, -0.4852999999999952], + [1.716999999999985, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-2.053000000000026, 0], + [-1.531000000000006, -0.7467000000000041], + [-0.8589999999999804, -1.306699999999992], + [0, -1.642700000000005], + [0.7469999999999857, -1.306700000000006], + [1.531000000000006, -0.8960000000000008], + [3.509333333333331, -0.5786666666666633], + [0, 1.661333333333332], + [-3.098666666666674, 0.541333333333327], + [-1.680000000000007, 0.2987000000000108], + [-0.7839999999999918, 0.7467000000000041], + [0, 1.157300000000006], + [0.896000000000015, 0.7092999999999989], + [1.717000000000013, 0], + [1.306999999999988, -0.7467000000000041], + [0.7089999999999748, -1.2693000000000012], + [0, -2.594666666666669], + [0, -1.493299999999991], + [-1.082999999999998, -0.9707000000000079], + [-1.680000000000007, 0], + [-1.2319999999999993, 0.8586999999999989], + [-1.754666666666679, -0.8773333333333255], + [0.5600000000000023, -1.493300000000005], + [1.269000000000005, -1.157300000000006], + [1.717000000000013, -0.6347000000000094], + [2.388999999999982, 0], + [1.86699999999999, 0.8960000000000008], + [1.045000000000016, 1.568000000000012], + [0, 6.794666666666672], + [-2.015999999999991, 0], + [0, -1.829333333333338], + [0.429333333333318, 0.05599999999999739], + [-0.7089999999999748, 1.2319999999999993], + [-1.082999999999998, 0.8960000000000008], + [-1.381, 0.4853000000000094], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-211.7300415039062, -77.67320251464844], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [0, 42.392], + [0, 0.6720000000000041], + [6.159999999999997, 0.6720000000000041], + [21.84, 22.400000000000006], + [18.75999999999999, 22.400000000000006], + [34.16, 0.6720000000000041], + [40.31999999999999, 0.6720000000000041], + [40.31999999999999, 42.392], + [33.768, 42.392], + [33.768, 8.456000000000003], + [36.232, 9.128], + [20.49600000000001, 30.632000000000005], + [19.824000000000012, 30.632000000000005], + [4.424000000000007, 9.128], + [6.608000000000004, 8.456000000000003], + [6.608000000000004, 42.392], + [0, 42.392], + [0, 42.392], + [0, 42.392] + ], + "i": [ + [0, 0], + [0, 13.90666666666666], + [-2.053333333333342, 0], + [-5.226666666666659, -7.242666666666665], + [1.026666666666671, 0], + [-5.133333333333326, 7.242666666666672], + [-2.053333333333342, 0], + [0, -13.90666666666667], + [2.183999999999997, 0], + [0, 11.312], + [-0.8213333333333424, -0.2240000000000038], + [5.245333333333321, -7.168000000000006], + [0.2239999999999895, 0], + [5.133333333333326, 7.168000000000006], + [-0.7280000000000086, 0.2240000000000038], + [0, -11.312], + [2.202666666666659, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, -13.90666666666667], + [2.053333333333342, 0], + [5.226666666666659, 7.242666666666672], + [-1.026666666666671, 0], + [5.133333333333326, -7.242666666666665], + [2.053333333333342, 0], + [0, 13.90666666666666], + [-2.183999999999997, 0], + [0, -11.312], + [0.8213333333333424, 0.2240000000000038], + [-5.245333333333321, 7.168000000000006], + [-0.2239999999999895, 0], + [-5.133333333333326, -7.168000000000006], + [0.7280000000000086, -0.2240000000000038], + [0, 11.312], + [-2.202666666666659, 0], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-211.7300415039062, -77.67320251464844], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "fl", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "r": 1, + "bm": 0 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [2.91259765625, 56.001014709472656], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [99.99999403953552, 99.99999403953552], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "rc", + "d": 1, + "s": { "a": 0, "k": [702.6863719370097, 144], "ix": 2 }, + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "r": { "a": 0, "k": 72, "ix": 2 } + }, + { + "ty": "fl", + "c": { "a": 0, "k": [0, 0, 0], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "r": 1, + "bm": 0 + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [56.54167175292969, -0.000022762338630855083], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [99.99999403953552, 99.99999403953552], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 80, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "tr", + "p": { "a": 0, "k": [122.0000003294881, 25.00000012138912], "ix": 2 }, + "a": { "a": 0, "k": [56.54167175292969, -0.00002288818359375], "ix": 2 }, + "s": { "a": 0, "k": [34.403572049765366, 34.403572049765366], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + } + ], + "ip": 0, + "op": 251, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "line_06", + "sr": 1, + "ks": { + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "nm": "line_06", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": false, + "v": [ + [12, 12], + [49.732, 12] + ], + "i": [ + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { + "a": 1, + "k": [ + { + "t": 163, + "s": [0], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 183, + "s": [100], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "w": { "a": 0, "k": 24, "ix": 2 }, + "lc": 2, + "lj": 1, + "ml": 4 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [159.64700317382812, 386.0762634277344], "ix": 2 }, + "a": { "a": 0, "k": [30.866, 12], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + } + ], + "ip": 0, + "op": 251, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "line_05", + "sr": 1, + "ks": { + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "nm": "line_05", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": false, + "v": [ + [12, 12], + [133.386, 12] + ], + "i": [ + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { + "a": 1, + "k": [ + { + "t": 140, + "s": [0], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 160, + "s": [100], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "w": { "a": 0, "k": 24, "ix": 2 }, + "lc": 2, + "lj": 1, + "ml": 4 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [201.4739990234375, 305.88525390625], "ix": 2 }, + "a": { "a": 0, "k": [72.693, 12], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + } + ], + "ip": 0, + "op": 251, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 4, + "nm": "line_04", + "sr": 1, + "ks": { + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "nm": "line_04", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": false, + "v": [ + [12, 12], + [217.895, 12] + ], + "i": [ + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { + "a": 1, + "k": [ + { + "t": 120, + "s": [0], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 143, + "s": [100], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "w": { "a": 0, "k": 24, "ix": 2 }, + "lc": 2, + "lj": 1, + "ml": 4 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [243.728515625, 225.6952362060547], "ix": 2 }, + "a": { "a": 0, "k": [114.9475, 12], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + } + ], + "ip": 0, + "op": 251, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 4, + "ty": 4, + "nm": "line_03", + "sr": 1, + "ks": { + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "nm": "line_03", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [-36.047, -71.491], + [-57.706, -49.832], + [-57.303, 12.081], + [-0.871, 71.477], + [0.204, 71.491], + [57.706, 13.985], + [57.304, -49.832], + [35.649, -71.491], + [-36.047, -71.491] + ], + "i": [ + [0, 0], + [0, -11.962], + [0, 0], + [-31.661, -0.575], + [-0.356, 0], + [0, 31.76], + [0, 0], + [11.962, 0], + [0, 0] + ], + "o": [ + [-11.962, 0], + [0, 0], + [0, 31.661], + [0.357, 0.01], + [31.761, 0], + [0, 0], + [0, -11.962], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { + "a": 1, + "k": [ + { + "t": 0, + "s": [0], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 43, + "s": [100], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "e": { "a": 0, "k": 0, "ix": 2 }, + "o": { + "a": 1, + "k": [ + { + "t": 0, + "s": [0], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 43, + "s": [121.00000000000001], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "m": 1 + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "w": { "a": 0, "k": 24, "ix": 2 }, + "lc": 2, + "lj": 1, + "ml": 4 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [243, 93.62023162841797], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + } + ], + "ip": 0, + "op": 251, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 5, + "ty": 4, + "nm": "line_02", + "sr": 1, + "ks": { + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "nm": "line_02", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": false, + "v": [ + [186.878, 129.076], + [100.884, 215.071], + [-100.885, 215.071], + [-186.878, 129.076], + [-186.878, -129.076], + [-100.885, -215.07], + [-61.924, -215.07] + ], + "i": [ + [0, 0], + [47.496, 0], + [0, 0], + [0, 47.491], + [0, 0], + [-47.491, 0], + [0, 0] + ], + "o": [ + [0, 47.491], + [0, 0], + [-47.491, 0], + [0, 0], + [0, -47.491], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 100, "ix": 2 }, + "e": { + "a": 1, + "k": [ + { + "t": 33, + "s": [100], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 70, + "s": [0], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "o": { + "a": 1, + "k": [ + { + "t": 33, + "s": [16], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 70, + "s": [126], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "m": 1 + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "w": { "a": 0, "k": 24, "ix": 2 }, + "lc": 2, + "lj": 1, + "ml": 4 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [245.79200744628906, 289.354736328125], "ix": 2 }, + "a": { "a": 0, "k": [0, 0.0004999999999881766], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + } + ], + "ip": 0, + "op": 251, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 6, + "ty": 4, + "nm": "line_01", + "sr": 1, + "ks": { + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "nm": "line_01", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": false, + "v": [ + [-63.344, -131.277], + [-22.649, -131.277], + [63.344, -45.283], + [63.344, 131.277] + ], + "i": [ + [0, 0], + [0, 0], + [0, -47.491], + [0, 0] + ], + "o": [ + [0, 0], + [47.492, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { + "a": 1, + "k": [ + { + "t": 50, + "s": [0], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 127, + "s": [100], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "o": { + "a": 1, + "k": [ + { + "t": 50, + "s": [0], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 87, + "s": [316], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "m": 1 + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "w": { "a": 0, "k": 24, "ix": 2 }, + "lc": 2, + "lj": 1, + "ml": 4 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [369.3269958496094, 205.56024169921875], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + } + ], + "ip": 0, + "op": 251, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 7, + "ty": 4, + "nm": "correct", + "sr": 1, + "ks": { + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "gr", + "nm": "Group 1", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": false, + "v": [ + [-56.365, -4.925], + [-9.754, 41.501], + [56.365, -41.501] + ], + "i": [ + [0, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { + "a": 1, + "k": [ + { + "t": 140, + "s": [0], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 167, + "s": [100], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "w": { "a": 0, "k": 24, "ix": 2 }, + "lc": 2, + "lj": 2, + "ml": 4 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [116.365, 101.5], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + { + "t": 140, + "s": [288.5679931640625, 424.9672546386719], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] }, + "ti": [0, 2.167], + "to": [0, 2.667] + }, + { + "t": 167, + "s": [288.5679931640625, 440.9672546386719], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] }, + "ti": [0, 1.333], + "to": [0, -2.167] + }, + { + "t": 180, + "s": [288.5679931640625, 411.9672546386719], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] }, + "ti": [0, -1.667], + "to": [0, -1.333] + }, + { + "t": 200, + "s": [288.5679931640625, 432.9672546386719], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] }, + "ti": [0, 0.833], + "to": [0, 1.667] + }, + { + "t": 213, + "s": [288.5679931640625, 421.96725463867193], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] }, + "ti": [0, -0.5], + "to": [0, -0.833] + }, + { + "t": 227, + "s": [288.5679931640625, 427.9672546386719], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] }, + "ti": [0, 0.5], + "to": [0, 0.5] + }, + { + "t": 237, + "s": [288.5679931640625, 424.9672546386719], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "a": { "a": 0, "k": [105.365, 142.5], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + } + ], + "ip": 0, + "op": 251, + "st": 0, + "bm": 0 + } + ], + "markers": [] +} diff --git a/frontend/public/lotties/search.json b/frontend/public/lotties/search.json new file mode 100644 index 000000000..a650cc5a0 --- /dev/null +++ b/frontend/public/lotties/search.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":60,"w":500,"h":500,"nm":"system-regular-42-search","ddd":0,"assets":[{"id":"comp_1","nm":"hover-search","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":".primary.design","cl":"primary design","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[150.995,147.901,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-41.707,-41.707],[41.707,41.707]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-42-search').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":41.73,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"fl","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-42-search').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.218],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[0]},{"i":{"x":[0.35],"y":[1]},"o":{"x":[0.522],"y":[0]},"t":29,"s":[29]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":48,"s":[-4]},{"t":59,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.218,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[223.962,223.958,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.35,"y":1},"o":{"x":0.522,"y":0},"t":29,"s":[310.962,188.958,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":48,"s":[203.962,233.958,0],"to":[0,0,0],"ti":[0,0,0]},{"t":59,"s":[223.962,223.958,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.218,0.218,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":1,"s":[100,100,100]},{"i":{"x":[0.35,0.35,0.667],"y":[1,1,1]},"o":{"x":[0.522,0.522,0.333],"y":[0,0,0]},"t":29,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":48,"s":[100,100,100]},{"t":59,"s":[100,100,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-80.542],[80.542,0],[0,80.542],[-80.542,0]],"o":[[0,80.542],[-80.542,0],[0,-80.542],[80.542,0]],"v":[[145.834,0],[0,145.834],[-145.834,0],[0,-145.834]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-42-search').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[2083,2083,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,3.446],[-3.446,0],[0,-3.446],[3.446,0]],"o":[[0,-3.446],[3.446,0],[0,3.446],[-3.446,0]],"v":[[-7.5,-1.25],[-1.25,-7.5],[5,-1.25],[-1.25,5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.391,0.391],[0,0],[0,1.739],[4.273,0],[0,-4.273],[-4.273,0],[-1.322,1.049],[0,0],[-0.256,0],[-0.195,0.195]],"o":[[0,0],[0.971,-1.294],[0,-4.273],[-4.273,0],[0,4.273],[1.815,0],[0,0],[0.195,0.195],[0.256,0],[0.391,-0.391]],"v":[[8.707,7.144],[4.947,3.385],[6.5,-1.25],[-1.25,-9],[-9,-1.25],[-1.25,6.5],[3.554,4.82],[7.293,8.558],[8,8.851],[8.707,8.558]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-42-search').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":60,"op":384,"st":60,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[2083,2083,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,3.446],[-3.446,0],[0,-3.446],[3.446,0]],"o":[[0,-3.446],[3.446,0],[0,3.446],[-3.446,0]],"v":[[-7.5,-1.25],[-1.25,-7.5],[5,-1.25],[-1.25,5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.391,0.391],[0,0],[0,1.739],[4.273,0],[0,-4.273],[-4.273,0],[-1.322,1.049],[0,0],[-0.256,0],[-0.195,0.195]],"o":[[0,0],[0.971,-1.294],[0,-4.273],[-4.273,0],[0,4.273],[1.815,0],[0,0],[0.195,0.195],[0.256,0],[0.391,-0.391]],"v":[[8.707,7.144],[4.947,3.385],[6.5,-1.25],[-1.25,-9],[-9,-1.25],[-1.25,6.5],[3.554,4.82],[7.293,8.558],[8,8.851],[8.707,8.558]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-42-search').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":1,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[0.91,0.91,0.914],"ix":1}}]}],"ip":0,"op":131,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"hover-search","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":500,"h":500,"ip":0,"op":70,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-search","dr":60}],"props":{}} \ No newline at end of file diff --git a/frontend/src/components/auth/CodeInputStep.tsx b/frontend/src/components/auth/CodeInputStep.tsx index 09958fafd..f992c8da6 100644 --- a/frontend/src/components/auth/CodeInputStep.tsx +++ b/frontend/src/components/auth/CodeInputStep.tsx @@ -78,11 +78,14 @@ export default function CodeInputStep({ const resendVerificationEmail = async () => { setIsResendingVerificationEmail(true); setIsLoading(true); - await mutateAsync({ email }); - setTimeout(() => { - setIsLoading(false); - setIsResendingVerificationEmail(false); - }, 2000); + try { + await mutateAsync({ email }); + } finally { + setTimeout(() => { + setIsLoading(false); + setIsResendingVerificationEmail(false); + }, 1000); + } }; return ( diff --git a/frontend/src/components/projects/NewProjectModal.tsx b/frontend/src/components/projects/NewProjectModal.tsx index dcd3040d8..c98118a96 100644 --- a/frontend/src/components/projects/NewProjectModal.tsx +++ b/frontend/src/components/projects/NewProjectModal.tsx @@ -72,7 +72,7 @@ const NewProjectForm = ({ onOpenChange, projectType }: NewProjectFormProps) => { OrgPermissionSubjects.ProjectTemplates ); - const { data: projectTemplates = [] } = useListProjectTemplates({ + const { data: projectTemplates = [] } = useListProjectTemplates(projectType, { enabled: Boolean(canReadProjectTemplates && subscription?.projectTemplates) }); diff --git a/frontend/src/components/projects/ProjectSettings/ProjectSettings.tsx b/frontend/src/components/projects/ProjectSettings/ProjectSettings.tsx new file mode 100644 index 000000000..3919ad86c --- /dev/null +++ b/frontend/src/components/projects/ProjectSettings/ProjectSettings.tsx @@ -0,0 +1,30 @@ +import { useState } from "react"; + +import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; + +import { ProjectTemplatesTab } from "./components"; + +const tabs = [ + { name: "Project Templates", key: "project-templates", component: ProjectTemplatesTab } +]; + +export const ProjectSettings = () => { + const [selectedTab, setSelectedTab] = useState(tabs[0].key); + + return ( + + + {tabs.map((tab) => ( + + {tab.name} + + ))} + + {tabs.map(({ key, component: Component }) => ( + + + + ))} + + ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/ProjectTemplatesTab.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/ProjectTemplatesTab.tsx similarity index 100% rename from frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/ProjectTemplatesTab.tsx rename to frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/ProjectTemplatesTab.tsx diff --git a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/DeleteProjectTemplateModal.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/DeleteProjectTemplateModal.tsx similarity index 95% rename from frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/DeleteProjectTemplateModal.tsx rename to frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/DeleteProjectTemplateModal.tsx index 09537ce57..8ccdb0191 100644 --- a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/DeleteProjectTemplateModal.tsx +++ b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/DeleteProjectTemplateModal.tsx @@ -41,7 +41,7 @@ export const DeleteProjectTemplateModal = ({ isOpen, onOpenChange, template }: P diff --git a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/EditProjectTemplateSection.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/EditProjectTemplateSection.tsx similarity index 100% rename from frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/EditProjectTemplateSection.tsx rename to frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/EditProjectTemplateSection.tsx diff --git a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/EditProjectTemplate.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/EditProjectTemplate.tsx similarity index 90% rename from frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/EditProjectTemplate.tsx rename to frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/EditProjectTemplate.tsx index 98a675940..666edf87b 100644 --- a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/EditProjectTemplate.tsx +++ b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/EditProjectTemplate.tsx @@ -7,6 +7,7 @@ import { Button, DeleteActionModal } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { usePopUp } from "@app/hooks"; import { TProjectTemplate, useDeleteProjectTemplate } from "@app/hooks/api/projectTemplates"; +import { ProjectType } from "@app/hooks/api/workspace/types"; import { ProjectTemplateDetailsModal } from "../../ProjectTemplateDetailsModal"; import { ProjectTemplateEnvironmentsForm } from "./ProjectTemplateEnvironmentsForm"; @@ -24,7 +25,7 @@ export const EditProjectTemplate = ({ isInfisicalTemplate, projectTemplate, onBa "editDetails" ] as const); - const { id: templateId, name, description } = projectTemplate; + const { id: templateId, name, description, type } = projectTemplate; const deleteProjectTemplate = useDeleteProjectTemplate(); @@ -94,10 +95,12 @@ export const EditProjectTemplate = ({ isInfisicalTemplate, projectTemplate, onBa
)}
- + {type === ProjectType.SecretManager && ( + + )} handlePopUpToggle("removeTemplate", isOpen)} onDeleteApproved={handleRemoveTemplate} diff --git a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx similarity index 82% rename from frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx rename to frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx index b1a639e89..a4bc3a73a 100644 --- a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx +++ b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx @@ -1,19 +1,18 @@ import { Controller, FormProvider, useForm } from "react-hook-form"; -import { faChevronLeft, faPlus, faSave } from "@fortawesome/free-solid-svg-icons"; +import { faChevronLeft, faSave } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { twMerge } from "tailwind-merge"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, Input, Modal, ModalContent, ModalTrigger } from "@app/components/v2"; +import { Button, FormControl, Input } from "@app/components/v2"; import { ProjectPermissionSub } from "@app/context"; import { isCustomProjectRole } from "@app/helpers/roles"; -import { usePopUp } from "@app/hooks"; import { TProjectTemplate, useUpdateProjectTemplate } from "@app/hooks/api/projectTemplates"; import { slugSchema } from "@app/lib/schemas"; +import { AddPoliciesButton } from "@app/pages/project/RoleDetailsBySlugPage/components/AddPoliciesButton"; import { GeneralPermissionPolicies } from "@app/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies"; -import { NewPermissionRule } from "@app/pages/project/RoleDetailsBySlugPage/components/NewPermissionRule"; import { PermissionEmptyState } from "@app/pages/project/RoleDetailsBySlugPage/components/PermissionEmptyState"; import { formRolePermission2API, @@ -44,8 +43,6 @@ export const ProjectTemplateEditRoleForm = ({ role, isDisabled }: Props) => { - const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const); - const formMethods = useForm({ values: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : undefined, resolver: zodResolver(formSchema) @@ -119,34 +116,17 @@ export const ProjectTemplateEditRoleForm = ({ - handlePopUpToggle("createPolicy", isOpen)} - > - - - - - handlePopUpToggle("createPolicy")} /> - - +
)} diff --git a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx similarity index 93% rename from frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx rename to frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx index b72d12c73..3d54bbb20 100644 --- a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx +++ b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx @@ -19,7 +19,7 @@ import { THead, Tr } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context"; import { TProjectTemplate, useUpdateProjectTemplate } from "@app/hooks/api/projectTemplates"; import { slugSchema } from "@app/lib/schemas"; @@ -32,9 +32,10 @@ const formSchema = z.object({ environments: z .object({ name: z.string().trim().min(1), - slug: slugSchema({ min: 1, max: 32 }) + slug: slugSchema({ min: 1, max: 64 }) }) .array() + .nullish() }); type TFormSchema = z.infer; @@ -55,6 +56,8 @@ export const ProjectTemplateEnvironmentsForm = ({ resolver: zodResolver(formSchema) }); + const { subscription } = useSubscription(); + const { fields: environments, move, @@ -67,7 +70,7 @@ export const ProjectTemplateEnvironmentsForm = ({ const onFormSubmit = async (form: TFormSchema) => { try { const { environments: updatedEnvs } = await updateProjectTemplate.mutateAsync({ - environments: form.environments.map((env, index) => ({ + environments: form.environments?.map((env, index) => ({ ...env, position: index + 1 })), @@ -89,6 +92,9 @@ export const ProjectTemplateEnvironmentsForm = ({ } }; + const isEnvironmentLimitExceeded = + Boolean(subscription.environmentLimit) && environments.length >= subscription.environmentLimit; + return (
{(isAllowed) => ( diff --git a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateRolesSection.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateRolesSection.tsx similarity index 100% rename from frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateRolesSection.tsx rename to frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateRolesSection.tsx diff --git a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/index.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/index.tsx similarity index 100% rename from frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/index.tsx rename to frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/index.tsx diff --git a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/index.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/index.tsx similarity index 100% rename from frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/index.tsx rename to frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/index.tsx diff --git a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx similarity index 90% rename from frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx rename to frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx index e601e0319..216253653 100644 --- a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx +++ b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx @@ -12,6 +12,7 @@ import { ModalContent, TextArea } from "@app/components/v2"; +import { useGetProjectTypeFromRoute } from "@app/hooks"; import { TProjectTemplate, useCreateProjectTemplate, @@ -20,7 +21,7 @@ import { import { slugSchema } from "@app/lib/schemas"; const formSchema = z.object({ - name: slugSchema({ min: 1, max: 32, field: "Name" }), + name: slugSchema({ min: 1, max: 64, field: "Name" }), description: z.string().max(500).optional() }); @@ -41,6 +42,7 @@ type FormProps = { const ProjectTemplateForm = ({ onComplete, projectTemplate }: FormProps) => { const createProjectTemplate = useCreateProjectTemplate(); const updateProjectTemplate = useUpdateProjectTemplate(); + const projectType = useGetProjectTypeFromRoute(); const { handleSubmit, @@ -55,9 +57,17 @@ const ProjectTemplateForm = ({ onComplete, projectTemplate }: FormProps) => { }); const onFormSubmit = async (data: FormData) => { + if (!projectType) { + createNotification({ + text: "Failed to determine project type", + type: "error" + }); + return; + } + const mutation = projectTemplate ? updateProjectTemplate.mutateAsync({ templateId: projectTemplate.id, ...data }) - : createProjectTemplate.mutateAsync(data); + : createProjectTemplate.mutateAsync({ ...data, type: projectType }); try { const template = await mutation; createNotification({ diff --git a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplatesSection.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/ProjectTemplatesSection.tsx similarity index 98% rename from frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplatesSection.tsx rename to frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/ProjectTemplatesSection.tsx index d4a076930..ec3f0aaff 100644 --- a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplatesSection.tsx +++ b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/ProjectTemplatesSection.tsx @@ -50,7 +50,7 @@ export const ProjectTemplatesSection = () => { className="absolute min-h-[10rem] w-full" >
-

+

Create and configure templates with predefined roles and environments to streamline project setup

diff --git a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplatesTable.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/ProjectTemplatesTable.tsx similarity index 73% rename from frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplatesTable.tsx rename to frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/ProjectTemplatesTable.tsx index b8dc00ab2..0465973e2 100644 --- a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplatesTable.tsx +++ b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/ProjectTemplatesTable.tsx @@ -23,8 +23,9 @@ import { Tr } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context"; -import { usePopUp } from "@app/hooks"; +import { useGetProjectTypeFromRoute, usePopUp } from "@app/hooks"; import { TProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates"; +import { ProjectType } from "@app/hooks/api/workspace/types"; import { DeleteProjectTemplateModal } from "./DeleteProjectTemplateModal"; @@ -35,9 +36,12 @@ type Props = { export const ProjectTemplatesTable = ({ onEdit }: Props) => { const { subscription } = useSubscription(); - const { isPending, data: projectTemplates = [] } = useListProjectTemplates({ - enabled: subscription?.projectTemplates + const projectType = useGetProjectTypeFromRoute(); + + const { isPending, data: projectTemplates = [] } = useListProjectTemplates(projectType, { + enabled: subscription?.projectTemplates && Boolean(projectType) }); + const [search, setSearch] = useState(""); const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["deleteTemplate"] as const); @@ -50,6 +54,10 @@ export const ProjectTemplatesTable = ({ onEdit }: Props) => { [search, projectTemplates] ); + const isSecretManagerTemplates = projectType === ProjectType.SecretManager; + + const colSpan = isSecretManagerTemplates ? 4 : 3; + return (
{ Name Roles - Environments + {isSecretManagerTemplates && Environments} - {isPending && ( + {subscription?.projectTemplates && isPending && ( )} {filteredTemplates.map((template) => { - const { id, name, roles, environments, description } = template; + const { id, name, roles, environments = [], description } = template; return ( onEdit(template)} @@ -116,28 +124,30 @@ export const ProjectTemplatesTable = ({ onEdit }: Props) => { )} - - {environments.length} - {environments.length > 0 && ( - - {environments - .sort((a, b) => (a.position > b.position ? 1 : -1)) - .map((env) => ( -
  • {env.name}
  • - ))} - - } - > - -
    - )} - + {isSecretManagerTemplates && environments && ( + + {environments.length} + {environments.length > 0 && ( + + {environments + .sort((a, b) => (a.position > b.position ? 1 : -1)) + .map((env) => ( +
  • {env.name}
  • + ))} + + } + > + +
    + )} + + )} {name !== "default" && ( { ); })} - {!isPending && filteredTemplates?.length === 0 && ( + {(!subscription?.projectTemplates || + (!isPending && filteredTemplates?.length === 0)) && ( - + diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewLdapPasswordRotationGeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewLdapPasswordRotationGeneratedCredentials.tsx index 238dabea3..9e1476118 100644 --- a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewLdapPasswordRotationGeneratedCredentials.tsx +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewLdapPasswordRotationGeneratedCredentials.tsx @@ -18,9 +18,7 @@ export const ViewLdapPasswordRotationGeneratedCredentials = ({ - - {activeCredentials?.dn} - + {activeCredentials?.dn} {activeCredentials?.password} @@ -28,9 +26,7 @@ export const ViewLdapPasswordRotationGeneratedCredentials = ({ } inactiveCredentials={ <> - - {inactiveCredentials?.dn} - + {inactiveCredentials?.dn} {inactiveCredentials?.password} diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx index a8a00e17f..969c4a049 100644 --- a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx @@ -62,6 +62,7 @@ const Content = ({ secretRotation }: ContentProps) => { let Component: ReactNode; switch (generatedCredentialsResponse.type) { case SecretRotation.PostgresCredentials: + case SecretRotation.MySqlCredentials: case SecretRotation.MsSqlCredentials: Component = ( ({ - resolver: zodResolver(SecretRotationV2FormSchema), + resolver: zodResolver(SecretRotationV2FormSchema(Boolean(secretRotation))), defaultValues: secretRotation ? { ...secretRotation, diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/LdapPasswordRotationParametersFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/LdapPasswordRotationParametersFields.tsx index 9c9d8329f..243c18369 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/LdapPasswordRotationParametersFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/LdapPasswordRotationParametersFields.tsx @@ -2,40 +2,135 @@ import { Controller, useFormContext } from "react-hook-form"; import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; import { DEFAULT_PASSWORD_REQUIREMENTS } from "@app/components/secret-rotations-v2/forms/schemas/shared"; -import { FormControl, Input } from "@app/components/v2"; +import { FormControl, Input, Select, SelectItem } from "@app/components/v2"; import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; +import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation"; export const LdapPasswordRotationParametersFields = () => { - const { control } = useFormContext< + const { control, watch, setValue } = useFormContext< TSecretRotationV2Form & { type: SecretRotation.LdapPassword; } >(); + const [id, rotationMethod] = watch(["id", "parameters.rotationMethod"]); + const isUpdate = Boolean(id); + return ( <> ( + Determines how the rotation will be performed: +
      +
    • + Connection Principal - The Connection + principal will rotate the target principal's password. +
    • +
    • + Target Principal - The target principal + will rotate their own password. +
    • +
    + + } + tooltipClassName="max-w-sm" errorText={error?.message} - label="Distinguished Name (DN)" + isError={Boolean(error?.message)} + label="Rotation Method" + helperText={ + // eslint-disable-next-line no-nested-ternary + isUpdate + ? "Cannot be updated." + : value === LdapPasswordRotationMethod.ConnectionPrincipal + ? "The connection principal will rotate the target principal's password" + : "The target principal will rotate their own password" + } > - + onValueChange={(val) => { + setValue( + "temporaryParameters", + val === LdapPasswordRotationMethod.TargetPrincipal + ? { + password: "" + } + : undefined + ); + onChange(val); + }} + className="w-full border border-mineshaft-500 capitalize" + position="popper" + dropdownContainerClassName="max-w-none" + > + {Object.values(LdapPasswordRotationMethod).map((method) => { + return ( + + {method.replace("-", " ")} + + ); + })} +
    )} /> +
    + ( + + + + )} + /> + {rotationMethod === LdapPasswordRotationMethod.TargetPrincipal && !isUpdate && ( + ( + + + + )} + /> + )} +
    Password Requirements
    -
    +
    { label="Password Length" isError={Boolean(error)} errorText={error?.message} - helperText="The length of the password to generate" + tooltipText="The length of the password to generate" > { label="Digit Count" isError={Boolean(error)} errorText={error?.message} - helperText="Minimum number of digits" + tooltipText="Minimum number of digits" > { label="Lowercase Character Count" isError={Boolean(error)} errorText={error?.message} - helperText="Minimum number of lowercase characters" + tooltipText="Minimum number of lowercase characters" > { label="Uppercase Character Count" isError={Boolean(error)} errorText={error?.message} - helperText="Minimum number of uppercase characters" + tooltipText="Minimum number of uppercase characters" > { label="Symbol Count" isError={Boolean(error)} errorText={error?.message} - helperText="Minimum number of symbols" + tooltipText="Minimum number of symbols" > { label="Allowed Symbols" isError={Boolean(error)} errorText={error?.message} - helperText="Symbols to use in generated password" + tooltipText="Symbols to use in generated password" > = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationParametersFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationParametersFields, + [SecretRotation.MySqlCredentials]: SqlCredentialsRotationParametersFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationParametersFields, [SecretRotation.AzureClientSecret]: AzureClientSecretRotationParametersFields, [SecretRotation.LdapPassword]: LdapPasswordRotationParametersFields, diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/LdapPasswordRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/LdapPasswordRotationReviewFields.tsx index 1ffcad139..586bc87b1 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/LdapPasswordRotationReviewFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/LdapPasswordRotationReviewFields.tsx @@ -15,13 +15,35 @@ export const LdapPasswordRotationReviewFields = () => { const [parameters, { dn, password }] = watch(["parameters", "secretsMapping"]); + const { passwordRequirements } = parameters; + return ( <> - {parameters.dn} + {parameters.dn} + {passwordRequirements && ( + + {passwordRequirements.length} + + {passwordRequirements.required.digits} + + + {passwordRequirements.required.lowercase} + + + {passwordRequirements.required.uppercase} + + + {passwordRequirements.required.symbols} + + + {passwordRequirements.allowedSymbols} + + + )} - {dn} + {dn} {password} diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx index 2bfdc16fd..98367eed3 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx @@ -15,6 +15,7 @@ import { SqlCredentialsRotationReviewFields } from "./shared"; const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationReviewFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationReviewFields, + [SecretRotation.MySqlCredentials]: SqlCredentialsRotationReviewFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationReviewFields, [SecretRotation.AzureClientSecret]: AzureClientSecretRotationReviewFields, [SecretRotation.LdapPassword]: LdapPasswordRotationReviewFields, diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/shared/SecretRotationReviewSection.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/shared/SecretRotationReviewSection.tsx index 7e2da4a07..fdff4af5c 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/shared/SecretRotationReviewSection.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/shared/SecretRotationReviewSection.tsx @@ -1,7 +1,7 @@ import { ReactNode } from "react"; type Props = { - label: "Parameters" | "Secrets Mapping"; + label: "Parameters" | "Secrets Mapping" | "Password Requirements"; children: ReactNode; }; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/LdapPasswordRotationSecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/LdapPasswordRotationSecretsMappingFields.tsx index 01d2e0d74..0c5c90662 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/LdapPasswordRotationSecretsMappingFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/LdapPasswordRotationSecretsMappingFields.tsx @@ -17,7 +17,7 @@ export const LdapPasswordRotationSecretsMappingFields = () => { const items = [ { - name: "DN", + name: "DN/UPN", input: ( ( diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx index 9da51272b..f77dc99e5 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx @@ -12,6 +12,7 @@ import { SqlCredentialsRotationSecretsMappingFields } from "./shared"; const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationSecretsMappingFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationSecretsMappingFields, + [SecretRotation.MySqlCredentials]: SqlCredentialsRotationSecretsMappingFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationSecretsMappingFields, [SecretRotation.AzureClientSecret]: AzureClientSecretRotationSecretsMappingFields, [SecretRotation.LdapPassword]: LdapPasswordRotationSecretsMappingFields, diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts index b0484ae67..6d6fc64e2 100644 --- a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts @@ -5,17 +5,39 @@ import { AwsIamUserSecretRotationSchema } from "@app/components/secret-rotations import { AzureClientSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/azure-client-secret-rotation-schema"; import { LdapPasswordRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema"; import { MsSqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema"; +import { MySqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mysql-credentials-rotation-schema"; import { PostgresCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; +import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation"; -const SecretRotationUnionSchema = z.discriminatedUnion("type", [ - Auth0ClientSecretRotationSchema, - AzureClientSecretRotationSchema, - PostgresCredentialsRotationSchema, - MsSqlCredentialsRotationSchema, - LdapPasswordRotationSchema, - AwsIamUserSecretRotationSchema -]); +export const SecretRotationV2FormSchema = (isUpdate: boolean) => + z + .intersection( + z.discriminatedUnion("type", [ + Auth0ClientSecretRotationSchema, + AzureClientSecretRotationSchema, + PostgresCredentialsRotationSchema, + MsSqlCredentialsRotationSchema, + MySqlCredentialsRotationSchema, + LdapPasswordRotationSchema, + AwsIamUserSecretRotationSchema + ]), + z.object({ id: z.string().optional() }) + ) + .superRefine((val, ctx) => { + if (val.type !== SecretRotation.LdapPassword || isUpdate) return; -export const SecretRotationV2FormSchema = SecretRotationUnionSchema; + // this has to go on union or breaks discrimination + if ( + val.parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal && + !val.temporaryParameters?.password + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Password required", + path: ["temporaryParameters", "password"] + }); + } + }); -export type TSecretRotationV2Form = z.infer; +export type TSecretRotationV2Form = z.infer>; diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema.ts index e18609f04..58e998ee7 100644 --- a/frontend/src/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema.ts +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema.ts @@ -2,8 +2,9 @@ import { z } from "zod"; import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema"; import { PasswordRequirementsSchema } from "@app/components/secret-rotations-v2/forms/schemas/shared"; -import { DistinguishedNameRegex } from "@app/helpers/string"; +import { DistinguishedNameRegex, UserPrincipalNameRegex } from "@app/helpers/string"; import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; +import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation"; export const LdapPasswordRotationSchema = z .object({ @@ -12,13 +13,24 @@ export const LdapPasswordRotationSchema = z dn: z .string() .trim() - .regex(DistinguishedNameRegex, "Invalid Distinguished Name format") - .min(1, "Distinguished Name (DN) required"), - passwordRequirements: PasswordRequirementsSchema.optional() + .min(1, "DN/UPN required") + .refine( + (value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value), + { + message: "Invalid DN/UPN format" + } + ), + passwordRequirements: PasswordRequirementsSchema.optional(), + rotationMethod: z.nativeEnum(LdapPasswordRotationMethod).optional() }), secretsMapping: z.object({ - dn: z.string().trim().min(1, "Distinguished Name (DN) required"), + dn: z.string().trim().min(1, "DN/UPN required"), password: z.string().trim().min(1, "Password required") - }) + }), + temporaryParameters: z + .object({ + password: z.string().min(1, "Password required") + }) + .optional() }) .merge(BaseSecretRotationSchema); diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/mysql-credentials-rotation-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/mysql-credentials-rotation-schema.ts new file mode 100644 index 000000000..7322615c2 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/mysql-credentials-rotation-schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema"; +import { SqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/shared"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +export const MySqlCredentialsRotationSchema = z + .object({ + type: z.literal(SecretRotation.MySqlCredentials) + }) + .merge(SqlCredentialsRotationSchema) + .merge(BaseSecretRotationSchema); diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/shared/password-requirements-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/shared/password-requirements-schema.ts index a02852ec8..1bab3b0bd 100644 --- a/frontend/src/components/secret-rotations-v2/forms/schemas/shared/password-requirements-schema.ts +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/shared/password-requirements-schema.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +export type TPasswordRequirements = z.infer; + export const PasswordRequirementsSchema = z .object({ length: z diff --git a/frontend/src/components/secret-scanning/CreateSecretScanningDataSourceModal.tsx b/frontend/src/components/secret-scanning/CreateSecretScanningDataSourceModal.tsx new file mode 100644 index 000000000..a3943cf35 --- /dev/null +++ b/frontend/src/components/secret-scanning/CreateSecretScanningDataSourceModal.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Modal, ModalContent } from "@app/components/v2"; +import { + SecretScanningDataSource, + TSecretScanningDataSource +} from "@app/hooks/api/secretScanningV2"; + +import { SecretScanningDataSourceForm } from "./forms"; +import { SecretScanningDataSourceModalHeader } from "./SecretScanningDataSourceModalHeader"; +import { SecretScanningDataSourceSelect } from "./SecretScanningDataSourceSelect"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + onComplete: (dataSource: TSecretScanningDataSource) => void; + selectedDataSource: SecretScanningDataSource | null; + setSelectedDataSource: (selectedDataSource: SecretScanningDataSource | null) => void; +}; + +const Content = ({ setSelectedDataSource, selectedDataSource, ...props }: ContentProps) => { + if (selectedDataSource) { + return ( + setSelectedDataSource(null)} + type={selectedDataSource} + {...props} + /> + ); + } + + return ; +}; + +export const CreateSecretScanningDataSourceModal = ({ onOpenChange, isOpen, ...props }: Props) => { + const [selectedDataSource, setSelectedDataSource] = useState( + null + ); + + return ( + { + if (!open) setSelectedDataSource(null); + onOpenChange(open); + }} + > + + ) : ( + + ) + } + onPointerDownOutside={(e) => e.preventDefault()} + className={selectedDataSource ? "max-w-2xl" : "max-w-3xl"} + subTitle={ + selectedDataSource ? undefined : "Select a data source to configure secret scanning for." + } + bodyClassName="overflow-visible" + > + { + setSelectedDataSource(null); + onOpenChange(false); + }} + selectedDataSource={selectedDataSource} + setSelectedDataSource={setSelectedDataSource} + {...props} + /> + + + ); +}; diff --git a/frontend/src/components/secret-scanning/DeleteSecretScanningDataSourceModal.tsx b/frontend/src/components/secret-scanning/DeleteSecretScanningDataSourceModal.tsx new file mode 100644 index 000000000..9cf918dfb --- /dev/null +++ b/frontend/src/components/secret-scanning/DeleteSecretScanningDataSourceModal.tsx @@ -0,0 +1,66 @@ +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal } from "@app/components/v2"; +import { SECRET_SCANNING_DATA_SOURCE_MAP } from "@app/helpers/secretScanningV2"; +import { + TSecretScanningDataSource, + useDeleteSecretScanningDataSource +} from "@app/hooks/api/secretScanningV2"; + +type Props = { + dataSource?: TSecretScanningDataSource; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onComplete?: () => void; +}; + +export const DeleteSecretScanningDataSourceModal = ({ + isOpen, + onOpenChange, + dataSource, + onComplete +}: Props) => { + const deleteDataSource = useDeleteSecretScanningDataSource(); + + if (!dataSource) return null; + + const { id: dataSourceId, name, type, projectId } = dataSource; + + const handleDeleteDataSource = async () => { + const dataSourceType = SECRET_SCANNING_DATA_SOURCE_MAP[type].name; + + try { + await deleteDataSource.mutateAsync({ + dataSourceId, + type, + projectId + }); + + createNotification({ + text: `Successfully deleted ${dataSourceType} Data Source`, + type: "success" + }); + + if (onComplete) onComplete(); + onOpenChange(false); + } catch { + createNotification({ + text: `Failed to delete ${dataSourceType} Data Source`, + type: "error" + }); + } + }; + + return ( + +

    + Findings associated with this data source will be preserved. +

    +
    + ); +}; diff --git a/frontend/src/components/secret-scanning/EditSecretScanningDataSourceModal.tsx b/frontend/src/components/secret-scanning/EditSecretScanningDataSourceModal.tsx new file mode 100644 index 000000000..318a96bed --- /dev/null +++ b/frontend/src/components/secret-scanning/EditSecretScanningDataSourceModal.tsx @@ -0,0 +1,36 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { TSecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; + +import { SecretScanningDataSourceForm } from "./forms"; +import { SecretScanningDataSourceModalHeader } from "./SecretScanningDataSourceModalHeader"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + dataSource?: TSecretScanningDataSource; +}; + +export const EditSecretScanningDataSourceModal = ({ + dataSource, + onOpenChange, + ...props +}: Props) => { + if (!dataSource) return null; + + return ( + + } + className="max-w-2xl" + bodyClassName="overflow-visible" + > + onOpenChange(false)} + onCancel={() => onOpenChange(false)} + dataSource={dataSource} + type={dataSource.type} + /> + + + ); +}; diff --git a/frontend/src/components/secret-scanning/SecretScanningDataSourceModalHeader.tsx b/frontend/src/components/secret-scanning/SecretScanningDataSourceModalHeader.tsx new file mode 100644 index 000000000..fb1f4236b --- /dev/null +++ b/frontend/src/components/secret-scanning/SecretScanningDataSourceModalHeader.tsx @@ -0,0 +1,47 @@ +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SECRET_SCANNING_DATA_SOURCE_MAP } from "@app/helpers/secretScanningV2"; +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; + +type Props = { + type: SecretScanningDataSource; + isConfigured: boolean; +}; + +export const SecretScanningDataSourceModalHeader = ({ type, isConfigured }: Props) => { + const dataSourceDetails = SECRET_SCANNING_DATA_SOURCE_MAP[type]; + + return ( +
    + {`${dataSourceDetails.name} +
    +
    + {dataSourceDetails.name} Data Source + +
    + + Docs + +
    +
    +
    +

    + {isConfigured ? "Edit" : "Connect a"} {dataSourceDetails.name} Data Source +

    +
    +
    + ); +}; diff --git a/frontend/src/components/secret-scanning/SecretScanningDataSourceSelect.tsx b/frontend/src/components/secret-scanning/SecretScanningDataSourceSelect.tsx new file mode 100644 index 000000000..c507a6ef3 --- /dev/null +++ b/frontend/src/components/secret-scanning/SecretScanningDataSourceSelect.tsx @@ -0,0 +1,91 @@ +import { faWrench } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Spinner, Tooltip } from "@app/components/v2"; +import { SECRET_SCANNING_DATA_SOURCE_MAP } from "@app/helpers/secretScanningV2"; +import { + SecretScanningDataSource, + useSecretScanningDataSourceOptions +} from "@app/hooks/api/secretScanningV2"; + +type Props = { + onSelect: (type: SecretScanningDataSource) => void; +}; + +export const SecretScanningDataSourceSelect = ({ onSelect }: Props) => { + const { isPending, data: dataSourceOptions } = useSecretScanningDataSourceOptions(); + + if (isPending) { + return ( +
    + +

    Loading options...

    +
    + ); + } + + return ( +
    + {dataSourceOptions?.map(({ type }) => { + const { image, name, size } = SECRET_SCANNING_DATA_SOURCE_MAP[type]; + + return ( + + ); + })} + +

    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 + + . +

    + + } + > +
    + +
    + Coming Soon +
    +
    +
    +
    + ); +}; diff --git a/frontend/src/components/secret-scanning/SecretScanningScanStatus.tsx b/frontend/src/components/secret-scanning/SecretScanningScanStatus.tsx new file mode 100644 index 000000000..c74ee477f --- /dev/null +++ b/frontend/src/components/secret-scanning/SecretScanningScanStatus.tsx @@ -0,0 +1,90 @@ +import { faArrowRotateForward, faCheck, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { formatDistance } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { Badge, Tooltip } from "@app/components/v2"; +import { SecretScanningScanStatus } from "@app/hooks/api/secretScanningV2"; + +type Props = { + status: SecretScanningScanStatus; + statusMessage?: string | null; + className?: string; + scannedAt?: string | null; +}; + +export const SecretScanningScanStatusBadge = ({ + status, + statusMessage, + className, + scannedAt +}: Props) => { + if (status === SecretScanningScanStatus.Failed) { + let errorMessage = statusMessage; + if (statusMessage) { + try { + errorMessage = JSON.stringify(JSON.parse(statusMessage), null, 2); + } catch { + errorMessage = statusMessage; + } + } + + return ( + +
    +
    + +
    Failure Reason
    +
    +
    {errorMessage}
    + {scannedAt && ( +
    + Attempted {formatDistance(new Date(scannedAt), new Date(), { addSuffix: true })} +
    + )} +
    +
    + } + > +
    + + + Scan Error + +
    + + ); + } + + if (status === SecretScanningScanStatus.Queued || status === SecretScanningScanStatus.Scanning) { + return ( + + + Scanning + + ); + } + + return ( + + + Complete + + ); +}; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/GitHubDataSourceConfigFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/GitHubDataSourceConfigFields.tsx new file mode 100644 index 000000000..be369bd07 --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/GitHubDataSourceConfigFields.tsx @@ -0,0 +1,126 @@ +import { useEffect } from "react"; +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { MultiValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { FilterableSelect, FormControl, Select, SelectItem, Tooltip } from "@app/components/v2"; +import { + TGitHubRadarConnectionRepository, + useGitHubRadarConnectionListRepositories +} from "@app/hooks/api/appConnections/github-radar"; +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; + +import { TSecretScanningDataSourceForm } from "../schemas"; +import { SecretScanningDataSourceConnectionField } from "../SecretScanningDataSourceConnectionField"; + +enum ScanMethod { + AllRepositories = "all-repositories", + SelectRepositories = "select-repositories" +} + +export const GitHubDataSourceConfigFields = () => { + const { control, watch, setValue } = useFormContext< + TSecretScanningDataSourceForm & { + type: SecretScanningDataSource.GitHub; + } + >(); + + const connectionId = useWatch({ control, name: "connection.id" }); + const isUpdate = Boolean(watch("id")); + + const { data: repositories, isPending: areRepositoriesLoading } = + useGitHubRadarConnectionListRepositories(connectionId, { enabled: Boolean(connectionId) }); + + const includeRepos = watch("config.includeRepos"); + + const scanMethod = + !includeRepos || includeRepos[0] === "*" + ? ScanMethod.AllRepositories + : ScanMethod.SelectRepositories; + + useEffect(() => { + if (!includeRepos) { + setValue("config.includeRepos", ["*"]); + } + }, [includeRepos, setValue]); + + return ( + <> + { + if (scanMethod === ScanMethod.SelectRepositories) { + setValue("config.includeRepos", []); + } + }} + /> + + + + {scanMethod === ScanMethod.SelectRepositories && ( + ( + Ensure that your connection has the correct permissions.} + > +
    + Don't see the repository you're looking for?{" "} + +
    + + } + > + value.includes(repository.name))} + onChange={(newValue) => { + onChange( + newValue + ? (newValue as MultiValue).map( + (p) => p.name + ) + : null + ); + }} + options={repositories} + placeholder="Select repositories..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.name} + /> +
    + )} + /> + )} + + ); +}; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx new file mode 100644 index 000000000..bebcf28fc --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx @@ -0,0 +1,55 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Switch } from "@app/components/v2"; +import { RESOURCE_DESCRIPTION_HELPER } from "@app/helpers/secretScanningV2"; +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; + +import { TSecretScanningDataSourceForm } from "../schemas"; +import { GitHubDataSourceConfigFields } from "./GitHubDataSourceConfigFields"; + +const COMPONENT_MAP: Record = { + [SecretScanningDataSource.GitHub]: GitHubDataSourceConfigFields +}; + +export const SecretScanningDataSourceConfigFields = () => { + const { watch, control } = useFormContext(); + + const type = watch("type"); + + const Component = COMPONENT_MAP[type]; + const autoScanDescription = RESOURCE_DESCRIPTION_HELPER[type]; + + return ( + <> +

    Connect and configure your Data Source.

    + + { + return ( + + +

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

    +
    +
    + ); + }} + /> + + ); +}; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/index.ts b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/index.ts new file mode 100644 index 000000000..31d24284f --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/index.ts @@ -0,0 +1 @@ +export * from "./SecretScanningDataSourceConfigFields"; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConnectionField.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConnectionField.tsx new file mode 100644 index 000000000..3f995e1d0 --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConnectionField.tsx @@ -0,0 +1,103 @@ +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 { OrgPermissionSubjects, useOrgPermission } from "@app/context"; +import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP } from "@app/helpers/secretScanningV2"; +import { useListAvailableAppConnections } from "@app/hooks/api/appConnections"; + +import { TSecretScanningDataSourceForm } from "./schemas"; + +type Props = { + onChange?: VoidFunction; + isUpdate?: boolean; +}; + +export const SecretScanningDataSourceConnectionField = ({ + onChange: callback, + isUpdate +}: Props) => { + const { permission } = useOrgPermission(); + const { control, watch } = useFormContext(); + + const dataSourceType = watch("type"); + const app = SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[dataSourceType]; + + const { data: availableConnections, isPending } = useListAvailableAppConnections(app); + + const connectionName = APP_CONNECTION_MAP[app].name; + + const canCreateConnection = permission.can( + OrgPermissionAppConnectionActions.Create, + OrgPermissionSubjects.AppConnections + ); + + return ( + <> + ( + + Check out{" "} + + our docs + {" "} + to ensure your connection has the required permissions for secret scanning. +

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

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

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

    + Provide a name and description for this Data Source. +

    + ( + + + + )} + control={control} + name="name" + /> + ( + + @@ -409,9 +493,10 @@ export const SecretApprovalRequestChanges = ({ ); })}
    -
    +
    -
    +
    Reviewers
    {secretApprovalRequestDetails?.policy?.approvers @@ -435,10 +520,10 @@ export const SecretApprovalRequestChanges = ({ const reviewer = reviewedUsers?.[requiredApprover.userId]; return (
    -
    +
    )} - + {getReviewedStatusSymbol(reviewer?.status)}
    diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index d28f28392..49bf1a72c 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -29,6 +29,11 @@ import { ProjectPermissionSecretActions, ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; import { useGetImportedSecretsSingleEnv, @@ -98,7 +103,14 @@ const Page = () => { page, setPerPage, orderBy - } = usePagination(DashboardSecretsOrderBy.Name); + } = usePagination(DashboardSecretsOrderBy.Name, { + initPerPage: getUserTablePreference("secretDashboardTable", PreferenceKey.PerPage, 100) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("secretDashboardTable", PreferenceKey.PerPage, newPerPage); + }; const [snapshotId, setSnapshotId] = useState(null); const isRollbackMode = Boolean(snapshotId); @@ -558,7 +570,7 @@ const Page = () => { page={page} perPage={perPage} onChangePage={(newPage) => setPage(newPage)} - onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + onChangePerPage={handlePerPageChange} /> )} val.toLowerCase() === val, "Must be lowercase"), - environment: z.object({ name: z.string(), slug: z.string() }) + environment: z.object({ name: z.string(), slug: z.string() }), + usernameTemplate: z.string().nullable().optional() }); type TForm = z.infer; @@ -93,7 +94,8 @@ export const AwsElastiCacheInputForm = ({ "UserId": "{{username}}" }` }, - environment: isSingleEnvironmentMode ? environments[0] : undefined + environment: isSingleEnvironmentMode ? environments[0] : undefined, + usernameTemplate: "{{randomUsername}}" } }); @@ -104,10 +106,13 @@ export const AwsElastiCacheInputForm = ({ maxTTL, provider, defaultTTL, - environment + environment, + usernameTemplate }: TForm) => { // wait till previous request is finished if (createDynamicSecret.isPending) return; + + const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}"; try { await createDynamicSecret.mutateAsync({ provider: { type: DynamicSecretProviders.AwsElastiCache, inputs: provider }, @@ -116,7 +121,9 @@ export const AwsElastiCacheInputForm = ({ path: secretPath, defaultTTL, projectSlug, - environmentSlug: environment.slug + environmentSlug: environment.slug, + usernameTemplate: + !usernameTemplate || isDefaultUsernameTemplate ? undefined : usernameTemplate }); onCompleted(); } catch { @@ -270,6 +277,25 @@ export const AwsElastiCacheInputForm = ({ Modify ElastiCache Statements + ( + + + + )} + /> { const valMs = ms(val); if (valMs < 60 * 1000) @@ -42,7 +66,8 @@ const formSchema = z.object({ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); }), name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"), - environment: z.object({ name: z.string(), slug: z.string() }) + environment: z.object({ name: z.string(), slug: z.string() }), + usernameTemplate: z.string().nullable().optional() }); type TForm = z.infer; @@ -66,27 +91,35 @@ export const AwsIamInputForm = ({ const { control, formState: { isSubmitting }, - handleSubmit + handleSubmit, + watch } = useForm({ resolver: zodResolver(formSchema), defaultValues: { - environment: isSingleEnvironmentMode ? environments[0] : undefined + environment: isSingleEnvironmentMode ? environments[0] : undefined, + usernameTemplate: "{{randomUsername}}", + provider: { + method: DynamicSecretAwsIamAuth.AssumeRole + } } }); const createDynamicSecret = useCreateDynamicSecret(); + const isAccessKeyMethod = watch("provider.method") === DynamicSecretAwsIamAuth.AccessKey; const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL, - environment + environment, + usernameTemplate }: TForm) => { // wait till previous request is finished if (createDynamicSecret.isPending) return; try { + const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}"; await createDynamicSecret.mutateAsync({ provider: { type: DynamicSecretProviders.AwsIam, inputs: provider }, maxTTL, @@ -94,7 +127,9 @@ export const AwsIamInputForm = ({ path: secretPath, defaultTTL, projectSlug, - environmentSlug: environment.slug + environmentSlug: environment.slug, + usernameTemplate: + !usernameTemplate || isDefaultUsernameTemplate ? undefined : usernameTemplate }); onCompleted(); } catch { @@ -121,7 +156,7 @@ export const AwsIamInputForm = ({ isError={Boolean(error)} errorText={error?.message} > - + )} /> @@ -164,38 +199,82 @@ export const AwsIamInputForm = ({ Configuration
    -
    - ( - ( + + - - )} - /> - ( - - - - )} - /> -
    + + Assume Role (Recommended) + + Access Key + + + )} + /> + {isAccessKeyMethod ? ( +
    + ( + + + + )} + /> + ( + + + + )} + /> +
    + ) : ( +
    + ( + + + + )} + /> +
    + )}
    )} /> + ( + + + + )} + /> {!isSingleEnvironmentMode && ( 24 * 60 * 60 * 1000) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); }), - name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"), - environment: z.object({ name: z.string(), slug: z.string() }) + name: slugSchema(), + environment: z.object({ name: z.string(), slug: z.string() }), + usernameTemplate: z.string().nullable().optional() }); type TForm = z.infer; @@ -93,7 +95,8 @@ export const CassandraInputForm = ({ resolver: zodResolver(formSchema), defaultValues: { provider: getSqlStatements(), - environment: isSingleEnvironmentMode ? environments[0] : undefined + environment: isSingleEnvironmentMode ? environments[0] : undefined, + usernameTemplate: "{{randomUsername}}" } }); @@ -104,11 +107,13 @@ export const CassandraInputForm = ({ maxTTL, provider, defaultTTL, - environment + environment, + usernameTemplate }: TForm) => { // wait till previous request is finished if (createDynamicSecret.isPending) return; + const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}"; try { await createDynamicSecret.mutateAsync({ provider: { type: DynamicSecretProviders.Cassandra, inputs: provider }, @@ -117,7 +122,9 @@ export const CassandraInputForm = ({ path: secretPath, defaultTTL, projectSlug, - environmentSlug: environment.slug + environmentSlug: environment.slug, + usernameTemplate: + !usernameTemplate || isDefaultUsernameTemplate ? undefined : usernameTemplate }); onCompleted(); } catch { @@ -298,6 +305,25 @@ export const CassandraInputForm = ({ Modify CQL Statements + ( + + + + )} + /> , + provider: DynamicSecretProviders.Vertica, + title: "Vertica" + }, + { + icon: , + provider: DynamicSecretProviders.Kubernetes, + title: "Kubernetes" + }, { icon: , provider: DynamicSecretProviders.GcpIam, @@ -478,7 +491,44 @@ export const CreateDynamicSecretForm = ({ /> )} - + {wizardStep === WizardSteps.ProviderInputs && + selectedProvider === DynamicSecretProviders.Kubernetes && ( + + + + )} + {wizardStep === WizardSteps.ProviderInputs && + selectedProvider === DynamicSecretProviders.Vertica && ( + + + + )} {wizardStep === WizardSteps.ProviderInputs && selectedProvider === DynamicSecretProviders.GcpIam && ( 24 * 60 * 60 * 1000) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); }), - name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"), - environment: z.object({ name: z.string(), slug: z.string() }) + name: slugSchema(), + environment: z.object({ name: z.string(), slug: z.string() }), + usernameTemplate: z.string().nullable().optional() }); type TForm = z.infer; @@ -113,7 +115,8 @@ export const ElasticSearchInputForm = ({ roles: ["superuser"], port: 443 }, - environment: isSingleEnvironmentMode ? environments[0] : undefined + environment: isSingleEnvironmentMode ? environments[0] : undefined, + usernameTemplate: "{{randomUsername}}" } }); @@ -124,10 +127,12 @@ export const ElasticSearchInputForm = ({ maxTTL, provider, defaultTTL, - environment + environment, + usernameTemplate }: TForm) => { // wait till previous request is finished if (createDynamicSecret.isPending) return; + const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}"; try { await createDynamicSecret.mutateAsync({ provider: { type: DynamicSecretProviders.ElasticSearch, inputs: provider }, @@ -136,7 +141,9 @@ export const ElasticSearchInputForm = ({ path: secretPath, defaultTTL, projectSlug, - environmentSlug: environment.slug + environmentSlug: environment.slug, + usernameTemplate: + !usernameTemplate || isDefaultUsernameTemplate ? undefined : usernameTemplate }); onCompleted(); } catch { @@ -418,6 +425,25 @@ export const ElasticSearchInputForm = ({ )} />
    + ( + + + + )} + /> {!isSingleEnvironmentMode && ( { + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + name: slugSchema(), + environment: z.object({ name: z.string(), slug: z.string() }), + usernameTemplate: z.string().trim().optional() + }) + .superRefine((data, ctx) => { + if (data.provider.authMethod === AuthMethod.Gateway && !data.provider.gatewayId) { + ctx.addIssue({ + path: ["provider.gatewayId"], + code: z.ZodIssueCode.custom, + message: "When auth method is set to Gateway, a gateway must be selected" + }); + } + if (data.provider.authMethod === AuthMethod.Api && !data.provider.clusterToken) { + ctx.addIssue({ + path: ["provider.clusterToken"], + code: z.ZodIssueCode.custom, + message: "When auth method is set to Token, a cluster token must be provided" + }); + } + }); + +type TForm = z.infer & FieldValues; + +type Props = { + onCompleted: () => void; + onCancel: () => void; + secretPath: string; + projectSlug: string; + environments: WorkspaceEnv[]; + isSingleEnvironmentMode?: boolean; +}; + +export const KubernetesInputForm = ({ + onCompleted, + onCancel, + secretPath, + projectSlug, + environments, + isSingleEnvironmentMode +}: Props) => { + const { + control, + formState: { isSubmitting }, + handleSubmit, + watch + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + provider: { + url: "", + clusterToken: "", + ca: "", + sslEnabled: false, + serviceAccountName: "", + namespace: "", + credentialType: KubernetesDynamicSecretCredentialType.Static, + gatewayId: undefined, + audiences: [], + authMethod: AuthMethod.Api + } as const, + environment: isSingleEnvironmentMode ? environments[0] : undefined + } + }); + + const { fields, append, remove } = useFieldArray({ + control, + name: "provider.audiences" + }); + + const createDynamicSecret = useCreateDynamicSecret(); + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); + + const sslEnabled = watch("provider.sslEnabled"); + const credentialType = watch("provider.credentialType"); + const authMethod = watch("provider.authMethod"); + + const handleCreateDynamicSecret = async (formData: TForm) => { + const { provider, usernameTemplate, ...rest } = formData; + // wait till previous request is finished + if (createDynamicSecret.isPending) return; + + try { + const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}"; + await createDynamicSecret.mutateAsync({ + provider: { type: DynamicSecretProviders.Kubernetes, inputs: provider }, + maxTTL: rest.maxTTL, + name: rest.name, + path: secretPath, + defaultTTL: rest.defaultTTL, + projectSlug, + environmentSlug: rest.environment.slug, + usernameTemplate: + !usernameTemplate || isDefaultUsernameTemplate ? undefined : usernameTemplate + }); + + onCompleted(); + } catch { + createNotification({ + type: "error", + text: "Failed to create dynamic secret" + }); + } + }; + + return ( + +
    +
    +
    + ( + + + + )} + /> +
    +
    + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
    +
    + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
    +
    +
    +
    + Configuration + +
    + + Docs + +
    +
    +
    +
    +
    +
    +
    + + {(isAllowed) => ( + ( + + +
    + +
    +
    +
    + )} + /> + )} +
    +
    + ( + + + + )} + /> + +
    + + Enable SSL + + If enabled, you can optionally provide a custom CA certificate. Leave + blank to use the system/public CA. + + } + > + + + + ( + + )} + /> +
    + + ( + +