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 b098368c4..a32ed56a3 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -53,6 +53,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"; @@ -110,6 +111,7 @@ import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integ declare module "@fastify/request-context" { interface RequestContextData { reqId: string; + orgId?: string; identityAuthInfo?: { identityId: string; oidc?: { @@ -268,6 +270,7 @@ declare module "fastify" { microsoftTeams: TMicrosoftTeamsServiceFactory; assumePrivileges: TAssumePrivilegeServiceFactory; githubOrgSync: TGithubOrgSyncServiceFactory; + internalCertificateAuthority: TInternalCertificateAuthorityServiceFactory; }; // 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 276669b2a..22b13f923 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -68,6 +68,9 @@ import { TDynamicSecrets, TDynamicSecretsInsert, TDynamicSecretsUpdate, + TExternalCertificateAuthorities, + TExternalCertificateAuthoritiesInsert, + TExternalCertificateAuthoritiesUpdate, TExternalGroupOrgRoleMappings, TExternalGroupOrgRoleMappingsInsert, TExternalGroupOrgRoleMappingsUpdate, @@ -155,6 +158,9 @@ import { TIntegrations, TIntegrationsInsert, TIntegrationsUpdate, + TInternalCertificateAuthorities, + TInternalCertificateAuthoritiesInsert, + TInternalCertificateAuthoritiesUpdate, TInternalKms, TInternalKmsInsert, TInternalKmsUpdate, @@ -538,6 +544,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, 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/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/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/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/index.ts b/backend/src/db/schemas/index.ts index 0bf44c413..59eca6af8 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -20,6 +20,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 +50,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"; 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 730474ad1..0485a04ff 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", 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/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index b0914d5c4..d90f28184 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -154,7 +154,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 +171,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/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/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index 6b5014acc..17176162b 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 @@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability"; 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 { ProjectPermissionApprovalActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; @@ -98,7 +98,7 @@ export const accessApprovalPolicyServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionApprovalActions.Create, ProjectPermissionSub.SecretApproval ); const env = await projectEnvDAL.findOne({ slug: environment, projectId: project.id }); @@ -256,7 +256,10 @@ export const accessApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionApprovalActions.Edit, + ProjectPermissionSub.SecretApproval + ); const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => { const doc = await accessApprovalPolicyDAL.updateById( @@ -341,7 +344,7 @@ export const accessApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, + ProjectPermissionApprovalActions.Delete, ProjectPermissionSub.SecretApproval ); @@ -432,7 +435,10 @@ export const accessApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionApprovalActions.Read, + ProjectPermissionSub.SecretApproval + ); return policy; }; 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..017356a5d 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"; @@ -22,6 +23,7 @@ import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-poli import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal"; import { TGroupDALFactory } from "../group/group-dal"; import { TPermissionServiceFactory } from "../permission/permission-service"; +import { ProjectPermissionApprovalActions, ProjectPermissionSub } from "../permission/project-permission"; import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; import { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types"; import { TAccessApprovalRequestDALFactory } from "./access-approval-request-dal"; @@ -323,26 +325,22 @@ 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({ + const { membership, hasRole, permission } = await permissionService.getProjectPermission({ actor, actorId, projectId: accessApprovalRequest.projectId, @@ -355,6 +353,20 @@ 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 canBypassApproval = permission.can( + ProjectPermissionApprovalActions.AllowAccessBypass, + ProjectPermissionSub.SecretApproval + ); + const cannotBypassUnderSoftEnforcement = !(isSoftEnforcement && canBypassApproval); + + if (!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 @@ -363,21 +375,49 @@ export const accessApprovalRequestServiceFactory = ({ 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 +425,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 +456,7 @@ export const accessApprovalRequestServiceFactory = ({ }, tx ); - privilegeId = privilege.id; + privilegeIdToSet = privilege.id; } else { // Temporary access const relativeTempAllocatedTimeInMs = ms(accessApprovalRequest.temporaryRange!); @@ -421,23 +468,57 @@ 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 }, 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 365ada987..bd500b377 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 @@ -20,7 +21,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"; @@ -231,6 +232,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", @@ -241,6 +243,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", @@ -266,7 +269,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", @@ -315,7 +320,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", @@ -375,7 +379,13 @@ export enum EventType { MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_LIST = "microsoft-teams-workflow-integration-list", PROJECT_ASSUME_PRIVILEGE_SESSION_START = "project-assume-privileges-session-start", - PROJECT_ASSUME_PRIVILEGE_SESSION_END = "project-assume-privileges-session-end" + PROJECT_ASSUME_PRIVILEGE_SESSION_END = "project-assume-privileges-session-end", + + UPDATE_ORG = "update-org", + + CREATE_PROJECT = "create-project", + UPDATE_PROJECT = "update-project", + DELETE_PROJECT = "delete-project" } export const filterableSecretEvents: EventType[] = [ @@ -1772,7 +1782,8 @@ interface CreateCa { type: EventType.CREATE_CA; metadata: { caId: string; - dn: string; + name: string; + dn?: string; }; } @@ -1780,7 +1791,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[]; }; } @@ -1788,7 +1807,8 @@ interface UpdateCa { type: EventType.UPDATE_CA; metadata: { caId: string; - dn: string; + name: string; + dn?: string; status: CaStatus; }; } @@ -1797,7 +1817,8 @@ interface DeleteCa { type: EventType.DELETE_CA; metadata: { caId: string; - dn: string; + name: string; + dn?: string; }; } @@ -1867,6 +1888,15 @@ interface IssueCert { }; } +interface ImportCert { + type: EventType.IMPORT_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + interface SignCert { type: EventType.SIGN_CERT; metadata: { @@ -2034,7 +2064,7 @@ interface CreatePkiSubscriber { caId?: string; name: string; commonName: string; - ttl: string; + ttl?: string; subjectAlternativeNames: string[]; keyUsages: CertKeyUsage[]; extendedKeyUsages: CertExtendedKeyUsage[]; @@ -2076,7 +2106,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; }; } @@ -2098,6 +2136,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: { @@ -2451,14 +2499,6 @@ interface DeleteProjectTemplateEvent { }; } -interface ApplyProjectTemplateEvent { - type: EventType.APPLY_PROJECT_TEMPLATE; - metadata: { - template: string; - projectId: string; - }; -} - interface GetAppConnectionsEvent { type: EventType.GET_APP_CONNECTIONS; metadata: { @@ -2913,6 +2953,59 @@ interface MicrosoftTeamsWorkflowIntegrationUpdateEvent { }; } +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 @@ -3037,6 +3130,7 @@ export type Event = | IssueSshHostHostCert | CreateCa | GetCa + | GetCAs | UpdateCa | DeleteCa | RenewCa @@ -3047,6 +3141,7 @@ export type Event = | ImportCaCert | GetCaCrls | IssueCert + | ImportCert | SignCert | GetCaCertificateTemplates | GetCert @@ -3072,7 +3167,9 @@ export type Event = | GetPkiSubscriber | IssuePkiSubscriberCert | SignPkiSubscriberCert + | AutomatedRenewPkiSubscriberCert | ListPkiSubscriberCerts + | GetSubscriberActiveCertBundle | CreateKmsEvent | UpdateKmsEvent | DeleteKmsEvent @@ -3117,7 +3214,6 @@ export type Event = | CreateProjectTemplateEvent | UpdateProjectTemplateEvent | DeleteProjectTemplateEvent - | ApplyProjectTemplateEvent | GetAppConnectionsEvent | GetAvailableAppConnectionsDetailsEvent | GetAppConnectionEvent @@ -3179,4 +3275,8 @@ export type Event = | MicrosoftTeamsWorkflowIntegrationGetTeamsEvent | MicrosoftTeamsWorkflowIntegrationGetEvent | MicrosoftTeamsWorkflowIntegrationListEvent - | MicrosoftTeamsWorkflowIntegrationUpdateEvent; + | MicrosoftTeamsWorkflowIntegrationUpdateEvent + | 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/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts index 6a8f807ad..5259d4616 100644 --- a/backend/src/ee/services/license/__mocks__/license-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -29,7 +29,9 @@ export const getDefaultOnPremFeatures = () => { secretApproval: true, secretRotation: true, caCrl: false, - sshHostGroups: false + sshHostGroups: false, + enterpriseSecretSyncs: false, + enterpriseAppConnections: false }; }; diff --git a/backend/src/ee/services/license/license-dal.ts b/backend/src/ee/services/license/license-dal.ts index cab428e86..88a2dadf6 100644 --- a/backend/src/ee/services/license/license-dal.ts +++ b/backend/src/ee/services/license/license-dal.ts @@ -19,7 +19,7 @@ export const licenseDALFactory = (db: TDbClient) => { .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) .where(`${TableName.Users}.isGhost`, false) .count(); - return Number(doc?.[0].count); + return Number(doc?.[0]?.count ?? 0); } catch (error) { throw new DatabaseError({ error, name: "Count of Org Members" }); } diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 8ef91c6f8..d8ca362bd 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -55,7 +55,9 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ projectTemplates: false, kmip: false, gateway: false, - sshHostGroups: false + sshHostGroups: false, + enterpriseSecretSyncs: false, + enterpriseAppConnections: false }); export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index f5b1f96ec..ceeebbf7a 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -92,6 +92,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 +189,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, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 358849fb2..f509c7127 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -27,7 +27,7 @@ export type TFeatureSet = { slug: null; tier: -1; workspaceLimit: null; - workspacesUsed: 0; + workspacesUsed: number; dynamicSecret: false; memberLimit: null; membersUsed: number; @@ -72,6 +72,8 @@ export type TFeatureSet = { kmip: false; gateway: false; sshHostGroups: false; + enterpriseSecretSyncs: false; + enterpriseAppConnections: false; }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index a018ffe81..2a422d55f 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -2,6 +2,7 @@ import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability" import { ProjectPermissionActions, + ProjectPermissionApprovalActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, @@ -25,7 +26,6 @@ const buildAdminPermissionRules = () => { [ ProjectPermissionSub.SecretFolders, ProjectPermissionSub.SecretImports, - ProjectPermissionSub.SecretApproval, ProjectPermissionSub.Role, ProjectPermissionSub.Integrations, ProjectPermissionSub.Webhooks, @@ -55,6 +55,18 @@ const buildAdminPermissionRules = () => { ); }); + can( + [ + ProjectPermissionApprovalActions.Read, + ProjectPermissionApprovalActions.Edit, + ProjectPermissionApprovalActions.Create, + ProjectPermissionApprovalActions.Delete, + ProjectPermissionApprovalActions.AllowChangeBypass, + ProjectPermissionApprovalActions.AllowAccessBypass + ], + ProjectPermissionSub.SecretApproval + ); + can( [ ProjectPermissionCertificateActions.Read, @@ -243,7 +255,7 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.SecretImports ); - can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretApproval); + can([ProjectPermissionApprovalActions.Read], ProjectPermissionSub.SecretApproval); can([ProjectPermissionSecretRotationActions.Read], ProjectPermissionSub.SecretRotation); can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback); @@ -391,7 +403,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretFolders); can(ProjectPermissionDynamicSecretActions.ReadRootCredential, ProjectPermissionSub.DynamicSecrets); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretImports); - can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionApprovalActions.Read, ProjectPermissionSub.SecretApproval); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionSecretRotationActions.Read, ProjectPermissionSub.SecretRotation); can(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 5474facf6..5d06e085d 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -34,6 +34,15 @@ export enum ProjectPermissionSecretActions { Delete = "delete" } +export enum ProjectPermissionApprovalActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + AllowChangeBypass = "allow-change-bypass", + AllowAccessBypass = "allow-access-bypass" +} + export enum ProjectPermissionCmekActions { Read = "read", Create = "create", @@ -242,7 +251,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.Settings] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] - | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] + | [ProjectPermissionApprovalActions, ProjectPermissionSub.SecretApproval] | [ ProjectPermissionSecretRotationActions, ( @@ -439,7 +448,7 @@ const PkiSubscriberConditionSchema = z const GeneralPermissionSchema = [ z.object({ subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionApprovalActions).describe( "Describe what action an entity can take." ) }), @@ -605,7 +614,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."), 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..bc2877ef2 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 @@ -3,7 +3,7 @@ import picomatch from "picomatch"; 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 { ProjectPermissionApprovalActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; import { containsGlobPatterns } from "@app/lib/picomatch"; @@ -89,7 +89,7 @@ export const secretApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionApprovalActions.Create, ProjectPermissionSub.SecretApproval ); @@ -204,7 +204,10 @@ export const secretApprovalPolicyServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionApprovalActions.Edit, + ProjectPermissionSub.SecretApproval + ); const plan = await licenseService.getPlan(actorOrgId); if (!plan.secretApproval) { @@ -301,7 +304,7 @@ export const secretApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, + ProjectPermissionApprovalActions.Delete, ProjectPermissionSub.SecretApproval ); @@ -340,7 +343,10 @@ export const secretApprovalPolicyServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionApprovalActions.Read, + ProjectPermissionSub.SecretApproval + ); const sapPolicies = await secretApprovalPolicyDAL.find({ projectId, deletedAt: null }); return sapPolicies; @@ -413,7 +419,10 @@ export const secretApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionApprovalActions.Read, + ProjectPermissionSub.SecretApproval + ); return sapPolicy; }; 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..500312a7a 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 @@ -62,7 +62,11 @@ import { TUserDALFactory } from "@app/services/user/user-dal"; import { TLicenseServiceFactory } from "../license/license-service"; import { throwIfMissingSecretReadValueOrDescribePermission } from "../permission/permission-fns"; import { TPermissionServiceFactory } from "../permission/permission-service"; -import { ProjectPermissionSecretActions, ProjectPermissionSub } from "../permission/project-permission"; +import { + ProjectPermissionApprovalActions, + ProjectPermissionSecretActions, + ProjectPermissionSub +} from "../permission/project-permission"; import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal"; import { TSecretSnapshotServiceFactory } from "../secret-snapshot/secret-snapshot-service"; import { TSecretApprovalRequestDALFactory } from "./secret-approval-request-dal"; @@ -504,7 +508,7 @@ export const secretApprovalRequestServiceFactory = ({ }); } - const { hasRole } = await permissionService.getProjectPermission({ + const { hasRole, permission } = await permissionService.getProjectPermission({ actor: ActorType.USER, actorId, projectId, @@ -531,7 +535,13 @@ export const secretApprovalRequestServiceFactory = ({ ).length; const isSoftEnforcement = secretApprovalRequest.policy.enforcementLevel === EnforcementLevel.Soft; - if (!hasMinApproval && !isSoftEnforcement) + if ( + !hasMinApproval && + !( + isSoftEnforcement && + permission.can(ProjectPermissionApprovalActions.AllowChangeBypass, ProjectPermissionSub.SecretApproval) + ) + ) throw new BadRequestError({ message: "Doesn't have minimum approvals needed" }); const { botKey, shouldUseSecretV2Bridge, project } = await projectBotService.getBotKey(projectId); 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 b253a4c6c..ad58a2a7e 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -36,6 +36,8 @@ export const KeyStorePrefixes = { `sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const, SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const, SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const, + 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}`, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 1a30923d5..2eeb86380 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -5,6 +5,8 @@ import { } from "@app/ee/services/secret-rotation-v2/secret-rotation-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"; @@ -1707,6 +1709,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." } }; @@ -1778,6 +1793,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.", @@ -1788,7 +1811,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.", @@ -1802,7 +1827,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.", @@ -1991,6 +2018,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.` @@ -2084,6 +2152,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." } } }; @@ -2237,6 +2309,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." } } }; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index c9e01cef5..198ccb9bf 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -81,6 +81,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) @@ -318,6 +321,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, @@ -329,8 +343,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/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/queue/queue-service.ts b/backend/src/queue/queue-service.ts index e55b1f38e..c4aae9aaf 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -14,6 +14,7 @@ import { import { getConfig } from "@app/lib/config/env"; import { buildRedisFromConfig, TRedisConfigKeys } from "@app/lib/config/redis"; import { logger } from "@app/lib/logger"; +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { TFailedIntegrationSyncEmailsPayload, TIntegrationSyncPayload, @@ -36,6 +37,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", @@ -44,6 +46,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", @@ -84,7 +87,9 @@ export enum QueueJobs { SecretRotationV2QueueRotations = "secret-rotation-v2-queue-rotations", SecretRotationV2RotateSecrets = "secret-rotation-v2-rotate-secrets", SecretRotationV2SendNotification = "secret-rotation-v2-send-notification", - InvalidateCache = "invalidate-cache" + InvalidateCache = "invalidate-cache", + CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber", + PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal" } export type TQueueJobTypes = { @@ -245,6 +250,17 @@ export type TQueueJobTypes = { }; }; }; + [QueueName.CaLifecycle]: { + name: QueueJobs.CaOrderCertificateForSubscriber; + payload: { + subscriberId: string; + caType: CaType; + }; + }; + [QueueName.PkiSubscriber]: { + name: QueueJobs.PkiSubscriberDailyAutoRenewal; + payload: undefined; + }; }; export type TQueueServiceFactory = ReturnType; 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 cb22c8c3c..0402c1a32 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -132,6 +132,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"; @@ -199,6 +203,7 @@ 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 { projectDALFactory } from "@app/services/project/project-dal"; import { projectQueueFactory } from "@app/services/project/project-queue"; @@ -817,6 +822,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); @@ -842,17 +849,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({ @@ -901,23 +900,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, @@ -937,17 +919,6 @@ export const registerRoutes = async ( licenseService }); - const certificateEstService = certificateEstServiceFactory({ - certificateAuthorityService, - certificateTemplateService, - certificateTemplateDAL, - certificateAuthorityCertDAL, - certificateAuthorityDAL, - projectDAL, - kmsService, - licenseService - }); - const pkiAlertService = pkiAlertServiceFactory({ pkiAlertDAL, pkiCollectionDAL, @@ -965,20 +936,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, @@ -1014,7 +971,8 @@ export const registerRoutes = async ( secretVersionV2BridgeDAL, secretVersionTagV2BridgeDAL, resourceMetadataDAL, - appConnectionDAL + appConnectionDAL, + licenseService }); const secretQueueService = secretQueueFactory({ @@ -1631,7 +1589,8 @@ export const registerRoutes = async ( const appConnectionService = appConnectionServiceFactory({ appConnectionDAL, permissionService, - kmsService + kmsService, + licenseService }); const secretSyncService = secretSyncServiceFactory({ @@ -1642,7 +1601,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({ @@ -1684,6 +1690,59 @@ 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 + }); + await secretRotationV2QueueServiceFactory({ secretRotationV2Service, secretRotationV2DAL, @@ -1704,6 +1763,7 @@ export const registerRoutes = async ( await telemetryQueue.startTelemetryCheck(); await dailyResourceCleanUp.startCleanUp(); await dailyExpiringPkiItemAlert.startSendingAlerts(); + await pkiSubscriberQueue.startDailyAutoRenewalJob(); await kmsService.startService(); await microsoftTeamsService.start(); @@ -1769,6 +1829,7 @@ export const registerRoutes = async ( sshHost: sshHostService, sshHostGroup: sshHostGroupService, certificateAuthority: certificateAuthorityService, + internalCertificateAuthority: internalCertificateAuthorityService, certificateTemplate: certificateTemplateService, certificateAuthorityCrl: certificateAuthorityCrlService, certificateEst: certificateEstService, diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 87d82c241..209044434 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, @@ -272,3 +274,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/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 b9ce3deb8..0fea749c0 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 { @@ -38,7 +43,6 @@ 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 { PostgresConnectionListItemSchema, SanitizedPostgresConnectionSchema @@ -78,7 +82,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedWindmillConnectionSchema.options, ...SanitizedLdapConnectionSchema.options, ...SanitizedTeamCityConnectionSchema.options, - ...SanitizedOCIConnectionSchema.options + ...SanitizedOCIConnectionSchema.options, + ...SanitizedOnePassConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -100,7 +105,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 6f6fa1991..1c46b4ea9 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"; @@ -13,7 +15,6 @@ 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 { registerPostgresConnectionRouter } from "./postgres-connection-router"; import { registerTeamCityConnectionRouter } from "./teamcity-connection-router"; import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; @@ -42,5 +43,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { 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/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/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index c489d685d..b3fceb201 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -312,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 2e983cb83..651faede4 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -263,6 +263,17 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actor: req.permission.type, actorOrgId: req.permission.orgId }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.params.workspaceId, + event: { + type: EventType.DELETE_PROJECT, + metadata: workspace + } + }); + return { workspace }; } }); @@ -297,6 +308,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 @@ -375,6 +397,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 }; @@ -411,6 +444,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 @@ -448,6 +492,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 @@ -486,6 +541,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 @@ -524,6 +589,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-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/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..fb055877d 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -1,3 +1,4 @@ +import { registerCaRouter } from "./certificate-authority-router"; import { registerGroupProjectRouter } from "./group-project-router"; import { registerIdentityOrgRouter } from "./identity-org-router"; import { registerIdentityProjectRouter } from "./identity-project-router"; @@ -14,6 +15,7 @@ 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(registerCaRouter, { prefix: "/pki/ca" }); await server.register( async (orgRouter) => { await orgRouter.register(registerOrgRouter); 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 6e09f1293..25c6394fa 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -17,7 +17,8 @@ export enum AppConnection { HCVault = "hashicorp-vault", LDAP = "ldap", TeamCity = "teamcity", - OCI = "oci" + OCI = "oci", + OnePass = "1password" } export enum AWSRegion { @@ -66,3 +67,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 f6fd894a6..86e728008 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, @@ -53,7 +64,6 @@ import { } from "./humanitec"; import { getLdapConnectionListItem, LdapConnectionMethod, validateLdapConnectionCredentials } from "./ldap"; import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; -import { getOCIConnectionListItem, OCIConnectionMethod, validateOCIConnectionCredentials } from "./oci"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; import { getTeamCityConnectionListItem, @@ -93,7 +103,8 @@ export const listAppConnectionOptions = () => { getHCVaultConnectionListItem(), getLdapConnectionListItem(), getTeamCityConnectionListItem(), - getOCIConnectionListItem() + getOCIConnectionListItem(), + getOnePassConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -163,7 +174,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); @@ -192,6 +204,7 @@ 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: @@ -255,5 +268,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 c32336453..ddd0b1087 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", @@ -19,5 +19,29 @@ export const APP_CONNECTION_NAME_MAP: Record = { [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.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 }; diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 85b63138a..7d7508fc2 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, @@ -17,6 +21,8 @@ import { import { auth0ConnectionService } from "@app/services/app-connection/auth0/auth0-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"; @@ -49,8 +55,6 @@ 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 { ValidatePostgresConnectionCredentialsSchema } from "./postgres"; import { ValidateTeamCityConnectionCredentialsSchema } from "./teamcity"; import { teamcityConnectionService } from "./teamcity/teamcity-connection-service"; @@ -65,6 +69,7 @@ export type TAppConnectionServiceFactoryDep = { appConnectionDAL: TAppConnectionDALFactory; permissionService: Pick; kmsService: Pick; + licenseService: Pick; }; export type TAppConnectionServiceFactory = ReturnType; @@ -88,13 +93,15 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record { const listAppConnectionsByOrg = async (actor: OrgServiceActor, app?: AppConnection) => { const { permission } = await permissionService.getOrgPermission( @@ -191,6 +198,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, @@ -253,6 +267,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, @@ -399,6 +420,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, @@ -468,6 +496,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 42ccfc84e..459096ae5 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, @@ -76,12 +88,6 @@ import { TValidateLdapConnectionCredentialsSchema } from "./ldap"; import { TMsSqlConnection, TMsSqlConnectionInput, TValidateMsSqlConnectionCredentialsSchema } from "./mssql"; -import { - TOCIConnection, - TOCIConnectionConfig, - TOCIConnectionInput, - TValidateOCIConnectionCredentialsSchema -} from "./oci"; import { TPostgresConnection, TPostgresConnectionInput, @@ -132,6 +138,7 @@ export type TAppConnection = { id: string } & ( | TLdapConnection | TTeamCityConnection | TOCIConnection + | TOnePassConnection ); export type TAppConnectionRaw = NonNullable>>; @@ -158,6 +165,7 @@ export type TAppConnectionInput = { id: string } & ( | TLdapConnectionInput | TTeamCityConnectionInput | TOCIConnectionInput + | TOnePassConnectionInput ); export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput; @@ -189,7 +197,8 @@ export type TAppConnectionConfig = | THCVaultConnectionConfig | TLdapConnectionConfig | TTeamCityConnectionConfig - | TOCIConnectionConfig; + | TOCIConnectionConfig + | TOnePassConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -210,7 +219,8 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateHCVaultConnectionCredentialsSchema | TValidateLdapConnectionCredentialsSchema | TValidateTeamCityConnectionCredentialsSchema - | TValidateOCIConnectionCredentialsSchema; + | TValidateOCIConnectionCredentialsSchema + | TValidateOnePassConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; 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..fa66758c3 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,73 @@ 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({ + ...configuration, + 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 +378,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..457863121 --- /dev/null +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts @@ -0,0 +1,263 @@ +import * as x509 from "@peculiar/x509"; +import { KeyObject } from "crypto"; +import { z } from "zod"; + +import { 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"; + +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 + }; + }; + + return { + issueCertificate + }; +}; 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..ffec0d762 --- /dev/null +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-schemas.ts @@ -0,0 +1,62 @@ +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 +).extend({ + configuration: InternalCertificateAuthorityConfigurationSchema.optional() +}); 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..8c16ef3c2 --- /dev/null +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts @@ -0,0 +1,1984 @@ +/* eslint-disable no-bitwise */ +import { ForbiddenError } 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, + 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 + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + + const certificateTemplates = await certificateTemplateDAL.find({ caId }); + + return { + certificateTemplates, + 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..f8ea82a59 --- /dev/null +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts @@ -0,0 +1,223 @@ +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; +}; 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/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/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..795371c76 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,12 @@ export const pkiSubscriberServiceFactory = ({ }) ); + if (enableAutoRenewal) { + if (!autoRenewalPeriodInDays) { + throw new BadRequestError({ message: "autoRenewalPeriodInDays is required when enableAutoRenewal is true" }); + } + } + const newSubscriber = await pkiSubscriberDAL.create({ caId, projectId, @@ -124,7 +146,9 @@ export const pkiSubscriberServiceFactory = ({ ttl, subjectAlternativeNames, keyUsages, - extendedKeyUsages + extendedKeyUsages, + enableAutoRenewal, + autoRenewalPeriodInDays }); return newSubscriber; @@ -161,7 +185,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 +210,8 @@ export const pkiSubscriberServiceFactory = ({ subjectAlternativeNames, keyUsages, extendedKeyUsages, + enableAutoRenewal, + autoRenewalPeriodInDays, actorId, actorAuthMethod, actor, @@ -202,6 +239,12 @@ export const pkiSubscriberServiceFactory = ({ }) ); + if (enableAutoRenewal) { + if (!autoRenewalPeriodInDays && !subscriber.autoRenewalPeriodInDays) { + throw new BadRequestError({ message: "autoRenewalPeriodInDays is required when enableAutoRenewal is true" }); + } + } + const updatedSubscriber = await pkiSubscriberDAL.updateById(subscriber.id, { caId, name, @@ -210,7 +253,9 @@ export const pkiSubscriberServiceFactory = ({ ttl, subjectAlternativeNames, keyUsages, - extendedKeyUsages + extendedKeyUsages, + enableAutoRenewal, + autoRenewalPeriodInDays }); return updatedSubscriber; @@ -251,28 +296,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 +330,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 +412,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 +434,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 +454,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 +468,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 +602,7 @@ export const pkiSubscriberServiceFactory = ({ }); const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ - caCertId: ca.activeCaCertId, + caCertId: ca.internalCa.activeCaCertId, certificateAuthorityDAL, certificateAuthorityCertDAL, projectDAL, @@ -718,7 +629,8 @@ export const pkiSubscriberServiceFactory = ({ notBefore: notBeforeDate, notAfter: notAfterDate, keyUsages: selectedKeyUsages, - extendedKeyUsages: selectedExtendedKeyUsages + extendedKeyUsages: selectedExtendedKeyUsages, + projectId }, tx ); @@ -740,7 +652,7 @@ export const pkiSubscriberServiceFactory = ({ certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), issuingCaCertificate, serialNumber, - ca, + ca: expandInternalCa(ca), commonName: subscriber.commonName, subscriber }; @@ -793,6 +705,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 +820,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/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..d042ee32a 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"; @@ -41,6 +42,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 +151,7 @@ type TProjectServiceFactoryDep = { >; projectUserMembershipRoleDAL: Pick; pkiSubscriberDAL: Pick; - certificateAuthorityDAL: Pick; + certificateAuthorityDAL: Pick; certificateDAL: Pick; certificateTemplateDAL: Pick; pkiAlertDAL: Pick; @@ -914,17 +916,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 +970,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 }) }, 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/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/user/user-service.ts b/backend/src/services/user/user-service.ts index 29f6300d6..aae32d91f 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -5,6 +5,7 @@ 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"; @@ -80,6 +81,17 @@ export const userServiceFactory = ({ const verifyEmailVerificationCode = async (username: string, code: string) => { // 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` }); 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/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-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/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/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/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..610d1fd47 100644 --- a/docs/documentation/platform/pr-workflows.mdx +++ b/docs/documentation/platform/pr-workflows.mdx @@ -33,6 +33,10 @@ 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**. + + ### 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/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/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/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/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/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/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index acf95485b..8a12532a4 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 a81c9db67..f8958c941 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -112,8 +112,10 @@ "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/acme-ca", "documentation/platform/pki/pki-issuer", "documentation/platform/pki/est", "documentation/platform/pki/alerting" @@ -470,6 +472,7 @@ { "group": "Connections", "pages": [ + "integrations/app-connections/1password", "integrations/app-connections/auth0", "integrations/app-connections/aws", "integrations/app-connections/azure-app-configuration", @@ -500,6 +503,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", @@ -1041,6 +1045,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": [ @@ -1276,6 +1292,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": [ @@ -1519,12 +1549,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 bcdd28a1d..cb374071f 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -32,7 +32,7 @@ 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. @@ -98,8 +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: + 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` @@ -113,10 +113,9 @@ 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. @@ -203,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` + 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/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-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..00d86e700 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; 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/const/routes.ts b/frontend/src/const/routes.ts index e390fbcc6..6ba6c4a69 100644 --- a/frontend/src/const/routes.ts +++ b/frontend/src/const/routes.ts @@ -284,8 +284,8 @@ export const ROUTE_PATHS = Object.freeze({ }, CertManager: { CertAuthDetailsByIDPage: setRoute( - "/cert-manager/$projectId/ca/$caId", - "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caId" + "/cert-manager/$projectId/ca/$caName", + "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caName" ), SubscribersPage: setRoute( "/cert-manager/$projectId/subscribers", diff --git a/frontend/src/context/ProjectPermissionContext/index.tsx b/frontend/src/context/ProjectPermissionContext/index.tsx index d7b7334ea..e8efffd6d 100644 --- a/frontend/src/context/ProjectPermissionContext/index.tsx +++ b/frontend/src/context/ProjectPermissionContext/index.tsx @@ -2,6 +2,7 @@ export { useProjectPermission } from "./ProjectPermissionContext"; export type { ProjectPermissionSet, TProjectPermission } from "./types"; export { ProjectPermissionActions, + ProjectPermissionApprovalActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index a640f6eaa..d49b8bc8e 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -24,6 +24,15 @@ export enum ProjectPermissionSecretActions { Delete = "delete" } +export enum ProjectPermissionApprovalActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + AllowChangeBypass = "allow-change-bypass", + AllowAccessBypass = "allow-access-bypass" +} + export enum ProjectPermissionDynamicSecretActions { ReadRootCredential = "read-root-credential", CreateRootCredential = "create-root-credential", @@ -285,7 +294,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.Settings] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] - | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] + | [ProjectPermissionApprovalActions, ProjectPermissionSub.SecretApproval] | [ ProjectPermissionIdentityActions, ( diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index 91fcd9055..8b64ee58a 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -10,6 +10,7 @@ export { export type { TProjectPermission } from "./ProjectPermissionContext"; export { ProjectPermissionActions, + ProjectPermissionApprovalActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 8caa13a5b..dd0daf968 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -23,6 +23,7 @@ import { HumanitecConnectionMethod, LdapConnectionMethod, MsSqlConnectionMethod, + OnePassConnectionMethod, PostgresConnectionMethod, TAppConnection, TeamCityConnectionMethod, @@ -34,7 +35,7 @@ import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-con export const APP_CONNECTION_MAP: Record< AppConnection, - { name: string; image: string; size?: number } + { name: string; image: string; size?: number; enterprise?: boolean } > = { [AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" }, [AppConnection.GitHub]: { name: "GitHub", image: "GitHub.png" }, @@ -63,7 +64,8 @@ export const APP_CONNECTION_MAP: Record< [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"]) => { @@ -89,6 +91,7 @@ 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: 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/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..c0da7af23 100644 --- a/frontend/src/hooks/api/accessApproval/mutation.tsx +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -131,20 +131,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/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 06a5056af..d099936d6 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -17,5 +17,6 @@ export enum AppConnection { 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 79cbb81b9..771422ad2 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -88,6 +88,10 @@ export type TOCIConnectionOption = TAppConnectionOptionBase & { app: AppConnection.OCI; }; +export type TOnePassConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.OnePass; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -106,7 +110,8 @@ export type TAppConnectionOption = | TAuth0ConnectionOption | THCVaultConnectionOption | TTeamCityConnectionOption - | TOCIConnectionOption; + | TOCIConnectionOption + | TOnePassConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -128,4 +133,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 2b29c2cd4..b53c9f751 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"; @@ -20,6 +21,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"; @@ -59,7 +61,8 @@ export type TAppConnection = | THCVaultConnection | TLdapConnection | TTeamCityConnection - | TOCIConnection; + | TOCIConnection + | TOnePassConnection; export type TAvailableAppConnection = Pick; @@ -106,4 +109,5 @@ export type TAppConnectionMap = { [AppConnection.LDAP]: TLdapConnection; [AppConnection.TeamCity]: TTeamCityConnection; [AppConnection.OCI]: TOCIConnection; + [AppConnection.OnePass]: TOnePassConnection; }; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index f726566cd..218742dee 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", @@ -189,7 +189,21 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.ADD_IDENTITY_LDAP_AUTH]: "Attached LDAP Auth to identity", [EventType.UPDATE_IDENTITY_LDAP_AUTH]: "Updated LDAP Auth for identity", [EventType.GET_IDENTITY_LDAP_AUTH]: "Retrieved LDAP Auth for identity", - [EventType.REVOKE_IDENTITY_LDAP_AUTH]: "Revoked LDAP Auth for identity" + [EventType.REVOKE_IDENTITY_LDAP_AUTH]: "Revoked LDAP Auth for identity", + + [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 b74969d6d..36bb65549 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", @@ -183,5 +183,19 @@ export enum EventType { MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_CHECK_INSTALLATION_STATUS = "microsoft-teams-workflow-integration-check-installation-status", MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET_TEAMS = "microsoft-teams-workflow-integration-get-teams", MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET = "microsoft-teams-workflow-integration-get", - MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_LIST = "microsoft-teams-workflow-integration-list" + MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_LIST = "microsoft-teams-workflow-integration-list", + + 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..9c865d451 100644 --- a/frontend/src/hooks/api/ca/mutations.tsx +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -3,64 +3,78 @@ 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 }) => { + queryClient.invalidateQueries({ + queryKey: caKeys.listCasByTypeAndProjectId(type, 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 +92,7 @@ export const useSignIntermediate = () => { }); }; -export const useImportCaCertificate = () => { +export const useImportCaCertificate = (projectId: string) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ caId, ...body }) => { @@ -92,6 +106,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/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/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/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 ec7b6a2dd..a861c215a 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -50,4 +50,6 @@ export type SubscriptionPlan = { enforceMfa: boolean; projectTemplates: boolean; kmip: boolean; + enterpriseSecretSyncs: boolean; + enterpriseAppConnections: boolean; }; diff --git a/frontend/src/lib/fn/date.ts b/frontend/src/lib/fn/date.ts index 66423b3cb..ab0a884d0 100644 --- a/frontend/src/lib/fn/date.ts +++ b/frontend/src/lib/fn/date.ts @@ -44,3 +44,25 @@ export const timeAgo = (inputDate: Date, currentDate: Date): string => { elapsedMilliseconds >= 0 ? "ago" : "from now" }`; }; + +export enum TimeUnit { + DAY = "days", + WEEK = "weeks", + MONTH = "months", + YEAR = "years" +} + +export const convertTimeUnitValueToDays = (unit: TimeUnit, value: number) => { + switch (unit) { + case TimeUnit.DAY: + return value; + case TimeUnit.WEEK: + return value * 7; + case TimeUnit.MONTH: + return value * 30; + case TimeUnit.YEAR: + return value * 365; + default: + throw new Error(`Unknown time unit: ${unit}`); + } +}; diff --git a/frontend/src/lib/schemas/slugSchema.ts b/frontend/src/lib/schemas/slugSchema.ts index ed97cb7d0..5a02c6a28 100644 --- a/frontend/src/lib/schemas/slugSchema.ts +++ b/frontend/src/lib/schemas/slugSchema.ts @@ -7,7 +7,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/frontend/src/pages/auth/SelectOrgPage/EmailDuplicationConfirmation.tsx b/frontend/src/pages/auth/SelectOrgPage/EmailDuplicationConfirmation.tsx index 0347aee39..fb4ac6543 100644 --- a/frontend/src/pages/auth/SelectOrgPage/EmailDuplicationConfirmation.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/EmailDuplicationConfirmation.tsx @@ -64,7 +64,7 @@ export const EmailDuplicationConfirmation = ({ onRemoveDuplicateLater }: Props) Multiple Accounts Detected

