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 59698d5b3..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", @@ -55,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", @@ -918,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", @@ -1999,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", @@ -2101,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", @@ -11828,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", @@ -19708,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", diff --git a/backend/package.json b/backend/package.json index 30aa9f68c..c2bfc29d9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", @@ -174,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", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index c967355c5..27f14fdb2 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -54,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"; @@ -83,6 +84,7 @@ import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-servi import { TPkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; import { TPkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service"; import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service"; +import { 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"; @@ -111,6 +113,7 @@ import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integ declare module "@fastify/request-context" { interface RequestContextData { reqId: string; + orgId?: string; identityAuthInfo?: { identityId: string; oidc?: { @@ -270,6 +273,8 @@ declare module "fastify" { 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 f614877bb..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, @@ -155,6 +161,9 @@ import { TIntegrations, TIntegrationsInsert, TIntegrationsUpdate, + TInternalCertificateAuthorities, + TInternalCertificateAuthoritiesInsert, + TInternalCertificateAuthoritiesUpdate, TInternalKms, TInternalKmsInsert, TInternalKmsUpdate, @@ -270,6 +279,9 @@ import { TSecretApprovalPoliciesApprovers, TSecretApprovalPoliciesApproversInsert, TSecretApprovalPoliciesApproversUpdate, + TSecretApprovalPoliciesBypassers, + TSecretApprovalPoliciesBypassersInsert, + TSecretApprovalPoliciesBypassersUpdate, TSecretApprovalPoliciesInsert, TSecretApprovalPoliciesUpdate, TSecretApprovalRequests, @@ -553,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, @@ -819,6 +841,12 @@ declare module "knex/types/tables" { TAccessApprovalPoliciesApproversUpdate >; + [TableName.AccessApprovalPolicyBypasser]: KnexOriginal.CompositeTableType< + TAccessApprovalPoliciesBypassers, + TAccessApprovalPoliciesBypassersInsert, + TAccessApprovalPoliciesBypassersUpdate + >; + [TableName.AccessApprovalRequest]: KnexOriginal.CompositeTableType< TAccessApprovalRequests, TAccessApprovalRequestsInsert, @@ -842,6 +870,11 @@ declare module "knex/types/tables" { TSecretApprovalPoliciesApproversInsert, TSecretApprovalPoliciesApproversUpdate >; + [TableName.SecretApprovalPolicyBypasser]: KnexOriginal.CompositeTableType< + TSecretApprovalPoliciesBypassers, + TSecretApprovalPoliciesBypassersInsert, + TSecretApprovalPoliciesBypassersUpdate + >; [TableName.SecretApprovalRequest]: KnexOriginal.CompositeTableType< TSecretApprovalRequests, TSecretApprovalRequestsInsert, 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/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/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/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/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 cbd4f64f9..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,11 +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(), - pkiSubscriberId: z.string().uuid().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 350a32b7a..637d0c632 100644 --- a/backend/src/db/schemas/dynamic-secrets.ts +++ b/backend/src/db/schemas/dynamic-secrets.ts @@ -28,7 +28,8 @@ export const DynamicSecretsSchema = z.object({ updatedAt: z.date(), encryptedInput: zodBuffer, projectGatewayId: z.string().uuid().nullable().optional(), - gatewayId: 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 3c9dd400c..00d1fd771 100644 --- a/backend/src/db/schemas/identity-kubernetes-auths.ts +++ b/backend/src/db/schemas/identity-kubernetes-auths.ts @@ -30,7 +30,8 @@ export const IdentityKubernetesAuthsSchema = z.object({ allowedAudience: z.string(), encryptedKubernetesTokenReviewerJwt: zodBuffer.nullable().optional(), encryptedKubernetesCaCertificate: zodBuffer.nullable().optional(), - gatewayId: z.string().uuid().nullable().optional() + gatewayId: z.string().uuid().nullable().optional(), + accessTokenPeriod: z.coerce.number().default(0) }); 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 index d5b15fc6a..e8d0658d5 100644 --- a/backend/src/db/schemas/identity-ldap-auths.ts +++ b/backend/src/db/schemas/identity-ldap-auths.ts @@ -24,7 +24,8 @@ export const IdentityLdapAuthsSchema = z.object({ searchFilter: z.string(), allowedFields: z.unknown().nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + accessTokenPeriod: z.coerce.number().default(0) }); export type TIdentityLdapAuths = z.infer; diff --git a/backend/src/db/schemas/identity-oci-auths.ts b/backend/src/db/schemas/identity-oci-auths.ts index e0be86b78..438837691 100644 --- a/backend/src/db/schemas/identity-oci-auths.ts +++ b/backend/src/db/schemas/identity-oci-auths.ts @@ -18,7 +18,8 @@ export const IdentityOciAuthsSchema = z.object({ identityId: z.string().uuid(), type: z.string(), tenancyOcid: z.string(), - allowedUsernames: z.string().nullable().optional() + allowedUsernames: z.string().nullable().optional(), + accessTokenPeriod: z.coerce.number().default(0) }); export type TIdentityOciAuths = z.infer; 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 c6f44fc82..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"; @@ -49,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"; @@ -90,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"; 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 c9040ecf6..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", @@ -93,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", diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index 6779d5407..fb0728707 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -34,7 +34,9 @@ export const OrganizationsSchema = z.object({ 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() + 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 index 08db19806..0cdff4250 100644 --- a/backend/src/db/schemas/pki-subscribers.ts +++ b/backend/src/db/schemas/pki-subscribers.ts @@ -16,10 +16,16 @@ export const PkiSubscribersSchema = z.object({ name: z.string(), commonName: z.string(), subjectAlternativeNames: z.string().array(), - ttl: z.string(), + ttl: z.string().nullable().optional(), keyUsages: z.string().array(), extendedKeyUsages: z.string().array(), - status: z.string() + 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; 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-sharing.ts b/backend/src/db/schemas/secret-sharing.ts index 7de34708c..7a7bf17bb 100644 --- a/backend/src/db/schemas/secret-sharing.ts +++ b/backend/src/db/schemas/secret-sharing.ts @@ -28,7 +28,6 @@ export const SecretSharingSchema = z.object({ encryptedSecret: zodBuffer.nullable().optional(), identifier: z.string().nullable().optional(), type: z.string().default("share"), - encryptedSalt: zodBuffer.nullable().optional(), authorizedEmails: z.unknown().nullable().optional() }); 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 b0914d5c4..5b3a08b4b 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -113,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(), @@ -154,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({ @@ -170,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/server/routes/v1/app-connection-routers/oci-connection-router.ts b/backend/src/ee/routes/v1/app-connection-routers/oci-connection-router.ts similarity index 94% rename from backend/src/server/routes/v1/app-connection-routers/oci-connection-router.ts rename to backend/src/ee/routes/v1/app-connection-routers/oci-connection-router.ts index d78eee3d9..e87e5b69e 100644 --- a/backend/src/server/routes/v1/app-connection-routers/oci-connection-router.ts +++ b/backend/src/ee/routes/v1/app-connection-routers/oci-connection-router.ts @@ -1,16 +1,16 @@ 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 { CreateOCIConnectionSchema, SanitizedOCIConnectionSchema, UpdateOCIConnectionSchema -} from "@app/services/app-connection/oci"; +} 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 "./app-connection-endpoints"; +import { registerAppConnectionEndpoints } from "../../../../server/routes/v1/app-connection-routers/app-connection-endpoints"; export const registerOCIConnectionRouter = async (server: FastifyZodProvider) => { registerAppConnectionEndpoints({ diff --git a/backend/src/ee/routes/v1/dynamic-secret-router.ts b/backend/src/ee/routes/v1/dynamic-secret-router.ts index 6e70effe4..bf5cce7d5 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,28 @@ 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 +]); + +const userTemplateSchema = z + .string() + .trim() + .max(255) + .refine((el) => validateUsernameTemplateCharacters(el)) + .refine((el) => + isValidHandleBarTemplate(el, { + allowedExpressions: (val) => ["randomUsername", "unixTimestamp"].includes(val) + }) + ); + export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", @@ -52,7 +76,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 +98,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 +142,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 +321,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/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/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index 8648ade7c..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, 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/server/routes/v1/secret-sync-routers/oci-vault-sync-router.ts b/backend/src/ee/routes/v1/secret-sync-routers/oci-vault-sync-router.ts similarity index 73% rename from backend/src/server/routes/v1/secret-sync-routers/oci-vault-sync-router.ts rename to backend/src/ee/routes/v1/secret-sync-routers/oci-vault-sync-router.ts index b46f27a50..2efe3e3f5 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/oci-vault-sync-router.ts +++ b/backend/src/ee/routes/v1/secret-sync-routers/oci-vault-sync-router.ts @@ -2,11 +2,10 @@ import { CreateOCIVaultSyncSchema, OCIVaultSyncSchema, UpdateOCIVaultSyncSchema -} from "@app/services/secret-sync/oci-vault"; +} 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"; -import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; - export const registerOCIVaultSyncRouter = async (server: FastifyZodProvider) => registerSyncSecretsEndpoints({ destination: SecretSync.OCIVault, 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/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/services/app-connection/oci/index.ts b/backend/src/ee/services/app-connections/oci/index.ts similarity index 100% rename from backend/src/services/app-connection/oci/index.ts rename to backend/src/ee/services/app-connections/oci/index.ts diff --git a/backend/src/services/app-connection/oci/oci-connection-enums.ts b/backend/src/ee/services/app-connections/oci/oci-connection-enums.ts similarity index 100% rename from backend/src/services/app-connection/oci/oci-connection-enums.ts rename to backend/src/ee/services/app-connections/oci/oci-connection-enums.ts diff --git a/backend/src/services/app-connection/oci/oci-connection-fns.ts b/backend/src/ee/services/app-connections/oci/oci-connection-fns.ts similarity index 100% rename from backend/src/services/app-connection/oci/oci-connection-fns.ts rename to backend/src/ee/services/app-connections/oci/oci-connection-fns.ts diff --git a/backend/src/services/app-connection/oci/oci-connection-schemas.ts b/backend/src/ee/services/app-connections/oci/oci-connection-schemas.ts similarity index 100% rename from backend/src/services/app-connection/oci/oci-connection-schemas.ts rename to backend/src/ee/services/app-connections/oci/oci-connection-schemas.ts diff --git a/backend/src/services/app-connection/oci/oci-connection-service.ts b/backend/src/ee/services/app-connections/oci/oci-connection-service.ts similarity index 68% rename from backend/src/services/app-connection/oci/oci-connection-service.ts rename to backend/src/ee/services/app-connections/oci/oci-connection-service.ts index 2d72135e5..c2e60399c 100644 --- a/backend/src/services/app-connection/oci/oci-connection-service.ts +++ b/backend/src/ee/services/app-connections/oci/oci-connection-service.ts @@ -1,7 +1,9 @@ +import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; -import { AppConnection } from "../app-connection-enums"; +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"; @@ -22,8 +24,23 @@ type TListOCIVaultKeysDTO = { vaultOcid: string; }; -export const ociConnectionService = (getAppConnection: TGetAppConnectionFunc) => { +// 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 { @@ -36,6 +53,8 @@ export const ociConnectionService = (getAppConnection: TGetAppConnectionFunc) => }; const listVaults = async ({ connectionId, compartmentOcid }: TListOCIVaultsDTO, actor: OrgServiceActor) => { + await checkPlan(licenseService, actor.orgId); + const appConnection = await getAppConnection(AppConnection.OCI, connectionId, actor); try { @@ -51,6 +70,8 @@ export const ociConnectionService = (getAppConnection: TGetAppConnectionFunc) => { connectionId, compartmentOcid, vaultOcid }: TListOCIVaultKeysDTO, actor: OrgServiceActor ) => { + await checkPlan(licenseService, actor.orgId); + const appConnection = await getAppConnection(AppConnection.OCI, connectionId, actor); try { diff --git a/backend/src/services/app-connection/oci/oci-connection-types.ts b/backend/src/ee/services/app-connections/oci/oci-connection-types.ts similarity index 87% rename from backend/src/services/app-connection/oci/oci-connection-types.ts rename to backend/src/ee/services/app-connections/oci/oci-connection-types.ts index 74ddfe0c8..e07554f29 100644 --- a/backend/src/services/app-connection/oci/oci-connection-types.ts +++ b/backend/src/ee/services/app-connections/oci/oci-connection-types.ts @@ -2,7 +2,7 @@ import z from "zod"; import { DiscriminativePick } from "@app/lib/types"; -import { AppConnection } from "../app-connection-enums"; +import { AppConnection } from "../../../../services/app-connection/app-connection-enums"; import { CreateOCIConnectionSchema, OCIConnectionSchema, 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 c4a6121d3..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 @@ -32,7 +33,7 @@ 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 { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; -import { CaStatus } from "@app/services/certificate-authority/certificate-authority-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"; @@ -243,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", @@ -253,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", @@ -278,7 +281,9 @@ export enum EventType { 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", @@ -327,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", @@ -401,7 +405,13 @@ export enum EventType { 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" + 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[] = [ @@ -1798,7 +1808,8 @@ interface CreateCa { type: EventType.CREATE_CA; metadata: { caId: string; - dn: string; + name: string; + dn?: string; }; } @@ -1806,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[]; }; } @@ -1814,7 +1833,8 @@ interface UpdateCa { type: EventType.UPDATE_CA; metadata: { caId: string; - dn: string; + name: string; + dn?: string; status: CaStatus; }; } @@ -1823,7 +1843,8 @@ interface DeleteCa { type: EventType.DELETE_CA; metadata: { caId: string; - dn: string; + name: string; + dn?: string; }; } @@ -1893,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: { @@ -2060,7 +2090,7 @@ interface CreatePkiSubscriber { caId?: string; name: string; commonName: string; - ttl: string; + ttl?: string; subjectAlternativeNames: string[]; keyUsages: CertKeyUsage[]; extendedKeyUsages: CertExtendedKeyUsage[]; @@ -2102,7 +2132,15 @@ interface IssuePkiSubscriberCert { metadata: { subscriberId: string; name: string; - serialNumber: string; + serialNumber?: string; + }; +} + +interface AutomatedRenewPkiSubscriberCert { + type: EventType.AUTOMATED_RENEW_SUBSCRIBER_CERT; + metadata: { + subscriberId: string; + name: string; }; } @@ -2124,6 +2162,16 @@ interface ListPkiSubscriberCerts { }; } +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: { @@ -2477,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: { @@ -3034,6 +3074,59 @@ interface SecretScanningConfigReadEvent { 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 @@ -3158,6 +3251,7 @@ export type Event = | IssueSshHostHostCert | CreateCa | GetCa + | GetCAs | UpdateCa | DeleteCa | RenewCa @@ -3168,6 +3262,7 @@ export type Event = | ImportCaCert | GetCaCrls | IssueCert + | ImportCert | SignCert | GetCaCertificateTemplates | GetCert @@ -3193,7 +3288,9 @@ export type Event = | GetPkiSubscriber | IssuePkiSubscriberCert | SignPkiSubscriberCert + | AutomatedRenewPkiSubscriberCert | ListPkiSubscriberCerts + | GetSubscriberActiveCertBundle | CreateKmsEvent | UpdateKmsEvent | DeleteKmsEvent @@ -3238,7 +3335,6 @@ export type Event = | CreateProjectTemplateEvent | UpdateProjectTemplateEvent | DeleteProjectTemplateEvent - | ApplyProjectTemplateEvent | GetAppConnectionsEvent | GetAvailableAppConnectionsDetailsEvent | GetAppConnectionEvent @@ -3313,4 +3409,8 @@ export type Event = | SecretScanningFindingListEvent | SecretScanningFindingUpdateEvent | SecretScanningConfigUpdateEvent - | SecretScanningConfigReadEvent; + | 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-service.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts index 4adf8b7e2..f3f3f3acd 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 @@ -132,7 +132,11 @@ export const dynamicSecretLeaseServiceFactory = ({ let result; try { - result = await selectedProvider.create(decryptedStoredInput, expireAt.getTime()); + result = await selectedProvider.create({ + inputs: decryptedStoredInput, + expireAt: expireAt.getTime(), + usernameTemplate: dynamicSecretCfg.usernameTemplate + }); } catch (error: unknown) { if (error && typeof error === "object" && error !== null && "sqlMessage" in error) { throw new BadRequestError({ message: error.sqlMessage as string }); 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 c39f07b5c..16ac10716 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -78,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` }); @@ -163,7 +164,8 @@ export const dynamicSecretServiceFactory = ({ defaultTTL, folderId: folder.id, name, - gatewayId: selectedGatewayId + gatewayId: selectedGatewayId, + usernameTemplate }, tx ); @@ -199,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` }); @@ -311,7 +314,8 @@ export const dynamicSecretServiceFactory = ({ defaultTTL, name: newName ?? name, status: null, - gatewayId: selectedGatewayId + gatewayId: selectedGatewayId, + usernameTemplate }, tx ); 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..56fa110d1 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts @@ -132,9 +132,15 @@ const generatePassword = () => { return customAlphabet(charset, 64)(); }; -const generateUsername = () => { +const generateUsername = (usernameTemplate?: string | null) => { 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 handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; export const AwsElastiCacheDatabaseProvider = (): TDynamicProviderFns => { @@ -168,13 +174,14 @@ export const AwsElastiCacheDatabaseProvider = (): TDynamicProviderFns => { return true; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = 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); 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..9d8e10f60 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts @@ -16,6 +16,7 @@ import { PutUserPolicyCommand, RemoveUserFromGroupCommand } from "@aws-sdk/client-iam"; +import handlebars from "handlebars"; import { z } from "zod"; import { BadRequestError } from "@app/lib/errors"; @@ -23,8 +24,14 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models"; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; export const AwsIamProvider = (): TDynamicProviderFns => { @@ -53,11 +60,13 @@ export const AwsIamProvider = (): TDynamicProviderFns => { return isConnected; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, usernameTemplate } = data; + const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const { policyArns, userGroups, policyDocument, awsPath, permissionBoundaryPolicyArn } = providerInputs; const createUserRes = await client.send( new CreateUserCommand({ 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..fce23b56f 100644 --- a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts +++ b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts @@ -14,8 +14,14 @@ const generatePassword = (size = 48) => { return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // 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) + }); }; export const CassandraProvider = (): TDynamicProviderFns => { @@ -69,11 +75,12 @@ export const CassandraProvider = (): TDynamicProviderFns => { return isConnected; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); 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..066822827 100644 --- a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts +++ b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts @@ -1,4 +1,5 @@ import { Client as ElasticSearchClient } from "@elastic/elasticsearch"; +import handlebars from "handlebars"; import { customAlphabet } from "nanoid"; import { z } from "zod"; @@ -12,8 +13,14 @@ const generatePassword = () => { return customAlphabet(charset, 64)(); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // 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) + }); }; 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 }) => { + const { inputs, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const connection = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); 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 737aaadea..3384e3781 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -6,6 +6,7 @@ import { AwsIamProvider } from "./aws-iam"; import { AzureEntraIDProvider } from "./azure-entra-id"; import { CassandraProvider } from "./cassandra"; import { ElasticSearchProvider } from "./elastic-search"; +import { KubernetesProvider } from "./kubernetes"; import { LdapProvider } from "./ldap"; import { DynamicSecretProviders, TDynamicProviderFns } from "./models"; import { MongoAtlasProvider } from "./mongo-atlas"; @@ -38,5 +39,6 @@ export const buildDynamicSecretProviders = ({ [DynamicSecretProviders.SapHana]: SapHanaProvider(), [DynamicSecretProviders.Snowflake]: SnowflakeProvider(), [DynamicSecretProviders.Totp]: TotpProvider(), - [DynamicSecretProviders.SapAse]: SapAseProvider() + [DynamicSecretProviders.SapAse]: SapAseProvider(), + [DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }) }); 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..8a54ba089 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts @@ -0,0 +1,199 @@ +import axios from "axios"; +import https from "https"; + +import { InternalServerError } from "@app/lib/errors"; +import { withGatewayProxy } from "@app/lib/gateway"; +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, TDynamicProviderFns } from "./models"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +type TKubernetesProviderDTO = { + gatewayService: Pick; +}; + +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; + }, + gatewayCallback: (host: string, port: number) => Promise + ): Promise => { + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + + const callbackResult = await withGatewayProxy( + async (port) => { + // 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("https://localhost", port); + return res; + }, + { + 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() + } + } + ); + + return callbackResult; + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + + const serviceAccountGetCallback = async (host: string, port: number) => { + const baseUrl = port ? `${host}:${port}` : host; + + await axios.get( + `${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${providerInputs.serviceAccountName}`, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${providerInputs.clusterToken}` + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent: new https.Agent({ + ca: providerInputs.ca, + rejectUnauthorized: providerInputs.sslEnabled + }) + } + ); + }; + + const url = new URL(providerInputs.url); + const k8sPort = url.port ? Number(url.port) : 443; + + try { + if (providerInputs.gatewayId) { + const k8sHost = url.hostname; + + await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sHost, + targetPort: k8sPort + }, + serviceAccountGetCallback + ); + } else { + const k8sHost = `${url.protocol}//${url.hostname}`; + await serviceAccountGetCallback(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 }: { inputs: unknown; expireAt: number }) => { + const providerInputs = await validateProviderInputs(inputs); + + const tokenRequestCallback = async (host: string, port: number) => { + 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", + Authorization: `Bearer ${providerInputs.clusterToken}` + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent: new https.Agent({ + ca: providerInputs.ca, + rejectUnauthorized: providerInputs.sslEnabled + }) + } + ); + + return res.data; + }; + + 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 { + const tokenData = providerInputs.gatewayId + ? await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sGatewayHost, + targetPort: k8sPort + }, + tokenRequestCallback + ) + : await tokenRequestCallback(k8sHost, k8sPort); + + return { + entityId: providerInputs.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) => { + 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..d0e3fbe66 100644 --- a/backend/src/ee/services/dynamic-secret/providers/ldap.ts +++ b/backend/src/ee/services/dynamic-secret/providers/ldap.ts @@ -22,8 +22,14 @@ const encodePassword = (password?: string) => { return base64Password; }; -const generateUsername = () => { - return alphaNumericNanoId(20); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // 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) + }); }; const generateLDIF = ({ @@ -190,7 +196,8 @@ export const LdapProvider = (): TDynamicProviderFns => { return dnArray; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { + const { inputs, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); @@ -217,7 +224,7 @@ export const LdapProvider = (): TDynamicProviderFns => { }); } } else { - const username = generateUsername(); + const username = generateUsername(usernameTemplate); 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 0c6eaf151..ed59f4d67 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -29,6 +29,10 @@ export enum LdapCredentialType { Static = "static" } +export enum KubernetesCredentialType { + Static = "static" +} + export enum TotpConfigType { URL = "url", MANUAL = "manual" @@ -277,6 +281,18 @@ export const LdapSchema = z.union([ }) ]); +export const DynamicSecretKubernetesSchema = z.object({ + url: z.string().url().trim().min(1), + gatewayId: z.string().nullable().optional(), + sslEnabled: z.boolean().default(true), + clusterToken: z.string().trim().min(1), + ca: z.string().optional(), + serviceAccountName: z.string().trim().min(1), + credentialType: z.literal(KubernetesCredentialType.Static), + namespace: z.string().trim().min(1), + audiences: z.array(z.string().trim().min(1)) +}); + export const DynamicSecretTotpSchema = z.discriminatedUnion("configType", [ z.object({ configType: z.literal(TotpConfigType.URL), @@ -320,7 +336,8 @@ export enum DynamicSecretProviders { SapHana = "sap-hana", Snowflake = "snowflake", Totp = "totp", - SapAse = "sap-ase" + SapAse = "sap-ase", + Kubernetes = "kubernetes" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ @@ -338,11 +355,16 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.AzureEntraID), inputs: AzureEntraIDSchema }), 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.Totp), inputs: DynamicSecretTotpSchema }), + z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }) ]); export type TDynamicProviderFns = { - create: (inputs: unknown, expireAt: number) => Promise<{ entityId: string; data: unknown }>; + create: (arg: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + }) => Promise<{ entityId: string; data: unknown }>; validateConnection: (inputs: unknown) => Promise; validateProviderInputs: (inputs: object) => Promise; revoke: (inputs: unknown, entityId: 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..8f8bf9430 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts @@ -1,4 +1,5 @@ import axios, { AxiosError } from "axios"; +import handlebars from "handlebars"; import { customAlphabet } from "nanoid"; import { z } from "zod"; @@ -12,8 +13,14 @@ const generatePassword = (size = 48) => { return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; export const MongoAtlasProvider = (): TDynamicProviderFns => { @@ -57,11 +64,12 @@ export const MongoAtlasProvider = (): TDynamicProviderFns => { return isConnected; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); 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..0a15209e0 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts @@ -1,3 +1,4 @@ +import handlebars from "handlebars"; import { MongoClient } from "mongodb"; import { customAlphabet } from "nanoid"; import { z } from "zod"; @@ -12,8 +13,14 @@ const generatePassword = (size = 48) => { return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; 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 }) => { + const { inputs, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); 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..e7d90d272 100644 --- a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts +++ b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts @@ -1,4 +1,5 @@ import axios, { Axios } from "axios"; +import handlebars from "handlebars"; import https from "https"; import { customAlphabet } from "nanoid"; import { z } from "zod"; @@ -14,8 +15,14 @@ const generatePassword = () => { return customAlphabet(charset, 64)(); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // 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 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 }) => { + const { inputs, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const connection = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); 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..855af2e29 100644 --- a/backend/src/ee/services/dynamic-secret/providers/redis.ts +++ b/backend/src/ee/services/dynamic-secret/providers/redis.ts @@ -15,8 +15,14 @@ const generatePassword = () => { return customAlphabet(charset, 64)(); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // 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) + }); }; const executeTransactions = async (connection: Redis, commands: string[]): Promise<(string | null)[] | null> => { @@ -115,11 +121,12 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => { return pingResponse; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const connection = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); 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..af2431058 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts @@ -15,8 +15,14 @@ const generatePassword = (size = 48) => { return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(25); +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) + }); }; enum SapCommands { @@ -81,11 +87,12 @@ export const SapAseProvider = (): TDynamicProviderFns => { return true; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { + const { inputs, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); - const username = `inf_${generateUsername()}`; - const password = `${generatePassword()}`; + const username = generateUsername(usernameTemplate); + 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..654e2d144 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts @@ -21,8 +21,14 @@ const generatePassword = (size = 48) => { return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // 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) + }); }; export const SapHanaProvider = (): TDynamicProviderFns => { @@ -91,10 +97,11 @@ export const SapHanaProvider = (): TDynamicProviderFns => { return testResult; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); 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..571d488c9 100644 --- a/backend/src/ee/services/dynamic-secret/providers/snowflake.ts +++ b/backend/src/ee/services/dynamic-secret/providers/snowflake.ts @@ -17,8 +17,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) => { + const randomUsername = `infisical_${alphaNumericNanoId(32)}`; // 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) + }); }; const getDaysToExpiry = (expiryDate: Date) => { @@ -82,12 +88,13 @@ export const SnowflakeProvider = (): TDynamicProviderFns => { return isValidConnection; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); 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 3ae85ed7b..ce16a1237 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -104,11 +104,21 @@ const generatePassword = (provider: SqlProviders, requirements?: PasswordRequire } }; -const generateUsername = (provider: SqlProviders) => { - // For oracle, the client assumes everything is upper case when not using quotes around the password - if (provider === SqlProviders.Oracle) return alphaNumericNanoId(32).toUpperCase(); +const generateUsername = (provider: SqlProviders, usernameTemplate?: string | null) => { + let randomUsername = ""; - return alphaNumericNanoId(32); + // For oracle, the client assumes everything is upper case when not using quotes around the password + if (provider === SqlProviders.Oracle) { + randomUsername = alphaNumericNanoId(32).toUpperCase(); + } else { + randomUsername = alphaNumericNanoId(32); + } + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; type TSqlDatabaseProviderDTO = { @@ -210,9 +220,12 @@ 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 }) => { + const { inputs, expireAt, usernameTemplate } = data; + const providerInputs = await validateProviderInputs(inputs); - const username = generateUsername(providerInputs.client); + const username = generateUsername(providerInputs.client, usernameTemplate); + const password = generatePassword(providerInputs.client, providerInputs.passwordRequirements); const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { const db = await $getClient({ ...providerInputs, port, host }); diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 1d33cafd6..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) { 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/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 { 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 a9ca8d4cc..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"; @@ -55,15 +56,24 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ kmip: false, gateway: false, sshHostGroups: false, - secretScanning: 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 () => { @@ -99,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..e2cf09bb1 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) { diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 1771a007a..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; @@ -73,6 +73,8 @@ export type TFeatureSet = { 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 6accb69e9..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, @@ -717,7 +717,7 @@ 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 ?? "", diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index bbca4f664..8fef9ba1b 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -10,6 +10,7 @@ import { ProjectPermissionKmipActions, ProjectPermissionMemberActions, ProjectPermissionPkiSubscriberActions, + ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, ProjectPermissionSecretRotationActions, ProjectPermissionSecretScanningConfigActions, @@ -28,7 +29,6 @@ const buildAdminPermissionRules = () => { [ ProjectPermissionSub.SecretFolders, ProjectPermissionSub.SecretImports, - ProjectPermissionSub.SecretApproval, ProjectPermissionSub.Role, ProjectPermissionSub.Integrations, ProjectPermissionSub.Webhooks, @@ -39,7 +39,6 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.AuditLogs, ProjectPermissionSub.IpAllowList, ProjectPermissionSub.CertificateAuthorities, - ProjectPermissionSub.CertificateTemplates, ProjectPermissionSub.PkiAlerts, ProjectPermissionSub.PkiCollections, ProjectPermissionSub.SshCertificateAuthorities, @@ -58,6 +57,28 @@ const buildAdminPermissionRules = () => { ); }); + 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, @@ -365,7 +386,7 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.Certificates ); - can([ProjectPermissionActions.Read], ProjectPermissionSub.CertificateTemplates); + can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); @@ -451,6 +472,7 @@ const buildViewerPermissionRules = () => { 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); diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 612914bcc..f0fe73d71 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -67,6 +67,11 @@ export enum OrgPermissionGroupActions { RemoveMembers = "remove-members" } +export enum OrgPermissionBillingActions { + Read = "read", + ManageBilling = "manage-billing" +} + export enum OrgPermissionSubjects { Workspace = "workspace", Role = "role", @@ -107,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] @@ -298,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); @@ -362,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); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 79e4bff6c..34cffcd98 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -87,6 +87,15 @@ 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", @@ -223,6 +232,11 @@ 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 @@ -279,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] @@ -462,6 +482,21 @@ const PkiSubscriberConditionSchema = z }) .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."), @@ -553,12 +588,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) @@ -651,7 +680,7 @@ const GeneralPermissionSchema = [ }) ]; -// 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."), @@ -756,6 +785,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + z.object({ + subject: z.literal(ProjectPermissionSub.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."), @@ -766,6 +805,7 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + ...GeneralPermissionSchema ]); 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 3877cbaf8..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`, @@ -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`, @@ -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/services/secret-sync/oci-vault/index.ts b/backend/src/ee/services/secret-sync/oci-vault/index.ts similarity index 100% rename from backend/src/services/secret-sync/oci-vault/index.ts rename to backend/src/ee/services/secret-sync/oci-vault/index.ts diff --git a/backend/src/services/secret-sync/oci-vault/oci-vault-sync-constants.ts b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-constants.ts similarity index 89% rename from backend/src/services/secret-sync/oci-vault/oci-vault-sync-constants.ts rename to backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-constants.ts index 9e2aad056..b864e354b 100644 --- a/backend/src/services/secret-sync/oci-vault/oci-vault-sync-constants.ts +++ b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-constants.ts @@ -6,5 +6,6 @@ export const OCI_VAULT_SYNC_LIST_OPTION: TSecretSyncListItem = { name: "OCI Vault", destination: SecretSync.OCIVault, connection: AppConnection.OCI, - canImportSecrets: true + canImportSecrets: true, + enterprise: true }; diff --git a/backend/src/services/secret-sync/oci-vault/oci-vault-sync-fns.ts b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-fns.ts similarity index 98% rename from backend/src/services/secret-sync/oci-vault/oci-vault-sync-fns.ts rename to backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-fns.ts index e270f2e02..5b05b2301 100644 --- a/backend/src/services/secret-sync/oci-vault/oci-vault-sync-fns.ts +++ b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-fns.ts @@ -1,7 +1,6 @@ import { secrets, vault } from "oci-sdk"; -import { delay } from "@app/lib/delay"; -import { getOCIProvider } from "@app/services/app-connection/oci"; +import { getOCIProvider } from "@app/ee/services/app-connections/oci"; import { TCreateOCIVaultVariable, TDeleteOCIVaultVariable, @@ -9,7 +8,8 @@ import { TOCIVaultSyncWithCredentials, TUnmarkOCIVaultVariableFromDeletion, TUpdateOCIVaultVariable -} from "@app/services/secret-sync/oci-vault/oci-vault-sync-types"; +} 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"; diff --git a/backend/src/services/secret-sync/oci-vault/oci-vault-sync-schemas.ts b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-schemas.ts similarity index 97% rename from backend/src/services/secret-sync/oci-vault/oci-vault-sync-schemas.ts rename to backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-schemas.ts index 84a58bc8a..a0bd29382 100644 --- a/backend/src/services/secret-sync/oci-vault/oci-vault-sync-schemas.ts +++ b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-schemas.ts @@ -66,5 +66,6 @@ export const OCIVaultSyncListItemSchema = z.object({ name: z.literal("OCI Vault"), connection: z.literal(AppConnection.OCI), destination: z.literal(SecretSync.OCIVault), - canImportSecrets: z.literal(true) + canImportSecrets: z.literal(true), + enterprise: z.boolean() }); diff --git a/backend/src/services/secret-sync/oci-vault/oci-vault-sync-types.ts b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-types.ts similarity index 94% rename from backend/src/services/secret-sync/oci-vault/oci-vault-sync-types.ts rename to backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-types.ts index c040cd0c0..8804b1322 100644 --- a/backend/src/services/secret-sync/oci-vault/oci-vault-sync-types.ts +++ b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-types.ts @@ -1,7 +1,7 @@ import { SimpleAuthenticationDetailsProvider } from "oci-sdk"; import { z } from "zod"; -import { TOCIConnection } from "@app/services/app-connection/oci"; +import { TOCIConnection } from "@app/ee/services/app-connections/oci"; import { CreateOCIVaultSyncSchema, OCIVaultSyncListItemSchema, OCIVaultSyncSchema } from "./oci-vault-sync-schemas"; diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 94015bc31..a7eec1078 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -1,5 +1,4 @@ -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"; @@ -39,6 +38,8 @@ export const KeyStorePrefixes = { 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}`, @@ -68,8 +69,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) => diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 7fa6ddc8e..71e284ae6 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -11,6 +11,8 @@ import { } 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"; @@ -152,7 +154,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." @@ -166,7 +170,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.", @@ -1714,6 +1719,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." } }; @@ -1785,6 +1803,14 @@ export const PKI_SUBSCRIBERS = { 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.", @@ -1795,7 +1821,9 @@ export const PKI_SUBSCRIBERS = { 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." + 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.", @@ -1809,7 +1837,9 @@ export const PKI_SUBSCRIBERS = { "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." + "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.", @@ -1998,6 +2028,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.` @@ -2070,7 +2141,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.", @@ -2091,6 +2162,10 @@ export const AppConnections = { 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." } } }; @@ -2244,6 +2319,9 @@ export const SecretSyncs = { 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." } } }; @@ -2315,7 +2393,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: { @@ -2349,7 +2430,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: { diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 3135dc00c..cb53a71a6 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -32,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}` @@ -72,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) @@ -241,7 +256,6 @@ const envSchema = z DATADOG_HOSTNAME: zpStr(z.string().optional()), /* CORS ----------------------------------------------------------------------------- */ - CORS_ALLOWED_ORIGINS: zpStr( z .string() @@ -251,7 +265,6 @@ const envSchema = z return JSON.parse(val) as string[]; }) ), - CORS_ALLOWED_HEADERS: zpStr( z .string() @@ -260,26 +273,38 @@ 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) && @@ -293,7 +318,6 @@ const envSchema = z 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(",") })); @@ -316,6 +340,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, @@ -327,8 +362,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/gateway/index.ts b/backend/src/lib/gateway/index.ts index 7a94c6384..4d6401eac 100644 --- a/backend/src/lib/gateway/index.ts +++ b/backend/src/lib/gateway/index.ts @@ -3,6 +3,7 @@ import crypto from "node:crypto"; import net from "node:net"; import quicDefault, * as quicModule from "@infisical/quic"; +import axios from "axios"; import { BadRequestError } from "../errors"; import { logger } from "../logger"; @@ -43,7 +44,7 @@ const createQuicConnection = async ( 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); + const isValidServerCertificate = serverCertificate.verify(caCertificate.publicKey); if (!isValidServerCertificate) return quic.native.CryptoError.BadCertificate; const subjectDetails = parseSubjectDetails(serverCertificate.subject); @@ -378,7 +379,12 @@ export const withGatewayProxy = async ( logger.error(new Error(proxyErrorMessage), "Failed to proxy"); } logger.error(err, "Failed to do gateway"); - throw new BadRequestError({ message: proxyErrorMessage || (err as Error)?.message }); + 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/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 afde8ef97..219b4a9a7 100644 --- a/backend/src/lib/logger/logger.ts +++ b/backend/src/lib/logger/logger.ts @@ -95,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][] = [ @@ -135,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..be9430669 100644 --- a/backend/src/lib/regex/index.ts +++ b/backend/src/lib/regex/index.ts @@ -1,3 +1,11 @@ +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?:\/\//); diff --git a/backend/src/lib/template/validate-handlebars.ts b/backend/src/lib/template/validate-handlebars.ts index a83c9efc2..08343e962 100644 --- a/backend/src/lib/template/validate-handlebars.ts +++ b/backend/src/lib/template/validate-handlebars.ts @@ -19,3 +19,15 @@ export const validateHandlebarTemplate = (templateName: string, template: string 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" && dto?.allowedExpressions?.(path.original)) return true; + } + return false; + }); +}; 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 06ff66374..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"; @@ -18,8 +17,10 @@ import { 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, @@ -42,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", @@ -50,6 +52,7 @@ 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", @@ -94,7 +97,9 @@ export enum QueueJobs { InvalidateCache = "invalidate-cache", SecretScanningV2FullScan = "secret-scanning-v2-full-scan", SecretScanningV2DiffScan = "secret-scanning-v2-diff-scan", - SecretScanningV2SendNotification = "secret-scanning-v2-notification" + SecretScanningV2SendNotification = "secret-scanning-v2-notification", + CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber", + PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal" } export type TQueueJobTypes = { @@ -268,6 +273,17 @@ export type TQueueJobTypes = { 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 = [ @@ -303,10 +319,10 @@ const isQueueEnabled = (name: QueueName) => { 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 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 b12c9b0d3..7b4b9a99b 100644 --- a/backend/src/server/config/rateLimiter.ts +++ b/backend/src/server/config/rateLimiter.ts @@ -1,14 +1,12 @@ 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) => { 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..afea5c9f9 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, @@ -157,6 +159,7 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { } 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 +184,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/routes/index.ts b/backend/src/server/routes/index.ts index 5f0a45144..509a2ef1f 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"; @@ -67,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"; @@ -136,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"; @@ -203,7 +213,10 @@ import { pkiCollectionDALFactory } from "@app/services/pki-collection/pki-collec 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"; @@ -387,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); @@ -522,6 +537,7 @@ export const registerRoutes = async ( const secretApprovalPolicyService = secretApprovalPolicyServiceFactory({ projectEnvDAL, secretApprovalPolicyApproverDAL: sapApproverDAL, + secretApprovalPolicyBypasserDAL: sapBypasserDAL, permissionService, secretApprovalPolicyDAL, licenseService, @@ -633,7 +649,6 @@ export const registerRoutes = async ( const userService = userServiceFactory({ userDAL, - userAliasDAL, orgMembershipDAL, tokenService, permissionService, @@ -734,12 +749,14 @@ export const registerRoutes = async ( userAliasDAL, identityTokenAuthDAL, identityAccessTokenDAL, + orgMembershipDAL, identityOrgMembershipDAL, authService: loginService, serverCfgDAL: superAdminDAL, kmsRootConfigDAL, orgService, keyStore, + orgDAL, licenseService, kmsService, microsoftTeamsService, @@ -798,7 +815,8 @@ export const registerRoutes = async ( const projectUserAdditionalPrivilegeService = projectUserAdditionalPrivilegeServiceFactory({ permissionService, projectMembershipDAL, - projectUserAdditionalPrivilegeDAL + projectUserAdditionalPrivilegeDAL, + accessApprovalRequestDAL }); const projectKeyService = projectKeyServiceFactory({ permissionService, @@ -826,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); @@ -840,6 +860,7 @@ export const registerRoutes = async ( const pkiCollectionDAL = pkiCollectionDALFactory(db); const pkiCollectionItemDAL = pkiCollectionItemDALFactory(db); const pkiSubscriberDAL = pkiSubscriberDALFactory(db); + const pkiTemplatesDAL = pkiTemplatesDALFactory(db); const certificateService = certificateServiceFactory({ certificateDAL, @@ -851,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({ @@ -910,23 +923,6 @@ export const registerRoutes = async ( groupDAL }); - const certificateAuthorityService = certificateAuthorityServiceFactory({ - certificateAuthorityDAL, - certificateAuthorityCertDAL, - certificateAuthoritySecretDAL, - certificateAuthorityCrlDAL, - certificateTemplateDAL, - certificateAuthorityQueue, - certificateDAL, - certificateBodyDAL, - certificateSecretDAL, - pkiCollectionDAL, - pkiCollectionItemDAL, - projectDAL, - kmsService, - permissionService - }); - const certificateAuthorityCrlService = certificateAuthorityCrlServiceFactory({ certificateAuthorityDAL, certificateAuthorityCrlDAL, @@ -946,17 +942,6 @@ export const registerRoutes = async ( licenseService }); - const certificateEstService = certificateEstServiceFactory({ - certificateAuthorityService, - certificateTemplateService, - certificateTemplateDAL, - certificateAuthorityCertDAL, - certificateAuthorityDAL, - projectDAL, - kmsService, - licenseService - }); - const pkiAlertService = pkiAlertServiceFactory({ pkiAlertDAL, pkiCollectionDAL, @@ -974,20 +959,6 @@ export const registerRoutes = async ( projectDAL }); - const pkiSubscriberService = pkiSubscriberServiceFactory({ - pkiSubscriberDAL, - certificateAuthorityDAL, - certificateAuthorityCertDAL, - certificateAuthoritySecretDAL, - certificateAuthorityCrlDAL, - certificateDAL, - certificateBodyDAL, - certificateSecretDAL, - projectDAL, - kmsService, - permissionService - }); - const projectTemplateService = projectTemplateServiceFactory({ licenseService, permissionService, @@ -1023,7 +994,8 @@ export const registerRoutes = async ( secretVersionV2BridgeDAL, secretVersionTagV2BridgeDAL, resourceMetadataDAL, - appConnectionDAL + appConnectionDAL, + licenseService }); const secretQueueService = secretQueueFactory({ @@ -1269,6 +1241,7 @@ export const registerRoutes = async ( const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({ accessApprovalPolicyDAL, accessApprovalPolicyApproverDAL, + accessApprovalPolicyBypasserDAL, groupDAL, permissionService, projectEnvDAL, @@ -1277,7 +1250,8 @@ export const registerRoutes = async ( userDAL, accessApprovalRequestDAL, additionalPrivilegeDAL: projectUserAdditionalPrivilegeDAL, - accessApprovalRequestReviewerDAL + accessApprovalRequestReviewerDAL, + orgMembershipDAL }); const accessApprovalRequestService = accessApprovalRequestServiceFactory({ @@ -1640,7 +1614,8 @@ export const registerRoutes = async ( const appConnectionService = appConnectionServiceFactory({ appConnectionDAL, permissionService, - kmsService + kmsService, + licenseService }); const secretSyncService = secretSyncServiceFactory({ @@ -1651,7 +1626,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({ @@ -1693,6 +1715,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, @@ -1733,6 +1823,7 @@ export const registerRoutes = async ( await telemetryQueue.startTelemetryCheck(); await dailyResourceCleanUp.startCleanUp(); await dailyExpiringPkiItemAlert.startSendingAlerts(); + await pkiSubscriberQueue.startDailyAutoRenewalJob(); await kmsService.startService(); await microsoftTeamsService.start(); @@ -1798,12 +1889,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, diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 87d82c241..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(), @@ -272,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 8610a611b..0bade9904 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -1,7 +1,13 @@ 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 { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -161,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", 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 1fd8218f9..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 { @@ -42,7 +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 { OCIConnectionListItemSchema, SanitizedOCIConnectionSchema } from "@app/services/app-connection/oci"; +import { MySqlConnectionListItemSchema, SanitizedMySqlConnectionSchema } from "@app/services/app-connection/mysql"; import { PostgresConnectionListItemSchema, SanitizedPostgresConnectionSchema @@ -76,6 +81,7 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedVercelConnectionSchema.options, ...SanitizedPostgresConnectionSchema.options, ...SanitizedMsSqlConnectionSchema.options, + ...SanitizedMySqlConnectionSchema.options, ...SanitizedCamundaConnectionSchema.options, ...SanitizedAuth0ConnectionSchema.options, ...SanitizedHCVaultConnectionSchema.options, @@ -83,7 +89,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedWindmillConnectionSchema.options, ...SanitizedLdapConnectionSchema.options, ...SanitizedTeamCityConnectionSchema.options, - ...SanitizedOCIConnectionSchema.options + ...SanitizedOCIConnectionSchema.options, + ...SanitizedOnePassConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -99,6 +106,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ VercelConnectionListItemSchema, PostgresConnectionListItemSchema, MsSqlConnectionListItemSchema, + MySqlConnectionListItemSchema, CamundaConnectionListItemSchema, Auth0ConnectionListItemSchema, HCVaultConnectionListItemSchema, @@ -106,7 +114,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ WindmillConnectionListItemSchema, LdapConnectionListItemSchema, TeamCityConnectionListItemSchema, - OCIConnectionListItemSchema + OCIConnectionListItemSchema, + OnePassConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { 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 43f7968e7..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"; @@ -14,7 +16,7 @@ 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 { registerOCIConnectionRouter } from "./oci-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"; @@ -38,11 +40,13 @@ 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 0e4cec8e1..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: { @@ -138,7 +138,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { } }, 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: { @@ -524,7 +586,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { } }, 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 de7927573..d9ef62087 100644 --- a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -114,10 +114,12 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide CharacterType.Numbers, CharacterType.Colon, CharacterType.Period, - CharacterType.ForwardSlash + CharacterType.ForwardSlash, + CharacterType.Hyphen ])(val), { - message: "Kubernetes host must only contain alphabets, numbers, colons, periods, and forward slashes." + message: + "Kubernetes host must only contain alphabets, numbers, colons, periods, hyphen, and forward slashes." } ), caCert: z.string().trim().default("").describe(KUBERNETES_AUTH.ATTACH.caCert), @@ -234,11 +236,13 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide CharacterType.Numbers, CharacterType.Colon, CharacterType.Period, - CharacterType.ForwardSlash + CharacterType.ForwardSlash, + CharacterType.Hyphen ])(val); }, { - message: "Kubernetes host must only contain alphabets, numbers, colons, periods, and forward slashes." + message: + "Kubernetes host must only contain alphabets, numbers, colons, periods, hyphen, and forward slashes." } ), caCert: z.string().trim().optional().describe(KUBERNETES_AUTH.UPDATE.caCert), 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 018e457fa..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"; @@ -104,6 +105,16 @@ 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" }); diff --git a/backend/src/server/routes/v1/invite-org-router.ts b/backend/src/server/routes/v1/invite-org-router.ts index 501bebdab..77ae0e627 100644 --- a/backend/src/server/routes/v1/invite-org-router.ts +++ b/backend/src/server/routes/v1/invite-org-router.ts @@ -16,7 +16,12 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { 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({ @@ -115,7 +120,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/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index e14dacebb..b3fceb201 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -281,7 +281,18 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { kmsProductEnabled: z.boolean().optional(), sshProductEnabled: z.boolean().optional(), scannerProductEnabled: z.boolean().optional(), - shareSecretsProductEnabled: 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: { 200: z.object({ @@ -301,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/pki-subscriber-router.ts b/backend/src/server/routes/v1/pki-subscriber-router.ts index d04b8b4bb..761904fd1 100644 --- a/backend/src/server/routes/v1/pki-subscriber-router.ts +++ b/backend/src/server/routes/v1/pki-subscriber-router.ts @@ -5,6 +5,7 @@ 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"; @@ -90,7 +91,8 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => ttl: z .string() .trim() - .refine((val) => ms(val) > 0, "TTL must be a positive number") + .refine((val) => !val || ms(val) > 0, "TTL must be a positive number") + .optional() .describe(PKI_SUBSCRIBERS.CREATE.ttl), subjectAlternativeNames: validateAltNameField .array() @@ -108,7 +110,9 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => .array() .default([]) .transform((arr) => Array.from(new Set(arr))) - .describe(PKI_SUBSCRIBERS.CREATE.extendedKeyUsages) + .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 @@ -134,7 +138,7 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => caId: subscriber.caId ?? undefined, name: subscriber.name, commonName: subscriber.commonName, - ttl: subscriber.ttl, + ttl: subscriber.ttl ?? undefined, subjectAlternativeNames: subscriber.subjectAlternativeNames, keyUsages: subscriber.keyUsages as CertKeyUsage[], extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[] @@ -179,7 +183,7 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => ttl: z .string() .trim() - .refine((val) => ms(val) > 0, "TTL must be a positive number") + .refine((val) => !val || ms(val) > 0, "TTL must be a positive number") .optional() .describe(PKI_SUBSCRIBERS.UPDATE.ttl), keyUsages: z @@ -193,7 +197,9 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => .array() .transform((arr) => Array.from(new Set(arr))) .optional() - .describe(PKI_SUBSCRIBERS.UPDATE.extendedKeyUsages) + .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 @@ -219,7 +225,7 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => caId: subscriber.caId ?? undefined, name: subscriber.name, commonName: subscriber.commonName, - ttl: subscriber.ttl, + ttl: subscriber.ttl ?? undefined, subjectAlternativeNames: subscriber.subjectAlternativeNames, keyUsages: subscriber.keyUsages as CertKeyUsage[], extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[] @@ -278,6 +284,67 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => } }); + 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", @@ -420,6 +487,72 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => } }); + 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", diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 0ad1d9725..808e1485c 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -270,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 }; } }); @@ -304,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 @@ -382,6 +404,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.UPDATE_PROJECT, + metadata: req.body + } + }); + return { workspace }; @@ -418,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 @@ -455,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 @@ -493,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 @@ -531,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 diff --git a/backend/src/server/routes/v1/secret-sharing-router.ts b/backend/src/server/routes/v1/secret-sharing-router.ts index e712ee138..653103938 100644 --- a/backend/src/server/routes/v1/secret-sharing-router.ts +++ b/backend/src/server/routes/v1/secret-sharing-router.ts @@ -62,9 +62,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => }), body: z.object({ hashedHex: z.string().min(1).optional(), - password: z.string().optional(), - email: z.string().optional(), - hash: z.string().optional() + password: z.string().optional() }), response: { 200: z.object({ @@ -91,8 +89,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => hashedHex: req.body.hashedHex, password: req.body.password, orgId: req.permission?.orgId, - email: req.body.email, - hash: req.body.hash + actorId: req.permission?.id }); if (sharedSecret.secret?.orgId) { @@ -156,7 +153,13 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => expiresAt: z.string(), expiresAfterViews: z.number().min(1).optional(), accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization), - emails: z.string().email().array().max(100).optional() + 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 b5bd62ad6..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"; @@ -10,7 +12,6 @@ import { registerGcpSyncRouter } from "./gcp-sync-router"; import { registerGitHubSyncRouter } from "./github-sync-router"; import { registerHCVaultSyncRouter } from "./hc-vault-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; -import { registerOCIVaultSyncRouter } from "./oci-vault-sync-router"; import { registerTeamCitySyncRouter } from "./teamcity-sync-router"; import { registerTerraformCloudSyncRouter } from "./terraform-cloud-sync-router"; import { registerVercelSyncRouter } from "./vercel-sync-router"; @@ -33,5 +34,6 @@ 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/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 3d92bfb1a..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,13 +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(), @@ -206,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 }; } @@ -262,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; } }); @@ -341,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; } }); @@ -366,7 +384,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - cas: z.array(CertificateAuthoritiesSchema) + cas: z.array(InternalCertificateAuthorityResponseSchema) }) } }, 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 654eaedab..227818bf0 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -12,13 +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", - OCI = "oci" + OCI = "oci", + OnePass = "1password" } export enum AWSRegion { @@ -67,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 d66dabf23..fdf205afa 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, @@ -58,7 +69,8 @@ import { } from "./humanitec"; import { getLdapConnectionListItem, LdapConnectionMethod, validateLdapConnectionCredentials } from "./ldap"; import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; -import { getOCIConnectionListItem, OCIConnectionMethod, validateOCIConnectionCredentials } from "./oci"; +import { MySqlConnectionMethod } from "./mysql/mysql-connection-enums"; +import { getMySqlConnectionListItem } from "./mysql/mysql-connection-fns"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; import { getTeamCityConnectionListItem, @@ -92,6 +104,7 @@ export const listAppConnectionOptions = () => { getVercelConnectionListItem(), getPostgresConnectionListItem(), getMsSqlConnectionListItem(), + getMySqlConnectionListItem(), getCamundaConnectionListItem(), getAzureClientSecretsConnectionListItem(), getWindmillConnectionListItem(), @@ -99,7 +112,8 @@ export const listAppConnectionOptions = () => { getHCVaultConnectionListItem(), getLdapConnectionListItem(), getTeamCityConnectionListItem(), - getOCIConnectionListItem() + getOCIConnectionListItem(), + getOnePassConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -162,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, @@ -170,7 +185,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.HCVault]: validateHCVaultConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.LDAP]: validateLdapConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.TeamCity]: validateTeamCityConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.OCI]: validateOCIConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.OCI]: validateOCIConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.OnePass]: validateOnePassConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); @@ -201,9 +217,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: @@ -256,6 +274,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [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, @@ -265,5 +284,21 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.HCVault]: platformManagedCredentialsNotSupported, [AppConnection.LDAP]: platformManagedCredentialsNotSupported, // we could support this in the future [AppConnection.TeamCity]: platformManagedCredentialsNotSupported, - [AppConnection.OCI]: 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 a30b82345..0042fdf42 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -1,4 +1,4 @@ -import { AppConnection } from "./app-connection-enums"; +import { AppConnection, AppConnectionPlanType } from "./app-connection-enums"; export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.AWS]: "AWS", @@ -14,11 +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.OCI]: "OCI" + [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 cbee29d78..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,6 +12,7 @@ import { DiscriminativePick, OrgServiceActor } from "@app/lib/types"; import { decryptAppConnection, encryptAppConnectionCredentials, + enterpriseAppCheck, getAppConnectionMethodName, listAppConnectionOptions, TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM, @@ -18,6 +22,8 @@ import { auth0ConnectionService } from "@app/services/app-connection/auth0/auth0 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"; @@ -51,8 +57,7 @@ import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; import { humanitecConnectionService } from "./humanitec/humanitec-connection-service"; import { ValidateLdapConnectionCredentialsSchema } from "./ldap"; import { ValidateMsSqlConnectionCredentialsSchema } from "./mssql"; -import { ValidateOCIConnectionCredentialsSchema } from "./oci"; -import { ociConnectionService } from "./oci/oci-connection-service"; +import { ValidateMySqlConnectionCredentialsSchema } from "./mysql"; import { ValidatePostgresConnectionCredentialsSchema } from "./postgres"; import { ValidateTeamCityConnectionCredentialsSchema } from "./teamcity"; import { teamcityConnectionService } from "./teamcity/teamcity-connection-service"; @@ -67,6 +72,7 @@ export type TAppConnectionServiceFactoryDep = { appConnectionDAL: TAppConnectionDALFactory; permissionService: Pick; kmsService: Pick; + licenseService: Pick; }; export type TAppConnectionServiceFactory = ReturnType; @@ -84,6 +90,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record { const listAppConnectionsByOrg = async (actor: OrgServiceActor, app?: AppConnection) => { const { permission } = await permissionService.getOrgPermission( @@ -194,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, @@ -256,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, @@ -402,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, @@ -472,6 +502,7 @@ export const appConnectionServiceFactory = ({ hcvault: hcVaultConnectionService(connectAppConnectionById), windmill: windmillConnectionService(connectAppConnectionById), teamcity: teamcityConnectionService(connectAppConnectionById), - oci: ociConnectionService(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 c5c071e9a..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, @@ -82,12 +94,7 @@ import { TValidateLdapConnectionCredentialsSchema } from "./ldap"; import { TMsSqlConnection, TMsSqlConnectionInput, TValidateMsSqlConnectionCredentialsSchema } from "./mssql"; -import { - TOCIConnection, - TOCIConnectionConfig, - TOCIConnectionInput, - TValidateOCIConnectionCredentialsSchema -} from "./oci"; +import { TMySqlConnection, TMySqlConnectionInput, TValidateMySqlConnectionCredentialsSchema } from "./mysql"; import { TPostgresConnection, TPostgresConnectionInput, @@ -131,6 +138,7 @@ export type TAppConnection = { id: string } & ( | TVercelConnection | TPostgresConnection | TMsSqlConnection + | TMySqlConnection | TCamundaConnection | TAzureClientSecretsConnection | TWindmillConnection @@ -139,11 +147,12 @@ export type TAppConnection = { id: string } & ( | 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 @@ -158,6 +167,7 @@ export type TAppConnectionInput = { id: string } & ( | TVercelConnectionInput | TPostgresConnectionInput | TMsSqlConnectionInput + | TMySqlConnectionInput | TCamundaConnectionInput | TAzureClientSecretsConnectionInput | TWindmillConnectionInput @@ -166,9 +176,10 @@ export type TAppConnectionInput = { id: string } & ( | TLdapConnectionInput | TTeamCityConnectionInput | TOCIConnectionInput + | TOnePassConnectionInput ); -export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput; +export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput | TMySqlConnectionInput; export type TCreateAppConnectionDTO = Pick< TAppConnectionInput, @@ -198,7 +209,8 @@ export type TAppConnectionConfig = | THCVaultConnectionConfig | TLdapConnectionConfig | TTeamCityConnectionConfig - | TOCIConnectionConfig; + | TOCIConnectionConfig + | TOnePassConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -212,6 +224,7 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateHumanitecConnectionCredentialsSchema | TValidatePostgresConnectionCredentialsSchema | TValidateMsSqlConnectionCredentialsSchema + | TValidateMySqlConnectionCredentialsSchema | TValidateCamundaConnectionCredentialsSchema | TValidateVercelConnectionCredentialsSchema | TValidateTerraformCloudConnectionCredentialsSchema @@ -220,7 +233,8 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateHCVaultConnectionCredentialsSchema | TValidateLdapConnectionCredentialsSchema | TValidateTeamCityConnectionCredentialsSchema - | TValidateOCIConnectionCredentialsSchema; + | TValidateOCIConnectionCredentialsSchema + | TValidateOnePassConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; 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..c4c94b4fc 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, @@ -14,17 +13,14 @@ import { LdapConnectionMethod, LdapProvider } from "./ldap-connection-enums"; export const LdapConnectionSimpleBindCredentialsSchema = z.object({ provider: z.nativeEnum(LdapProvider).describe(AppConnections.CREDENTIALS.LDAP.provider), - url: z - .string() - .trim() - .min(1, "URL required") - .regex(new RE2(/^ldaps?:\/\//)) - .describe(AppConnections.CREDENTIALS.LDAP.url), + url: z.string().trim().min(1, "URL required").regex(LdapUrlRegex).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..bee85b14c 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(); @@ -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 d504e38ed..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.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); - 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.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); - 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 4820cfe00..fab62ddbf 100644 --- a/backend/src/services/certificate-authority/certificate-authority-validators.ts +++ b/backend/src/services/certificate-authority/certificate-authority-validators.ts @@ -15,7 +15,7 @@ export const validateAltNameField = z .trim() .refine( (name) => { - return isFQDN(name) || z.string().email().safeParse(name).success || isValidIp(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" 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 aafbe56f4..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, @@ -65,6 +82,7 @@ export const certificateDALFactory = (db: TDbClient) => { return { ...certificateOrm, countCertificatesInProject, - countCertificatesForPkiSubscriber + countCertificatesForPkiSubscriber, + findLatestActiveCertForSubscriber }; }; diff --git a/backend/src/services/certificate/certificate-fns.ts b/backend/src/services/certificate/certificate-fns.ts index 7eeb62d93..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) { - 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 be3f8677e..3921774fd 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -1,45 +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 { NotFoundError } from "@app/lib/errors"; +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; }; @@ -54,6 +66,8 @@ export const certificateServiceFactory = ({ certificateAuthorityCertDAL, certificateAuthorityCrlDAL, certificateAuthoritySecretDAL, + pkiCollectionDAL, + pkiCollectionItemDAL, projectDAL, kmsService, permissionService @@ -63,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 @@ -80,8 +93,7 @@ export const certificateServiceFactory = ({ ); return { - cert, - ca + cert }; }; @@ -96,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 @@ -114,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 }; @@ -132,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 @@ -151,8 +160,7 @@ export const certificateServiceFactory = ({ const deletedCert = await certificateDAL.deleteById(cert.id); return { - deletedCert, - ca + deletedCert }; }; @@ -170,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, @@ -211,7 +232,7 @@ export const certificateServiceFactory = ({ kmsService }); - return { revokedAt, cert, ca }; + return { revokedAt, cert, ca: expandInternalCa(ca) }; }; /** @@ -220,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 @@ -239,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 }); @@ -253,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 }; }; @@ -284,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 @@ -307,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 }); @@ -322,27 +572,32 @@ 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 + }); + + certificateChain = `${caCert}\n${caCertChain}`.trim(); + } let privateKey: string | null = null; try { const { certPrivateKey } = await getCertificateCredentials({ certId: cert.id, - projectId: ca.projectId, + projectId: cert.projectId, certificateSecretDAL, projectDAL, kmsService @@ -360,8 +615,7 @@ export const certificateServiceFactory = ({ certificateChain, privateKey, serialNumber, - cert, - ca + cert }; }; @@ -371,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/identity-access-token/identity-access-token-service.ts b/backend/src/services/identity-access-token/identity-access-token-service.ts index 6a082c432..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 }; 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 a3ec1bdeb..3314d8cab 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,6 +2,7 @@ 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"; @@ -185,7 +186,13 @@ export const identityKubernetesAuthServiceFactory = ({ return res.data; }; - const [k8sHost, k8sPort] = identityKubernetesAuth.kubernetesHost.split(":"); + let { kubernetesHost } = identityKubernetesAuth; + + if (kubernetesHost.startsWith("https://") || kubernetesHost.startsWith("http://")) { + kubernetesHost = new RE2("^https?:\\/\\/").replace(kubernetesHost, ""); + } + + const [k8sHost, k8sPort] = kubernetesHost.split(":"); const data = identityKubernetesAuth.gatewayId ? await $gatewayProxyWrapper( 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 7a9cb88b5..12edd266f 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 @@ -63,6 +63,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-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/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..c4f0856a1 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 @@ -206,7 +319,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 +620,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 39a1680a9..ae82cd1bc 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -24,5 +24,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ kmsProductEnabled: true, sshProductEnabled: true, scannerProductEnabled: true, - shareSecretsProductEnabled: 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 bcbd9e0e5..bfd24e639 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -361,7 +361,9 @@ export const orgServiceFactory = ({ kmsProductEnabled, sshProductEnabled, scannerProductEnabled, - shareSecretsProductEnabled + shareSecretsProductEnabled, + maxSharedSecretLifetime, + maxSharedSecretViewLimit } }: TUpdateOrgDTO) => { const appCfg = getConfig(); @@ -469,7 +471,9 @@ export const orgServiceFactory = ({ kmsProductEnabled, sshProductEnabled, scannerProductEnabled, - shareSecretsProductEnabled + shareSecretsProductEnabled, + maxSharedSecretLifetime, + maxSharedSecretViewLimit }); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); return org; @@ -823,7 +827,11 @@ 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) { @@ -1235,10 +1243,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 9625934fb..8b2485ac4 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -81,6 +81,8 @@ export type TUpdateOrgDTO = { 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-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 index 7ffeea3fa..337f81d8c 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-schema.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-schema.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + import { PkiSubscribersSchema } from "@app/db/schemas"; export const sanitizedPkiSubscriber = PkiSubscribersSchema.pick({ @@ -10,5 +12,13 @@ export const sanitizedPkiSubscriber = PkiSubscribersSchema.pick({ subjectAlternativeNames: true, ttl: true, keyUsages: true, - extendedKeyUsages: 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 index 5b15786b1..5bedbd60c 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-service.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-service.ts @@ -1,23 +1,20 @@ /* eslint-disable no-bitwise */ import { ForbiddenError, subject } from "@casl/ability"; import * as x509 from "@peculiar/x509"; -import crypto, { KeyObject } from "crypto"; -import { z } from "zod"; 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 { 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, CertExtendedKeyUsageOIDToName, @@ -27,27 +24,34 @@ import { } 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 { CaStatus } from "@app/services/certificate-authority/certificate-authority-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 { 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"; @@ -57,16 +61,24 @@ type TPkiSubscriberServiceFactoryDep = { TPkiSubscriberDALFactory, "create" | "findById" | "updateById" | "deleteById" | "transaction" | "find" | "findOne" >; - certificateAuthorityDAL: Pick; + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + "findByIdWithAssociatedCa" | "findById" | "transaction" | "create" | "updateById" | "findWithAssociatedCa" + >; certificateAuthorityCertDAL: Pick; certificateAuthoritySecretDAL: Pick; + certificateAuthorityQueue: Pick; certificateAuthorityCrlDAL: Pick; - certificateDAL: Pick; - certificateBodyDAL: Pick; - certificateSecretDAL: 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; @@ -78,11 +90,13 @@ export const pkiSubscriberServiceFactory = ({ certificateAuthoritySecretDAL, certificateAuthorityCrlDAL, certificateDAL, - certificateBodyDAL, certificateSecretDAL, + certificateBodyDAL, projectDAL, kmsService, - permissionService + permissionService, + certificateAuthorityQueue, + internalCaFns }: TPkiSubscriberServiceFactoryDep) => { const createSubscriber = async ({ name, @@ -93,6 +107,8 @@ export const pkiSubscriberServiceFactory = ({ subjectAlternativeNames, keyUsages, extendedKeyUsages, + enableAutoRenewal, + autoRenewalPeriodInDays, projectId, actorId, actorAuthMethod, @@ -115,6 +131,21 @@ export const pkiSubscriberServiceFactory = ({ }) ); + 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, @@ -124,7 +155,9 @@ export const pkiSubscriberServiceFactory = ({ ttl, subjectAlternativeNames, keyUsages, - extendedKeyUsages + extendedKeyUsages, + enableAutoRenewal, + autoRenewalPeriodInDays }); return newSubscriber; @@ -161,7 +194,18 @@ export const pkiSubscriberServiceFactory = ({ }) ); - return subscriber; + 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 ({ @@ -175,6 +219,8 @@ export const pkiSubscriberServiceFactory = ({ subjectAlternativeNames, keyUsages, extendedKeyUsages, + enableAutoRenewal, + autoRenewalPeriodInDays, actorId, actorAuthMethod, actor, @@ -202,6 +248,23 @@ export const pkiSubscriberServiceFactory = ({ }) ); + 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, @@ -210,7 +273,9 @@ export const pkiSubscriberServiceFactory = ({ ttl, subjectAlternativeNames, keyUsages, - extendedKeyUsages + extendedKeyUsages, + enableAutoRenewal, + autoRenewalPeriodInDays }); return updatedSubscriber; @@ -251,28 +316,26 @@ export const pkiSubscriberServiceFactory = ({ return subscriber; }; - const issueSubscriberCert = async ({ + const orderSubscriberCert = async ({ subscriberName, projectId, actorId, actorAuthMethod, actor, actorOrgId - }: TIssuePkiSubscriberCertDTO) => { + }: 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 ca = await certificateAuthorityDAL.findById(subscriber.caId); - if (!ca) throw new NotFoundError({ message: `CA with ID '${subscriber.caId}' not found` }); - const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId: ca.projectId, + projectId: subscriber.projectId, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.CertificateManager @@ -287,200 +350,69 @@ export const pkiSubscriberServiceFactory = ({ 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.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); - if (ca.requireTemplateForIssuance) { - throw new BadRequestError({ message: "Certificate template is required for issuance" }); - } - const caCert = await certificateAuthorityCertDAL.findById(ca.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)); - 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" }); + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId); + if (ca.internalCa?.id) { + throw new BadRequestError({ message: "CA does not support ordering of certificates" }); } - // check not after constraint - if (notAfterDate > caCertNotAfterDate) { - throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + if (ca.status !== CaStatus.ACTIVE) { + throw new BadRequestError({ message: "CA is disabled" }); } - 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=${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[]; - 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}` }); + if (ca.externalCa?.id && ca.externalCa.type === CaType.ACME) { + await certificateAuthorityQueue.orderCertificateForSubscriber({ + subscriberId: subscriber.id, + caType: ca.externalCa.type }); - const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); - extensions.push(altNamesExtension); + return subscriber; } - 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 + 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 }); - const skLeafObj = KeyObject.from(leafKeys.privateKey); - const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + 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 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 { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: subscriber.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager }); - const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ - caCertId: caCert.id, - certificateAuthorityDAL, - certificateAuthorityCertDAL, - projectDAL, - kmsService - }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSubscriberActions.IssueCert, + subject(ProjectPermissionSub.PkiSubscribers, { + name: subscriber.name + }) + ); - const certificateChainPem = `${issuingCaCertificate}\n${caCertChain}`.trim(); + if (subscriber.status !== PkiSubscriberStatus.ACTIVE) + throw new BadRequestError({ message: "Subscriber is not active" }); - const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ - plainText: Buffer.from(certificateChainPem) - }); + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId); + if (ca.internalCa?.id) { + return internalCaFns.issueCertificate(subscriber, ca); + } - 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[] - }, - 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 - }; + throw new BadRequestError({ message: "CA does not support immediate issuance of certificates" }); }; const signSubscriberCert = async ({ @@ -500,8 +432,8 @@ export const pkiSubscriberServiceFactory = ({ 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.findById(subscriber.caId); - if (!ca) throw new NotFoundError({ message: `CA with ID '${subscriber.caId}' not found` }); + 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, @@ -522,11 +454,10 @@ export const pkiSubscriberServiceFactory = ({ 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.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); - if (ca.requireTemplateForIssuance) { - throw new BadRequestError({ message: "Certificate template is required for issuance" }); - } - const caCert = await certificateAuthorityCertDAL.findById(ca.activeCaCertId); + 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, @@ -543,7 +474,7 @@ export const pkiSubscriberServiceFactory = ({ const caCertObj = new x509.X509Certificate(decryptedCaCert); const notBeforeDate = new Date(); - const notAfterDate = new Date(new Date().getTime() + ms(subscriber.ttl)); + const notAfterDate = new Date(new Date().getTime() + ms(subscriber.ttl ?? "0")); const caCertNotBeforeDate = new Date(caCertObj.notBefore); const caCertNotAfterDate = new Date(caCertObj.notAfter); @@ -557,7 +488,7 @@ export const pkiSubscriberServiceFactory = ({ throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); } - const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); const csrObj = new x509.Pkcs10CertificateRequest(csr); @@ -691,7 +622,7 @@ export const pkiSubscriberServiceFactory = ({ }); const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ - caCertId: ca.activeCaCertId, + caCertId: ca.internalCa.activeCaCertId, certificateAuthorityDAL, certificateAuthorityCertDAL, projectDAL, @@ -718,7 +649,8 @@ export const pkiSubscriberServiceFactory = ({ notBefore: notBeforeDate, notAfter: notAfterDate, keyUsages: selectedKeyUsages, - extendedKeyUsages: selectedExtendedKeyUsages + extendedKeyUsages: selectedExtendedKeyUsages, + projectId }, tx ); @@ -740,7 +672,7 @@ export const pkiSubscriberServiceFactory = ({ certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), issuingCaCertificate, serialNumber, - ca, + ca: expandInternalCa(ca), commonName: subscriber.commonName, subscriber }; @@ -793,6 +725,114 @@ export const pkiSubscriberServiceFactory = ({ }; }; + 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, @@ -800,6 +840,8 @@ export const pkiSubscriberServiceFactory = ({ deleteSubscriber, issueSubscriberCert, signSubscriberCert, - listSubscriberCerts + 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 index 690148f16..6881eea74 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-types.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-types.ts @@ -12,10 +12,12 @@ export type TCreatePkiSubscriberDTO = { name: string; commonName: string; status: PkiSubscriberStatus; - ttl: string; + ttl?: string; subjectAlternativeNames: string[]; keyUsages: CertKeyUsage[]; extendedKeyUsages: CertExtendedKeyUsage[]; + enableAutoRenewal?: boolean; + autoRenewalPeriodInDays?: number; } & TProjectPermission; export type TGetPkiSubscriberDTO = { @@ -32,6 +34,8 @@ export type TUpdatePkiSubscriberDTO = { subjectAlternativeNames?: string[]; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; + enableAutoRenewal?: boolean; + autoRenewalPeriodInDays?: number; } & TProjectPermission; export type TDeletePkiSubscriberDTO = { @@ -42,6 +46,10 @@ export type TIssuePkiSubscriberCertDTO = { subscriberName: string; } & TProjectPermission; +export type TOrderPkiSubscriberCertDTO = { + subscriberName: string; +} & TProjectPermission; + export type TSignPkiSubscriberCertDTO = { subscriberName: string; csr: string; @@ -52,3 +60,12 @@ export type TListPkiSubscriberCertsDTO = { 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/project-dal.ts b/backend/src/services/project/project-dal.ts index 43f1d57e4..bdcee1e61 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -425,6 +425,21 @@ 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, @@ -437,6 +452,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 38631a8fa..46f609777 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"; @@ -16,6 +17,7 @@ import { ProjectPermissionActions, ProjectPermissionCertificateActions, ProjectPermissionPkiSubscriberActions, + ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, ProjectPermissionSshHostActions, ProjectPermissionSub @@ -41,6 +43,7 @@ import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subsc 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"; @@ -149,7 +152,7 @@ type TProjectServiceFactoryDep = { >; projectUserMembershipRoleDAL: Pick; pkiSubscriberDAL: Pick; - certificateAuthorityDAL: Pick; + certificateAuthorityDAL: Pick; certificateDAL: Pick; certificateTemplateDAL: Pick; pkiAlertDAL: Pick; @@ -914,17 +917,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)); }; /** @@ -965,13 +971,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 }) }, @@ -1130,15 +1132,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 }) + ) + ) }; }; diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts index e56b10e46..24739b01b 100644 --- a/backend/src/services/secret-sharing/secret-sharing-service.ts +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -61,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" }); } }; @@ -97,10 +99,22 @@ 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(); - let salt: string | undefined; - let encryptedSalt: Buffer | undefined; const orgEmails = []; if (emails && emails.length > 0) { @@ -117,10 +131,6 @@ export const secretSharingServiceFactory = ({ }); } } - - // Generate salt for signing email hashes (if emails are provided) - salt = crypto.randomBytes(32).toString("hex"); - encryptedSalt = encryptWithRoot(Buffer.from(salt)); } const encryptedSecret = encryptWithRoot(Buffer.from(secretValue)); @@ -142,14 +152,13 @@ export const secretSharingServiceFactory = ({ userId: actorId, orgId, accessType, - authorizedEmails: emails && emails.length > 0 ? JSON.stringify(emails) : undefined, - encryptedSalt + 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 && salt) { + if (emails) { const user = await userDAL.findById(actorId); if (!user) { @@ -158,9 +167,6 @@ export const secretSharingServiceFactory = ({ for await (const email of emails) { try { - const hmac = crypto.createHmac("sha256", salt).update(email); - const hash = hmac.digest("hex"); - // Only show the username to emails which are part of the organization const respondentUsername = orgEmails.includes(email) ? user.username : undefined; @@ -170,7 +176,7 @@ export const secretSharingServiceFactory = ({ substitutions: { name, respondentUsername, - secretRequestUrl: `${appCfg.SITE_URL}/shared/secret/${idToReturn}?email=${encodeURIComponent(email)}&hash=${hash}` + secretRequestUrl: `${appCfg.SITE_URL}/shared/secret/${idToReturn}` }, template: SmtpTemplates.SecretRequestCompleted }); @@ -458,9 +464,8 @@ export const secretSharingServiceFactory = ({ sharedSecretId, hashedHex, orgId, - password, - email, - hash + actorId, + password }: TGetActiveSharedSecretByIdDTO) => { const sharedSecret = isUuidV4(sharedSecretId) ? await secretSharingDAL.findOne({ @@ -490,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()) { @@ -508,31 +524,6 @@ export const secretSharingServiceFactory = ({ }); } - const decryptWithRoot = kmsService.decryptWithRootKey(); - - if (sharedSecret.authorizedEmails && sharedSecret.encryptedSalt) { - // Verify both params were passed - if (!email || !hash) { - throw new BadRequestError({ - message: "This secret is email protected. Parameters must include email and hash." - }); - - // Verify that email is authorized to view shared secret - } else if (!(sharedSecret.authorizedEmails as string[]).includes(email)) { - throw new UnauthorizedError({ message: "Email not authorized to view secret" }); - - // Verify that hash matches - } else { - const salt = decryptWithRoot(sharedSecret.encryptedSalt).toString(); - const hmac = crypto.createHmac("sha256", salt).update(email); - const rebuiltHash = hmac.digest("hex"); - - if (rebuiltHash !== hash) { - throw new UnauthorizedError({ message: "Email not authorized to view secret" }); - } - } - } - // Password checks const isPasswordProtected = Boolean(sharedSecret.password); const hasProvidedPassword = Boolean(password); @@ -545,6 +536,8 @@ 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) { diff --git a/backend/src/services/secret-sharing/secret-sharing-types.ts b/backend/src/services/secret-sharing/secret-sharing-types.ts index 049dbb913..3d968edf3 100644 --- a/backend/src/services/secret-sharing/secret-sharing-types.ts +++ b/backend/src/services/secret-sharing/secret-sharing-types.ts @@ -37,11 +37,8 @@ export type TGetActiveSharedSecretByIdDTO = { sharedSecretId: string; hashedHex?: string; orgId?: string; + actorId?: string; password?: string; - - // For secrets shared with specific emails - email?: string; - hash?: string; }; export type TValidateActiveSharedSecretDTO = TGetActiveSharedSecretByIdDTO & { 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..c832fbbdb --- /dev/null +++ b/backend/src/services/secret-sync/1password/1password-sync-fns.ts @@ -0,0 +1,226 @@ +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, + 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, 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-secrets-manager/aws-secrets-manager-sync-fns.ts b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts index cef1bee15..1b1daf2ac 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 @@ -57,7 +57,7 @@ const sleep = async () => setTimeout(resolve, 1000); }); -const getSecretsRecord = async (client: SecretsManagerClient): Promise => { +const getSecretsRecord = async (client: SecretsManagerClient, keySchema?: string): Promise => { const awsSecretsRecord: TAwsSecretsRecord = {}; let hasNext = true; let nextToken: string | undefined; @@ -72,7 +72,7 @@ const getSecretsRecord = async (client: SecretsManagerClient): Promise { - if (secretEntry.Name) { + if (secretEntry.Name && matchesSchema(secretEntry.Name, keySchema)) { awsSecretsRecord[secretEntry.Name] = secretEntry; } }); @@ -311,7 +311,7 @@ export const AwsSecretsManagerSyncFns = { const client = await getSecretsManagerClient(secretSync); - const awsSecretsRecord = await getSecretsRecord(client); + const awsSecretsRecord = await getSecretsRecord(client, syncOptions.keySchema); const awsValuesRecord = await getSecretValuesRecord(client, awsSecretsRecord); @@ -468,14 +468,16 @@ export const AwsSecretsManagerSyncFns = { getSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials): Promise => { const client = await getSecretsManagerClient(secretSync); - const awsSecretsRecord = await getSecretsRecord(client); + const awsSecretsRecord = await getSecretsRecord(client, 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 ?? "" }]) ); } @@ -501,11 +503,11 @@ export const AwsSecretsManagerSyncFns = { } }, removeSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials, secretMap: TSecretMap) => { - const { destinationConfig } = secretSync; + const { destinationConfig, syncOptions } = secretSync; const client = await getSecretsManagerClient(secretSync); - const awsSecretsRecord = await getSecretsRecord(client); + const awsSecretsRecord = await getSecretsRecord(client, syncOptions.keySchema); if (destinationConfig.mappingBehavior === AwsSecretsManagerSyncMappingBehavior.OneToOne) { for await (const secretKey of Object.keys(awsSecretsRecord)) { 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/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index a0982c5b6..24f7d05f8 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -13,7 +13,8 @@ export enum SecretSync { Windmill = "windmill", HCVault = "hashicorp-vault", TeamCity = "teamcity", - OCIVault = "oci-vault" + OCIVault = "oci-vault", + OnePass = "1password" } export enum SecretSyncInitialSyncBehavior { @@ -26,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 1bb4da9db..dbf3a3699 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -1,6 +1,9 @@ import { AxiosError } from "axios"; import RE2 from "re2"; +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 @@ -11,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, @@ -21,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"; @@ -29,7 +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 { OCI_VAULT_SYNC_LIST_OPTION, OCIVaultSyncFns } from "./oci-vault"; +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"; @@ -50,7 +54,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.Windmill]: WINDMILL_SYNC_LIST_OPTION, [SecretSync.HCVault]: HC_VAULT_SYNC_LIST_OPTION, [SecretSync.TeamCity]: TEAMCITY_SYNC_LIST_OPTION, - [SecretSync.OCIVault]: OCI_VAULT_SYNC_LIST_OPTION + [SecretSync.OCIVault]: OCI_VAULT_SYNC_LIST_OPTION, + [SecretSync.OnePass]: ONEPASS_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -171,6 +176,8 @@ export const SecretSyncFns = { 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}` @@ -239,6 +246,9 @@ export const SecretSyncFns = { 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}` @@ -297,6 +307,8 @@ export const SecretSyncFns = { 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}` @@ -327,3 +339,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 21cb912b4..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", @@ -16,7 +16,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Windmill]: "Windmill", [SecretSync.HCVault]: "Hashicorp Vault", [SecretSync.TeamCity]: "TeamCity", - [SecretSync.OCIVault]: "OCI Vault" + [SecretSync.OCIVault]: "OCI Vault", + [SecretSync.OnePass]: "1Password" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -34,5 +35,25 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Windmill]: AppConnection.Windmill, [SecretSync.HCVault]: AppConnection.HCVault, [SecretSync.TeamCity]: AppConnection.TeamCity, - [SecretSync.OCIVault]: AppConnection.OCI + [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-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 64d027e18..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, @@ -67,7 +79,6 @@ import { THumanitecSyncListItem, THumanitecSyncWithCredentials } from "./humanitec"; -import { TOCIVaultSync, TOCIVaultSyncInput, TOCIVaultSyncListItem, TOCIVaultSyncWithCredentials } from "./oci-vault"; import { TTeamCitySync, TTeamCitySyncInput, @@ -97,7 +108,8 @@ export type TSecretSync = | TWindmillSync | THCVaultSync | TTeamCitySync - | TOCIVaultSync; + | TOCIVaultSync + | TOnePassSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -114,7 +126,8 @@ export type TSecretSyncWithCredentials = | TWindmillSyncWithCredentials | THCVaultSyncWithCredentials | TTeamCitySyncWithCredentials - | TOCIVaultSyncWithCredentials; + | TOCIVaultSyncWithCredentials + | TOnePassSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -131,7 +144,8 @@ export type TSecretSyncInput = | TWindmillSyncInput | THCVaultSyncInput | TTeamCitySyncInput - | TOCIVaultSyncInput; + | TOCIVaultSyncInput + | TOnePassSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -148,7 +162,8 @@ export type TSecretSyncListItem = | TWindmillSyncListItem | THCVaultSyncListItem | TTeamCitySyncListItem - | TOCIVaultSyncListItem; + | TOCIVaultSyncListItem + | TOnePassSyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; 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/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 22fc51d1a..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,7 +21,9 @@ 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"; @@ -33,7 +35,8 @@ import { TAdminBootstrapInstanceDTO, TAdminGetIdentitiesDTO, TAdminGetUsersDTO, - TAdminSignUpDTO + TAdminSignUpDTO, + TGetOrganizationsDTO } from "./super-admin-types"; type TSuperAdminServiceFactoryDep = { @@ -41,6 +44,8 @@ type TSuperAdminServiceFactoryDep = { identityTokenAuthDAL: TIdentityTokenAuthDALFactory; identityAccessTokenDAL: TIdentityAccessTokenDALFactory; identityOrgMembershipDAL: TIdentityOrgDALFactory; + orgDAL: TOrgDALFactory; + orgMembershipDAL: TOrgMembershipDALFactory; serverCfgDAL: TSuperAdminDALFactory; userDAL: TUserDALFactory; userAliasDAL: Pick; @@ -73,6 +78,8 @@ export const superAdminServiceFactory = ({ serverCfgDAL, userDAL, identityDAL, + orgDAL, + orgMembershipDAL, userAliasDAL, authService, orgService, @@ -257,8 +264,8 @@ export const superAdminServiceFactory = ({ const adminSignUp = async ({ lastName, firstName, - salt, email, + salt, password, verifier, publicKey, @@ -272,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, { @@ -292,8 +300,8 @@ export const superAdminServiceFactory = ({ { firstName, lastName, - username: email, - email, + username: sanitizedEmail, + email: sanitizedEmail, superAdmin: true, isGhost: false, isAccepted: true, @@ -348,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) => { @@ -361,8 +370,8 @@ export const superAdminServiceFactory = ({ { firstName: "Admin", lastName: "User", - username: email, - email, + username: sanitizedEmail, + email: sanitizedEmail, superAdmin: true, isGhost: false, isAccepted: true, @@ -372,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( { @@ -519,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, @@ -661,6 +711,9 @@ export const superAdminServiceFactory = ({ deleteIdentitySuperAdminAccess, deleteUserSuperAdminAccess, invalidateCache, - checkIfInvalidatingCache + 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 c804bed74..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", diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index eba497f0f..b5a29fc8c 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -8,16 +8,18 @@ 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 getUsersByFilter = async ({ limit, @@ -41,7 +43,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 +67,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 +169,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 +233,7 @@ export const userDALFactory = (db: TDbClient) => { createUserEncryption, findOneUserAction, createUserAction, - getUsersByFilter + getUsersByFilter, + findAllMyAccounts }; }; 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/go.mod b/cli/go.mod index a2b256f8a..229e37137 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -5,6 +5,8 @@ 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 @@ -19,10 +21,10 @@ require ( 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 @@ -43,10 +47,8 @@ require ( 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/BobuSumisu/aho-corasick v1.0.3 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect - github.com/Masterminds/sprig/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 @@ -65,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 @@ -105,13 +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.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 @@ -122,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 cb1b1c1cf..d253c70d5 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -127,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= @@ -147,11 +148,11 @@ 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= @@ -307,6 +308,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= @@ -373,8 +376,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= @@ -385,12 +386,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= @@ -402,6 +406,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= @@ -420,7 +426,6 @@ 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= @@ -451,9 +456,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= @@ -661,6 +672,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/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/login.go b/cli/packages/cmd/login.go index b1c868d8c..b0ce7564b 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" @@ -981,7 +984,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/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/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/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/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 index be57ab01b..c9c71c80d 100644 --- a/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx +++ b/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx @@ -1,4 +1,4 @@ --- title: "Issue Certificate" -openapi: "POST /api/v1/pki/subscribers/{subscriberName}/issue-cert" +openapi: "POST /api/v1/pki/subscribers/{subscriberName}/issue-certificate" --- 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/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-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/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/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 2cf4edc0e..66c4c706a 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,21 +94,29 @@ 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 + + + 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. + 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. - 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-redis.png) @@ -123,14 +131,14 @@ The Infisical AWS ElastiCache dynamic secret allows you to generate AWS ElastiCa - 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) ## 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 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) @@ -141,4 +149,4 @@ To extend the life of the generated dynamic secret leases past its initial time Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret - \ No newline at end of file + diff --git a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx index 730e2b287..56a10419c 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/\ @@ -94,28 +94,36 @@ Replace **\** with your AWS account id and **\** w - 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 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 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 + 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 ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png) - 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. ![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) @@ -130,14 +138,14 @@ Replace **\** with your AWS account id and **\** w - Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + 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) diff --git a/docs/documentation/platform/dynamic-secrets/cassandra.mdx b/docs/documentation/platform/dynamic-secrets/cassandra.mdx index e7ec4f69d..628432bea 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,39 @@ 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 + + + 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 +116,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 0e1bc5104..6c5028bb2 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,95 +34,115 @@ 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 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 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 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. - + + 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. - + + 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. - ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-input-modal-elastic-search.png) + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + +![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. + 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. + - 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-redis.png) - ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.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 fall 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. + +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 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..d6d051ff7 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/kubernetes.mdx @@ -0,0 +1,243 @@ +--- +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 + +## 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 + +## RBAC Configuration + +Before you can start generating dynamic service account tokens, you'll need to configure the appropriate permissions in your Kubernetes cluster. This involves setting up Role-Based Access Control (RBAC) to allow the creation and management of service account tokens. + +The RBAC configuration serves a crucial security purpose: it creates a dedicated service account with minimal permissions that can only create and manage service account tokens. This follows the principle of least privilege, ensuring that the token generation process is secure and controlled. + +The following RBAC configuration creates the necessary permissions for generating service account tokens: + +```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 +``` + +This configuration: + +1. Creates a `ClusterRole` named `tokenrequest` that allows: + - Creating and getting service account tokens + - Getting service account information +2. Creates a `ClusterRoleBinding` that binds the role to a service account named `infisical-token-requester` in the `default` namespace + +You can customize the service account name and namespace according to your needs. + +## Obtaining the Cluster Token + +After setting up the RBAC configuration, you need to obtain a token for the service account that will be used to create dynamic secrets. Here's how to get the token: + +1. Create a service account in your Kubernetes cluster that will be used to create service account tokens: + +```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. Create a long-lived service account token using this configuration file: + +```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 +``` + +3. Link the secret to the service account: + +```bash +kubectl patch serviceaccount infisical-token-requester -p '{"secrets": [{"name": "infisical-token-requester-token"}]}' -n default +``` + +4. Retrieve the token: + +```bash +kubectl get secret infisical-token-requester-token -n default -o=jsonpath='{.data.token}' | base64 --decode +``` + +This token will be used as the "Cluster Token" in the dynamic secret configuration. + +## Obtaining the Cluster URL + +The cluster URL is the address of your Kubernetes API server. The simplest way to find it is to use the `kubectl cluster-info` command: + +```bash +kubectl cluster-info +``` + +This command works for all Kubernetes environments (managed services like GKE, EKS, AKS, or self-hosted clusters) and will show you the Kubernetes control plane address, which is your cluster URL. + + + Make sure the cluster URL is accessible from where you're running Infisical. + If you're using a private cluster, you'll need to configure a [Gateway](/documentation/platform/gateways/overview) to + access it. + + +## 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) + + + + 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. + + + Token with permissions to create service account tokens + + + Name of the service account to generate tokens for + + + Kubernetes namespace where the service account exists + + + Optional list of audiences to include in the generated token + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes.png) + + + + 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 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 a1731432c..1a3eca404 100644 --- a/docs/documentation/platform/dynamic-secrets/ldap.mdx +++ b/docs/documentation/platform/dynamic-secrets/ldap.mdx @@ -123,6 +123,13 @@ 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 + diff --git a/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx b/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx index 5eda1669e..5d27d16e2 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,104 @@ 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 + + + + 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. + 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. + 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 fall 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. + +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 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 ec384f7a9..f71922473 100644 --- a/docs/documentation/platform/dynamic-secrets/mongo-db.mdx +++ b/docs/documentation/platform/dynamic-secrets/mongo-db.mdx @@ -62,25 +62,32 @@ Create a user with the required permission in your MongoDB instance. This user w 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` ``. - + 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 + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-mongodb.png) - 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 certificate. + If this step fails, you may have to add the CA certificate. - 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) @@ -95,14 +102,14 @@ Create a user with the required permission in your MongoDB instance. This user w - 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) ## 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 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 2a279ce90..0a73bf129 100644 --- a/docs/documentation/platform/dynamic-secrets/mssql.mdx +++ b/docs/documentation/platform/dynamic-secrets/mssql.mdx @@ -62,7 +62,7 @@ Create a user with the required permission in your SQL instance. This user will 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). @@ -71,22 +71,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-statements-mssql.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-statements-mssql.png) + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + + + 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. + 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) @@ -101,14 +109,14 @@ Create a user with the required permission in your SQL instance. This user will - 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 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) diff --git a/docs/documentation/platform/dynamic-secrets/mysql.mdx b/docs/documentation/platform/dynamic-secrets/mysql.mdx index f88a88d35..6f708ebba 100644 --- a/docs/documentation/platform/dynamic-secrets/mysql.mdx +++ b/docs/documentation/platform/dynamic-secrets/mysql.mdx @@ -61,29 +61,37 @@ Create a user with the required permission in your SQL instance. This user will 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). - 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 + + + 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. + 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. ![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) @@ -98,14 +106,14 @@ Create a user with the required permission in your SQL instance. This user will - 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) ## 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 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) @@ -116,4 +124,4 @@ To extend the life of the generated dynamic secret leases past its initial time Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret - \ No newline at end of file + diff --git a/docs/documentation/platform/dynamic-secrets/oracle.mdx b/docs/documentation/platform/dynamic-secrets/oracle.mdx index c7b34bec9..02b379b98 100644 --- a/docs/documentation/platform/dynamic-secrets/oracle.mdx +++ b/docs/documentation/platform/dynamic-secrets/oracle.mdx @@ -61,7 +61,7 @@ Create a user with the required permission in your SQL instance. This user will 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). @@ -70,20 +70,30 @@ Create a user with the required permission in your SQL instance. This user will + ![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 + + 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. + 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) @@ -98,14 +108,14 @@ Create a user with the required permission in your SQL instance. This user will - 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 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) @@ -116,4 +126,4 @@ To extend the life of the generated dynamic secret leases past its initial time Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret - \ No newline at end of file + diff --git a/docs/documentation/platform/dynamic-secrets/postgresql.mdx b/docs/documentation/platform/dynamic-secrets/postgresql.mdx index feb81d6d6..f13c9c762 100644 --- a/docs/documentation/platform/dynamic-secrets/postgresql.mdx +++ b/docs/documentation/platform/dynamic-secrets/postgresql.mdx @@ -62,7 +62,7 @@ Create a user with the required permission in your SQL instance. This user will 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). @@ -71,22 +71,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-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 + + + 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. + 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) @@ -101,14 +109,14 @@ Create a user with the required permission in your SQL instance. This user will - 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 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) diff --git a/docs/documentation/platform/dynamic-secrets/rabbit-mq.mdx b/docs/documentation/platform/dynamic-secrets/rabbit-mq.mdx index 6ac5ac069..09c04e61b 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,98 +18,113 @@ 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. + - - 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. - - A CA may be required if your DB requires it for incoming connections. This is often the case when connecting to a managed service. - +Allowed template variables are +- `{{randomUsername}}`: Random username string +- `{{unixTimestamp}}`: Current Unix timestamp + - ![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. + 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. + - 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-redis.png) - ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.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 fall 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. + +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 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 43fbc6b61..b3e585204 100644 --- a/docs/documentation/platform/dynamic-secrets/redis.mdx +++ b/docs/documentation/platform/dynamic-secrets/redis.mdx @@ -56,21 +56,29 @@ 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 + + + 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. + 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. - 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-redis.png) @@ -85,14 +93,14 @@ Create a user with the required permission in your Redis instance. This user wil - 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) ## 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 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) @@ -103,4 +111,4 @@ To extend the life of the generated dynamic secret leases past its initial time Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret - \ No newline at end of file + diff --git a/docs/documentation/platform/dynamic-secrets/sap-ase.mdx b/docs/documentation/platform/dynamic-secrets/sap-ase.mdx index 3b7a895fb..2737ab084 100644 --- a/docs/documentation/platform/dynamic-secrets/sap-ase.mdx +++ b/docs/documentation/platform/dynamic-secrets/sap-ase.mdx @@ -62,21 +62,30 @@ 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 + + + +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..597d69803 100644 --- a/docs/documentation/platform/dynamic-secrets/sap-hana.mdx +++ b/docs/documentation/platform/dynamic-secrets/sap-hana.mdx @@ -62,14 +62,25 @@ 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 + + + + 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 +91,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..86378bbf6 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,23 @@ 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 + + + + 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 +122,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 +138,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/identities/universal-auth.mdx b/docs/documentation/platform/identities/universal-auth.mdx index 44f468a17..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 @@ -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,11 +75,12 @@ 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. @@ -96,6 +99,7 @@ using the Universal Auth authentication method. - 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. @@ -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: + + 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. - - 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. + In certain cases, you may want to extend the lifespan of an access token; to do so, you must set a max TTL parameter. - 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. -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/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/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/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/subscribers.mdx b/docs/documentation/platform/pki/subscribers.mdx index 3aebe50e2..1903d246a 100644 --- a/docs/documentation/platform/pki/subscribers.mdx +++ b/docs/documentation/platform/pki/subscribers.mdx @@ -24,7 +24,7 @@ A[Issuing CA] --> C1[Certificate] 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, TLL, etc. +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 @@ -49,17 +49,32 @@ In the following steps, we explore how to issue a X.509 certificate for a subscr ![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) - Here's some guidance on each field. + 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. + - **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). @@ -68,6 +83,10 @@ In the following steps, we explore how to issue a X.509 certificate for a subscr 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. @@ -123,8 +142,13 @@ openssl verify -verbose -crl_check -crl_download -CAfile chain.pem cert.pem - To renew a certificate, you have to issue a new certificate for the same - subscriber. The original certificate will continue to be valid through its - original TTL unless explicitly revoked. + 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 ffa85f6c5..c248c4dfa 100644 --- a/docs/documentation/platform/pr-workflows.mdx +++ b/docs/documentation/platform/pr-workflows.mdx @@ -33,6 +33,14 @@ 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. 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-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/sso/auth0-oidc.mdx b/docs/documentation/platform/sso/auth0-oidc.mdx index 0665a7b30..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) @@ -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 562360ecb..22ef00c89 100644 --- a/docs/documentation/platform/sso/auth0-saml.mdx +++ b/docs/documentation/platform/sso/auth0-saml.mdx @@ -23,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": "", @@ -76,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. @@ -96,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 137dc6564..0957dc4d1 100644 --- a/docs/documentation/platform/sso/azure.mdx +++ b/docs/documentation/platform/sso/azure.mdx @@ -5,7 +5,7 @@ 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. @@ -26,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. @@ -89,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. @@ -109,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/overview.mdx b/docs/documentation/platform/sso/general-oidc/overview.mdx index 76ac982f8..07ddaaedd 100644 --- a/docs/documentation/platform/sso/general-oidc/overview.mdx +++ b/docs/documentation/platform/sso/general-oidc/overview.mdx @@ -70,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 99223c815..84888b2f9 100644 --- a/docs/documentation/platform/sso/google-saml.mdx +++ b/docs/documentation/platform/sso/google-saml.mdx @@ -24,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. @@ -84,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 0898c0715..3cad22247 100644 --- a/docs/documentation/platform/sso/jumpcloud.mdx +++ b/docs/documentation/platform/sso/jumpcloud.mdx @@ -5,7 +5,7 @@ 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. @@ -83,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/overview.mdx b/docs/documentation/platform/sso/keycloak-oidc/overview.mdx index 06d8dfa43..2c75fc6fe 100644 --- a/docs/documentation/platform/sso/keycloak-oidc/overview.mdx +++ b/docs/documentation/platform/sso/keycloak-oidc/overview.mdx @@ -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 ba6aa0c3a..daca360b4 100644 --- a/docs/documentation/platform/sso/keycloak-saml.mdx +++ b/docs/documentation/platform/sso/keycloak-saml.mdx @@ -5,7 +5,7 @@ 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. @@ -13,36 +13,36 @@ description: "Learn how to configure Keycloak SAML for Infisical SSO." 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 2af689e4c..ecdf6ca39 100644 --- a/docs/documentation/platform/sso/okta.mdx +++ b/docs/documentation/platform/sso/okta.mdx @@ -93,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/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/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/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.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png index d412109fa..0ba6aa172 100644 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes.png new file mode 100644 index 000000000..011dfadc7 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes.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/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/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-create-2.png b/docs/images/platform/pki/subscriber/subscriber-create-2.png index fdfa44d27..25ff2c190 100644 Binary files a/docs/images/platform/pki/subscriber/subscriber-create-2.png 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/pr-workflows/create-change-policy.png b/docs/images/platform/pr-workflows/create-change-policy.png index afe945b0a..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/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/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/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/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 index ff51ce1d9..58fb3c1d3 100644 --- a/docs/integrations/app-connections/oci.mdx +++ b/docs/integrations/app-connections/oci.mdx @@ -3,6 +3,13 @@ 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 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/secret-syncs/1password.mdx b/docs/integrations/secret-syncs/1password.mdx new file mode 100644 index 000000000..a33f54c8d --- /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. + + 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/oci-vault.mdx b/docs/integrations/secret-syncs/oci-vault.mdx index 67a3426aa..00b7120e7 100644 --- a/docs/integrations/secret-syncs/oci-vault.mdx +++ b/docs/integrations/secret-syncs/oci-vault.mdx @@ -3,6 +3,13 @@ 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) diff --git a/docs/integrations/secret-syncs/overview.mdx b/docs/integrations/secret-syncs/overview.mdx index 87527fd02..65ec4e6cf 100644 --- a/docs/integrations/secret-syncs/overview.mdx +++ b/docs/integrations/secret-syncs/overview.mdx @@ -99,6 +99,8 @@ via the UI or API for the third-party service you intend to sync secrets to. 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. + **Example:** - Infisical key: `SECRET_1` - Schema: `INFISICAL_{{secretKey}}` diff --git a/docs/internals/bug-bounty.mdx b/docs/internals/bug-bounty.mdx index e45de05bf..fddd41683 100644 --- a/docs/internals/bug-bounty.mdx +++ b/docs/internals/bug-bounty.mdx @@ -10,8 +10,8 @@ We value reports that help identify vulnerabilities that affect the integrity of ### 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. +- You will receive follow ups from our team if we deam your report to be a legitimate vulnerability or need further clarification. We do not respond to spam, auto generated reports, inaccurate claims, or submissions that are clearly out of scope. + ### What's in Scope? @@ -27,7 +27,7 @@ Bounties are based on severity, impact, and exploitability, as well as whether t | --- | --- | --- | | **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 | +| **Medium** | Info disclosure, scoped DoS (e.g. ReDoS with auth), or minor access control issues | $100 - $1,000 | | **Low / Informational** | Missing headers, CSP warnings, theoretical flaws, self-hosting misconfigurations | Recognition only | diff --git a/docs/internals/permissions/organization-permissions.mdx b/docs/internals/permissions/organization-permissions.mdx index 6de3bd6fe..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 diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index c279e32f0..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` diff --git a/docs/mint.json b/docs/mint.json index e4de2100a..9be86c64b 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -112,11 +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" + ] + } ] }, { @@ -191,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,7 +224,8 @@ "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" ] }, { @@ -447,6 +457,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" @@ -475,6 +486,7 @@ { "group": "Connections", "pages": [ + "integrations/app-connections/1password", "integrations/app-connections/auth0", "integrations/app-connections/aws", "integrations/app-connections/azure-app-configuration", @@ -489,6 +501,7 @@ "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", @@ -506,6 +519,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", @@ -1000,6 +1014,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": [ @@ -1088,6 +1115,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": [ @@ -1256,6 +1295,18 @@ "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": [ @@ -1335,6 +1386,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": [ @@ -1578,12 +1643,34 @@ "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/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", diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index e08f8239d..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) @@ -658,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/frontend/package-lock.json b/frontend/package-lock.json index 121dcd094..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", diff --git a/frontend/package.json b/frontend/package.json index 7cd636343..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", 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/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/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx index ef2691d69..3d54bbb20 100644 --- a/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx +++ b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx @@ -32,7 +32,7 @@ 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() diff --git a/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx index 5b5728dac..216253653 100644 --- a/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx +++ b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx @@ -21,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() }); 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-syncs/SecretSyncSelect.tsx b/frontend/src/components/secret-syncs/SecretSyncSelect.tsx index cbcba4513..62d99544f 100644 --- a/frontend/src/components/secret-syncs/SecretSyncSelect.tsx +++ b/frontend/src/components/secret-syncs/SecretSyncSelect.tsx @@ -2,16 +2,23 @@ import { faWrench } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Spinner, Tooltip } from "@app/components/v2"; +import { useSubscription } from "@app/context"; import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; +import { usePopUp } from "@app/hooks"; import { SecretSync, useSecretSyncOptions } from "@app/hooks/api/secretSyncs"; +import { UpgradePlanModal } from "../license/UpgradePlanModal"; + type Props = { onSelect: (destination: SecretSync) => void; }; export const SecretSyncSelect = ({ onSelect }: Props) => { + const { subscription } = useSubscription(); const { isPending, data: secretSyncOptions } = useSecretSyncOptions(); + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); + if (isPending) { return (
@@ -23,14 +30,17 @@ export const SecretSyncSelect = ({ onSelect }: Props) => { return (
- {secretSyncOptions?.map(({ destination }) => { + {secretSyncOptions?.map(({ destination, enterprise }) => { const { image, name } = SECRET_SYNC_MAP[destination]; return ( ); })} + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use every Secret Sync if you switch to Infisical's Enterprise plan." + /> { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.OnePass } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + + const { data: vaults, isLoading: isVaultsLoading } = useOnePassConnectionListVaults( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + return ( + <> + { + setValue("destinationConfig.vaultId", ""); + }} + /> + + ( + +
+ Don't see the vault you're looking for?{" "} + +
+
+ } + > + v.id === value) ?? null} + onChange={(option) => onChange((option as SingleValue)?.id ?? null)} + options={vaults} + placeholder="Select a vault..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 2cac1ae20..48541b272 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -3,6 +3,7 @@ import { useFormContext } from "react-hook-form"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { TSecretSyncForm } from "../schemas"; +import { OnePassSyncFields } from "./1PasswordSyncFields"; import { AwsParameterStoreSyncFields } from "./AwsParameterStoreSyncFields"; import { AwsSecretsManagerSyncFields } from "./AwsSecretsManagerSyncFields"; import { AzureAppConfigurationSyncFields } from "./AzureAppConfigurationSyncFields"; @@ -55,6 +56,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.OCIVault: return ; + case SecretSync.OnePass: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 7c2b13936..6050cab21 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -49,6 +49,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Windmill: case SecretSync.HCVault: case SecretSync.TeamCity: + case SecretSync.OnePass: case SecretSync.OCIVault: AdditionalSyncOptionsFieldsComponent = null; break; @@ -145,11 +146,15 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { href="https://infisical.com/docs/integrations/secret-syncs/overview#key-schemas" target="_blank" rel="noopener noreferrer" + className="underline" > Key Schema
{" "} to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. +
+
+ Destination secrets that do not match the schema will not be deleted or updated. } > diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/OnePassSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/OnePassSyncReviewFields.tsx new file mode 100644 index 000000000..1c31fb6c3 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/OnePassSyncReviewFields.tsx @@ -0,0 +1,12 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const OnePassSyncReviewFields = () => { + const { watch } = useFormContext(); + const vaultId = watch("destinationConfig.vaultId"); + + return {vaultId}; +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 144ccb2a8..a53eec5e7 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -24,6 +24,7 @@ import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; import { HCVaultSyncReviewFields } from "./HCVaultSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields"; +import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields"; import { TeamCitySyncReviewFields } from "./TeamCitySyncReviewFields"; import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields"; import { VercelSyncReviewFields } from "./VercelSyncReviewFields"; @@ -96,6 +97,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.OCIVault: DestinationFieldsComponent = ; break; + case SecretSync.OnePass: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/schemas/1password-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/1password-sync-destination-schema.ts new file mode 100644 index 000000000..36b144776 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/1password-sync-destination-schema.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const OnePassSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.OnePass), + destinationConfig: z.object({ + vaultId: z.string().trim().min(1, "Vault ID required") + }) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index 232b8cedf..792226dae 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { OnePassSyncDestinationSchema } from "./1password-sync-destination-schema"; import { AwsParameterStoreSyncDestinationSchema } from "./aws-parameter-store-sync-destination-schema"; import { AwsSecretsManagerSyncDestinationSchema } from "./aws-secrets-manager-sync-destination-schema"; import { AzureAppConfigurationSyncDestinationSchema } from "./azure-app-configuration-sync-destination-schema"; @@ -31,7 +32,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ WindmillSyncDestinationSchema, HCVaultSyncDestinationSchema, TeamCitySyncDestinationSchema, - OCIVaultSyncDestinationSchema + OCIVaultSyncDestinationSchema, + OnePassSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx index dd6d3575e..a42ee02a5 100644 --- a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx +++ b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx @@ -51,6 +51,7 @@ type Props = Omit, "onChange" | "val isVisible?: boolean; isReadOnly?: boolean; isDisabled?: boolean; + canEditButNotView?: boolean; secretPath?: string; environment?: string; containerClassName?: string; @@ -70,6 +71,7 @@ export const InfisicalSecretInput = forwardRef( containerClassName, secretPath: propSecretPath, environment: propEnvironment, + canEditButNotView, ...props }, ref @@ -273,6 +275,7 @@ export const InfisicalSecretInput = forwardRef( { @@ -51,6 +52,7 @@ type Props = TextareaHTMLAttributes & { isReadOnly?: boolean; isDisabled?: boolean; containerClassName?: string; + canEditButNotView?: boolean; }; const commonClassName = "font-mono text-sm caret-white border-none outline-none w-full break-all"; @@ -66,6 +68,7 @@ export const SecretInput = forwardRef( isDisabled, isReadOnly, onFocus, + canEditButNotView, ...props }, ref @@ -93,7 +96,15 @@ export const SecretInput = forwardRef( onFocus={(evt) => { onFocus?.(evt); setIsSecretFocused.on(); - evt.currentTarget.select(); + if (canEditButNotView && value === HIDDEN_SECRET_VALUE) { + evt.currentTarget.select(); + } + }} + onMouseDown={(e) => { + if (canEditButNotView && value === HIDDEN_SECRET_VALUE) { + e.preventDefault(); + e.currentTarget.select(); + } }} disabled={isDisabled} spellCheck={false} diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx index 05ca539ef..91c376662 100644 --- a/frontend/src/components/v2/Select/Select.tsx +++ b/frontend/src/components/v2/Select/Select.tsx @@ -125,6 +125,7 @@ export const SelectItem = forwardRef( return ( & PkiTemplateSubjectFields) + ) + ] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index 91fcd9055..833956d77 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -2,6 +2,7 @@ export { useOrganization } from "./OrganizationContext"; export type { TOrgPermission } from "./OrgPermissionContext"; export { OrgPermissionActions, + OrgPermissionBillingActions, OrgPermissionGroupActions, OrgPermissionIdentityActions, OrgPermissionSubjects, @@ -18,6 +19,7 @@ export { ProjectPermissionKmipActions, ProjectPermissionMemberActions, ProjectPermissionPkiSubscriberActions, + ProjectPermissionPkiTemplateActions, ProjectPermissionSshHostActions, ProjectPermissionSub, useProjectPermission diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 73f47d812..d36bb55fd 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -25,6 +25,8 @@ import { HumanitecConnectionMethod, LdapConnectionMethod, MsSqlConnectionMethod, + MySqlConnectionMethod, + OnePassConnectionMethod, PostgresConnectionMethod, TAppConnection, TeamCityConnectionMethod, @@ -36,7 +38,7 @@ import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-con export const APP_CONNECTION_MAP: Record< AppConnection, - { name: string; image: string; size?: number; icon?: IconDefinition } + { name: string; image: string; size?: number; icon?: IconDefinition; enterprise?: boolean } > = { [AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" }, [AppConnection.GitHub]: { name: "GitHub", image: "GitHub.png" }, @@ -64,13 +66,15 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.Vercel]: { name: "Vercel", image: "Vercel.png" }, [AppConnection.Postgres]: { name: "PostgreSQL", image: "Postgres.png" }, [AppConnection.MsSql]: { name: "Microsoft SQL Server", image: "MsSql.png" }, + [AppConnection.MySql]: { name: "MySQL", image: "MySql.png" }, [AppConnection.Camunda]: { name: "Camunda", image: "Camunda.png" }, [AppConnection.Windmill]: { name: "Windmill", image: "Windmill.png" }, [AppConnection.Auth0]: { name: "Auth0", image: "Auth0.png", size: 40 }, [AppConnection.HCVault]: { name: "Hashicorp Vault", image: "Vault.png", size: 65 }, [AppConnection.LDAP]: { name: "LDAP", image: "LDAP.png", size: 65 }, [AppConnection.TeamCity]: { name: "TeamCity", image: "TeamCity.png" }, - [AppConnection.OCI]: { name: "OCI", image: "Oracle.png" } + [AppConnection.OCI]: { name: "OCI", image: "Oracle.png", enterprise: true }, + [AppConnection.OnePass]: { name: "1Password", image: "1Password.png" } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -98,9 +102,11 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case HumanitecConnectionMethod.ApiToken: case TerraformCloudConnectionMethod.ApiToken: case VercelConnectionMethod.ApiToken: + case OnePassConnectionMethod.ApiToken: return { name: "API Token", icon: faKey }; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: + case MySqlConnectionMethod.UsernameAndPassword: return { name: "Username & Password", icon: faLock }; case HCVaultConnectionMethod.AccessToken: case TeamCityConnectionMethod.AccessToken: diff --git a/frontend/src/helpers/secretRotationsV2.ts b/frontend/src/helpers/secretRotationsV2.ts index 451658135..a654a1782 100644 --- a/frontend/src/helpers/secretRotationsV2.ts +++ b/frontend/src/helpers/secretRotationsV2.ts @@ -15,6 +15,11 @@ export const SECRET_ROTATION_MAP: Record< image: "MsSql.png", size: 50 }, + [SecretRotation.MySqlCredentials]: { + name: "MySQL Credentials", + image: "MySql.png", + size: 50 + }, [SecretRotation.Auth0ClientSecret]: { name: "Auth0 Client Secret", image: "Auth0.png", @@ -40,6 +45,7 @@ export const SECRET_ROTATION_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.LdapPassword]: AppConnection.LDAP, @@ -50,6 +56,7 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record = { [SecretRotation.PostgresCredentials]: true, [SecretRotation.MsSqlCredentials]: true, + [SecretRotation.MySqlCredentials]: true, [SecretRotation.Auth0ClientSecret]: false, [SecretRotation.AzureClientSecret]: true, [SecretRotation.LdapPassword]: false, diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 80df92ac3..88a0f7517 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -51,6 +51,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.Windmill]: AppConnection.Windmill, [SecretSync.HCVault]: AppConnection.HCVault, [SecretSync.TeamCity]: AppConnection.TeamCity, - [SecretSync.OCIVault]: AppConnection.OCI + [SecretSync.OCIVault]: AppConnection.OCI, + [SecretSync.OnePass]: AppConnection.OnePass }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/helpers/string.ts b/frontend/src/helpers/string.ts index ddd9fb7c9..ff12afced 100644 --- a/frontend/src/helpers/string.ts +++ b/frontend/src/helpers/string.ts @@ -15,3 +15,5 @@ export const isValidPath = (val: string): boolean => { export const DistinguishedNameRegex = /^(?:(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*)(?:,(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*))*)?$/; + +export const UserPrincipalNameRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; diff --git a/frontend/src/helpers/userTablePreferences.ts b/frontend/src/helpers/userTablePreferences.ts new file mode 100644 index 000000000..a23438746 --- /dev/null +++ b/frontend/src/helpers/userTablePreferences.ts @@ -0,0 +1,73 @@ +const TABLE_PREFERENCES_KEY = "userTablePreferences"; + +export enum PreferenceKey { + PerPage = "perPage" +} + +interface TableSpecificPreferences { + [preferenceKey: string]: any; +} + +interface UserTablePreferences { + [tableName: string]: TableSpecificPreferences; +} + +// Retrieves all table preferences from localStorage +const getAllTablePreferences = (): UserTablePreferences => { + try { + const preferencesString = localStorage.getItem(TABLE_PREFERENCES_KEY); + if (preferencesString) { + return JSON.parse(preferencesString) as UserTablePreferences; + } + } catch (error) { + console.error("Error reading user table preferences from localStorage:", error); + } + return {}; +}; + +// Saves all table preferences to localStorage +const saveAllTablePreferences = (preferences: UserTablePreferences): void => { + try { + localStorage.setItem(TABLE_PREFERENCES_KEY, JSON.stringify(preferences)); + } catch (error) { + console.error("Error saving user table preferences to localStorage:", error); + } +}; + +// Retrieves a specific preference for a given table +export const getUserTablePreference = ( + tableName: string, + preferenceKey: PreferenceKey, + defaultValue: T +): T => { + const preferences = getAllTablePreferences(); + if ( + preferences && + typeof preferences === "object" && + tableName in preferences && + preferenceKey in preferences[tableName] + ) { + const value = preferences[tableName][preferenceKey]; + + if (value !== undefined && value !== null) { + return value as T; + } + } + return defaultValue; +}; + +// Sets a specific preference for a given table and saves it to localStorage +export const setUserTablePreference = ( + tableName: string, + preferenceKey: PreferenceKey, + value: any +): void => { + const preferences = getAllTablePreferences(); + + if (!preferences[tableName]) { + preferences[tableName] = {}; + } + + preferences[tableName][preferenceKey] = value; + saveAllTablePreferences(preferences); +}; diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx index 9fda2377a..e4224e110 100644 --- a/frontend/src/hooks/api/accessApproval/mutation.tsx +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -21,6 +21,7 @@ export const useCreateAccessApprovalPolicy = () => { projectSlug, approvals, approvers, + bypassers, name, secretPath, enforcementLevel, @@ -30,6 +31,7 @@ export const useCreateAccessApprovalPolicy = () => { environment, projectSlug, approvals, + bypassers, approvers, secretPath, name, @@ -53,6 +55,7 @@ export const useUpdateAccessApprovalPolicy = () => { mutationFn: async ({ id, approvers, + bypassers, approvals, name, secretPath, @@ -62,6 +65,7 @@ export const useUpdateAccessApprovalPolicy = () => { const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, { approvals, approvers, + bypassers, secretPath, name, enforcementLevel, @@ -131,20 +135,27 @@ export const useReviewAccessRequest = () => { projectSlug: string; envSlug?: string; requestedBy?: string; + bypassReason?: string; } >({ - mutationFn: async ({ requestId, status }) => { + mutationFn: async ({ requestId, status, bypassReason }) => { const { data } = await apiRequest.post( `/api/v1/access-approvals/requests/${requestId}/review`, { - status + status, + bypassReason } ); return data; }, - onSuccess: (_, { projectSlug, envSlug, requestedBy }) => { + onSuccess: (_, { projectSlug, envSlug, requestedBy, bypassReason }) => { queryClient.invalidateQueries({ - queryKey: accessApprovalKeys.getAccessApprovalRequests(projectSlug, envSlug, requestedBy) + queryKey: accessApprovalKeys.getAccessApprovalRequests( + projectSlug, + envSlug, + requestedBy, + bypassReason + ) }); queryClient.invalidateQueries({ queryKey: accessApprovalKeys.getAccessApprovalRequestCount(projectSlug) diff --git a/frontend/src/hooks/api/accessApproval/queries.tsx b/frontend/src/hooks/api/accessApproval/queries.tsx index 1aa40c588..6370f4a59 100644 --- a/frontend/src/hooks/api/accessApproval/queries.tsx +++ b/frontend/src/hooks/api/accessApproval/queries.tsx @@ -19,8 +19,12 @@ export const accessApprovalKeys = { getAccessApprovalPolicyOfABoard: (workspaceId: string, environment: string) => [{ workspaceId, environment }, "access-approval-policy"] as const, - getAccessApprovalRequests: (projectSlug: string, envSlug?: string, requestedBy?: string) => - [{ projectSlug, envSlug, requestedBy }, "access-approvals-requests"] as const, + getAccessApprovalRequests: ( + projectSlug: string, + envSlug?: string, + requestedBy?: string, + bypassReason?: string + ) => [{ projectSlug, envSlug, requestedBy, bypassReason }, "access-approvals-requests"] as const, getAccessApprovalRequestCount: (projectSlug: string) => [{ projectSlug }, "access-approval-request-count"] as const }; diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index e2d620fe2..54a53b2ab 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -16,6 +16,7 @@ export type TAccessApprovalPolicy = { enforcementLevel: EnforcementLevel; updatedAt: Date; approvers?: Approver[]; + bypassers?: Bypasser[]; allowedSelfApprovals: boolean; }; @@ -24,11 +25,21 @@ export enum ApproverType { Group = "group" } +export enum BypasserType { + User = "user", + Group = "group" +} + export type Approver = { id: string; type: ApproverType; }; +export type Bypasser = { + id: string; + type: BypasserType; +}; + export type TAccessApprovalRequest = { id: string; policyId: string; @@ -68,6 +79,7 @@ export type TAccessApprovalRequest = { name: string; approvals: number; approvers: string[]; + bypassers: string[]; secretPath?: string | null; envId: string; enforcementLevel: EnforcementLevel; @@ -146,6 +158,7 @@ export type TCreateAccessPolicyDTO = { name?: string; environment: string; approvers?: Approver[]; + bypassers?: Bypasser[]; approvals?: number; secretPath?: string; enforcementLevel?: EnforcementLevel; @@ -156,6 +169,7 @@ export type TUpdateAccessPolicyDTO = { id: string; name?: string; approvers?: Approver[]; + bypassers?: Bypasser[]; secretPath?: string; environment?: string; approvals?: number; diff --git a/frontend/src/hooks/api/admin/index.ts b/frontend/src/hooks/api/admin/index.ts index 5bedf1158..f4e57d7cc 100644 --- a/frontend/src/hooks/api/admin/index.ts +++ b/frontend/src/hooks/api/admin/index.ts @@ -1,16 +1,2 @@ -export { - useAdminDeleteUser, - useAdminGrantServerAdminAccess, - useAdminRemoveIdentitySuperAdminAccess, - useCreateAdminUser, - useInvalidateCache, - useRemoveUserServerAdminAccess, - useUpdateServerConfig, - useUpdateServerEncryptionStrategy -} from "./mutation"; -export { - useAdminGetUsers, - useGetAdminIntegrationsConfig, - useGetServerConfig, - useGetServerRootKmsEncryptionDetails -} from "./queries"; +export * from "./mutation"; +export * from "./queries"; diff --git a/frontend/src/hooks/api/admin/mutation.ts b/frontend/src/hooks/api/admin/mutation.ts index f220573c9..916067f00 100644 --- a/frontend/src/hooks/api/admin/mutation.ts +++ b/frontend/src/hooks/api/admin/mutation.ts @@ -63,6 +63,41 @@ export const useAdminDeleteUser = () => { queryClient.invalidateQueries({ queryKey: [adminStandaloneKeys.getUsers] }); + queryClient.invalidateQueries({ queryKey: adminStandaloneKeys.getOrganizations }); + } + }); +}; + +export const useAdminDeleteOrganizationMembership = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ organizationId, membershipId }) => { + await apiRequest.delete( + `/api/v1/admin/organization-management/organizations/${organizationId}/memberships/${membershipId}` + ); + + return {}; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [adminStandaloneKeys.getOrganizations] + }); + } + }); +}; + +export const useAdminDeleteOrganization = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (organizationId: string) => { + await apiRequest.delete( + `/api/v1/admin/organization-management/organizations/${organizationId}` + ); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [adminStandaloneKeys.getOrganizations] + }); } }); }; diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts index 85c6c153e..c628df955 100644 --- a/frontend/src/hooks/api/admin/queries.ts +++ b/frontend/src/hooks/api/admin/queries.ts @@ -6,8 +6,10 @@ import { Identity } from "@app/hooks/api/identities/types"; import { User } from "../types"; import { AdminGetIdentitiesFilters, + AdminGetOrganizationsFilters, AdminGetUsersFilters, AdminIntegrationsConfig, + OrganizationWithProjects, TGetInvalidatingCacheStatus, TGetServerRootKmsEncryptionDetails, TServerConfig @@ -15,12 +17,15 @@ import { export const adminStandaloneKeys = { getUsers: "get-users", + getOrganizations: "get-organizations", getIdentities: "get-identities" }; export const adminQueryKeys = { serverConfig: () => ["server-config"] as const, getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const, + getOrganizations: (filters: AdminGetOrganizationsFilters) => + [adminStandaloneKeys.getOrganizations, { filters }] as const, getIdentities: (filters: AdminGetIdentitiesFilters) => [adminStandaloneKeys.getIdentities, { filters }] as const, getAdminSlackConfig: () => ["admin-slack-config"] as const, @@ -34,6 +39,28 @@ export const fetchServerConfig = async () => { return data.config; }; +export const useAdminGetOrganizations = (filters: AdminGetOrganizationsFilters) => { + return useInfiniteQuery({ + initialPageParam: 0, + queryKey: adminQueryKeys.getOrganizations(filters), + queryFn: async ({ pageParam }) => { + const { data } = await apiRequest.get<{ organizations: OrganizationWithProjects[] }>( + "/api/v1/admin/organization-management/organizations", + { + params: { + ...filters, + offset: pageParam + } + } + ); + + return data.organizations; + }, + getNextPageParam: (lastPage, pages) => + lastPage.length !== 0 ? pages.length * filters.limit : undefined + }); +}; + export const useGetServerConfig = ({ options = {} }: { diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index 8850b3375..149014d85 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -1,3 +1,5 @@ +import { Organization } from "../types"; + export enum LoginMethod { EMAIL = "email", GOOGLE = "google", @@ -8,6 +10,27 @@ export enum LoginMethod { OIDC = "oidc" } +export type OrganizationWithProjects = Organization & { + members: { + user: { + id: string; + email: string | null; + username: string; + firstName: string | null; + lastName: string | null; + }; + membershipId: string; + role: string; + roleId: string | null; + }[]; + projects: { + name: string; + id: string; + slug: string; + createdAt: string; + }[]; +}; + export type TServerConfig = { initialized: boolean; allowSignUp: boolean; @@ -51,6 +74,11 @@ export type TCreateAdminUserDTO = { salt: string; }; +export type AdminGetOrganizationsFilters = { + limit: number; + searchTerm: string; +}; + export type AdminGetUsersFilters = { limit: number; searchTerm: string; diff --git a/frontend/src/hooks/api/appConnections/1password/index.ts b/frontend/src/hooks/api/appConnections/1password/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/1password/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/1password/queries.tsx b/frontend/src/hooks/api/appConnections/1password/queries.tsx new file mode 100644 index 000000000..f73562652 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/1password/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TOnePassVault } from "./types"; + +const onePassConnectionKeys = { + all: [...appConnectionKeys.all, "1password"] as const, + listVaults: (connectionId: string) => + [...onePassConnectionKeys.all, "vaults", connectionId] as const +}; + +export const useOnePassConnectionListVaults = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TOnePassVault[], + unknown, + TOnePassVault[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: onePassConnectionKeys.listVaults(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/1password/${connectionId}/vaults` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/1password/types.ts b/frontend/src/hooks/api/appConnections/1password/types.ts new file mode 100644 index 000000000..9386e9ddd --- /dev/null +++ b/frontend/src/hooks/api/appConnections/1password/types.ts @@ -0,0 +1,12 @@ +export type TOnePassVault = { + id: string; + name: string; + type: string; + items: number; + + attributeVersion: number; + contentVersion: number; + + createdAt: string; + updatedAt: string; +}; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 6abe47f3e..6ed986453 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -12,11 +12,13 @@ export enum AppConnection { Vercel = "vercel", Postgres = "postgres", MsSql = "mssql", + MySql = "mysql", Camunda = "camunda", Windmill = "windmill", Auth0 = "auth0", HCVault = "hashicorp-vault", LDAP = "ldap", TeamCity = "teamcity", - OCI = "oci" + OCI = "oci", + OnePass = "1password" } diff --git a/frontend/src/hooks/api/appConnections/types/1password-connection.ts b/frontend/src/hooks/api/appConnections/types/1password-connection.ts new file mode 100644 index 000000000..fcf307a15 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/1password-connection.ts @@ -0,0 +1,14 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum OnePassConnectionMethod { + ApiToken = "api-token" +} + +export type TOnePassConnection = TRootAppConnection & { app: AppConnection.OnePass } & { + method: OnePassConnectionMethod.ApiToken; + credentials: { + apiToken: string; + instanceUrl: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 2a01b5cf8..9725e1a9c 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -65,6 +65,10 @@ export type TMsSqlConnectionOption = TAppConnectionOptionBase & { app: AppConnection.MsSql; }; +export type TMySqlConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.MySql; +}; + export type TCamundaConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Camunda; }; @@ -93,6 +97,10 @@ export type TOCIConnectionOption = TAppConnectionOptionBase & { app: AppConnection.OCI; }; +export type TOnePassConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.OnePass; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -106,12 +114,14 @@ export type TAppConnectionOption = | TVercelConnectionOption | TPostgresConnectionOption | TMsSqlConnectionOption + | TMySqlConnectionOption | TCamundaConnectionOption | TWindmillConnectionOption | TAuth0ConnectionOption | THCVaultConnectionOption | TTeamCityConnectionOption - | TOCIConnectionOption; + | TOCIConnectionOption + | TOnePassConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -127,6 +137,7 @@ export type TAppConnectionOptionMap = { [AppConnection.Vercel]: TVercelConnectionOption; [AppConnection.Postgres]: TPostgresConnectionOption; [AppConnection.MsSql]: TMsSqlConnectionOption; + [AppConnection.MySql]: TMySqlConnectionOption; [AppConnection.Camunda]: TCamundaConnectionOption; [AppConnection.Windmill]: TWindmillConnectionOption; [AppConnection.Auth0]: TAuth0ConnectionOption; @@ -134,4 +145,5 @@ export type TAppConnectionOptionMap = { [AppConnection.LDAP]: TLdapConnectionOption; [AppConnection.TeamCity]: TTeamCityConnectionOption; [AppConnection.OCI]: TOCIConnectionOption; + [AppConnection.OnePass]: TOnePassConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index b3cef013e..e88a59bf1 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -1,4 +1,5 @@ import { AppConnection } from "../enums"; +import { TOnePassConnection } from "./1password-connection"; import { TAppConnectionOption } from "./app-options"; import { TAuth0Connection } from "./auth0-connection"; import { TAwsConnection } from "./aws-connection"; @@ -14,6 +15,7 @@ import { THCVaultConnection } from "./hc-vault-connection"; import { THumanitecConnection } from "./humanitec-connection"; import { TLdapConnection } from "./ldap-connection"; import { TMsSqlConnection } from "./mssql-connection"; +import { TMySqlConnection } from "./mysql-connection"; import { TOCIConnection } from "./oci-connection"; import { TPostgresConnection } from "./postgres-connection"; import { TTeamCityConnection } from "./teamcity-connection"; @@ -21,6 +23,7 @@ import { TTerraformCloudConnection } from "./terraform-cloud-connection"; import { TVercelConnection } from "./vercel-connection"; import { TWindmillConnection } from "./windmill-connection"; +export * from "./1password-connection"; export * from "./auth0-connection"; export * from "./aws-connection"; export * from "./azure-app-configuration-connection"; @@ -35,6 +38,7 @@ export * from "./hc-vault-connection"; export * from "./humanitec-connection"; export * from "./ldap-connection"; export * from "./mssql-connection"; +export * from "./mysql-connection"; export * from "./oci-connection"; export * from "./postgres-connection"; export * from "./teamcity-connection"; @@ -56,13 +60,15 @@ export type TAppConnection = | TVercelConnection | TPostgresConnection | TMsSqlConnection + | TMySqlConnection | TCamundaConnection | TWindmillConnection | TAuth0Connection | THCVaultConnection | TLdapConnection | TTeamCityConnection - | TOCIConnection; + | TOCIConnection + | TOnePassConnection; export type TAvailableAppConnection = Pick; @@ -103,6 +109,7 @@ export type TAppConnectionMap = { [AppConnection.Vercel]: TVercelConnection; [AppConnection.Postgres]: TPostgresConnection; [AppConnection.MsSql]: TMsSqlConnection; + [AppConnection.MySql]: TMySqlConnection; [AppConnection.Camunda]: TCamundaConnection; [AppConnection.Windmill]: TWindmillConnection; [AppConnection.Auth0]: TAuth0Connection; @@ -110,4 +117,5 @@ export type TAppConnectionMap = { [AppConnection.LDAP]: TLdapConnection; [AppConnection.TeamCity]: TTeamCityConnection; [AppConnection.OCI]: TOCIConnection; + [AppConnection.OnePass]: TOnePassConnection; }; diff --git a/frontend/src/hooks/api/appConnections/types/mysql-connection.ts b/frontend/src/hooks/api/appConnections/types/mysql-connection.ts new file mode 100644 index 000000000..0b8ab67c6 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/mysql-connection.ts @@ -0,0 +1,13 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +import { TBaseSqlConnectionCredentials } from "./shared"; + +export enum MySqlConnectionMethod { + UsernameAndPassword = "username-and-password" +} + +export type TMySqlConnection = TRootAppConnection & { app: AppConnection.MySql } & { + method: MySqlConnectionMethod.UsernameAndPassword; + credentials: TBaseSqlConnectionCredentials; +}; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index b0b5f64b3..ca5a38e0b 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -68,6 +68,7 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.IMPORT_CA_CERT]: "Import CA certificate", [EventType.GET_CA_CRL]: "Get CA CRL", [EventType.ISSUE_CERT]: "Issue certificate", + [EventType.IMPORT_CERT]: "Import certificate", [EventType.GET_CERT]: "Get certificate", [EventType.DELETE_CERT]: "Delete certificate", [EventType.REVOKE_CERT]: "Revoke certificate", @@ -123,7 +124,6 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.CREATE_PROJECT_TEMPLATE]: "Create project template", [EventType.UPDATE_PROJECT_TEMPLATE]: "Update project template", [EventType.DELETE_PROJECT_TEMPLATE]: "Delete project template", - [EventType.APPLY_PROJECT_TEMPLATE]: "Apply project template", [EventType.GET_APP_CONNECTIONS]: "List App Connections", [EventType.GET_AVAILABLE_APP_CONNECTIONS_DETAILS]: "List App Connections Details", [EventType.GET_APP_CONNECTION]: "Get App Connection", @@ -203,7 +203,21 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.SECRET_SCANNING_FINDING_LIST]: "List Secret Scanning Findings", [EventType.SECRET_SCANNING_FINDING_UPDATE]: "Update Secret Scanning Finding Status", [EventType.SECRET_SCANNING_CONFIG_GET]: "Get Secret Scanning Configuration", - [EventType.SECRET_SCANNING_CONFIG_UPDATE]: "Update Secret Scanning Configuration" + [EventType.SECRET_SCANNING_CONFIG_UPDATE]: "Update Secret Scanning Configuration", + + [EventType.CREATE_PKI_SUBSCRIBER]: "Create PKI subscriber", + [EventType.UPDATE_PKI_SUBSCRIBER]: "Update PKI subscriber", + [EventType.DELETE_PKI_SUBSCRIBER]: "Delete PKI subscriber", + [EventType.GET_PKI_SUBSCRIBER]: "Get PKI subscriber", + [EventType.ISSUE_PKI_SUBSCRIBER_CERT]: "Issue PKI subscriber certificate", + [EventType.SIGN_PKI_SUBSCRIBER_CERT]: "Sign PKI subscriber certificate", + [EventType.AUTOMATED_RENEW_SUBSCRIBER_CERT]: "Automated renew PKI subscriber certificate", + [EventType.LIST_PKI_SUBSCRIBER_CERTS]: "List PKI subscriber certificates", + + [EventType.UPDATE_ORG]: "Update Organization", + [EventType.CREATE_PROJECT]: "Create Project", + [EventType.UPDATE_PROJECT]: "Update Project", + [EventType.DELETE_PROJECT]: "Delete Project" }; export const userAgentTypeToNameMap: { [K in UserAgentType]: string } = { diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 2139f8dd8..67f0bac99 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -81,6 +81,7 @@ export enum EventType { IMPORT_CA_CERT = "import-certificate-authority-cert", GET_CA_CRL = "get-certificate-authority-crl", ISSUE_CERT = "issue-cert", + IMPORT_CERT = "import-cert", GET_CERT = "get-cert", DELETE_CERT = "delete-cert", REVOKE_CERT = "revoke-cert", @@ -131,7 +132,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", @@ -197,5 +197,19 @@ export enum EventType { 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" + SECRET_SCANNING_CONFIG_UPDATE = "secret-scanning-config-update", + + 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", + UPDATE_ORG = "update-org", + + CREATE_PROJECT = "create-project", + UPDATE_PROJECT = "update-project", + DELETE_PROJECT = "delete-project" } diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 745d0368f..8774e1a73 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -505,7 +505,8 @@ interface CreateCa { type: EventType.CREATE_CA; metadata: { caId: string; - dn: string; + name: string; + dn?: string; }; } @@ -514,12 +515,14 @@ interface GetCa { metadata: { caId: string; dn: string; + name: string; }; } interface UpdateCa { type: EventType.UPDATE_CA; metadata: { + name: string; caId: string; dn: string; status: CaStatus; @@ -530,6 +533,7 @@ interface DeleteCa { type: EventType.DELETE_CA; metadata: { caId: string; + name: string; dn: string; }; } @@ -583,6 +587,14 @@ interface IssueCert { serialNumber: string; }; } +interface ImportCert { + type: EventType.IMPORT_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} interface GetCert { type: EventType.GET_CERT; @@ -895,6 +907,7 @@ export type Event = | ImportCaCert | GetCaCrl | IssueCert + | ImportCert | GetCert | DeleteCert | RevokeCert diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index 8838ef748..6d05b5ef3 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -1,10 +1,10 @@ import { SshCaStatus } from "../sshCa"; import { SshCertTemplateStatus } from "../sshCertificateTemplates"; -import { CaStatus, CaType } from "./enums"; +import { CaStatus, InternalCaType } from "./enums"; -export const caTypeToNameMap: { [K in CaType]: string } = { - [CaType.ROOT]: "Root", - [CaType.INTERMEDIATE]: "Intermediate" +export const caTypeToNameMap: { [K in InternalCaType]: string } = { + [InternalCaType.ROOT]: "Root", + [InternalCaType.INTERMEDIATE]: "Intermediate" }; export const caStatusToNameMap: { [K in CaStatus]: string } = { diff --git a/frontend/src/hooks/api/ca/enums.tsx b/frontend/src/hooks/api/ca/enums.tsx index 35d86c452..b2b725612 100644 --- a/frontend/src/hooks/api/ca/enums.tsx +++ b/frontend/src/hooks/api/ca/enums.tsx @@ -1,4 +1,9 @@ export enum CaType { + INTERNAL = "internal", + ACME = "acme" +} + +export enum InternalCaType { ROOT = "root", INTERMEDIATE = "intermediate" } @@ -12,3 +17,7 @@ export enum CaStatus { export enum CaRenewalType { EXISTING = "existing" } + +export enum AcmeDnsProvider { + ROUTE53 = "route53" +} diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx index 47d7baee8..82e9ea3be 100644 --- a/frontend/src/hooks/api/ca/index.tsx +++ b/frontend/src/hooks/api/ca/index.tsx @@ -1,4 +1,4 @@ -export { CaRenewalType, CaStatus, CaType } from "./enums"; +export { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from "./enums"; export { useCreateCa, useCreateCertificate, @@ -9,10 +9,13 @@ export { useUpdateCa } from "./mutations"; export { + useGetCa, useGetCaById, useGetCaCert, useGetCaCerts, useGetCaCertTemplates, useGetCaCrls, - useGetCaCsr + useGetCaCsr, + useListCasByProjectId, + useListCasByTypeAndProjectId } from "./queries"; diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx index 93ac4e6f0..c05760beb 100644 --- a/frontend/src/hooks/api/ca/mutations.tsx +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -3,64 +3,82 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { workspaceKeys } from "../workspace"; +import { CaType } from "./enums"; import { caKeys } from "./queries"; import { - TCertificateAuthority, - TCreateCaDTO, + TCreateCertificateAuthorityDTO, TCreateCertificateDTO, TCreateCertificateResponse, - TDeleteCaDTO, + TDeleteCertificateAuthorityDTO, TImportCaCertificateDTO, TImportCaCertificateResponse, TRenewCaDTO, TRenewCaResponse, TSignIntermediateDTO, TSignIntermediateResponse, - TUpdateCaDTO + TUnifiedCertificateAuthority, + TUpdateCertificateAuthorityDTO } from "./types"; -export const useCreateCa = () => { +export const useUpdateCa = () => { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async (body) => { - const { - data: { ca } - } = await apiRequest.post<{ ca: TCertificateAuthority }>("/api/v1/pki/ca/", body); - return ca; + return useMutation({ + mutationFn: async ({ caName, ...body }) => { + const { data } = await apiRequest.patch( + `/api/v1/pki/ca/${body.type}/${caName}`, + body + ); + + return data; }, - onSuccess: (_, { projectSlug }) => { - queryClient.invalidateQueries({ queryKey: workspaceKeys.getWorkspaceCas({ projectSlug }) }); + onSuccess: ({ projectId, type }, { caName }) => { + caKeys.getCaByNameAndProjectId(caName, projectId); + queryClient.invalidateQueries({ + queryKey: caKeys.listCasByTypeAndProjectId(type, projectId) + }); + queryClient.invalidateQueries({ + queryKey: caKeys.getCaByNameAndProjectId(caName, projectId) + }); } }); }; -export const useUpdateCa = () => { +export const useCreateCa = () => { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ caId, projectSlug, ...body }) => { - const { - data: { ca } - } = await apiRequest.patch<{ ca: TCertificateAuthority }>(`/api/v1/pki/ca/${caId}`, body); - return ca; + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + `/api/v1/pki/ca/${body.type}`, + body + ); + return data; }, - onSuccess: ({ id }, { projectSlug }) => { - queryClient.invalidateQueries({ queryKey: workspaceKeys.getWorkspaceCas({ projectSlug }) }); - queryClient.invalidateQueries({ queryKey: caKeys.getCaById(id) }); + onSuccess: (_, { type, projectId }) => { + queryClient.invalidateQueries({ + queryKey: caKeys.listCasByTypeAndProjectId(type, projectId) + }); } }); }; export const useDeleteCa = () => { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ caId }) => { - const { - data: { ca } - } = await apiRequest.delete<{ ca: TCertificateAuthority }>(`/api/v1/pki/ca/${caId}`); - return ca; + return useMutation({ + mutationFn: async ({ caName, type, projectId }) => { + const { data } = await apiRequest.delete( + `/api/v1/pki/ca/${type}/${caName}`, + { + data: { + projectId + } + } + ); + return data; }, - onSuccess: (_, { projectSlug }) => { - queryClient.invalidateQueries({ queryKey: workspaceKeys.getWorkspaceCas({ projectSlug }) }); + onSuccess: (_, { type, projectId }) => { + queryClient.invalidateQueries({ + queryKey: caKeys.listCasByTypeAndProjectId(type, projectId) + }); } }); }; @@ -78,7 +96,7 @@ export const useSignIntermediate = () => { }); }; -export const useImportCaCertificate = () => { +export const useImportCaCertificate = (projectId: string) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ caId, ...body }) => { @@ -92,6 +110,9 @@ export const useImportCaCertificate = () => { queryClient.invalidateQueries({ queryKey: workspaceKeys.getWorkspaceCas({ projectSlug }) }); queryClient.invalidateQueries({ queryKey: caKeys.getCaCerts(caId) }); queryClient.invalidateQueries({ queryKey: caKeys.getCaCert(caId) }); + queryClient.invalidateQueries({ + queryKey: caKeys.listCasByTypeAndProjectId(CaType.INTERNAL, projectId) + }); } }); }; diff --git a/frontend/src/hooks/api/ca/queries.tsx b/frontend/src/hooks/api/ca/queries.tsx index d5eb89644..4f10c6e84 100644 --- a/frontend/src/hooks/api/ca/queries.tsx +++ b/frontend/src/hooks/api/ca/queries.tsx @@ -3,10 +3,14 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { TCertificateTemplate } from "../certificateTemplates/types"; -import { TCertificateAuthority } from "./types"; +import { CaType } from "./enums"; +import { TCertificateAuthority, TUnifiedCertificateAuthority } from "./types"; export const caKeys = { getCaById: (caId: string) => [{ caId }, "ca"], + getCaByNameAndProjectId: (caName: string, projectId: string) => [{ caName, projectId }, "ca"], + listCasByTypeAndProjectId: (type: CaType, projectId: string) => [{ type, projectId }, "cas"], + listCasByProjectId: (projectId: string) => [{ projectId }, "cas"], getCaCerts: (caId: string) => [{ caId }, "ca-cert"], getCaCrls: (caId: string) => [{ caId }, "ca-crls"], getCaCert: (caId: string) => [{ caId }, "ca-cert"], @@ -16,6 +20,53 @@ export const caKeys = { getCaEstConfig: (caId: string) => [{ caId }, "ca-est-config"] }; +export const useGetCa = ({ + caName, + projectId, + type +}: { + caName: string; + projectId: string; + type: CaType; +}) => { + return useQuery({ + queryKey: caKeys.getCaByNameAndProjectId(caName, projectId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/pki/ca/${type}/${caName}?projectId=${projectId}` + ); + return data; + }, + enabled: Boolean(caName && projectId && type) + }); +}; + +export const useListCasByTypeAndProjectId = (type: CaType, projectId: string) => { + return useQuery({ + queryKey: caKeys.listCasByTypeAndProjectId(type, projectId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/pki/ca/${type}?projectId=${projectId}` + ); + + return data; + } + }); +}; + +export const useListCasByProjectId = (projectId: string) => { + return useQuery({ + queryKey: caKeys.listCasByProjectId(projectId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateAuthorities: TUnifiedCertificateAuthority[]; + }>(`/api/v2/pki/ca?projectId=${projectId}`); + + return data.certificateAuthorities; + } + }); +}; + export const useGetCaById = (caId: string) => { return useQuery({ queryKey: caKeys.getCaById(caId), diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index 25e5112e0..f3703fe52 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -1,11 +1,73 @@ import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "../certificates/enums"; -import { CaRenewalType, CaStatus, CaType } from "./enums"; +import { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from "./enums"; + +export type TAcmeCertificateAuthority = { + id: string; + projectId: string; + type: CaType.ACME; + status: CaStatus; + name: string; + enableDirectIssuance: boolean; + configuration: { + dnsAppConnectionId: string; + dnsProviderConfig: { + provider: AcmeDnsProvider.ROUTE53; + hostedZoneId: string; + }; + directoryUrl: string; + accountEmail: string; + }; +}; + +export type TInternalCertificateAuthority = { + id: string; + projectId: string; + type: CaType.INTERNAL; + status: CaStatus; + name: string; + enableDirectIssuance: boolean; + configuration: { + type: InternalCaType; + friendlyName?: string; + commonName: string; + organization: string; + ou: string; + country: string; + province: string; + locality: string; + maxPathLength: number; + keyAlgorithm: CertKeyAlgorithm; + notAfter?: string; + notBefore?: string; + dn?: string; + parentCaId?: string; + serialNumber?: string; + activeCaCertId?: string; + }; +}; + +export type TUnifiedCertificateAuthority = + | TAcmeCertificateAuthority + | TInternalCertificateAuthority; + +export type TCreateCertificateAuthorityDTO = Omit; +export type TUpdateCertificateAuthorityDTO = Partial & { + caName: string; + projectId: string; + type: CaType; +}; + +export type TDeleteCertificateAuthorityDTO = { + caName: string; + type: CaType; + projectId: string; +}; export type TCertificateAuthority = { id: string; parentCaId?: string; projectId: string; - type: CaType; + type: InternalCaType; status: CaStatus; friendlyName: string; organization: string; @@ -25,22 +87,6 @@ export type TCertificateAuthority = { updatedAt: string; }; -export type TCreateCaDTO = { - projectSlug: string; - type: string; - friendlyName?: string; - organization: string; - ou: string; - country: string; - province: string; - locality: string; - commonName: string; - notAfter?: string; - maxPathLength: number; - keyAlgorithm: CertKeyAlgorithm; - requireTemplateForIssuance: boolean; -}; - export type TUpdateCaDTO = { projectSlug: string; caId: string; diff --git a/frontend/src/hooks/api/certificateTemplates/index.tsx b/frontend/src/hooks/api/certificateTemplates/index.tsx index 61dfb35a2..a48248a6b 100644 --- a/frontend/src/hooks/api/certificateTemplates/index.tsx +++ b/frontend/src/hooks/api/certificateTemplates/index.tsx @@ -1,8 +1,11 @@ export { useCreateCertTemplate, + useCreateCertTemplateV2, useCreateEstConfig, useDeleteCertTemplate, + useDeleteCertTemplateV2, useUpdateCertTemplate, + useUpdateCertTemplateV2, useUpdateEstConfig } from "./mutations"; -export { useGetCertTemplate, useGetEstConfig } from "./queries"; +export { useGetCertTemplate, useGetEstConfig, useListCertificateTemplates } from "./queries"; diff --git a/frontend/src/hooks/api/certificateTemplates/mutations.tsx b/frontend/src/hooks/api/certificateTemplates/mutations.tsx index 55bbcd417..d1069e7e6 100644 --- a/frontend/src/hooks/api/certificateTemplates/mutations.tsx +++ b/frontend/src/hooks/api/certificateTemplates/mutations.tsx @@ -8,9 +8,12 @@ import { certTemplateKeys } from "./queries"; import { TCertificateTemplate, TCreateCertificateTemplateDTO, + TCreateCertificateTemplateV2DTO, TCreateEstConfigDTO, TDeleteCertificateTemplateDTO, + TDeleteCertificateTemplateV2DTO, TUpdateCertificateTemplateDTO, + TUpdateCertificateTemplateV2DTO, TUpdateEstConfigDTO } from "./types"; @@ -73,6 +76,58 @@ export const useDeleteCertTemplate = () => { }); }; +export const useCreateCertTemplateV2 = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post<{ + certificateTemplate: TCertificateTemplate; + }>("/api/v2/pki/certificate-templates", dto); + return data.certificateTemplate; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ queryKey: certTemplateKeys.listTemplates({ projectId }) }); + } + }); +}; + +export const useUpdateCertTemplateV2 = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (dto) => { + const { data } = await apiRequest.patch<{ certificateTemplate: TCertificateTemplate }>( + `/api/v2/pki/certificate-templates/${dto.templateName}`, + dto + ); + + return data.certificateTemplate; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ queryKey: certTemplateKeys.listTemplates({ projectId }) }); + } + }); +}; + +export const useDeleteCertTemplateV2 = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (dto) => { + const { data } = await apiRequest.delete<{ certificateTemplate: TCertificateTemplate }>( + `/api/v2/pki/certificate-templates/${dto.templateName}`, + { + data: { + projectId: dto.projectId + } + } + ); + return data.certificateTemplate; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ queryKey: certTemplateKeys.listTemplates({ projectId }) }); + } + }); +}; + export const useCreateEstConfig = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/hooks/api/certificateTemplates/queries.tsx b/frontend/src/hooks/api/certificateTemplates/queries.tsx index 7ee5bbd30..435345ad9 100644 --- a/frontend/src/hooks/api/certificateTemplates/queries.tsx +++ b/frontend/src/hooks/api/certificateTemplates/queries.tsx @@ -2,10 +2,20 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TCertificateTemplate, TEstConfig } from "./types"; +import { + TCertificateTemplate, + TCertificateTemplateV2, + TEstConfig, + TListCertificateTemplatesDTO +} from "./types"; export const certTemplateKeys = { getCertTemplateById: (id: string) => [{ id }, "cert-template"], + listTemplates: ({ projectId, ...el }: { limit?: number; offset?: number; projectId: string }) => [ + "list-template", + projectId, + el + ], getEstConfig: (id: string) => [{ id }, "cert-template-est-config"] }; @@ -22,6 +32,29 @@ export const useGetCertTemplate = (id: string) => { }); }; +export const useListCertificateTemplates = ({ + limit = 100, + offset = 0, + projectId +}: TListCertificateTemplatesDTO) => { + return useQuery({ + queryKey: certTemplateKeys.listTemplates({ limit, offset, projectId }), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateTemplates: TCertificateTemplateV2[]; + totalCount?: number; + }>("/api/v2/pki/certificate-templates", { + params: { + limit, + offset, + projectId + } + }); + return data; + } + }); +}; + export const useGetEstConfig = (certificateTemplateId: string) => { return useQuery({ queryKey: certTemplateKeys.getEstConfig(certificateTemplateId), diff --git a/frontend/src/hooks/api/certificateTemplates/types.ts b/frontend/src/hooks/api/certificateTemplates/types.ts index 14367a280..1c2a47178 100644 --- a/frontend/src/hooks/api/certificateTemplates/types.ts +++ b/frontend/src/hooks/api/certificateTemplates/types.ts @@ -14,6 +14,26 @@ export type TCertificateTemplate = { extendedKeyUsages: CertExtendedKeyUsage[]; }; +export type TCertificateTemplateV2 = { + id: string; + caId: string; + caName: string; + projectId: string; + pkiCollectionId?: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + keyUsages: CertKeyUsage[]; + extendedKeyUsages: CertExtendedKeyUsage[]; + updatedAt: string; + createdAt: string; + ca: { + name: string; + id: string; + }; +}; + export type TCreateCertificateTemplateDTO = { caId: string; pkiCollectionId?: string; @@ -44,6 +64,34 @@ export type TDeleteCertificateTemplateDTO = { projectId: string; }; +export type TCreateCertificateTemplateV2DTO = { + caName: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + projectId: string; + keyUsages: CertKeyUsage[]; + extendedKeyUsages: CertExtendedKeyUsage[]; +}; + +export type TUpdateCertificateTemplateV2DTO = { + templateName: string; + caName?: string; + name?: string; + commonName?: string; + subjectAlternativeName?: string; + ttl?: string; + projectId: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; +}; + +export type TDeleteCertificateTemplateV2DTO = { + templateName: string; + projectId: string; +}; + export type TCreateEstConfigDTO = { certificateTemplateId: string; caChain?: string; @@ -67,3 +115,9 @@ export type TEstConfig = { isEnabled: boolean; disableBootstrapCertValidation: boolean; }; + +export type TListCertificateTemplatesDTO = { + limit?: number; + offset?: number; + projectId: string; +}; diff --git a/frontend/src/hooks/api/certificates/index.tsx b/frontend/src/hooks/api/certificates/index.tsx index dd922fd6a..ddac04730 100644 --- a/frontend/src/hooks/api/certificates/index.tsx +++ b/frontend/src/hooks/api/certificates/index.tsx @@ -1,2 +1,2 @@ -export { useDeleteCert, useRevokeCert } from "./mutations"; +export { useDeleteCert, useImportCertificate, useRevokeCert } from "./mutations"; export { useGetCert, useGetCertBody } from "./queries"; diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index 74d6b5cbb..12f8e834b 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -4,7 +4,13 @@ import { apiRequest } from "@app/config/request"; import { pkiSubscriberKeys } from "../pkiSubscriber/queries"; import { workspaceKeys } from "../workspace"; -import { TCertificate, TDeleteCertDTO, TRevokeCertDTO } from "./types"; +import { + TCertificate, + TDeleteCertDTO, + TImportCertificateDTO, + TImportCertificateResponse, + TRevokeCertDTO +} from "./types"; export const useDeleteCert = () => { const queryClient = useQueryClient(); @@ -49,3 +55,21 @@ export const useRevokeCert = () => { } }); }; + +export const useImportCertificate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + "/api/v1/pki/certificates/import-certificate", + body + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.forWorkspaceCertificates(projectSlug) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index a9bcf5fbc..c1dd59eca 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -25,3 +25,21 @@ export type TRevokeCertDTO = { serialNumber: string; revocationReason: string; }; + +export type TImportCertificateDTO = { + projectSlug: string; + + certificatePem: string; + privateKeyPem: string; + chainPem: string; + + pkiCollectionId?: string; + friendlyName?: string; +}; + +export type TImportCertificateResponse = { + certificate: string; + certificateChain: string; + privateKey: string; + serialNumber: string; +}; diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 1aedf264f..45fb20955 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -13,6 +13,7 @@ export type TDynamicSecret = { status?: DynamicSecretStatus; statusDetails?: string; maxTTL: string; + usernameTemplate?: string | null; metadata?: { key: string; value: string }[]; }; @@ -31,7 +32,8 @@ export enum DynamicSecretProviders { SapHana = "sap-hana", Snowflake = "snowflake", Totp = "totp", - SapAse = "sap-ase" + SapAse = "sap-ase", + Kubernetes = "kubernetes" } export enum SqlProviders { @@ -261,6 +263,20 @@ export type TDynamicSecretProvider = algorithm?: string; digits?: number; }; + } + | { + type: DynamicSecretProviders.Kubernetes; + inputs: { + url: string; + clusterToken: string; + ca?: string; + serviceAccountName: string; + credentialType: "dynamic" | "static"; + namespace: string; + gatewayId?: string; + sslEnabled: boolean; + audiences: string[]; + }; }; export type TCreateDynamicSecretDTO = { @@ -272,6 +288,7 @@ export type TCreateDynamicSecretDTO = { environmentSlug: string; name: string; metadata?: { key: string; value: string }[]; + usernameTemplate?: string; }; export type TUpdateDynamicSecretDTO = { @@ -285,6 +302,7 @@ export type TUpdateDynamicSecretDTO = { defaultTTL?: string; maxTTL?: string | null; inputs?: unknown; + usernameTemplate?: string | null; }; }; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index 748745986..c08081f11 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -163,7 +163,8 @@ export const useUpdateIdentityUniversalAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + accessTokenPeriod }) => { const { data: { identityUniversalAuth } @@ -172,7 +173,8 @@ export const useUpdateIdentityUniversalAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + accessTokenPeriod }); return identityUniversalAuth; }, diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index e31b39cbe..c26466213 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -107,6 +107,7 @@ export type IdentityUniversalAuth = { accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; accessTokenTrustedIps: IdentityTrustedIp[]; + accessTokenPeriod: number; }; export type AddIdentityUniversalAuthDTO = { @@ -118,6 +119,7 @@ export type AddIdentityUniversalAuthDTO = { accessTokenTTL: number; accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; + accessTokenPeriod: number; accessTokenTrustedIps: { ipAddress: string; }[]; @@ -132,6 +134,7 @@ export type UpdateIdentityUniversalAuthDTO = { accessTokenTTL?: number; accessTokenMaxTTL?: number; accessTokenNumUsesLimit?: number; + accessTokenPeriod?: number; accessTokenTrustedIps?: { ipAddress: string; }[]; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 947353162..cd620bb64 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -118,7 +118,9 @@ export const useUpdateOrg = () => { kmsProductEnabled, sshProductEnabled, scannerProductEnabled, - shareSecretsProductEnabled + shareSecretsProductEnabled, + maxSharedSecretLifetime, + maxSharedSecretViewLimit }) => { return apiRequest.patch(`/api/v1/organization/${orgId}`, { name, @@ -136,7 +138,9 @@ export const useUpdateOrg = () => { kmsProductEnabled, sshProductEnabled, scannerProductEnabled, - shareSecretsProductEnabled + shareSecretsProductEnabled, + maxSharedSecretLifetime, + maxSharedSecretViewLimit }); }, onSuccess: () => { diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index ab015f890..068cfad6d 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -26,6 +26,8 @@ export type Organization = { sshProductEnabled: boolean; scannerProductEnabled: boolean; shareSecretsProductEnabled: boolean; + maxSharedSecretLifetime: number; + maxSharedSecretViewLimit: number | null; }; export type UpdateOrgDTO = { @@ -46,6 +48,8 @@ export type UpdateOrgDTO = { sshProductEnabled?: boolean; scannerProductEnabled?: boolean; shareSecretsProductEnabled?: boolean; + maxSharedSecretViewLimit?: number | null; + maxSharedSecretLifetime?: number; }; export type BillingDetails = { diff --git a/frontend/src/hooks/api/pkiSubscriber/index.tsx b/frontend/src/hooks/api/pkiSubscriber/index.tsx index b086839df..40ab88df1 100644 --- a/frontend/src/hooks/api/pkiSubscriber/index.tsx +++ b/frontend/src/hooks/api/pkiSubscriber/index.tsx @@ -2,6 +2,7 @@ export { useCreatePkiSubscriber, useDeletePkiSubscriber, useIssuePkiSubscriberCert, + useOrderPkiSubscriberCert, useUpdatePkiSubscriber } from "./mutations"; export { useGetPkiSubscriber, useGetPkiSubscriberCertificates } from "./queries"; diff --git a/frontend/src/hooks/api/pkiSubscriber/mutations.tsx b/frontend/src/hooks/api/pkiSubscriber/mutations.tsx index a7d0eef92..b30924f97 100644 --- a/frontend/src/hooks/api/pkiSubscriber/mutations.tsx +++ b/frontend/src/hooks/api/pkiSubscriber/mutations.tsx @@ -108,3 +108,17 @@ export const useIssuePkiSubscriberCert = () => { } }); }; + +export const useOrderPkiSubscriberCert = () => { + return useMutation<{ message: string }, object, TIssuePkiSubscriberCertDTO>({ + mutationFn: async ({ subscriberName, projectId }) => { + const { data } = await apiRequest.post( + `/api/v1/pki/subscribers/${subscriberName}/order-certificate`, + { + projectId + } + ); + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/pkiSubscriber/queries.tsx b/frontend/src/hooks/api/pkiSubscriber/queries.tsx index d9948ed5c..ee6791552 100644 --- a/frontend/src/hooks/api/pkiSubscriber/queries.tsx +++ b/frontend/src/hooks/api/pkiSubscriber/queries.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { TReactQueryOptions } from "@app/types/reactQuery"; import { TCertificate } from "../certificates/types"; import { TPkiSubscriber } from "./types"; @@ -38,13 +39,16 @@ export const pkiSubscriberKeys = { ] as const }; -export const useGetPkiSubscriber = ({ - subscriberName, - projectId -}: { - subscriberName: string; - projectId: string; -}) => { +export const useGetPkiSubscriber = ( + { + subscriberName, + projectId + }: { + subscriberName: string; + projectId: string; + }, + options?: TReactQueryOptions["options"] +) => { return useQuery({ queryKey: pkiSubscriberKeys.getPkiSubscriber({ subscriberName, projectId }), queryFn: async () => { @@ -58,21 +62,25 @@ export const useGetPkiSubscriber = ({ ); return pkiSubscriber; }, - enabled: Boolean(subscriberName) && Boolean(projectId) + enabled: Boolean(subscriberName) && Boolean(projectId), + ...options }); }; -export const useGetPkiSubscriberCertificates = ({ - subscriberName, - projectId, - offset, - limit -}: { - subscriberName: string; - projectId: string; - offset: number; - limit: number; -}) => { +export const useGetPkiSubscriberCertificates = ( + { + subscriberName, + projectId, + offset, + limit + }: { + subscriberName: string; + projectId: string; + offset: number; + limit: number; + }, + options?: TReactQueryOptions["options"] +) => { return useQuery({ queryKey: pkiSubscriberKeys.specificPkiSubscriberCertificates({ subscriberName, @@ -97,6 +105,7 @@ export const useGetPkiSubscriberCertificates = ({ ); return { certificates, totalCount }; }, - enabled: Boolean(subscriberName) && Boolean(projectId) + enabled: Boolean(subscriberName) && Boolean(projectId), + ...options }); }; diff --git a/frontend/src/hooks/api/pkiSubscriber/types.ts b/frontend/src/hooks/api/pkiSubscriber/types.ts index e6050dd13..628cfec0a 100644 --- a/frontend/src/hooks/api/pkiSubscriber/types.ts +++ b/frontend/src/hooks/api/pkiSubscriber/types.ts @@ -5,6 +5,11 @@ export enum PkiSubscriberStatus { DISABLED = "disabled" } +export enum SubscriberOperationStatus { + SUCCESS = "success", + FAILED = "failed" +} + export type TPkiSubscriber = { id: string; projectId: string; @@ -12,10 +17,16 @@ export type TPkiSubscriber = { name: string; commonName: string; status: PkiSubscriberStatus; - ttl: string; + ttl?: string; subjectAlternativeNames: string[]; keyUsages: CertKeyUsage[]; extendedKeyUsages: CertExtendedKeyUsage[]; + supportsImmediateCertIssuance?: boolean; + enableAutoRenewal?: boolean; + autoRenewalPeriodInDays?: number; + lastOperationStatus?: SubscriberOperationStatus; + lastOperationMessage?: string; + lastOperationAt?: string; }; export type TCreatePkiSubscriberDTO = { @@ -23,10 +34,12 @@ export type TCreatePkiSubscriberDTO = { caId: string; name: string; commonName: string; - ttl: string; + ttl?: string; subjectAlternativeNames: string[]; keyUsages: CertKeyUsage[]; extendedKeyUsages: CertExtendedKeyUsage[]; + enableAutoRenewal?: boolean; + autoRenewalPeriodInDays?: number; }; export type TUpdatePkiSubscriberDTO = { @@ -40,6 +53,8 @@ export type TUpdatePkiSubscriberDTO = { subjectAlternativeNames?: string[]; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; + enableAutoRenewal?: boolean; + autoRenewalPeriodInDays?: number; }; export type TDeletePkiSubscriberDTO = { @@ -51,3 +66,8 @@ export type TIssuePkiSubscriberCertDTO = { subscriberName: string; projectId: string; }; + +export type TOrderPkiSubscriberCertDTO = { + subscriberName: string; + projectId: string; +}; diff --git a/frontend/src/hooks/api/secretApproval/mutation.tsx b/frontend/src/hooks/api/secretApproval/mutation.tsx index f9aca5bee..e2d566e25 100644 --- a/frontend/src/hooks/api/secretApproval/mutation.tsx +++ b/frontend/src/hooks/api/secretApproval/mutation.tsx @@ -14,6 +14,7 @@ export const useCreateSecretApprovalPolicy = () => { workspaceId, approvals, approvers, + bypassers, secretPath, name, enforcementLevel, @@ -24,6 +25,7 @@ export const useCreateSecretApprovalPolicy = () => { workspaceId, approvals, approvers, + bypassers, secretPath, name, enforcementLevel, @@ -46,6 +48,7 @@ export const useUpdateSecretApprovalPolicy = () => { mutationFn: async ({ id, approvers, + bypassers, approvals, secretPath, name, @@ -55,6 +58,7 @@ export const useUpdateSecretApprovalPolicy = () => { const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, { approvals, approvers, + bypassers, secretPath, name, enforcementLevel, diff --git a/frontend/src/hooks/api/secretApproval/types.ts b/frontend/src/hooks/api/secretApproval/types.ts index 0a4b0d495..15afcf119 100644 --- a/frontend/src/hooks/api/secretApproval/types.ts +++ b/frontend/src/hooks/api/secretApproval/types.ts @@ -25,6 +25,16 @@ export type Approver = { type: ApproverType; }; +export enum BypasserType { + User = "user", + Group = "group" +} + +export type Bypasser = { + id: string; + type: BypasserType; +}; + export type TGetSecretApprovalPoliciesDTO = { workspaceId: string; }; @@ -41,6 +51,7 @@ export type TCreateSecretPolicyDTO = { environment: string; secretPath?: string | null; approvers?: Approver[]; + bypassers?: Bypasser[]; approvals?: number; enforcementLevel: EnforcementLevel; allowedSelfApprovals: boolean; @@ -50,6 +61,7 @@ export type TUpdateSecretPolicyDTO = { id: string; name?: string; approvers?: Approver[]; + bypassers?: Bypasser[]; secretPath?: string | null; approvals?: number; allowedSelfApprovals?: boolean; diff --git a/frontend/src/hooks/api/secretApprovalRequest/types.ts b/frontend/src/hooks/api/secretApprovalRequest/types.ts index f9684ee09..3ac2574b5 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/types.ts +++ b/frontend/src/hooks/api/secretApprovalRequest/types.ts @@ -57,7 +57,7 @@ export type TSecretApprovalRequest = { secretPath: string; hasMerged: boolean; status: "open" | "close"; - policy: Omit & { + policy: Omit & { approvers: { userId: string; email: string; @@ -65,6 +65,13 @@ export type TSecretApprovalRequest = { lastName: string; username: string; }[]; + bypassers: { + userId: string; + email: string; + firstName: string; + lastName: string; + username: string; + }[]; }; statusChangedByUserId: string; statusChangedByUser?: { diff --git a/frontend/src/hooks/api/secretRotationsV2/enums.ts b/frontend/src/hooks/api/secretRotationsV2/enums.ts index 3b38c1d49..5daab0d9a 100644 --- a/frontend/src/hooks/api/secretRotationsV2/enums.ts +++ b/frontend/src/hooks/api/secretRotationsV2/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", LdapPassword = "ldap-password", diff --git a/frontend/src/hooks/api/secretRotationsV2/types/index.ts b/frontend/src/hooks/api/secretRotationsV2/types/index.ts index a1ef0bd15..6f1a82c68 100644 --- a/frontend/src/hooks/api/secretRotationsV2/types/index.ts +++ b/frontend/src/hooks/api/secretRotationsV2/types/index.ts @@ -31,9 +31,15 @@ import { TSqlCredentialsRotationOption } from "@app/hooks/api/secretRotationsV2/ import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; import { DiscriminativePick } from "@app/types"; +import { + TMySqlCredentialsRotation, + TMySqlCredentialsRotationGeneratedCredentialsResponse +} from "./mysql-credentials-rotation"; + export type TSecretRotationV2 = ( | TPostgresCredentialsRotation | TMsSqlCredentialsRotation + | TMySqlCredentialsRotation | TAuth0ClientSecretRotation | TAzureClientSecretRotation | TLdapPasswordRotation @@ -56,6 +62,7 @@ export type TSecretRotationV2Response = { secretRotation: TSecretRotationV2 }; export type TViewSecretRotationGeneratedCredentialsResponse = | TPostgresCredentialsRotationGeneratedCredentialsResponse | TMsSqlCredentialsRotationGeneratedCredentialsResponse + | TMySqlCredentialsRotationGeneratedCredentialsResponse | TAuth0ClientSecretRotationGeneratedCredentialsResponse | TAzureClientSecretRotationGeneratedCredentialsResponse | TLdapPasswordRotationGeneratedCredentialsResponse @@ -105,6 +112,7 @@ export type TViewSecretRotationV2GeneratedCredentialsDTO = { export type TSecretRotationOptionMap = { [SecretRotation.PostgresCredentials]: TSqlCredentialsRotationOption; [SecretRotation.MsSqlCredentials]: TSqlCredentialsRotationOption; + [SecretRotation.MySqlCredentials]: TSqlCredentialsRotationOption; [SecretRotation.Auth0ClientSecret]: TAuth0ClientSecretRotationOption; [SecretRotation.AzureClientSecret]: TAzureClientSecretRotationOption; [SecretRotation.LdapPassword]: TLdapPasswordRotationOption; @@ -114,6 +122,7 @@ export type TSecretRotationOptionMap = { export type TSecretRotationGeneratedCredentialsResponseMap = { [SecretRotation.PostgresCredentials]: TPostgresCredentialsRotationGeneratedCredentialsResponse; [SecretRotation.MsSqlCredentials]: TMsSqlCredentialsRotationGeneratedCredentialsResponse; + [SecretRotation.MySqlCredentials]: TMySqlCredentialsRotationGeneratedCredentialsResponse; [SecretRotation.Auth0ClientSecret]: TAuth0ClientSecretRotationGeneratedCredentialsResponse; [SecretRotation.AzureClientSecret]: TAzureClientSecretRotationGeneratedCredentialsResponse; [SecretRotation.LdapPassword]: TLdapPasswordRotationGeneratedCredentialsResponse; diff --git a/frontend/src/hooks/api/secretRotationsV2/types/ldap-password-rotation.ts b/frontend/src/hooks/api/secretRotationsV2/types/ldap-password-rotation.ts index b8d2ade2b..42a1116cf 100644 --- a/frontend/src/hooks/api/secretRotationsV2/types/ldap-password-rotation.ts +++ b/frontend/src/hooks/api/secretRotationsV2/types/ldap-password-rotation.ts @@ -1,3 +1,4 @@ +import { TPasswordRequirements } from "@app/components/secret-rotations-v2/forms/schemas/shared"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { @@ -5,10 +6,17 @@ import { TSecretRotationV2GeneratedCredentialsResponseBase } from "@app/hooks/api/secretRotationsV2/types/shared"; +export enum LdapPasswordRotationMethod { + ConnectionPrincipal = "connection-principal", + TargetPrincipal = "target-principal" +} + export type TLdapPasswordRotation = TSecretRotationV2Base & { type: SecretRotation.LdapPassword; parameters: { dn: string; + rotationMethod?: LdapPasswordRotationMethod; + passwordRequirements?: TPasswordRequirements; }; secretsMapping: { dn: string; diff --git a/frontend/src/hooks/api/secretRotationsV2/types/mysql-credentials-rotation.ts b/frontend/src/hooks/api/secretRotationsV2/types/mysql-credentials-rotation.ts new file mode 100644 index 000000000..0577a269c --- /dev/null +++ b/frontend/src/hooks/api/secretRotationsV2/types/mysql-credentials-rotation.ts @@ -0,0 +1,17 @@ +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; +import { + TSecretRotationV2Base, + TSecretRotationV2GeneratedCredentialsResponseBase, + TSqlCredentialsRotationGeneratedCredentials, + TSqlCredentialsRotationProperties +} from "@app/hooks/api/secretRotationsV2/types/shared"; + +export type TMySqlCredentialsRotation = TSecretRotationV2Base & { + type: SecretRotation.MySqlCredentials; +} & TSqlCredentialsRotationProperties; + +export type TMySqlCredentialsRotationGeneratedCredentialsResponse = + TSecretRotationV2GeneratedCredentialsResponseBase< + SecretRotation.MySqlCredentials, + TSqlCredentialsRotationGeneratedCredentials + >; diff --git a/frontend/src/hooks/api/secretRotationsV2/types/shared/sql-credentials-rotation.ts b/frontend/src/hooks/api/secretRotationsV2/types/shared/sql-credentials-rotation.ts index 1a212e9c9..3be6679f0 100644 --- a/frontend/src/hooks/api/secretRotationsV2/types/shared/sql-credentials-rotation.ts +++ b/frontend/src/hooks/api/secretRotationsV2/types/shared/sql-credentials-rotation.ts @@ -14,8 +14,11 @@ export type TSqlCredentialsRotationProperties = { export type TSqlCredentialsRotationOption = { name: string; - type: SecretRotation.PostgresCredentials | SecretRotation.MsSqlCredentials; - connection: AppConnection.Postgres | AppConnection.MsSql; + type: + | SecretRotation.PostgresCredentials + | SecretRotation.MsSqlCredentials + | SecretRotation.MySqlCredentials; + connection: AppConnection.Postgres | AppConnection.MsSql | AppConnection.MySql; template: { secretsMapping: TSqlCredentialsRotationProperties["secretsMapping"]; createUserStatement: string; diff --git a/frontend/src/hooks/api/secretSharing/queries.ts b/frontend/src/hooks/api/secretSharing/queries.ts index cfd505ff0..13867aa05 100644 --- a/frontend/src/hooks/api/secretSharing/queries.ts +++ b/frontend/src/hooks/api/secretSharing/queries.ts @@ -11,13 +11,10 @@ export const secretSharingKeys = { allSecretRequests: () => ["secretRequests"] as const, specificSecretRequests: ({ offset, limit }: { offset: number; limit: number }) => [...secretSharingKeys.allSecretRequests(), { offset, limit }] as const, - getSecretById: (arg: { - id: string; - hashedHex: string | null; - password?: string; - email?: string; - hash?: string; - }) => ["shared-secret", arg], + getSecretById: (arg: { id: string; hashedHex: string | null; password?: string }) => [ + "shared-secret", + arg + ], getSecretRequestById: (arg: { id: string }) => ["secret-request", arg] as const }; @@ -73,34 +70,24 @@ export const useGetSecretRequests = ({ export const useGetActiveSharedSecretById = ({ sharedSecretId, hashedHex, - password, - email, - hash + password }: { sharedSecretId: string; hashedHex: string | null; password?: string; - - // For secrets shared to specific emails (optional) - email?: string; - hash?: string; }) => { return useQuery({ queryKey: secretSharingKeys.getSecretById({ id: sharedSecretId, hashedHex, - password, - email, - hash + password }), queryFn: async () => { const { data } = await apiRequest.post( `/api/v1/secret-sharing/shared/public/${sharedSecretId}`, { ...(hashedHex && { hashedHex }), - password, - email, - hash + password } ); diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 65a31e427..7185563d5 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -13,7 +13,8 @@ export enum SecretSync { Windmill = "windmill", HCVault = "hashicorp-vault", TeamCity = "teamcity", - OCIVault = "oci-vault" + OCIVault = "oci-vault", + OnePass = "1password" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/1password-sync.ts b/frontend/src/hooks/api/secretSyncs/types/1password-sync.ts new file mode 100644 index 000000000..98556d2af --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/1password-sync.ts @@ -0,0 +1,15 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TOnePassSync = TRootSecretSync & { + destination: SecretSync.OnePass; + destinationConfig: { + vaultId: string; + }; + connection: { + app: AppConnection.OnePass; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index e3de6029a..f28a0820b 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -1,6 +1,7 @@ import { SecretSync, SecretSyncImportBehavior } from "@app/hooks/api/secretSyncs"; import { DiscriminativePick } from "@app/types"; +import { TOnePassSync } from "./1password-sync"; import { TAwsParameterStoreSync } from "./aws-parameter-store-sync"; import { TAwsSecretsManagerSync } from "./aws-secrets-manager-sync"; import { TAzureAppConfigurationSync } from "./azure-app-configuration-sync"; @@ -21,6 +22,7 @@ export type TSecretSyncOption = { name: string; destination: SecretSync; canImportSecrets: boolean; + enterprise?: boolean; }; export type TSecretSync = @@ -38,7 +40,8 @@ export type TSecretSync = | TWindmillSync | THCVaultSync | TTeamCitySync - | TOCIVaultSync; + | TOCIVaultSync + | TOnePassSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index fd73a9595..dad9e11fa 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -51,4 +51,6 @@ export type SubscriptionPlan = { projectTemplates: boolean; kmip: boolean; secretScanning: boolean; + enterpriseSecretSyncs: boolean; + enterpriseAppConnections: boolean; }; diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index 0774275f9..715c3532d 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -1,6 +1,7 @@ export { useAddUserToWsE2EE, useAddUserToWsNonE2EE, + useRemoveMyDuplicateAccounts, useRevokeMySessionById, useSendEmailVerificationCode, useVerifyEmailVerificationCode @@ -14,6 +15,7 @@ export { useDeleteOrgMembership, useGetMyAPIKeys, useGetMyAPIKeysV2, + useGetMyDuplicateAccount, useGetMyIp, useGetMyOrganizationProjects, useGetMySessions, diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 1b873b31c..ee274ab31 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -184,3 +184,12 @@ export const useRevokeMySessionById = () => { } }); }; + +export const useRemoveMyDuplicateAccounts = () => { + return useMutation({ + mutationFn: async () => { + const { data } = await apiRequest.post("/api/v1/user/remove-duplicate-accounts"); + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 06cde4d34..ea451db02 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -37,6 +37,33 @@ export const useGetUser = () => queryFn: fetchUserDetails }); +export const fetchUserDuplicateAccounts = async () => { + const { data } = await apiRequest.get<{ + users: Array< + User & { + isMyAccount: boolean; + organizations: { name: string; slug: string }[]; + devices: { + ip: string; + userAgent: string; + }[]; + } + >; + }>("/api/v1/user/duplicate-accounts"); + return data.users; +}; + +export const useGetMyDuplicateAccount = () => + useQuery({ + queryKey: userKeys.getMyDuplicateAccount, + staleTime: 60 * 1000, // 1 min in ms + queryFn: fetchUserDuplicateAccounts, + select: (users) => ({ + duplicateAccounts: users.filter((el) => !el.isMyAccount), + myAccount: users?.find((el) => el.isMyAccount) + }) + }); + export const useDeleteMe = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/users/query-keys.tsx b/frontend/src/hooks/api/users/query-keys.tsx index 34d969b49..aacbb73b9 100644 --- a/frontend/src/hooks/api/users/query-keys.tsx +++ b/frontend/src/hooks/api/users/query-keys.tsx @@ -1,5 +1,6 @@ export const userKeys = { getUser: ["user"] as const, + getMyDuplicateAccount: ["user-duplicate-account"] as const, getPrivateKey: ["user"] as const, userAction: ["user-action"] as const, userProjectFavorites: (orgId: string) => [{ orgId }, "user-project-favorites"] as const, diff --git a/frontend/src/layouts/AdminLayout/AdminLayout.tsx b/frontend/src/layouts/AdminLayout/AdminLayout.tsx index 41e9e6606..097654f26 100644 --- a/frontend/src/layouts/AdminLayout/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout/AdminLayout.tsx @@ -1,27 +1,23 @@ import { useTranslation } from "react-i18next"; -import { faArrowLeft, faInfo, faMobile, faQuestion } from "@fortawesome/free-solid-svg-icons"; +import { faMobile } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, Outlet } from "@tanstack/react-router"; +import { Outlet, useRouterState } from "@tanstack/react-router"; -import { WishForm } from "@app/components/features/WishForm"; import { Banner } from "@app/components/page-frames/Banner"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from "@app/components/v2"; -import { envConfig } from "@app/config/env"; +import { BreadcrumbContainer, TBreadcrumbFormat } from "@app/components/v2"; import { useServerConfig } from "@app/context"; -import { ProjectType } from "@app/hooks/api/workspace/types"; import { InsecureConnectionBanner } from "../OrganizationLayout/components/InsecureConnectionBanner"; -import { INFISICAL_SUPPORT_OPTIONS } from "../OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar"; +import { AdminSidebar } from "./Sidebar"; export const AdminLayout = () => { const { t } = useTranslation(); const { config } = useServerConfig(); + const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context }); + + const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined; + const containerHeight = config.pageFrameContent ? "h-[94vh]" : "h-screen"; return ( @@ -30,54 +26,11 @@ export const AdminLayout = () => {