- You're currently logged in as{" "} + You're currently logged in as{" "} {duplicateAccounts?.data?.myAccount?.username}.

diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx index 2ca179c87..a736b52a7 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx @@ -70,10 +70,25 @@ export const SelectOrganizationSection = () => { const handleSelectOrganization = useCallback( async (organization: Organization) => { - const canBypassOrgAuth = - organization.bypassOrgAuthEnabled && - organization.userRole === OrgMembershipRole.Admin && - isAdminLogin; + const isUserOrgAdmin = organization.userRole === OrgMembershipRole.Admin; + const canBypassOrgAuth = organization.bypassOrgAuthEnabled && isUserOrgAdmin && isAdminLogin; + + if (isAdminLogin) { + if (!organization.bypassOrgAuthEnabled) { + createNotification({ + text: "This organization does not have bypass org auth enabled", + type: "error" + }); + return; + } + if (!isUserOrgAdmin) { + createNotification({ + text: "Only organization admins can bypass org auth", + type: "error" + }); + return; + } + } if (organization.authEnforced && !canBypassOrgAuth) { // org has an org-level auth method enabled (e.g. SAML) diff --git a/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/CertAuthDetailsByIDPage.tsx b/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/CertAuthDetailsByIDPage.tsx index df5680668..e76720275 100644 --- a/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/CertAuthDetailsByIDPage.tsx +++ b/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/CertAuthDetailsByIDPage.tsx @@ -16,7 +16,8 @@ import { } from "@app/components/v2"; import { ROUTE_PATHS } from "@app/const/routes"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; -import { useDeleteCa, useGetCaById } from "@app/hooks/api"; +import { CaType, useDeleteCa, useGetCa } from "@app/hooks/api"; +import { TInternalCertificateAuthority } from "@app/hooks/api/ca/types"; import { ProjectType } from "@app/hooks/api/workspace/types"; import { usePopUp } from "@app/hooks/usePopUp"; @@ -31,14 +32,18 @@ import { } from "./components"; const Page = () => { + const { currentWorkspace } = useWorkspace(); const navigate = useNavigate(); const params = useParams({ from: ROUTE_PATHS.CertManager.CertAuthDetailsByIDPage.id }); - const caId = params.caId as string; - const { data } = useGetCaById(caId); + const { caName } = params as { caName: string }; + const { data } = useGetCa({ + caName, + projectId: currentWorkspace?.id || "", + type: CaType.INTERNAL + }) as { data: TInternalCertificateAuthority }; - const { currentWorkspace } = useWorkspace(); const projectId = currentWorkspace?.id || ""; const { mutateAsync: deleteCa } = useDeleteCa(); @@ -50,11 +55,15 @@ const Page = () => { "renewCa" ] as const); - const onRemoveCaSubmit = async (caIdToDelete: string) => { + const onRemoveCaSubmit = async () => { try { if (!currentWorkspace?.slug) return; - await deleteCa({ caId: caIdToDelete, projectSlug: currentWorkspace.slug }); + await deleteCa({ + caName, + projectId: currentWorkspace.id, + type: CaType.INTERNAL + }); createNotification({ text: "Successfully deleted CA", @@ -63,7 +72,7 @@ const Page = () => { handlePopUpClose("deleteCa"); navigate({ - to: `/${ProjectType.CertificateManager}/$projectId/certificates` as const, + to: `/${ProjectType.CertificateManager}/$projectId/certificate-authorities` as const, params: { projectId } @@ -80,7 +89,7 @@ const Page = () => {
{data && (
- +
@@ -101,12 +110,7 @@ const Page = () => { ? "hover:!bg-red-500 hover:!text-white" : "pointer-events-none cursor-not-allowed opacity-50" )} - onClick={() => - handlePopUpOpen("deleteCa", { - caId: data.id, - dn: data.dn - }) - } + onClick={() => handlePopUpOpen("deleteCa")} disabled={!isAllowed} > Delete CA @@ -118,12 +122,12 @@ const Page = () => {
- +
- - - + + +
@@ -139,7 +143,7 @@ const Page = () => { subTitle="This action will delete other CAs and certificates below it in your CA hierarchy." onChange={(isOpen) => handlePopUpToggle("deleteCa", isOpen)} deleteKey="confirm" - onDeleteApproved={() => onRemoveCaSubmit((popUp?.deleteCa?.data as { caId: string })?.caId)} + onDeleteApproved={onRemoveCaSubmit} />
); diff --git a/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaDetailsSection.tsx b/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaDetailsSection.tsx index 06fa5d1d9..f12fcfd28 100644 --- a/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaDetailsSection.tsx +++ b/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaDetailsSection.tsx @@ -4,22 +4,24 @@ import { format } from "date-fns"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, IconButton, Tooltip } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { useTimedReset } from "@app/hooks"; -import { CaStatus, CaType, useGetCaById } from "@app/hooks/api"; +import { CaStatus, CaType, InternalCaType, useGetCa } from "@app/hooks/api"; import { caStatusToNameMap, caTypeToNameMap } from "@app/hooks/api/ca/constants"; +import { TInternalCertificateAuthority } from "@app/hooks/api/ca/types"; import { certKeyAlgorithmToNameMap } from "@app/hooks/api/certificates/constants"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { - caId: string; + caName: string; handlePopUpOpen: ( popUpName: keyof UsePopUpState<["ca", "renewCa", "installCaCert"]>, data?: object ) => void; }; -export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => { +export const CaDetailsSection = ({ caName, handlePopUpOpen }: Props) => { + const { currentWorkspace } = useWorkspace(); const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset({ initialState: "Copy ID to clipboard" }); @@ -27,7 +29,13 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => { initialState: "Copy ID to clipboard" }); - const { data: ca } = useGetCaById(caId); + const { data } = useGetCa({ + caName, + projectId: currentWorkspace.id, + type: CaType.INTERNAL + }); + + const ca = data as TInternalCertificateAuthority; return ca ? (
@@ -45,7 +53,7 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => { onClick={(e) => { e.stopPropagation(); handlePopUpOpen("ca", { - caId: ca.id + name: ca.name }); }} > @@ -59,7 +67,7 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {

CA Type

-

{caTypeToNameMap[ca.type]}

+

{caTypeToNameMap[ca.configuration.type]}

CA ID

@@ -82,36 +90,39 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
- {ca.type === CaType.INTERMEDIATE && ca.status !== CaStatus.PENDING_CERTIFICATE && ( -
-

Parent CA ID

-
-

- {ca.parentCaId ? ca.parentCaId : "N/A - External Parent CA"} -

- {ca.parentCaId && ( -
- - { - navigator.clipboard.writeText(ca.parentCaId as string); - setCopyTextParentId("Copied"); - }} - > - - - -
- )} + {ca.configuration.type === InternalCaType.INTERMEDIATE && + ca.status !== CaStatus.PENDING_CERTIFICATE && ( +
+

Parent CA ID

+
+

+ {ca.configuration.parentCaId + ? ca.configuration.parentCaId + : "N/A - External Parent CA"} +

+ {ca.configuration.parentCaId && ( +
+ + { + navigator.clipboard.writeText(ca.configuration.parentCaId as string); + setCopyTextParentId("Copied"); + }} + > + + + +
+ )} +
-
- )} + )}
-

Friendly Name

-

{ca.friendlyName}

+

Name

+

{ca.name}

Status

@@ -119,29 +130,33 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {

Key Algorithm

-

{certKeyAlgorithmToNameMap[ca.keyAlgorithm]}

+

+ {certKeyAlgorithmToNameMap[ca.configuration.keyAlgorithm]} +

Max Path Length

-

{ca.maxPathLength ?? "-"}

+

{ca.configuration.maxPathLength ?? "-"}

Not Before

- {ca.notBefore ? format(new Date(ca.notBefore), "yyyy-MM-dd") : "-"} + {ca.configuration.notBefore + ? format(new Date(ca.configuration.notBefore), "yyyy-MM-dd") + : "-"}

Not After

- {ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "-"} + {ca.configuration.notAfter + ? format(new Date(ca.configuration.notAfter), "yyyy-MM-dd") + : "-"}

-

Template Issuance Required

-

- {ca.requireTemplateForIssuance ? "True" : "False"} -

+

Enable Direct Issuance

+

{ca.enableDirectIssuance ? "True" : "False"}

{ca.status === CaStatus.ACTIVE && ( { colorSchema="primary" type="submit" onClick={() => { - if (ca.type === CaType.INTERMEDIATE && !ca.parentCaId) { + if ( + ca.configuration.type === InternalCaType.INTERMEDIATE && + !ca.configuration.parentCaId + ) { // intermediate CA with external parent CA handlePopUpOpen("installCaCert", { - caId, + caId: ca.id, isParentCaExternal: true }); return; } handlePopUpOpen("renewCa", { - caId + caId: ca.id }); }} > @@ -190,7 +208,7 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => { type="submit" onClick={() => { handlePopUpOpen("installCaCert", { - caId + caId: ca.id }); }} > diff --git a/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/route.tsx b/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/route.tsx index aa7540a7f..ab04ecba4 100644 --- a/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/route.tsx +++ b/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/route.tsx @@ -3,7 +3,7 @@ import { createFileRoute, linkOptions } from "@tanstack/react-router"; import { CertAuthDetailsByIDPage } from "./CertAuthDetailsByIDPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caId" + "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caName" )({ component: CertAuthDetailsByIDPage, beforeLoad: ({ context, params }) => { diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/CertificateAuthoritiesPage.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/CertificateAuthoritiesPage.tsx index 0efe2dbdc..a2bff12ed 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/CertificateAuthoritiesPage.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/CertificateAuthoritiesPage.tsx @@ -5,6 +5,7 @@ import { ProjectPermissionCan } from "@app/components/permissions"; import { PageHeader } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { ExternalCaSection } from "./components/ExternalCaSection"; import { CaSection } from "./components"; export const CertificateAuthoritiesPage = () => { @@ -17,7 +18,7 @@ export const CertificateAuthoritiesPage = () => {
{ a={ProjectPermissionSub.CertificateAuthorities} > +
diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaInstallCertModal/ExternalCaInstallForm.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaInstallCertModal/ExternalCaInstallForm.tsx index 9ff62ff2e..6f79cbb24 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaInstallCertModal/ExternalCaInstallForm.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaInstallCertModal/ExternalCaInstallForm.tsx @@ -41,7 +41,7 @@ export const ExternalCaInstallForm = ({ caId, handlePopUpToggle }: Props) => { }); const { data: csr } = useGetCaCsr(caId); - const { mutateAsync: importCaCertificate } = useImportCaCertificate(); + const { mutateAsync: importCaCertificate } = useImportCaCertificate(currentWorkspace.id); useEffect(() => { reset(); diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaInstallCertModal/InternalCaInstallForm.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaInstallCertModal/InternalCaInstallForm.tsx index 4d0aa4a8e..677407dc8 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaInstallCertModal/InternalCaInstallForm.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaInstallCertModal/InternalCaInstallForm.tsx @@ -55,7 +55,7 @@ export const InternalCaInstallForm = ({ caId, handlePopUpToggle }: Props) => { const { data: csr } = useGetCaCsr(caId); const { mutateAsync: signIntermediate } = useSignIntermediate(); - const { mutateAsync: importCaCertificate } = useImportCaCertificate(); + const { mutateAsync: importCaCertificate } = useImportCaCertificate(currentWorkspace.id); const { control, diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaModal.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaModal.tsx index c27d4dc39..07bf1493a 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaModal.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaModal.tsx @@ -17,10 +17,18 @@ import { // DatePicker } from "@app/components/v2"; import { useWorkspace } from "@app/context"; -import { CaType, useCreateCa, useGetCaById, useUpdateCa } from "@app/hooks/api/ca"; +import { + CaStatus, + CaType, + InternalCaType, + useCreateCa, + useGetCa, + useUpdateCa +} from "@app/hooks/api/ca"; import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants"; import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { slugSchema } from "@app/lib/schemas"; const isValidDate = (dateString: string) => { const date = new Date(dateString); @@ -35,23 +43,31 @@ const getDateTenYearsFromToday = () => { const schema = z .object({ - type: z.enum([CaType.ROOT, CaType.INTERMEDIATE]), - friendlyName: z.string(), - organization: z.string(), - ou: z.string(), - country: z.string(), - province: z.string(), - locality: z.string(), - commonName: z.string(), - notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }), - maxPathLength: z.string(), - keyAlgorithm: z.enum([ - CertKeyAlgorithm.RSA_2048, - CertKeyAlgorithm.RSA_4096, - CertKeyAlgorithm.ECDSA_P256, - CertKeyAlgorithm.ECDSA_P384 - ]), - requireTemplateForIssuance: z.boolean() + type: z.nativeEnum(CaType), + name: slugSchema({ + field: "Name" + }), + enableDirectIssuance: z.boolean(), + status: z.nativeEnum(CaStatus), + configuration: z + .object({ + type: z.enum([InternalCaType.ROOT, InternalCaType.INTERMEDIATE]), + organization: z.string(), + ou: z.string(), + country: z.string(), + province: z.string(), + locality: z.string(), + commonName: z.string(), + notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }), + maxPathLength: z.string(), + keyAlgorithm: z.enum([ + CertKeyAlgorithm.RSA_2048, + CertKeyAlgorithm.RSA_4096, + CertKeyAlgorithm.ECDSA_P256, + CertKeyAlgorithm.ECDSA_P384 + ]) + }) + .required() }) .required(); @@ -63,15 +79,17 @@ type Props = { }; const caTypes = [ - { label: "Root", value: CaType.ROOT }, - { label: "Intermediate", value: CaType.INTERMEDIATE } + { label: "Root", value: InternalCaType.ROOT }, + { label: "Intermediate", value: InternalCaType.INTERMEDIATE } ]; export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { const { currentWorkspace } = useWorkspace(); - // const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); - - const { data: ca } = useGetCaById((popUp?.ca?.data as { caId: string })?.caId || ""); + const { data: ca } = useGetCa({ + caName: (popUp?.ca?.data as { name: string })?.name || "", + projectId: currentWorkspace?.id || "", + type: CaType.INTERNAL + }); const { mutateAsync: createMutateAsync } = useCreateCa(); const { mutateAsync: updateMutateAsync } = useUpdateCa(); @@ -85,42 +103,12 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { } = useForm({ resolver: zodResolver(schema), defaultValues: { - type: CaType.ROOT, - friendlyName: "", - organization: "", - ou: "", - country: "", - province: "", - locality: "", - commonName: "", - notAfter: getDateTenYearsFromToday(), - maxPathLength: "-1", - keyAlgorithm: CertKeyAlgorithm.RSA_2048 - } - }); - - const caType = watch("type"); - - useEffect(() => { - if (ca) { - reset({ - type: ca.type, - friendlyName: ca.friendlyName, - organization: ca.organization, - ou: ca.ou, - country: ca.country, - province: ca.province, - locality: ca.locality, - commonName: ca.commonName, - notAfter: ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "", - maxPathLength: ca.maxPathLength ? String(ca.maxPathLength) : "", - keyAlgorithm: ca.keyAlgorithm, - requireTemplateForIssuance: ca.requireTemplateForIssuance - }); - } else { - reset({ - type: CaType.ROOT, - friendlyName: "", + type: CaType.INTERNAL, + name: "", + status: CaStatus.ACTIVE, + enableDirectIssuance: true, + configuration: { + type: InternalCaType.ROOT, organization: "", ou: "", country: "", @@ -129,25 +117,65 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { commonName: "", notAfter: getDateTenYearsFromToday(), maxPathLength: "-1", - keyAlgorithm: CertKeyAlgorithm.RSA_2048, - requireTemplateForIssuance: true + keyAlgorithm: CertKeyAlgorithm.RSA_2048 + } + } + }); + + const caType = watch("configuration.type"); + + useEffect(() => { + if (ca && ca.type === CaType.INTERNAL) { + reset({ + type: ca.type, + name: ca.name, + status: ca.status, + enableDirectIssuance: ca.enableDirectIssuance, + configuration: { + type: ca.configuration.type, + organization: ca.configuration.organization, + ou: ca.configuration.ou, + country: ca.configuration.country, + province: ca.configuration.province, + locality: ca.configuration.locality, + commonName: ca.configuration.commonName, + notAfter: ca.configuration.notAfter + ? format(new Date(ca.configuration.notAfter), "yyyy-MM-dd") + : "", + maxPathLength: ca.configuration.maxPathLength + ? String(ca.configuration.maxPathLength) + : "", + keyAlgorithm: ca.configuration.keyAlgorithm + } + }); + } else { + reset({ + type: CaType.INTERNAL, + name: "", + status: CaStatus.ACTIVE, + enableDirectIssuance: true, + configuration: { + type: InternalCaType.ROOT, + organization: "", + ou: "", + country: "", + province: "", + locality: "", + commonName: "", + notAfter: getDateTenYearsFromToday(), + maxPathLength: "-1", + keyAlgorithm: CertKeyAlgorithm.RSA_2048 + } }); } }, [ca]); const onFormSubmit = async ({ type, - friendlyName, - commonName, - organization, - ou, - country, - locality, - province, - notAfter, - maxPathLength, - keyAlgorithm, - requireTemplateForIssuance + name, + enableDirectIssuance, + status, + configuration }: FormData) => { try { if (!currentWorkspace?.slug) return; @@ -155,26 +183,29 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { if (ca) { // update await updateMutateAsync({ - projectSlug: currentWorkspace.slug, - caId: ca.id, - requireTemplateForIssuance + caName: ca.name, + projectId: currentWorkspace.id, + name, + type: CaType.INTERNAL, + status, + enableDirectIssuance, + configuration: { + ...configuration, + maxPathLength: Number(configuration.maxPathLength) + } }); } else { // create await createMutateAsync({ - projectSlug: currentWorkspace.slug, + projectId: currentWorkspace.id, + name, type, - friendlyName, - commonName, - organization, - ou, - country, - province, - locality, - notAfter, - maxPathLength: Number(maxPathLength), - keyAlgorithm, - requireTemplateForIssuance + status, + enableDirectIssuance, + configuration: { + ...configuration, + maxPathLength: Number(configuration.maxPathLength) + } }); } @@ -211,8 +242,8 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { )} ( + )} /> ( { ( { ( { ( { ( { ( { /> { return ( field.onChange(value)} isChecked={field.value} > -

Require Template for Certificate Issuance

+

Enable Direct Issuance

); diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaSection.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaSection.tsx index 697e5d3c1..f928f25d8 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaSection.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaSection.tsx @@ -6,7 +6,7 @@ import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; -import { CaStatus, useDeleteCa, useUpdateCa } from "@app/hooks/api"; +import { CaStatus, CaType, useDeleteCa, useUpdateCa } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; import { CaCertModal } from "./CaCertModal"; @@ -28,11 +28,11 @@ export const CaSection = () => { "upgradePlan" ] as const); - const onRemoveCaSubmit = async (caId: string) => { + const onRemoveCaSubmit = async (caName: string) => { try { if (!currentWorkspace?.slug) return; - await deleteCa({ caId, projectSlug: currentWorkspace.slug }); + await deleteCa({ caName, projectId: currentWorkspace.id, type: CaType.INTERNAL }); createNotification({ text: "Successfully deleted CA", @@ -48,11 +48,11 @@ export const CaSection = () => { } }; - const onUpdateCaStatus = async ({ caId, status }: { caId: string; status: CaStatus }) => { + const onUpdateCaStatus = async ({ caName, status }: { caName: string; status: CaStatus }) => { try { if (!currentWorkspace?.slug) return; - await updateCa({ caId, projectSlug: currentWorkspace.slug, status }); + await updateCa({ caName, projectId: currentWorkspace.id, type: CaType.INTERNAL, status }); createNotification({ text: `Successfully ${status === CaStatus.ACTIVE ? "enabled" : "disabled"} CA`, @@ -72,7 +72,7 @@ export const CaSection = () => { return (
-

Certificate Authorities

+

Internal Certificate Authorities

{ subTitle="This action will delete other CAs and certificates below it in your CA hierarchy." onChange={(isOpen) => handlePopUpToggle("deleteCa", isOpen)} deleteKey="confirm" - onDeleteApproved={() => onRemoveCaSubmit((popUp?.deleteCa?.data as { caId: string })?.caId)} + onDeleteApproved={() => + onRemoveCaSubmit((popUp?.deleteCa?.data as { caName: string })?.caName) + } /> { onChange={(isOpen) => handlePopUpToggle("caStatus", isOpen)} deleteKey="confirm" onDeleteApproved={() => - onUpdateCaStatus(popUp?.caStatus?.data as { caId: string; status: CaStatus }) + onUpdateCaStatus(popUp?.caStatus?.data as { caName: string; status: CaStatus }) } /> , data?: { caId?: string; + caName?: string; dn?: string; status?: CaStatus; description?: string; @@ -49,9 +51,8 @@ type Props = { export const CaTable = ({ handlePopUpOpen }: Props) => { const navigate = useNavigate(); const { currentWorkspace } = useWorkspace(); - const { data, isPending } = useListWorkspaceCas({ - projectSlug: currentWorkspace?.slug ?? "" - }); + const { data, isPending } = useListCasByTypeAndProjectId(CaType.INTERNAL, currentWorkspace.id); + const cas = data as TInternalCertificateAuthority[]; return (
@@ -59,7 +60,7 @@ export const CaTable = ({ handlePopUpOpen }: Props) => { - + @@ -69,33 +70,37 @@ export const CaTable = ({ handlePopUpOpen }: Props) => { {isPending && } {!isPending && - data && - data.length > 0 && - data.map((ca) => { + cas && + cas.length > 0 && + cas.map((ca) => { return ( navigate({ - to: `/${ProjectType.CertificateManager}/$projectId/ca/$caId` as const, + to: `/${ProjectType.CertificateManager}/$projectId/ca/$caName` as const, params: { projectId: currentWorkspace.id, - caId: ca.id + caName: ca.name } }) } > - + - +
Friendly NameName Status Type Valid Until
{ca.friendlyName}{ca.name} {caStatusToNameMap[ca.status]} {caTypeToNameMap[ca.type]}{caTypeToNameMap[ca.configuration.type]}
-

{ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "-"}

+

+ {ca.configuration.notAfter + ? format(new Date(ca.configuration.notAfter), "yyyy-MM-dd") + : "-"} +

@@ -172,7 +177,7 @@ export const CaTable = ({ handlePopUpOpen }: Props) => { onClick={(e) => { e.stopPropagation(); handlePopUpOpen("caStatus", { - caId: ca.id, + caName: ca.name, status: ca.status === CaStatus.ACTIVE ? CaStatus.DISABLED @@ -199,8 +204,7 @@ export const CaTable = ({ handlePopUpOpen }: Props) => { onClick={(e) => { e.stopPropagation(); handlePopUpOpen("deleteCa", { - caId: ca.id, - dn: ca.dn + caName: ca.name }); }} disabled={!isAllowed} diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx new file mode 100644 index 000000000..b7d91a851 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx @@ -0,0 +1,410 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FilterableSelect, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem, + Switch +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useListAvailableAppConnections } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + AcmeDnsProvider, + CaStatus, + CaType, + useCreateCa, + useGetCa, + useUpdateCa +} from "@app/hooks/api/ca"; +import { UsePopUpState } from "@app/hooks/usePopUp"; +import { slugSchema } from "@app/lib/schemas"; + +const schema = z + .object({ + type: z.nativeEnum(CaType), + name: slugSchema({ + field: "Name" + }), + enableDirectIssuance: z.boolean(), + status: z.nativeEnum(CaStatus), + configuration: z.object({ + dnsAppConnection: z.object({ + id: z.string(), + name: z.string() + }), + // currently specific to Route53 but can be extended to others by differentiating via the provider property + dnsProviderConfig: z.object({ + provider: z.nativeEnum(AcmeDnsProvider), + hostedZoneId: z.string() + }), + directoryUrl: z.string(), + accountEmail: z.string() + }) + }) + .required(); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["ca"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["ca"]>, state?: boolean) => void; +}; + +const caTypes = [{ label: "ACME", value: CaType.ACME }]; + +export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + + const { data: ca } = useGetCa({ + caName: (popUp?.ca?.data as { name: string })?.name || "", + projectId: currentWorkspace?.id || "", + type: (popUp?.ca?.data as { type: CaType })?.type || "" + }); + + const { mutateAsync: createMutateAsync } = useCreateCa(); + const { mutateAsync: updateMutateAsync } = useUpdateCa(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting }, + watch + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + type: CaType.ACME, + name: "", + status: CaStatus.ACTIVE, + enableDirectIssuance: true, + configuration: { + dnsAppConnection: { + id: "", + name: "" + }, + dnsProviderConfig: { + provider: AcmeDnsProvider.ROUTE53, + hostedZoneId: "" + }, + directoryUrl: "", + accountEmail: "" + } + } + }); + + const caType = watch("type"); + const dnsProvider = watch("configuration.dnsProviderConfig.provider"); + + const { data: availableConnections, isPending } = useListAvailableAppConnections( + AppConnection.AWS, + { + enabled: dnsProvider === AcmeDnsProvider.ROUTE53 + } + ); + + useEffect(() => { + if (ca) { + if (ca.type !== CaType.INTERNAL && availableConnections?.length) { + const selectedConnection = availableConnections?.find( + (connection) => connection.id === ca?.configuration.dnsAppConnectionId + ); + + reset({ + type: ca.type, + name: ca.name, + status: ca.status, + enableDirectIssuance: ca.enableDirectIssuance, + configuration: { + dnsAppConnection: { + id: ca.configuration.dnsAppConnectionId, + name: selectedConnection?.name || "" + }, + dnsProviderConfig: { + provider: ca.configuration.dnsProviderConfig.provider, + hostedZoneId: ca.configuration.dnsProviderConfig.hostedZoneId + }, + directoryUrl: ca.configuration.directoryUrl, + accountEmail: ca.configuration.accountEmail + } + }); + } + } else { + reset({ + type: CaType.ACME, + name: "", + status: CaStatus.ACTIVE, + enableDirectIssuance: true, + configuration: { + dnsAppConnection: { + id: "", + name: "" + }, + dnsProviderConfig: { + provider: AcmeDnsProvider.ROUTE53, + hostedZoneId: "" + }, + directoryUrl: "", + accountEmail: "" + } + }); + } + }, [ca, availableConnections]); + + const onFormSubmit = async ({ + type, + name, + enableDirectIssuance, + status, + configuration + }: FormData) => { + try { + if (!currentWorkspace?.slug) return; + + if (ca && type !== CaType.INTERNAL) { + await updateMutateAsync({ + caName: ca.name, + projectId: currentWorkspace.id, + name, + type, + status, + enableDirectIssuance, + configuration: { + ...configuration, + dnsAppConnectionId: configuration.dnsAppConnection.id + } + }); + } else { + await createMutateAsync({ + projectId: currentWorkspace.id, + name, + type, + status, + enableDirectIssuance, + configuration: { + ...configuration, + dnsAppConnectionId: configuration.dnsAppConnection.id + } + }); + } + + reset(); + handlePopUpToggle("ca", false); + + createNotification({ + text: `Successfully ${ca ? "updated" : "created"} CA`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create CA", + type: "error" + }); + } + }; + + return ( + { + reset(); + handlePopUpToggle("ca", isOpen); + }} + > + +
+ {ca && ( + + + + )} + ( + + + + )} + /> + ( + + + + )} + /> + {caType === CaType.ACME && ( + <> + ( + + + + )} + /> + ( + + { + onChange(newValue); + }} + isLoading={isPending} + options={availableConnections} + placeholder="Select connection..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + control={control} + name="configuration.dnsAppConnection" + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + + )} + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Enable Direct Issuance

+
+
+ ); + }} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaSection.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaSection.tsx new file mode 100644 index 000000000..ca4b7f0f8 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaSection.tsx @@ -0,0 +1,141 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { CaStatus, CaType, useDeleteCa, useUpdateCa } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { ExternalCaModal } from "./ExternalCaModal"; +import { ExternalCaTable } from "./ExternalCaTable"; + +export const ExternalCaSection = () => { + const { currentWorkspace } = useWorkspace(); + const { mutateAsync: deleteCa } = useDeleteCa(); + const { mutateAsync: updateCa } = useUpdateCa(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "ca", + "deleteCa", + "caStatus", // enable / disable + "upgradePlan" + ] as const); + + const onRemoveCaSubmit = async (caName: string, type: CaType) => { + try { + if (!currentWorkspace?.id) return; + + await deleteCa({ caName, type, projectId: currentWorkspace.id }); + + createNotification({ + text: "Successfully deleted CA", + type: "success" + }); + + handlePopUpClose("deleteCa"); + } catch { + createNotification({ + text: "Failed to delete CA", + type: "error" + }); + } + }; + + const onUpdateCaStatus = async ({ + name, + type, + status + }: { + name: string; + type: CaType; + status: CaStatus; + }) => { + try { + if (!currentWorkspace?.slug) return; + + await updateCa({ caName: name, type, status, projectId: currentWorkspace.id }); + + createNotification({ + text: `Successfully ${status === CaStatus.ACTIVE ? "enabled" : "disabled"} CA`, + type: "success" + }); + + handlePopUpClose("caStatus"); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${status === CaStatus.ACTIVE ? "enable" : "disable"} CA`, + type: "error" + }); + } + }; + + return ( +
+
+

External Certificate Authorities

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("deleteCa", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveCaSubmit( + (popUp?.deleteCa?.data as { name: string })?.name, + (popUp?.deleteCa?.data as { type: CaType })?.type + ) + } + /> + handlePopUpToggle("caStatus", isOpen)} + buttonText="Proceed" + deleteKey="confirm" + onDeleteApproved={() => + onUpdateCaStatus( + popUp?.caStatus?.data as { name: string; type: CaType; status: CaStatus } + ) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> +
+ ); +}; diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaTable.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaTable.tsx new file mode 100644 index 000000000..d6e12e214 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaTable.tsx @@ -0,0 +1,189 @@ +import { + faBan, + faCertificate, + faEllipsis, + faPencil, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Badge, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { CaStatus, CaType, useListCasByTypeAndProjectId } from "@app/hooks/api"; +import { caStatusToNameMap, getCaStatusBadgeVariant } from "@app/hooks/api/ca/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["ca", "deleteCa", "caStatus", "upgradePlan"]>, + data?: { + name?: string; + type?: CaType; + status?: CaStatus; + description?: string; + } + ) => void; +}; + +export const ExternalCaTable = ({ handlePopUpOpen }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data, isPending } = useListCasByTypeAndProjectId(CaType.ACME, currentWorkspace.id); + + return ( +
+ + + + + + + + + + + {isPending && } + {!isPending && + data && + data.length > 0 && + data.map((ca) => { + return ( + { + handlePopUpOpen("ca", { + name: ca.name, + type: ca.type + }); + }} + > + + + + + + ); + })} + +
NameTypeStatus +
{ca.name}{ca.type} + + {caStatusToNameMap[ca.status]} + + + + +
+ + + +
+
+ + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("ca", { + name: ca.name, + type: ca.type + }); + }} + disabled={!isAllowed} + icon={} + > + Edit CA + + )} + + {(ca.status === CaStatus.ACTIVE || ca.status === CaStatus.DISABLED) && ( + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("caStatus", { + name: ca.name, + type: ca.type, + status: + ca.status === CaStatus.ACTIVE + ? CaStatus.DISABLED + : CaStatus.ACTIVE + }); + }} + disabled={!isAllowed} + icon={} + > + {`${ca.status === CaStatus.ACTIVE ? "Disable" : "Enable"} CA`} + + )} + + )} + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("deleteCa", { + name: ca.name, + type: ca.type + }); + }} + disabled={!isAllowed} + icon={} + > + Delete CA + + )} + + +
+
+ {!isPending && data?.length === 0 && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx new file mode 100644 index 000000000..c4cffe7b0 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx @@ -0,0 +1,244 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem, + TextArea +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useGetCert, useImportCertificate, useListWorkspacePkiCollections } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { CertificateContent } from "./CertificateContent"; + +const schema = z.object({ + certificatePem: z.string().trim().min(1, "Certificate PEM is required"), + privateKeyPem: z.string().trim().min(1, "Private Key PEM is required"), + chainPem: z.string().trim().min(1, "Certificate Chain PEM is required"), + + friendlyName: z.string(), + collectionId: z.string().optional() +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["certificateImport"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["certificateImport"]>, + state?: boolean + ) => void; +}; + +type TCertificateDetails = { + serialNumber: string; + certificate: string; + certificateChain: string; + privateKey: string; +}; + +export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => { + const [certificateDetails, setCertificateDetails] = useState(null); + const { currentWorkspace } = useWorkspace(); + const { data: cert } = useGetCert( + (popUp?.certificateImport?.data as { serialNumber: string })?.serialNumber || "" + ); + + const { data } = useListWorkspacePkiCollections({ + workspaceId: currentWorkspace?.id || "" + }); + + const { mutateAsync: importCertificate } = useImportCertificate(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const onFormSubmit = async ({ + certificatePem, + privateKeyPem, + chainPem, + friendlyName, + collectionId + }: FormData) => { + try { + if (!currentWorkspace?.slug) return; + + const { serialNumber, certificate, certificateChain, privateKey } = await importCertificate({ + projectSlug: currentWorkspace.slug, + + certificatePem, + privateKeyPem, + chainPem, + + friendlyName, + pkiCollectionId: collectionId + }); + + reset(); + + setCertificateDetails({ + serialNumber, + certificate, + certificateChain, + privateKey + }); + + createNotification({ + text: "Successfully imported certificate", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to import certificate", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("certificateImport", isOpen); + reset(); + setCertificateDetails(null); + }} + > + + {!certificateDetails ? ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + @@ -409,7 +411,7 @@ export const SecretApprovalRequestChanges = ({ ); })} -
+
-
+
Reviewers
{secretApprovalRequestDetails?.policy?.approvers @@ -435,10 +437,10 @@ export const SecretApprovalRequestChanges = ({ const reviewer = reviewedUsers?.[requiredApprover.userId]; return (
-
+
)} - + {getReviewedStatusSymbol(reviewer?.status)}
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index d28f28392..49bf1a72c 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -29,6 +29,11 @@ import { ProjectPermissionSecretActions, ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; import { useGetImportedSecretsSingleEnv, @@ -98,7 +103,14 @@ const Page = () => { page, setPerPage, orderBy - } = usePagination(DashboardSecretsOrderBy.Name); + } = usePagination(DashboardSecretsOrderBy.Name, { + initPerPage: getUserTablePreference("secretDashboardTable", PreferenceKey.PerPage, 100) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("secretDashboardTable", PreferenceKey.PerPage, newPerPage); + }; const [snapshotId, setSnapshotId] = useState(null); const isRollbackMode = Boolean(snapshotId); @@ -558,7 +570,7 @@ const Page = () => { page={page} perPage={perPage} onChangePage={(newPage) => setPage(newPage)} - onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + onChangePerPage={handlePerPageChange} /> )} { if (secret.secretValueHidden) { - return canEditSecretValue ? hiddenValue : ""; + return canEditSecretValue ? HIDDEN_SECRET_VALUE : ""; } return secret.valueOverride || secret.value || ""; }; @@ -366,10 +366,11 @@ export const SecretItem = memo( isReadOnly={isReadOnly || isRotatedSecret} key="secret-value" isVisible={isVisible && !secretValueHidden} + canEditButNotView={secretValueHidden && !isOverriden} environment={environment} secretPath={secretPath} {...field} - defaultValue={secretValueHidden ? hiddenValue : undefined} + defaultValue={secretValueHidden ? HIDDEN_SECRET_VALUE : undefined} containerClassName="py-1.5 rounded-md transition-all" /> )} diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/1PasswordSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/1PasswordSyncDestinationSection.tsx new file mode 100644 index 000000000..09d598f31 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/1PasswordSyncDestinationSection.tsx @@ -0,0 +1,14 @@ +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TOnePassSync } from "@app/hooks/api/secretSyncs/types/1password-sync"; + +type Props = { + secretSync: TOnePassSync; +}; + +export const OnePassSyncDestinationSection = ({ secretSync }: Props) => { + const { + destinationConfig: { vaultId } + } = secretSync; + + return {vaultId}; +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx index b0c989ee2..f443c6106 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -10,6 +10,7 @@ import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissi import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs"; +import { OnePassSyncDestinationSection } from "./1PasswordSyncDestinationSection"; import { AwsParameterStoreSyncDestinationSection } from "./AwsParameterStoreSyncDestinationSection"; import { AwsSecretsManagerSyncDestinationSection } from "./AwsSecretsManagerSyncDestinationSection"; import { AzureAppConfigurationSyncDestinationSection } from "./AzureAppConfigurationSyncDestinationSection"; @@ -85,6 +86,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.OCIVault: DestinationComponents = ; break; + case SecretSync.OnePass: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index a53016d53..fbd66b9f5 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -50,6 +50,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.HCVault: case SecretSync.TeamCity: case SecretSync.OCIVault: + case SecretSync.OnePass: AdditionalSyncOptionsComponent = null; break; default: diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/SecretTagsSection/SecretTagsTable.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/SecretTagsSection/SecretTagsTable.tsx index 150e11064..d0633eb12 100644 --- a/frontend/src/pages/secret-manager/SettingsPage/components/SecretTagsSection/SecretTagsTable.tsx +++ b/frontend/src/pages/secret-manager/SettingsPage/components/SecretTagsSection/SecretTagsTable.tsx @@ -25,9 +25,14 @@ import { Tr } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { usePagination, useResetPageHelper } from "@app/hooks"; -import { useGetWsTags } from "@app/hooks/api"; import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { useGetWsTags } from "@app/hooks/api/tags"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { @@ -61,7 +66,14 @@ export const SecretTagsTable = ({ handlePopUpOpen }: Props) => { offset, orderDirection, toggleOrderDirection - } = usePagination(TagsOrderBy.Slug, { initPerPage: 10 }); + } = usePagination(TagsOrderBy.Slug, { + initPerPage: getUserTablePreference("secretTagsTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("secretTagsTable", PreferenceKey.PerPage, newPerPage); + }; const filteredTags = useMemo( () => @@ -151,7 +163,7 @@ export const SecretTagsTable = ({ handlePopUpOpen }: Props) => { page={page} perPage={perPage} onChangePage={setPage} - onChangePerPage={setPerPage} + onChangePerPage={handlePerPageChange} /> )} {!isPending && !filteredTags?.length && ( diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index f457cf209..bc19deb6c 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -1135,8 +1135,8 @@ const certManagerPkiSubscriberDetailsByIDPageRouteRoute = const certManagerCertAuthDetailsByIDPageRouteRoute = certManagerCertAuthDetailsByIDPageRouteImport.update({ - id: '/ca/$caId', - path: '/ca/$caId', + id: '/ca/$caName', + path: '/ca/$caName', getParentRoute: () => certManagerLayoutRoute, } as any) @@ -2503,10 +2503,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof secretManagerIntegrationsListPageRouteImport parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutSecretManagerProjectIdSecretManagerLayoutIntegrationsImport } - '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caId': { - id: '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caId' - path: '/ca/$caId' - fullPath: '/cert-manager/$projectId/ca/$caId' + '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caName': { + id: '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caName' + path: '/ca/$caName' + fullPath: '/cert-manager/$projectId/ca/$caName' preLoaderRoute: typeof certManagerCertAuthDetailsByIDPageRouteImport parentRoute: typeof certManagerLayoutImport } @@ -4105,7 +4105,7 @@ export interface FileRoutesByFullPath { '/ssh/$projectId/access-management': typeof projectAccessControlPageRouteSshRoute '/cert-manager/$projectId/subscribers/': typeof certManagerPkiSubscribersPageRouteRoute '/secret-manager/$projectId/integrations/': typeof secretManagerIntegrationsListPageRouteRoute - '/cert-manager/$projectId/ca/$caId': typeof certManagerCertAuthDetailsByIDPageRouteRoute + '/cert-manager/$projectId/ca/$caName': typeof certManagerCertAuthDetailsByIDPageRouteRoute '/cert-manager/$projectId/subscribers/$subscriberName': typeof certManagerPkiSubscriberDetailsByIDPageRouteRoute '/organization/app-connections/$appConnection/oauth/callback': typeof organizationAppConnectionsOauthCallbackPageRouteRoute '/secret-manager/$projectId/integrations/$integrationId': typeof secretManagerIntegrationsDetailsByIDPageRouteRoute @@ -4290,7 +4290,7 @@ export interface FileRoutesByTo { '/ssh/$projectId/access-management': typeof projectAccessControlPageRouteSshRoute '/cert-manager/$projectId/subscribers': typeof certManagerPkiSubscribersPageRouteRoute '/secret-manager/$projectId/integrations': typeof secretManagerIntegrationsListPageRouteRoute - '/cert-manager/$projectId/ca/$caId': typeof certManagerCertAuthDetailsByIDPageRouteRoute + '/cert-manager/$projectId/ca/$caName': typeof certManagerCertAuthDetailsByIDPageRouteRoute '/cert-manager/$projectId/subscribers/$subscriberName': typeof certManagerPkiSubscriberDetailsByIDPageRouteRoute '/organization/app-connections/$appConnection/oauth/callback': typeof organizationAppConnectionsOauthCallbackPageRouteRoute '/secret-manager/$projectId/integrations/$integrationId': typeof secretManagerIntegrationsDetailsByIDPageRouteRoute @@ -4495,7 +4495,7 @@ export interface FileRoutesById { '/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/access-management': typeof projectAccessControlPageRouteSshRoute '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers/': typeof certManagerPkiSubscribersPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/': typeof secretManagerIntegrationsListPageRouteRoute - '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caId': typeof certManagerCertAuthDetailsByIDPageRouteRoute + '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caName': typeof certManagerCertAuthDetailsByIDPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers/$subscriberName': typeof certManagerPkiSubscriberDetailsByIDPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback': typeof organizationAppConnectionsOauthCallbackPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/$integrationId': typeof secretManagerIntegrationsDetailsByIDPageRouteRoute @@ -4692,7 +4692,7 @@ export interface FileRouteTypes { | '/ssh/$projectId/access-management' | '/cert-manager/$projectId/subscribers/' | '/secret-manager/$projectId/integrations/' - | '/cert-manager/$projectId/ca/$caId' + | '/cert-manager/$projectId/ca/$caName' | '/cert-manager/$projectId/subscribers/$subscriberName' | '/organization/app-connections/$appConnection/oauth/callback' | '/secret-manager/$projectId/integrations/$integrationId' @@ -4876,7 +4876,7 @@ export interface FileRouteTypes { | '/ssh/$projectId/access-management' | '/cert-manager/$projectId/subscribers' | '/secret-manager/$projectId/integrations' - | '/cert-manager/$projectId/ca/$caId' + | '/cert-manager/$projectId/ca/$caName' | '/cert-manager/$projectId/subscribers/$subscriberName' | '/organization/app-connections/$appConnection/oauth/callback' | '/secret-manager/$projectId/integrations/$integrationId' @@ -5079,7 +5079,7 @@ export interface FileRouteTypes { | '/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/access-management' | '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers/' | '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/' - | '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caId' + | '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caName' | '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers/$subscriberName' | '/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback' | '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/$integrationId' @@ -5606,7 +5606,7 @@ export const routeTree = rootRoute "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/settings", "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/access-management", "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers", - "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caId", + "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caName", "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/identities/$identityId", "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/members/$membershipId", "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/pki-collections/$collectionId", @@ -5879,7 +5879,7 @@ export const routeTree = rootRoute "filePath": "secret-manager/IntegrationsListPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations" }, - "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caId": { + "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caName": { "filePath": "cert-manager/CertAuthDetailsByIDPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout" }, diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index d6553e663..7f2a250e7 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -296,7 +296,7 @@ const certManagerRoutes = route("/cert-manager/$projectId", [ route("/certificates", "cert-manager/CertificatesPage/route.tsx"), route("/certificate-authorities", "cert-manager/CertificateAuthoritiesPage/route.tsx"), route("/alerting", "cert-manager/AlertingPage/route.tsx"), - route("/ca/$caId", "cert-manager/CertAuthDetailsByIDPage/route.tsx"), + route("/ca/$caName", "cert-manager/CertAuthDetailsByIDPage/route.tsx"), route("/pki-collections/$collectionId", "cert-manager/PkiCollectionDetailsByIDPage/routes.tsx"), route("/settings", "cert-manager/SettingsPage/route.tsx"), route("/access-management", "project/AccessControlPage/route-cert-manager.tsx"),