diff --git a/backend/package-lock.json b/backend/package-lock.json index 7534ac1bd..f7bfcd4cc 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -63,6 +63,7 @@ "argon2": "^0.31.2", "aws-sdk": "^2.1553.0", "axios": "^1.11.0", + "axios-ntlm": "^1.4.4", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", "botbuilder": "^4.23.2", @@ -78,6 +79,7 @@ "googleapis": "^137.1.0", "handlebars": "^4.7.8", "hdb": "^0.19.10", + "httpntlm": "^1.8.13", "ioredis": "^5.3.2", "isomorphic-dompurify": "^2.22.0", "jmespath": "^0.16.0", @@ -12956,216 +12958,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@swc/core": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.107.tgz", - "integrity": "sha512-zKhqDyFcTsyLIYK1iEmavljZnf4CCor5pF52UzLAz4B6Nu/4GLU+2LQVAf+oRHjusG39PTPjd2AlRT3f3QWfsQ==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "peer": true, - "dependencies": { - "@swc/counter": "^0.1.1", - "@swc/types": "^0.1.5" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.3.107", - "@swc/core-darwin-x64": "1.3.107", - "@swc/core-linux-arm-gnueabihf": "1.3.107", - "@swc/core-linux-arm64-gnu": "1.3.107", - "@swc/core-linux-arm64-musl": "1.3.107", - "@swc/core-linux-x64-gnu": "1.3.107", - "@swc/core-linux-x64-musl": "1.3.107", - "@swc/core-win32-arm64-msvc": "1.3.107", - "@swc/core-win32-ia32-msvc": "1.3.107", - "@swc/core-win32-x64-msvc": "1.3.107" - }, - "peerDependencies": { - "@swc/helpers": "^0.5.0" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.107.tgz", - "integrity": "sha512-47tD/5vSXWxPd0j/ZllyQUg4bqalbQTsmqSw0J4dDdS82MWqCAwUErUrAZPRjBkjNQ6Kmrf5rpCWaGTtPw+ngw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.3.107.tgz", - "integrity": "sha512-hwiLJ2ulNkBGAh1m1eTfeY1417OAYbRGcb/iGsJ+LuVLvKAhU/itzsl535CvcwAlt2LayeCFfcI8gdeOLeZa9A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.107.tgz", - "integrity": "sha512-I2wzcC0KXqh0OwymCmYwNRgZ9nxX7DWnOOStJXV3pS0uB83TXAkmqd7wvMBuIl9qu4Hfomi9aDM7IlEEn9tumQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.107.tgz", - "integrity": "sha512-HWgnn7JORYlOYnGsdunpSF8A+BCZKPLzLtEUA27/M/ZuANcMZabKL9Zurt7XQXq888uJFAt98Gy+59PU90aHKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.107.tgz", - "integrity": "sha512-vfPF74cWfAm8hyhS8yvYI94ucMHIo8xIYU+oFOW9uvDlGQRgnUf/6DEVbLyt/3yfX5723Ln57U8uiMALbX5Pyw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.107.tgz", - "integrity": "sha512-uBVNhIg0ip8rH9OnOsCARUFZ3Mq3tbPHxtmWk9uAa5u8jQwGWeBx5+nTHpDOVd3YxKb6+5xDEI/edeeLpha/9g==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.107.tgz", - "integrity": "sha512-mvACkUvzSIB12q1H5JtabWATbk3AG+pQgXEN95AmEX2ZA5gbP9+B+mijsg7Sd/3tboHr7ZHLz/q3SHTvdFJrEw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.107.tgz", - "integrity": "sha512-J3P14Ngy/1qtapzbguEH41kY109t6DFxfbK4Ntz9dOWNuVY3o9/RTB841ctnJk0ZHEG+BjfCJjsD2n8H5HcaOA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.107.tgz", - "integrity": "sha512-ZBUtgyjTHlz8TPJh7kfwwwFma+ktr6OccB1oXC8fMSopD0AxVnQasgun3l3099wIsAB9eEsJDQ/3lDkOLs1gBA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.107.tgz", - "integrity": "sha512-Eyzo2XRqWOxqhE1gk9h7LWmUf4Bp4Xn2Ttb0ayAXFp6YSTxQIThXcT9kipXZqcpxcmDwoq8iWbbf2P8XL743EA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -13183,14 +12975,6 @@ "tslib": "^2.8.0" } }, - "node_modules/@swc/types": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.5.tgz", - "integrity": "sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/@techteamer/ocsp": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@techteamer/ocsp/-/ocsp-1.0.1.tgz", @@ -15195,6 +14979,18 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/axios-ntlm": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/axios-ntlm/-/axios-ntlm-1.4.4.tgz", + "integrity": "sha512-kpCRdzMfL8gi0Z0o96P3QPAK4XuC8iciGgxGXe+PeQ4oyjI2LZN8WSOKbu0Y9Jo3T/A7pB81n6jYVPIpglEuRA==", + "license": "MIT", + "dependencies": { + "axios": "^1.8.4", + "des.js": "^1.1.0", + "dev-null": "^0.1.1", + "js-md4": "^0.3.2" + } + }, "node_modules/axios-retry": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.0.0.tgz", @@ -16954,6 +16750,16 @@ "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -16981,6 +16787,12 @@ "node": ">=8" } }, + "node_modules/dev-null": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/dev-null/-/dev-null-0.1.1.tgz", + "integrity": "sha512-nMNZG0zfMgmdv8S5O0TM5cpwNbGKRGPCxVsr0SmA3NZZy9CYBbuNLL0PD3Acx9e5LIUgwONXtM9kM6RlawPxEQ==", + "license": "MIT" + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -19029,49 +18841,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, - "node_modules/gcp-metadata": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", - "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", - "optional": true, - "peer": true, - "dependencies": { - "gaxios": "^5.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/gcp-metadata/node_modules/gaxios": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", - "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", - "optional": true, - "peer": true, - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^5.0.0", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/gcp-metadata/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -19855,6 +19624,39 @@ "node": ">=0.10" } }, + "node_modules/httpntlm": { + "version": "1.8.13", + "resolved": "https://registry.npmjs.org/httpntlm/-/httpntlm-1.8.13.tgz", + "integrity": "sha512-2F2FDPiWT4rewPzNMg3uPhNkP3NExENlUGADRUDPQvuftuUTGW98nLZtGemCIW3G40VhWZYgkIDcQFAwZ3mf2Q==", + "funding": [ + { + "type": "paypal", + "url": "https://www.paypal.com/donate/?hosted_button_id=2CKNJLZJBW8ZC" + }, + { + "type": "buymeacoffee", + "url": "https://www.buymeacoffee.com/samdecrock" + } + ], + "dependencies": { + "des.js": "^1.0.1", + "httpreq": ">=0.4.22", + "js-md4": "^0.3.2", + "underscore": "~1.12.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/httpreq": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/httpreq/-/httpreq-1.1.1.tgz", + "integrity": "sha512-uhSZLPPD2VXXOSN8Cni3kIsoFHaU2pT/nySEU/fHr/ePbqHYr0jeiQRmUKLEirC09SFPsdMoA7LU7UXMd/w0Kw==", + "license": "MIT", + "engines": { + "node": ">= 6.15.1" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -30579,6 +30381,12 @@ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "dev": true }, + "node_modules/underscore": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", + "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==", + "license": "MIT" + }, "node_modules/undici": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz", diff --git a/backend/package.json b/backend/package.json index f84db13fc..8407588d8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -183,6 +183,7 @@ "argon2": "^0.31.2", "aws-sdk": "^2.1553.0", "axios": "^1.11.0", + "axios-ntlm": "^1.4.4", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", "botbuilder": "^4.23.2", @@ -198,6 +199,7 @@ "googleapis": "^137.1.0", "handlebars": "^4.7.8", "hdb": "^0.19.10", + "httpntlm": "^1.8.13", "ioredis": "^5.3.2", "isomorphic-dompurify": "^2.22.0", "jmespath": "^0.16.0", diff --git a/backend/src/db/migrations/20250826190000_add-properties-to-pki-subscriber.ts b/backend/src/db/migrations/20250826190000_add-properties-to-pki-subscriber.ts new file mode 100644 index 000000000..83a85c94f --- /dev/null +++ b/backend/src/db/migrations/20250826190000_add-properties-to-pki-subscriber.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasPropertiesCol = await knex.schema.hasColumn(TableName.PkiSubscriber, "properties"); + + if (!hasPropertiesCol) { + await knex.schema.alterTable(TableName.PkiSubscriber, (t) => { + t.jsonb("properties").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasPropertiesCol = await knex.schema.hasColumn(TableName.PkiSubscriber, "properties"); + + if (hasPropertiesCol) { + await knex.schema.alterTable(TableName.PkiSubscriber, (t) => { + t.dropColumn("properties"); + }); + } +} diff --git a/backend/src/db/schemas/pki-subscribers.ts b/backend/src/db/schemas/pki-subscribers.ts index 0cdff4250..ab3ee6459 100644 --- a/backend/src/db/schemas/pki-subscribers.ts +++ b/backend/src/db/schemas/pki-subscribers.ts @@ -25,7 +25,9 @@ export const PkiSubscribersSchema = z.object({ lastAutoRenewAt: z.date().nullable().optional(), lastOperationStatus: z.string().nullable().optional(), lastOperationMessage: z.string().nullable().optional(), - lastOperationAt: z.date().nullable().optional() + lastOperationAt: z.date().nullable().optional(), + azureAuthMethod: z.string().nullable().optional(), + properties: z.unknown().nullable().optional() }); export type TPkiSubscribers = z.infer; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 0cc272f15..76ad706cd 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2312,6 +2312,11 @@ export const AppConnections = { OKTA: { instanceUrl: "The URL used to access your Okta organization.", apiToken: "The API token used to authenticate with Okta." + }, + AZURE_ADCS: { + adcsUrl: "The URL of the Azure ADCS instance to connect with.", + username: "The username used to access Azure ADCS.", + password: "The password used to access Azure ADCS." } } }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 6dd7d190d..e7a130455 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -948,6 +948,7 @@ export const registerRoutes = async ( certificateAuthorityCrlDAL, certificateAuthoritySecretDAL, projectDAL, + appConnectionDAL, kmsService, permissionService, pkiCollectionDAL, 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 f184f2001..c2033f4b4 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 @@ -15,6 +15,10 @@ import { } 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 { + AzureADCSConnectionListItemSchema, + SanitizedAzureADCSConnectionSchema +} from "@app/services/app-connection/azure-adcs/azure-adcs-connection-schemas"; import { AzureAppConfigurationConnectionListItemSchema, SanitizedAzureAppConfigurationConnectionSchema @@ -150,7 +154,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedSupabaseConnectionSchema.options, ...SanitizedDigitalOceanConnectionSchema.options, ...SanitizedNetlifyConnectionSchema.options, - ...SanitizedOktaConnectionSchema.options + ...SanitizedOktaConnectionSchema.options, + ...SanitizedAzureADCSConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -190,7 +195,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ SupabaseConnectionListItemSchema, DigitalOceanConnectionListItemSchema, NetlifyConnectionListItemSchema, - OktaConnectionListItemSchema + OktaConnectionListItemSchema, + AzureADCSConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/azure-adcs-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/azure-adcs-connection-router.ts new file mode 100644 index 000000000..cd4d4d1fd --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/azure-adcs-connection-router.ts @@ -0,0 +1,18 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateAzureADCSConnectionSchema, + SanitizedAzureADCSConnectionSchema, + UpdateAzureADCSConnectionSchema +} from "@app/services/app-connection/azure-adcs"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerAzureADCSConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.AzureADCS, + server, + sanitizedResponseSchema: SanitizedAzureADCSConnectionSchema, + createSchema: CreateAzureADCSConnectionSchema, + updateSchema: UpdateAzureADCSConnectionSchema + }); +}; 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 28e3b4e49..70804d173 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -5,6 +5,7 @@ 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 { registerAzureADCSConnectionRouter } from "./azure-adcs-connection-router"; import { registerAzureAppConfigurationConnectionRouter } from "./azure-app-configuration-connection-router"; import { registerAzureClientSecretsConnectionRouter } from "./azure-client-secrets-connection-router"; import { registerAzureDevOpsConnectionRouter } from "./azure-devops-connection-router"; @@ -50,6 +51,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerCertificateAuthorityEndpoints({ + caType: CaType.AZURE_AD_CS, + server, + responseSchema: AzureAdCsCertificateAuthoritySchema, + createSchema: CreateAzureAdCsCertificateAuthoritySchema, + updateSchema: UpdateAzureAdCsCertificateAuthoritySchema + }); + + server.route({ + method: "GET", + url: "/:caId/templates", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + description: "Get available certificate templates from Azure AD CS CA", + params: z.object({ + caId: z.string().describe("Azure AD CS CA ID") + }), + querystring: z.object({ + projectId: z.string().describe("Project ID") + }), + response: { + 200: z.object({ + templates: z.array( + z.object({ + id: z.string().describe("Template identifier"), + name: z.string().describe("Template display name"), + description: z.string().optional().describe("Template description") + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const templates = await server.services.certificateAuthority.getAzureAdcsTemplates({ + caId: req.params.caId, + projectId: req.query.projectId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return { templates }; + } + }); +}; diff --git a/backend/src/server/routes/v1/certificate-authority-routers/index.ts b/backend/src/server/routes/v1/certificate-authority-routers/index.ts index 56a236911..d146e68be 100644 --- a/backend/src/server/routes/v1/certificate-authority-routers/index.ts +++ b/backend/src/server/routes/v1/certificate-authority-routers/index.ts @@ -1,6 +1,7 @@ import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { registerAcmeCertificateAuthorityRouter } from "./acme-certificate-authority-router"; +import { registerAzureAdCsCertificateAuthorityRouter } from "./azure-ad-cs-certificate-authority-router"; import { registerInternalCertificateAuthorityRouter } from "./internal-certificate-authority-router"; export * from "./internal-certificate-authority-router"; @@ -8,5 +9,6 @@ export * from "./internal-certificate-authority-router"; export const CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP: Record Promise> = { [CaType.INTERNAL]: registerInternalCertificateAuthorityRouter, - [CaType.ACME]: registerAcmeCertificateAuthorityRouter + [CaType.ACME]: registerAcmeCertificateAuthorityRouter, + [CaType.AZURE_AD_CS]: registerAzureAdCsCertificateAuthorityRouter }; diff --git a/backend/src/server/routes/v1/pki-subscriber-router.ts b/backend/src/server/routes/v1/pki-subscriber-router.ts index 0e9ec6e0c..029ed8010 100644 --- a/backend/src/server/routes/v1/pki-subscriber-router.ts +++ b/backend/src/server/routes/v1/pki-subscriber-router.ts @@ -112,7 +112,19 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => .transform((arr) => Array.from(new Set(arr))) .describe(PKI_SUBSCRIBERS.CREATE.extendedKeyUsages), enableAutoRenewal: z.boolean().optional().describe(PKI_SUBSCRIBERS.CREATE.enableAutoRenewal), - autoRenewalPeriodInDays: z.number().min(1).optional().describe(PKI_SUBSCRIBERS.CREATE.autoRenewalPeriodInDays) + autoRenewalPeriodInDays: z.number().min(1).optional().describe(PKI_SUBSCRIBERS.CREATE.autoRenewalPeriodInDays), + properties: z + .object({ + azureTemplateType: z.string().optional().describe("Azure ADCS Certificate Template Type"), + organization: z.string().optional().describe("Organization (O)"), + organizationalUnit: z.string().optional().describe("Organizational Unit (OU)"), + country: z.string().length(2).optional().describe("Country (C) - Two letter country code"), + state: z.string().optional().describe("State/Province (ST)"), + locality: z.string().optional().describe("Locality (L)"), + emailAddress: z.string().email().optional().describe("Email Address") + }) + .optional() + .describe("Additional subscriber properties and subject fields") }), response: { 200: sanitizedPkiSubscriber @@ -199,7 +211,19 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => .optional() .describe(PKI_SUBSCRIBERS.UPDATE.extendedKeyUsages), enableAutoRenewal: z.boolean().optional().describe(PKI_SUBSCRIBERS.UPDATE.enableAutoRenewal), - autoRenewalPeriodInDays: z.number().min(1).optional().describe(PKI_SUBSCRIBERS.UPDATE.autoRenewalPeriodInDays) + autoRenewalPeriodInDays: z.number().min(1).optional().describe(PKI_SUBSCRIBERS.UPDATE.autoRenewalPeriodInDays), + properties: z + .object({ + azureTemplateType: z.string().optional().describe("Azure ADCS Certificate Template Type"), + organization: z.string().optional().describe("Organization (O)"), + organizationalUnit: z.string().optional().describe("Organizational Unit (OU)"), + country: z.string().length(2).optional().describe("Country (C) - Two letter country code"), + state: z.string().optional().describe("State/Province (ST)"), + locality: z.string().optional().describe("Locality (L)"), + emailAddress: z.string().email().optional().describe("Email Address") + }) + .optional() + .describe("Additional subscriber properties and subject fields") }), response: { 200: sanitizedPkiSubscriber diff --git a/backend/src/server/routes/v2/certificate-authority-router.ts b/backend/src/server/routes/v2/certificate-authority-router.ts index d8b434fdf..28d5527a8 100644 --- a/backend/src/server/routes/v2/certificate-authority-router.ts +++ b/backend/src/server/routes/v2/certificate-authority-router.ts @@ -6,12 +6,14 @@ 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 { AzureAdCsCertificateAuthoritySchema } from "@app/services/certificate-authority/azure-ad-cs/azure-ad-cs-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 + AcmeCertificateAuthoritySchema, + AzureAdCsCertificateAuthoritySchema ]); export const registerCaRouter = async (server: FastifyZodProvider) => { @@ -52,19 +54,31 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { req.permission ); + const azureAdCsCas = await server.services.certificateAuthority.listCertificateAuthoritiesByProjectId( + { + projectId: req.query.projectId, + type: CaType.AZURE_AD_CS + }, + 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)] + caIds: [ + ...(internalCas ?? []).map((ca) => ca.id), + ...(acmeCas ?? []).map((ca) => ca.id), + ...(azureAdCsCas ?? []).map((ca) => ca.id) + ] } } }); return { - certificateAuthorities: [...(internalCas ?? []), ...(acmeCas ?? [])] + certificateAuthorities: [...(internalCas ?? []), ...(acmeCas ?? []), ...(azureAdCsCas ?? [])] }; } }); diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 263cbdd9b..76dcdd5f0 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -8,6 +8,7 @@ export enum AppConnection { AzureAppConfiguration = "azure-app-configuration", AzureClientSecrets = "azure-client-secrets", AzureDevOps = "azure-devops", + AzureADCS = "azure-adcs", Humanitec = "humanitec", TerraformCloud = "terraform-cloud", Vercel = "vercel", diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 9ffc358b6..94de51f1e 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -31,6 +31,11 @@ import { } from "./app-connection-types"; import { Auth0ConnectionMethod, getAuth0ConnectionListItem, validateAuth0ConnectionCredentials } from "./auth0"; import { AwsConnectionMethod, getAwsConnectionListItem, validateAwsConnectionCredentials } from "./aws"; +import { AzureADCSConnectionMethod } from "./azure-adcs"; +import { + getAzureADCSConnectionListItem, + validateAzureADCSConnectionCredentials +} from "./azure-adcs/azure-adcs-connection-fns"; import { AzureAppConfigurationConnectionMethod, getAzureAppConfigurationConnectionListItem, @@ -136,6 +141,7 @@ export const listAppConnectionOptions = () => { getAzureKeyVaultConnectionListItem(), getAzureAppConfigurationConnectionListItem(), getAzureDevopsConnectionListItem(), + getAzureADCSConnectionListItem(), getDatabricksConnectionListItem(), getHumanitecConnectionListItem(), getTerraformCloudConnectionListItem(), @@ -227,6 +233,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.AzureClientSecrets]: validateAzureClientSecretsConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.AzureDevOps]: validateAzureDevOpsConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.AzureADCS]: validateAzureADCSConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, @@ -300,6 +307,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case MsSqlConnectionMethod.UsernameAndPassword: case MySqlConnectionMethod.UsernameAndPassword: case OracleDBConnectionMethod.UsernameAndPassword: + case AzureADCSConnectionMethod.UsernamePassword: return "Username & Password"; case WindmillConnectionMethod.AccessToken: case HCVaultConnectionMethod.AccessToken: @@ -357,6 +365,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.AzureKeyVault]: platformManagedCredentialsNotSupported, [AppConnection.AzureAppConfiguration]: platformManagedCredentialsNotSupported, [AppConnection.AzureDevOps]: platformManagedCredentialsNotSupported, + [AppConnection.AzureADCS]: platformManagedCredentialsNotSupported, [AppConnection.Humanitec]: platformManagedCredentialsNotSupported, [AppConnection.Postgres]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, [AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 155b68ce1..a2ce02669 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -9,6 +9,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.AzureAppConfiguration]: "Azure App Configuration", [AppConnection.AzureClientSecrets]: "Azure Client Secrets", [AppConnection.AzureDevOps]: "Azure DevOps", + [AppConnection.AzureADCS]: "Azure ADCS", [AppConnection.Databricks]: "Databricks", [AppConnection.Humanitec]: "Humanitec", [AppConnection.TerraformCloud]: "Terraform Cloud", @@ -49,6 +50,7 @@ export const APP_CONNECTION_PLAN_MAP: Record; + rejectUnauthorized?: boolean; +} + +interface HttpNtlmResponse { + statusCode: number; + body: string; + headers: Record; +} + +// Types for credential parsing +interface ParsedCredentials { + domain: string; + username: string; + fullUsername: string; // domain\username format +} + +// Helper function to parse and normalize credentials for Windows authentication +const parseCredentials = (inputUsername: string): ParsedCredentials => { + // Ensure inputUsername is a string + if (typeof inputUsername !== "string" || !inputUsername.trim()) { + throw new BadRequestError({ + message: "Username must be a non-empty string" + }); + } + + let domain = ""; + let username = ""; + let fullUsername = ""; + + if (inputUsername.includes("\\")) { + // Already in domain\username format + const parts = inputUsername.split("\\"); + if (parts.length === 2) { + [domain, username] = parts; + fullUsername = inputUsername; + } else { + throw new BadRequestError({ + message: "Invalid domain\\username format. Expected format: DOMAIN\\username" + }); + } + } else if (inputUsername.includes("@")) { + // UPN format: user@domain.com + const [user, domainPart] = inputUsername.split("@"); + if (!user || !domainPart) { + throw new BadRequestError({ + message: "Invalid UPN format. Expected format: user@domain.com" + }); + } + + username = user; + // Extract NetBIOS name from FQDN + domain = domainPart.split(".")[0].toUpperCase(); + fullUsername = `${domain}\\${username}`; + } else { + // Plain username - assume local account or current domain + username = inputUsername; + domain = ""; + fullUsername = inputUsername; + } + + return { domain, username, fullUsername }; +}; + +// Helper to normalize URL +const normalizeAdcsUrl = (url: string): string => { + let normalizedUrl = url.trim(); + + // Remove trailing slash + normalizedUrl = normalizedUrl.replace(/\/$/, ""); + + // Ensure HTTPS protocol + if (normalizedUrl.startsWith("http://")) { + normalizedUrl = normalizedUrl.replace("http://", "https://"); + } else if (!normalizedUrl.startsWith("https://")) { + normalizedUrl = `https://${normalizedUrl}`; + } + + return normalizedUrl; +}; + +// NTLM request wrapper +const ntlmRequest = (options: HttpNtlmRequestOptions): Promise => { + return new Promise((resolve, reject) => { + const method = options.method || "GET"; + + if (method.toLowerCase() === "get") { + httpntlm.get(options, (err: Error | null, res: HttpNtlmResponse) => { + if (err) reject(err); + else resolve(res); + }); + } else if (method.toLowerCase() === "post") { + httpntlm.post(options, (err: Error | null, res: HttpNtlmResponse) => { + if (err) reject(err); + else resolve(res); + }); + } else { + reject(new Error(`Unsupported HTTP method: ${method}`)); + } + }); +}; + +// Test ADCS connectivity and authentication using NTLM +const testAdcsConnection = async ( + credentials: ParsedCredentials, + password: string, + baseUrl: string +): Promise => { + // Test endpoints in order of preference + const testEndpoints = [ + "/certsrv/certrqus.asp", // Certificate request status (most reliable) + "/certsrv/certfnsh.asp", // Certificate finalization + "/certsrv/default.asp", // Main ADCS page + "/certsrv/" // Root certsrv + ]; + + for (const endpoint of testEndpoints) { + try { + const testUrl = `${baseUrl}${endpoint}`; + + const response = await ntlmRequest({ + url: testUrl, + username: credentials.username, + password, + domain: credentials.domain, + workstation: "", + rejectUnauthorized: false + }); + + // Check if we got a successful response + if (response.statusCode === 200) { + const responseText = response.body; + + // Verify this is actually an ADCS server by checking content + const adcsIndicators = [ + "Microsoft Active Directory Certificate Services", + "Certificate Services", + "Request a certificate", + "certsrv", + "Certificate Template", + "Web Enrollment" + ]; + + const isAdcsServer = adcsIndicators.some((indicator) => + responseText.toLowerCase().includes(indicator.toLowerCase()) + ); + + if (isAdcsServer) { + // Successfully authenticated and confirmed ADCS + return true; + } + } + + // Handle authentication failures + if (response.statusCode === 401) { + throw new BadRequestError({ + message: "Authentication failed. Please verify your username, password, and domain are correct." + }); + } + + if (response.statusCode === 403) { + throw new BadRequestError({ + message: "Access denied. Your account may not have permission to access ADCS web enrollment." + }); + } + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + // Handle network and connection errors + if (error instanceof Error) { + if (error.message.includes("ENOTFOUND")) { + throw new BadRequestError({ + message: "Cannot resolve ADCS server hostname. Please verify the URL is correct." + }); + } + if (error.message.includes("ECONNREFUSED")) { + throw new BadRequestError({ + message: "Connection refused by ADCS server. Please verify the server is running and accessible." + }); + } + if (error.message.includes("ETIMEDOUT")) { + throw new BadRequestError({ + message: "Connection timeout. Please verify the server is accessible and not blocked by firewall." + }); + } + } + + // Continue to next endpoint for other errors + continue; + } + } + + // If we get here, no endpoint worked + throw new BadRequestError({ + message: "Could not connect to ADCS server. Please verify the server URL and that Web Enrollment is enabled." + }); +}; + +// Create authenticated NTLM client for ADCS operations +const createNtlmClient = (username: string, password: string, baseUrl: string) => { + const parsedCredentials = parseCredentials(username); + const normalizedUrl = normalizeAdcsUrl(baseUrl); + + return { + get: (endpoint: string, additionalOptions: Partial = {}) => { + return ntlmRequest({ + url: `${normalizedUrl}${endpoint}`, + username: parsedCredentials.username, + password, + domain: parsedCredentials.domain, + workstation: "", + rejectUnauthorized: false, + ...additionalOptions + }); + }, + post: (endpoint: string, body: string, additionalOptions: Partial = {}) => { + return ntlmRequest({ + method: "POST", + url: `${normalizedUrl}${endpoint}`, + username: parsedCredentials.username, + password, + domain: parsedCredentials.domain, + workstation: "", + rejectUnauthorized: false, + body, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...additionalOptions.headers + }, + ...additionalOptions + }); + }, + baseUrl: normalizedUrl, + credentials: parsedCredentials + }; +}; + +export const getAzureADCSConnectionCredentials = async ( + connectionId: string, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) { + throw new NotFoundError({ message: `Connection with ID '${connectionId}' not found` }); + } + + if (appConnection.app !== AppConnection.AzureADCS) { + throw new BadRequestError({ message: `Connection with ID '${connectionId}' is not an Azure ADCS connection` }); + } + + switch (appConnection.method) { + case AzureADCSConnectionMethod.UsernamePassword: + const credentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as { username: string; password: string; adcsUrl: string }; + + return { + username: credentials.username, + password: credentials.password, + adcsUrl: credentials.adcsUrl + }; + + default: + throw new BadRequestError({ + message: `Unsupported Azure ADCS connection method: ${appConnection.method}` + }); + } +}; + +export const validateAzureADCSConnectionCredentials = async (appConnection: TAzureADCSConnectionConfig) => { + const { credentials } = appConnection; + + try { + // Parse and validate credentials + const parsedCredentials = parseCredentials(credentials.username); + const normalizedUrl = normalizeAdcsUrl(credentials.adcsUrl); + + // Test the connection using NTLM + await testAdcsConnection(parsedCredentials, credentials.password, normalizedUrl); + + // If we get here, authentication was successful + return { + username: credentials.username, + password: credentials.password, + adcsUrl: credentials.adcsUrl + }; + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + // Handle unexpected errors + let errorMessage = "Unable to validate ADCS connection."; + if (error instanceof Error) { + if (error.message.includes("401") || error.message.includes("Unauthorized")) { + errorMessage = "NTLM authentication failed. Please verify your username, password, and domain are correct."; + } else if (error.message.includes("ENOTFOUND") || error.message.includes("ECONNREFUSED")) { + errorMessage = "Cannot connect to the ADCS server. Please verify the server URL is correct and accessible."; + } else if (error.message.includes("timeout")) { + errorMessage = "Connection to ADCS server timed out. Please verify the server is accessible."; + } else if ( + error.message.includes("certificate") || + error.message.includes("SSL") || + error.message.includes("TLS") + ) { + errorMessage = "SSL/TLS certificate error. The server certificate may be self-signed or invalid."; + } + } + + throw new BadRequestError({ + message: `Failed to validate Azure ADCS connection: ${errorMessage} Details: ${ + error instanceof Error ? error.message : "Unknown error" + }` + }); + } +}; + +export const getAzureADCSConnectionListItem = () => ({ + name: "Azure ADCS" as const, + app: AppConnection.AzureADCS as const, + methods: [AzureADCSConnectionMethod.UsernamePassword] as [AzureADCSConnectionMethod.UsernamePassword] +}); + +// Export helper functions for use in certificate ordering +export const createAdcsHttpClient = (username: string, password: string, baseUrl: string) => { + return createNtlmClient(username, password, baseUrl); +}; diff --git a/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-schemas.ts b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-schemas.ts new file mode 100644 index 000000000..8fab35ea3 --- /dev/null +++ b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-schemas.ts @@ -0,0 +1,77 @@ +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 { AzureADCSConnectionMethod } from "./azure-adcs-connection-enums"; + +export const AzureADCSConnectionAccessTokenCredentialsSchema = z.object({ + adcsUrl: z + .string() + .trim() + .min(1, "ADCS URL required") + .max(255) + .describe(AppConnections.CREDENTIALS.AZURE_ADCS.adcsUrl), + username: z + .string() + .trim() + .min(1, "Username required") + .max(255) + .describe(AppConnections.CREDENTIALS.AZURE_ADCS.username), + password: z + .string() + .trim() + .min(1, "Password required") + .max(255) + .describe(AppConnections.CREDENTIALS.AZURE_ADCS.password) +}); + +const BaseAzureADCSConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.AzureADCS) }); + +export const AzureADCSConnectionSchema = BaseAzureADCSConnectionSchema.extend({ + method: z.literal(AzureADCSConnectionMethod.UsernamePassword), + credentials: AzureADCSConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedAzureADCSConnectionSchema = z.discriminatedUnion("method", [ + BaseAzureADCSConnectionSchema.extend({ + method: z.literal(AzureADCSConnectionMethod.UsernamePassword), + credentials: AzureADCSConnectionAccessTokenCredentialsSchema.pick({ + username: true + }) + }) +]); + +export const ValidateAzureADCSConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(AzureADCSConnectionMethod.UsernamePassword) + .describe(AppConnections.CREATE(AppConnection.AzureADCS).method), + credentials: AzureADCSConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureADCS).credentials + ) + }) +]); + +export const CreateAzureADCSConnectionSchema = ValidateAzureADCSConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.AzureADCS) +); + +export const UpdateAzureADCSConnectionSchema = z + .object({ + credentials: AzureADCSConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.AzureADCS).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureADCS)); + +export const AzureADCSConnectionListItemSchema = z.object({ + name: z.literal("Azure ADCS"), + app: z.literal(AppConnection.AzureADCS), + methods: z.nativeEnum(AzureADCSConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-types.ts b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-types.ts new file mode 100644 index 000000000..051d13c0d --- /dev/null +++ b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-types.ts @@ -0,0 +1,23 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + AzureADCSConnectionSchema, + CreateAzureADCSConnectionSchema, + ValidateAzureADCSConnectionCredentialsSchema +} from "./azure-adcs-connection-schemas"; + +export type TAzureADCSConnection = z.infer; + +export type TAzureADCSConnectionInput = z.infer & { + app: AppConnection.AzureADCS; +}; + +export type TValidateAzureADCSConnectionCredentialsSchema = typeof ValidateAzureADCSConnectionCredentialsSchema; + +export type TAzureADCSConnectionConfig = DiscriminativePick< + TAzureADCSConnectionInput, + "method" | "app" | "credentials" +>; diff --git a/backend/src/services/app-connection/azure-adcs/index.ts b/backend/src/services/app-connection/azure-adcs/index.ts new file mode 100644 index 000000000..3cefd9090 --- /dev/null +++ b/backend/src/services/app-connection/azure-adcs/index.ts @@ -0,0 +1,4 @@ +export * from "./azure-adcs-connection-enums"; +export * from "./azure-adcs-connection-fns"; +export * from "./azure-adcs-connection-schemas"; +export * from "./azure-adcs-connection-types"; diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-enums.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-enums.ts new file mode 100644 index 000000000..d442dad30 --- /dev/null +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-enums.ts @@ -0,0 +1,12 @@ +export enum AzureAdCsTemplateType { + WEB_SERVER = "WebServer", + COMPUTER = "Computer", + USER = "User", + DOMAIN_CONTROLLER = "DomainController", + SUBORDINATE_CA = "SubordinateCA" +} + +export enum AzureAdCsAuthMethod { + CLIENT_CERTIFICATE = "client-certificate", + KERBEROS = "kerberos" +} diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts new file mode 100644 index 000000000..a25baa42a --- /dev/null +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts @@ -0,0 +1,1103 @@ +/* eslint-disable no-await-in-loop */ +import * as x509 from "@peculiar/x509"; +import RE2 from "re2"; + +import { TableName } from "@app/db/schemas"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; +import { OrgServiceActor } from "@app/lib/types"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; +import { + createAdcsHttpClient, + getAzureADCSConnectionCredentials +} from "@app/services/app-connection/azure-adcs/azure-adcs-connection-fns"; +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 { TPkiSubscriberProperties } from "@app/services/pki-subscriber/pki-subscriber-types"; +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 { + TAzureAdCsCertificateAuthority, + TCreateAzureAdCsCertificateAuthorityDTO, + TUpdateAzureAdCsCertificateAuthorityDTO +} from "./azure-ad-cs-certificate-authority-types"; + +type TAzureAdCsCertificateAuthorityFnsDeps = { + 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 AzureCertificateRequest = { + csr: string; + template: string; + attributes?: Record; +}; + +type AzureCertificateResponse = { + certificateId: string; + certificate: string; + certificateChain?: string; + status: "issued" | "pending" | "denied"; + disposition?: string; +}; + +const buildSubjectDN = (commonName: string, properties?: TPkiSubscriberProperties): string => { + // Validate and sanitize common name - it's required and cannot be empty + if (!commonName || !commonName.trim()) { + throw new BadRequestError({ message: "Common Name is required and cannot be empty" }); + } + + const sanitizedCN = commonName.trim().replace(new RE2("[,=+<>#;\\\\\\]]", "g"), ""); + if (!sanitizedCN) { + throw new BadRequestError({ message: "Common Name contains only invalid characters" }); + } + + let subject = `CN=${sanitizedCN}`; + + // Helper function to validate and sanitize DN component values + const sanitizeComponent = (value: string | undefined): string | null => { + if (!value || typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + + // Remove or escape problematic characters for DN components + // Be more aggressive in removing characters that can cause OID issues + const sanitized = trimmed.replace(new RE2('[,=+<>#;\\\\"\\/\\r\\n\\t]', "g"), "").trim(); + + // Additional validation to prevent empty components that cause OID errors + if (sanitized.length === 0) return null; + + // Ensure the component doesn't start or end with spaces or problematic chars + const finalSanitized = sanitized.replace(new RE2("^[\\\\s\\\\-_.]+|[\\\\s\\\\-_.]+$", "g"), ""); + + return finalSanitized.length > 0 ? finalSanitized : null; + }; + + // Build DN components in proper order for ADCS compatibility + // Order matters for Microsoft ADCS - follow X.500 standard ordering + + const emailAddress = sanitizeComponent(properties?.emailAddress); + if (emailAddress) { + // Enhanced email validation for DN usage + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + if (emailRegex.test(emailAddress) && emailAddress.length > 5 && emailAddress.length < 64) { + subject += `,E=${emailAddress}`; + } + } + + const organizationalUnit = sanitizeComponent(properties?.organizationalUnit); + if (organizationalUnit && organizationalUnit.length <= 64) { + subject += `,OU=${organizationalUnit}`; + } + + const organization = sanitizeComponent(properties?.organization); + if (organization && organization.length <= 64) { + subject += `,O=${organization}`; + } + + const locality = sanitizeComponent(properties?.locality); + if (locality && locality.length <= 64) { + subject += `,L=${locality}`; + } + + const state = sanitizeComponent(properties?.state); + if (state && state.length <= 64) { + subject += `,ST=${state}`; + } + + const country = sanitizeComponent(properties?.country); + if (country) { + // Country code must be exactly 2 uppercase letters + const countryCode = country.toUpperCase().replace(new RE2("[^A-Z]", "g"), ""); + if (countryCode.length === 2) { + subject += `,C=${countryCode}`; + } + } + + return subject; +}; + +export const castDbEntryToAzureAdCsCertificateAuthority = ( + ca: Awaited> +): TAzureAdCsCertificateAuthority & { credentials: unknown } => { + if (!ca.externalCa?.id) { + throw new BadRequestError({ message: "Malformed Azure AD Certificate Service certificate authority" }); + } + + return { + id: ca.id, + type: CaType.AZURE_AD_CS, + enableDirectIssuance: ca.enableDirectIssuance, + name: ca.name, + projectId: ca.projectId, + credentials: ca.externalCa.credentials, + configuration: { + azureAdcsConnectionId: ca.externalCa.dnsAppConnectionId as string + }, + status: ca.status as CaStatus + }; +}; + +const submitCertificateRequest = async ( + credentials: { username: string; password: string }, + caServiceUrl: string, + certificateRequest: AzureCertificateRequest +): Promise => { + try { + const adcsClient = createAdcsHttpClient(credentials.username, credentials.password, caServiceUrl); + + // Clean CSR by removing headers and newlines for ADCS submission + const cleanCsr = certificateRequest.csr + .replace(new RE2("-----BEGIN CERTIFICATE REQUEST-----", "g"), "") + .replace(new RE2("-----END CERTIFICATE REQUEST-----", "g"), "") + .replace(new RE2("\\\\r?\\\\n", "g"), ""); + + // Build certificate attributes including template and validity period + const certAttribParts: string[] = []; + + // Add template - this is required + if (certificateRequest.template && certificateRequest.template.trim()) { + certAttribParts.push(`CertificateTemplate:${certificateRequest.template.trim()}`); + } + + // Add validity period if specified by the user + if (certificateRequest.attributes?.validityPeriod) { + try { + const ttlMs = ms(certificateRequest.attributes.validityPeriod); + const expirationDate = new Date(Date.now() + ttlMs); + + // Format expiration date in RFC 2616 format for ADCS + const rfc2616Date = expirationDate.toUTCString(); + + // Add ExpirationDate attribute (requires EDITF_ATTRIBUTEENDDATE flag on CA) + certAttribParts.push(`ExpirationDate:${rfc2616Date}`); + } catch (error) { + // Invalid TTL format - will use template default validity period + } + } + + // Join all attributes with proper CRLF ending + const certAttrib = certAttribParts.length > 0 ? `${certAttribParts.join("\r\n")}\r\n` : ""; + + // Prepare form data for ADCS web interface - correct format based on Microsoft docs + const formData = new URLSearchParams({ + Mode: "newreq", + CertRequest: cleanCsr, + CertAttrib: certAttrib, + FriendlyType: "Saved-Request Certificate", + TargetStoreFlags: "0", + SaveCert: "yes" + }); + + const response = await adcsClient.post("/certsrv/certfnsh.asp", formData.toString()); + + const responseText = response.body; + + // Parse the HTML response to extract certificate information + let requestId: string | undefined; + let status: "issued" | "pending" | "denied" = "pending"; + let certificate = ""; + + // Look for request ID in various formats + const requestIdMatches = [ + new RE2("reqid[=:](\\d+)", "i"), + new RE2("request\\s+id[:\\s]+(\\d+)", "i"), + new RE2("certificate\\s+request\\s+(\\d+)", "i"), + new RE2("\\breqid=(\\d+)\\b", "i"), + new RE2("requestid[:\\s]*(\\d+)", "i") + ]; + + for (const regex of requestIdMatches) { + const match = responseText.match(regex); + if (match) { + [, requestId] = match; + break; + } + } + + // Check for immediate certificate issuance + const certMatch = responseText.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/); + if (certMatch) { + // Clean up the certificate format + certificate = certMatch[0].replace(new RE2("\\\\r\\\\n", "g"), "\n").replace(new RE2("\\\\r", "g"), "\n").trim(); + + // Validate the certificate format before using it + try { + const testCert = new x509.X509Certificate(certificate); + // If we get here, the certificate is valid + status = "issued"; + // Use testCert if needed for validation + if (testCert) { + // Certificate is valid + } + } catch (error) { + // If the extracted certificate is invalid, treat as pending + certificate = ""; + status = "pending"; + } + } + + // Check disposition message for status + if (responseText.includes("taken under submission") || responseText.includes("pending")) { + status = "pending"; + } else if (responseText.includes("denied") || responseText.includes("rejected")) { + status = "denied"; + } else if (responseText.includes("issued") || certificate) { + status = "issued"; + } + + // If we couldn't parse a request ID and don't have a certificate, something went wrong + if (!requestId && !certificate) { + // Check for specific error types first + let errorMessage = "Unknown error occurred"; + + // ASN.1 parsing errors (CSR format issues) + if ( + responseText.includes("ASN1") || + responseText.includes("Error Parsing Request") || + responseText.includes("unexpected end of data") || + responseText.includes("bad tag value met") || + responseText.includes("0x80093102") + ) { + const asn1Patterns = [ + new RE2("Error Parsing Request\\s+ASN1[^.]*\\.?", "i"), + new RE2("ASN1[^.]*\\.?", "i"), + new RE2("Error Parsing Request[^.]*\\.?", "i") + ]; + + for (const pattern of asn1Patterns) { + const match = responseText.match(pattern); + if (match) { + errorMessage = match[0].trim(); + break; + } + } + + errorMessage = `Certificate request format error: ${errorMessage}. This indicates the CSR (Certificate Signing Request) format is incompatible with ADCS.`; + } + // Template permission errors (policy denials) + else if ( + responseText.includes("Denied by Policy Module") || + responseText.includes("0x80094800") || + responseText.includes("template that is not supported") + ) { + const policyMatch = + responseText.match(new RE2('Denied by Policy Module[^"]*"([^"]*)')) || + responseText.match(new RE2('The disposition message is "([^"]*)')); + + if (policyMatch) { + errorMessage = policyMatch[1].trim(); + } + + errorMessage = `Certificate template permission error: ${errorMessage}. Verify that the connection account has enrollment permissions for the selected certificate template.`; + } + // General error extraction + else { + const errorPatterns = [ + /The disposition message is "([^"]*)/i, + /Denied by Policy Module[^"]*"([^"]*)/i, + /]*class[^>]*error[^>]*>(.*?)<\/p>/i, + /]*class[^>]*error[^>]*>(.*?)<\/div>/i, + /]*class[^>]*error[^>]*>(.*?)<\/span>/i, + /error[^<]*:([^<]*)/i, + /denied[^<]*:([^<]*)/i, + /The\s+request\s+contains\s+no\s+certificate\s+template\s+information/i, + /The\s+template\s+is\s+missing/i + ]; + + // Try each pattern to find the error message + for (const pattern of errorPatterns) { + const match = responseText.match(pattern); + if (match) { + errorMessage = match[1] ? match[1].trim() : match[0].trim(); + break; + } + } + } + + // Clean up HTML entities and tags from error message + errorMessage = errorMessage + .replace(/"/g, '"') + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/<[^>]*>/g, "") // Remove HTML tags + .replace(/\r\n/g, " ") + .replace(/\n/g, " ") + .replace(/\r/g, " ") + .replace(/\s+/g, " ") + .replace(/\s*[".]?\s*[".]?\s*$/, "") // Remove trailing quotes and periods + .replace(/^\s*[".]?\s*/, "") // Remove leading quotes and periods + .trim(); + + // Handle specific Microsoft ADCS OID-related errors + if ( + responseText.includes("Cannot get OID for name type") || + responseText.includes("OID for name type") || + responseText.includes("name type ''") + ) { + errorMessage = + "Certificate template OID resolution error. This may be caused by: " + + "1) Certificate template name doesn't exist or is not published, " + + "2) ADCS OID cache needs refresh, or " + + "3) Template permissions are insufficient. " + + "Please verify the template exists, is published, and you have enrollment permissions."; + } else if (responseText.includes("template") && responseText.includes("not found")) { + errorMessage = `Certificate template not found. Please verify the template name '${certificateRequest.template}' exists and is published on the ADCS server.`; + } else if (responseText.includes("access denied") || responseText.includes("permission")) { + errorMessage = "Access denied. You may not have permission to request certificates with this template."; + } else if (responseText.includes("subject") || responseText.includes("DN")) { + errorMessage = + "Invalid subject DN format. Please check that all subject field values contain only valid characters."; + } else if (responseText.includes("Computer") && responseText.includes("Machine")) { + errorMessage = + "Computer/Machine template error. These templates may require domain-joined machines or specific subject name formats."; + } else if ( + certificateRequest.template.toLowerCase().includes("computer") || + certificateRequest.template.toLowerCase().includes("machine") + ) { + errorMessage = + `Template '${certificateRequest.template}' failed because it requires domain authentication and automatic enrollment. ` + + `Computer/Machine templates are designed for domain-joined computers, not manual requests. ` + + `Solutions: 1) Use 'User' or 'WebServer' templates instead, 2) Create a custom template based on ${certificateRequest.template} but configured for manual enrollment, ` + + `3) Ask your ADCS administrator to modify the template to allow manual enrollment and 'Supply subject in request'.`; + } else if (errorMessage.length < 10 || errorMessage === certificateRequest.template) { + errorMessage = `Certificate request failed with template '${certificateRequest.template}'. This may indicate a template configuration issue, permission problem, or invalid subject information.`; + } + + throw new BadRequestError({ + message: `Certificate request failed: ${errorMessage}` + }); + } + + return { + certificateId: requestId || "immediate", + certificate, + certificateChain: "", + status + }; + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + if (error instanceof Error) { + let errorMessage = `Failed to submit certificate request to ADCS: ${error.message}`; + + if (error.message.includes("401") || error.message.includes("Unauthorized")) { + errorMessage = "Authentication failed. Please verify your username and password are correct."; + } else if (error.message.includes("403") || error.message.includes("Forbidden")) { + errorMessage = "Access denied. You may not have permission to request certificates with this template."; + } else if (error.message.includes("404") || error.message.includes("Not Found")) { + errorMessage = "ADCS endpoint not found. Please verify the ADCS URL is correct."; + } else if (error.message.includes("ENOTFOUND")) { + errorMessage = "Cannot connect to ADCS server. Please verify the server URL and network connectivity."; + } else if (error.message.includes("ETIMEDOUT")) { + errorMessage = "Request timed out. The ADCS server may be overloaded or unreachable."; + } + + throw new BadRequestError({ message: errorMessage }); + } + + throw new BadRequestError({ + message: `Failed to submit certificate request to ADCS: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } +}; + +const retrieveCertificate = async ( + credentials: { username: string; password: string }, + caServiceUrl: string, + certificateId: string +): Promise => { + try { + const adcsClient = createAdcsHttpClient(credentials.username, credentials.password, caServiceUrl); + + const response = await adcsClient.get(`/certsrv/certnew.cer?ReqID=${certificateId}&Enc=b64`, { + headers: { + Accept: "application/pkix-cert,application/x-x509-ca-cert,application/octet-stream,*/*" + } + }); + + const certData = response.body; + + // Check if the response contains HTML indicating the certificate is not ready + if (certData.includes("") || certData.includes("taken under submission") || certData.includes("pending")) { + throw new BadRequestError({ + message: `Certificate with ID ${certificateId} is still pending approval or processing` + }); + } + + // If certificate is already in PEM format, return as-is + if (certData.includes("-----BEGIN CERTIFICATE-----")) { + return certData.trim(); + } + + // Handle base64-encoded certificate data + let cleanCertData = certData.trim(); + + // Remove any HTML artifacts or unwanted characters, keeping only base64 + cleanCertData = cleanCertData.replace(/[^A-Za-z0-9+/=\s]/g, "").replace(/\s/g, ""); + + if (cleanCertData.length < 100) { + throw new BadRequestError({ + message: `Certificate data appears invalid or too short (${cleanCertData.length} characters). The certificate may still be pending.` + }); + } + + // Format as proper PEM certificate with 64 character lines + const formattedCert = cleanCertData.replace(/(.{64})/g, "$1\n").trim(); + const pemCert = `-----BEGIN CERTIFICATE-----\n${formattedCert}\n-----END CERTIFICATE-----`; + + // Validate the constructed PEM before returning + try { + // Test parse to ensure it's valid + const testCert = new x509.X509Certificate(pemCert); + // If we get here, the certificate is valid + if (testCert) { + return pemCert; + } + throw new Error("Certificate validation failed"); + } catch (error) { + throw new BadRequestError({ + message: `Failed to format certificate data from ADCS into valid PEM format: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + if (error instanceof Error) { + let errorMessage = `Failed to retrieve certificate with ID ${certificateId} from ADCS: ${error.message}`; + + if (error.message.includes("404") || error.message.includes("Not Found")) { + errorMessage = `Certificate with ID ${certificateId} not found. It may have been rejected or the ID is invalid.`; + } else if (error.message.includes("401") || error.message.includes("Unauthorized")) { + errorMessage = "Authentication failed while retrieving certificate. Please verify your credentials."; + } else if (error.message.includes("403") || error.message.includes("Forbidden")) { + errorMessage = + "Access denied while retrieving certificate. You may not have permission to access this certificate."; + } else if (error.message.includes("ETIMEDOUT")) { + errorMessage = "Timeout while retrieving certificate. The ADCS server may be overloaded."; + } + + throw new BadRequestError({ message: errorMessage }); + } + + throw new BadRequestError({ + message: `Failed to retrieve certificate with ID ${certificateId} from ADCS: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } +}; + +export const AzureAdCsCertificateAuthorityFns = ({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + pkiSubscriberDAL +}: TAzureAdCsCertificateAuthorityFnsDeps) => { + const createCertificateAuthority = async ({ + name, + projectId, + configuration, + enableDirectIssuance, + actor, + status + }: { + status: CaStatus; + name: string; + projectId: string; + configuration: TCreateAzureAdCsCertificateAuthorityDTO["configuration"]; + enableDirectIssuance: boolean; + actor: OrgServiceActor; + }) => { + // Azure ADCS does not support direct issuance - enforce this restriction + if (enableDirectIssuance) { + throw new BadRequestError({ + message: "Azure ADCS Certificate Authorities do not support direct issuance" + }); + } + + const { azureAdcsConnectionId } = configuration; + const appConnection = await appConnectionDAL.findById(azureAdcsConnectionId); + + if (!appConnection) { + throw new NotFoundError({ message: `App connection with ID '${azureAdcsConnectionId}' not found` }); + } + + if (appConnection.app !== AppConnection.AzureADCS) { + throw new BadRequestError({ + message: `App connection with ID '${azureAdcsConnectionId}' is not an Azure ADCS connection` + }); + } + + await appConnectionService.connectAppConnectionById( + appConnection.app as AppConnection, + azureAdcsConnectionId, + actor + ); + + const caEntity = await certificateAuthorityDAL.transaction(async (tx) => { + try { + const ca = await certificateAuthorityDAL.create( + { + projectId, + enableDirectIssuance: false, // Always false for Azure ADCS CAs + name, + status + }, + tx + ); + + await externalCertificateAuthorityDAL.create( + { + caId: ca.id, + dnsAppConnectionId: azureAdcsConnectionId, + type: CaType.AZURE_AD_CS, + configuration: {} + }, + tx + ); + + return await certificateAuthorityDAL.findByIdWithAssociatedCa(ca.id, tx); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + if ((error as any)?.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 castDbEntryToAzureAdCsCertificateAuthority(caEntity); + }; + + const updateCertificateAuthority = async ({ + id, + status, + configuration, + enableDirectIssuance, + actor, + name + }: { + id: string; + status?: CaStatus; + configuration: TUpdateAzureAdCsCertificateAuthorityDTO["configuration"]; + enableDirectIssuance?: boolean; + actor: OrgServiceActor; + name?: string; + }) => { + // Azure ADCS does not support direct issuance - enforce this restriction + if (enableDirectIssuance) { + throw new BadRequestError({ + message: "Azure ADCS Certificate Authorities do not support direct issuance" + }); + } + + const updatedCa = await certificateAuthorityDAL.transaction(async (tx) => { + if (configuration) { + const { azureAdcsConnectionId } = configuration; + const appConnection = await appConnectionDAL.findById(azureAdcsConnectionId); + + if (!appConnection) { + throw new NotFoundError({ message: `App connection with ID '${azureAdcsConnectionId}' not found` }); + } + + if (appConnection.app !== AppConnection.AzureADCS) { + throw new BadRequestError({ + message: `App connection with ID '${azureAdcsConnectionId}' is not an Azure ADCS connection` + }); + } + + await appConnectionService.connectAppConnectionById( + appConnection.app as AppConnection, + azureAdcsConnectionId, + actor + ); + + await externalCertificateAuthorityDAL.update( + { + caId: id, + type: CaType.AZURE_AD_CS + }, + { + appConnectionId: azureAdcsConnectionId, + configuration: {} + }, + tx + ); + } + + if (name || status || enableDirectIssuance !== undefined) { + await certificateAuthorityDAL.updateById( + id, + { + name, + status, + enableDirectIssuance: false // Always false for Azure ADCS CAs + }, + tx + ); + } + + return certificateAuthorityDAL.findByIdWithAssociatedCa(id, tx); + }); + + if (!updatedCa.externalCa?.id) { + throw new BadRequestError({ message: "Failed to update external certificate authority" }); + } + + return castDbEntryToAzureAdCsCertificateAuthority(updatedCa); + }; + + const listCertificateAuthorities = async ({ projectId }: { projectId: string }) => { + const cas = await certificateAuthorityDAL.findWithAssociatedCa({ + [`${TableName.CertificateAuthority}.projectId` as "projectId"]: projectId, + [`${TableName.ExternalCertificateAuthority}.type` as "type"]: CaType.AZURE_AD_CS + }); + + return cas.map(castDbEntryToAzureAdCsCertificateAuthority); + }; + + 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.AZURE_AD_CS) { + throw new BadRequestError({ message: "CA is not an Azure AD Certificate Service CA" }); + } + + const azureCa = castDbEntryToAzureAdCsCertificateAuthority(ca); + if (azureCa.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 + }); + + // Get credentials from the Azure ADCS connection + const { username, password, adcsUrl } = await getAzureADCSConnectionCredentials( + azureCa.configuration.azureAdcsConnectionId, + appConnectionDAL, + kmsService + ); + + const credentials = { + username, + password + }; + + const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); + const leafKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const skLeafObj = crypto.nativeCrypto.KeyObject.from(leafKeys.privateKey); + const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + + const subjectDN = buildSubjectDN( + subscriber.commonName, + subscriber.properties as TPkiSubscriberProperties | undefined + ); + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: subjectDN, + keys: leafKeys, + signingAlgorithm: alg + }); + + const csrPem = csrObj.toString("pem"); + + const properties = subscriber.properties as TPkiSubscriberProperties | undefined; + const azureTemplateType = properties?.azureTemplateType; + if (!azureTemplateType || typeof azureTemplateType !== "string") { + throw new BadRequestError({ + message: "Subscriber must have an Azure certificate template configured for Azure ADCS CA" + }); + } + + const templateInput = azureTemplateType.trim(); + if (!templateInput || templateInput.length === 0) { + throw new BadRequestError({ + message: "Certificate template name cannot be empty" + }); + } + + let templateValue = templateInput; + + // Always use just the template display name for ADCS submission + // ADCS expects format like "CertificateTemplate:WebServer" not the full template value + templateValue = templateInput; + + const certificateRequest: AzureCertificateRequest = { + csr: csrPem, + template: templateValue, + attributes: { + subject: buildSubjectDN(subscriber.commonName, subscriber.properties as TPkiSubscriberProperties | undefined), + subjectAlternativeName: subscriber.subjectAlternativeNames.join(","), + ...(subscriber.ttl && { validityPeriod: subscriber.ttl }) + } + }; + + // Add retry logic for OID caching issues + let submissionResponse; + const maxOidRetries = 3; + let oidRetryCount = 0; + + while (oidRetryCount <= maxOidRetries) { + try { + submissionResponse = await submitCertificateRequest(credentials, adcsUrl, certificateRequest); + break; // Success, exit retry loop + } catch (error) { + const isOidError = + error instanceof BadRequestError && + (error.message.includes("OID resolution error") || error.message.includes("Cannot get OID for name type")); + + if (isOidError && oidRetryCount < maxOidRetries) { + oidRetryCount += 1; + + // Wait before retry with increasing delays: 3s, 6s, 9s + const delay = 3000 * oidRetryCount; + await new Promise((resolve) => { + setTimeout(resolve, delay); + }); + // eslint-disable-next-line no-continue + continue; + } + + // If not an OID error or we've exhausted retries, re-throw the error + throw error; + } + } + + if (!submissionResponse) { + throw new BadRequestError({ + message: "Failed to submit certificate request after multiple attempts due to OID resolution issues" + }); + } + + // Handle both "issued" and "pending" status - ADCS may auto-approve or require manual approval + if (submissionResponse.status === "denied") { + throw new BadRequestError({ message: "Certificate request was denied by ADCS" }); + } + + let certificatePem = ""; + + if (submissionResponse.status === "issued" && submissionResponse.certificate) { + certificatePem = submissionResponse.certificate; + } else { + // For pending certificates, implement a retry mechanism with exponential backoff + const maxRetries = 5; + const initialDelay = 2000; // 2 seconds + let retryCount = 0; + let lastError: Error | null = null; + + // eslint-disable-next-line no-await-in-loop + while (retryCount < maxRetries) { + try { + // eslint-disable-next-line no-await-in-loop + certificatePem = await retrieveCertificate(credentials, adcsUrl, submissionResponse.certificateId); + break; // Success, exit retry loop + } catch (error) { + lastError = error as Error; + // eslint-disable-next-line no-plusplus + retryCount++; + + if (retryCount < maxRetries) { + // Wait with exponential backoff: 2s, 4s, 8s, 16s, 32s + const delay = initialDelay * 2 ** (retryCount - 1); + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, delay); + }); + } + } + } + + if (retryCount === maxRetries) { + throw new BadRequestError({ + message: `Certificate request submitted with ID ${submissionResponse.certificateId} but failed to retrieve after ${maxRetries} attempts. The certificate may still be pending approval or processing. Last error: ${lastError?.message || "Unknown error"}` + }); + } + } + + // Ensure we have a valid certificate before proceeding + if (!certificatePem) { + throw new BadRequestError({ + message: "Failed to obtain certificate from ADCS. The certificate may still be pending processing." + }); + } + + // Clean and validate the certificate PEM format + let cleanedCertificatePem = certificatePem.trim(); + + // Ensure proper PEM format + if (!cleanedCertificatePem.includes("-----BEGIN CERTIFICATE-----")) { + throw new BadRequestError({ + message: "Invalid certificate format received from ADCS. Expected PEM format." + }); + } + + // Remove any extra whitespace and ensure proper line endings + cleanedCertificatePem = cleanedCertificatePem.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim(); + + // Validate that we have both begin and end markers + if (!cleanedCertificatePem.includes("-----END CERTIFICATE-----")) { + throw new BadRequestError({ + message: "Invalid certificate format received from ADCS. Missing end marker." + }); + } + + let certObj: x509.X509Certificate; + try { + certObj = new x509.X509Certificate(cleanedCertificatePem); + } catch (error) { + throw new BadRequestError({ + message: `Failed to parse certificate from ADCS: ${error instanceof Error ? error.message : "Unknown error"}. Certificate data may be corrupted.` + }); + } + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(certObj.rawData)) + }); + + const certificateChainPem = submissionResponse.certificateChain || ""; + + 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 { + certificate: certificatePem, + certificateChain: certificateChainPem, + privateKey: skLeaf, + serialNumber: certObj.serialNumber, + ca: azureCa, + subscriber + }; + }; + + const getTemplates = async ({ caId, projectId }: { caId: string; projectId: string }) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca || ca.projectId !== projectId) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + const azureCa = castDbEntryToAzureAdCsCertificateAuthority(ca); + const { azureAdcsConnectionId } = azureCa.configuration; + + const appConnection = await appConnectionDAL.findById(azureAdcsConnectionId); + if (!appConnection) { + throw new NotFoundError({ message: `App connection with ID '${azureAdcsConnectionId}' not found` }); + } + + // Get credentials from the Azure ADCS connection + const { username, password, adcsUrl } = await getAzureADCSConnectionCredentials( + azureAdcsConnectionId, + appConnectionDAL, + kmsService + ); + + const credentials = { + username, + password + }; + + const client = createAdcsHttpClient(credentials.username, credentials.password, adcsUrl); + + try { + // Get available templates from ADCS web interface and filter to only usable ones + let availableTemplates: Array<{ id: string; name: string; description: string }> = []; + + try { + const requestFormResponse = await client.get("/certsrv/certrqxt.asp"); + const responseText = requestFormResponse.body; + + // ADCS returns JavaScript-based template info instead of HTML options + // Look for patterns like: getTemplateStringInfo(CTINFO_INDEX_REALNAME, null) and sRealName assignments + const parsedTemplates: Array<{ id: string; name: string }> = []; + + // Extract template names from JavaScript variable assignments like sRealName="WebServer" + const nameRegex = new RE2('sRealName\\s*=\\s*"([^"]+)"', "gi"); + const names: string[] = []; + let nameMatch = nameRegex.exec(responseText); + while (nameMatch !== null) { + names.push(nameMatch[1]); + nameMatch = nameRegex.exec(responseText); + } + + // Extract template IDs/values from encoded strings or other patterns + const valueRegex = new RE2('CertificateTemplate\\s*\\+\\s*"([^"]+)"', "gi"); + const values: string[] = []; + let valueMatch = valueRegex.exec(responseText); + while (valueMatch !== null) { + values.push(valueMatch[1]); + valueMatch = valueRegex.exec(responseText); + } + + // Also look for any remaining HTML option patterns as fallback + const optionRegex = new RE2(']+value="([^"]*)"[^>]*>([^<]*)', "gi"); + let optionMatch = optionRegex.exec(responseText); + while (optionMatch !== null) { + const templateValue = optionMatch[1].trim(); + const templateDisplayName = optionMatch[2].trim(); + + if ( + templateValue && + templateDisplayName && + templateValue !== "" && + templateValue !== "0" && + !templateDisplayName.toLowerCase().includes("select") + ) { + // Parse the encoded template value format: "E;User;1;1;41;16;-1509949440;0;..." + // The template ID is the second semicolon-delimited value + const templateParts = templateValue.split(";"); + const templateId = templateParts.length >= 2 ? templateParts[1] : templateDisplayName; + + parsedTemplates.push({ + id: templateId, + name: templateDisplayName + }); + } + optionMatch = optionRegex.exec(responseText); + } + + // Combine JavaScript-extracted names with any found values + if (names.length > 0) { + names.forEach((name) => { + parsedTemplates.push({ + id: name, + name + }); + }); + } + + // If we successfully parsed templates, use them; otherwise fall back to common ones + if (parsedTemplates.length > 0) { + availableTemplates = parsedTemplates.map((template) => ({ + id: template.id, + name: template.name, + description: `Certificate template: ${template.name}` + })); + } else { + // Fallback to known working templates + availableTemplates = [ + { id: "User", name: "User", description: "User authentication certificate" }, + { id: "WebServer", name: "Web Server", description: "Web server certificate" } + ]; + } + } catch (requestError) { + // Fallback to known working templates if we can't parse the form + availableTemplates = [ + { id: "User", name: "User", description: "User authentication certificate" }, + { id: "WebServer", name: "Web Server", description: "Web server certificate" } + ]; + } + + // Return all available templates - let user decide what to use + return availableTemplates; + } catch (error) { + throw new BadRequestError({ message: "Failed to retrieve certificate templates from Azure ADCS" }); + } + }; + + return { + createCertificateAuthority, + updateCertificateAuthority, + listCertificateAuthorities, + orderSubscriberCertificate, + getTemplates + }; +}; diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts new file mode 100644 index 000000000..6b737e8e2 --- /dev/null +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +import { CaType } from "../certificate-authority-enums"; +import { + BaseCertificateAuthoritySchema, + GenericCreateCertificateAuthorityFieldsSchema, + GenericUpdateCertificateAuthorityFieldsSchema +} from "../certificate-authority-schemas"; + +export const AzureAdCsCertificateAuthorityConfigurationSchema = z.object({ + azureAdcsConnectionId: z.string().uuid().trim().describe("Azure ADCS Connection ID") +}); + +export const AzureAdCsCertificateAuthorityCredentialsSchema = z.object({ + clientId: z.string(), + clientSecret: z.string().optional(), + certificateThumbprint: z.string().optional() +}); + +export const AzureAdCsCertificateAuthoritySchema = BaseCertificateAuthoritySchema.extend({ + type: z.literal(CaType.AZURE_AD_CS), + configuration: AzureAdCsCertificateAuthorityConfigurationSchema +}); + +export const CreateAzureAdCsCertificateAuthoritySchema = GenericCreateCertificateAuthorityFieldsSchema( + CaType.AZURE_AD_CS +).extend({ + configuration: AzureAdCsCertificateAuthorityConfigurationSchema +}); + +export const UpdateAzureAdCsCertificateAuthoritySchema = GenericUpdateCertificateAuthorityFieldsSchema( + CaType.AZURE_AD_CS +).extend({ + configuration: AzureAdCsCertificateAuthorityConfigurationSchema.optional() +}); diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-types.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-types.ts new file mode 100644 index 000000000..b955f4c82 --- /dev/null +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-types.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +import { + AzureAdCsCertificateAuthoritySchema, + CreateAzureAdCsCertificateAuthoritySchema, + UpdateAzureAdCsCertificateAuthoritySchema +} from "./azure-ad-cs-certificate-authority-schemas"; + +export type TAzureAdCsCertificateAuthority = z.infer; + +export type TAzureAdCsCertificateAuthorityInput = z.infer; + +export type TCreateAzureAdCsCertificateAuthorityDTO = z.infer; + +export type TUpdateAzureAdCsCertificateAuthorityDTO = z.infer; diff --git a/backend/src/services/certificate-authority/certificate-authority-enums.ts b/backend/src/services/certificate-authority/certificate-authority-enums.ts index 8de80495e..f9b769a86 100644 --- a/backend/src/services/certificate-authority/certificate-authority-enums.ts +++ b/backend/src/services/certificate-authority/certificate-authority-enums.ts @@ -1,6 +1,7 @@ export enum CaType { INTERNAL = "internal", - ACME = "acme" + ACME = "acme", + AZURE_AD_CS = "azure-ad-cs" } export enum InternalCaType { @@ -17,3 +18,9 @@ export enum CaStatus { export enum CaRenewalType { EXISTING = "existing" } + +export enum CaCapability { + ISSUE_CERTIFICATES = "issue-certificates", + REVOKE_CERTIFICATES = "revoke-certificates", + RENEW_CERTIFICATES = "renew-certificates" +} diff --git a/backend/src/services/certificate-authority/certificate-authority-maps.ts b/backend/src/services/certificate-authority/certificate-authority-maps.ts index d13f65138..746a5c2d6 100644 --- a/backend/src/services/certificate-authority/certificate-authority-maps.ts +++ b/backend/src/services/certificate-authority/certificate-authority-maps.ts @@ -1,6 +1,33 @@ -import { CaType } from "./certificate-authority-enums"; +import { CaCapability, CaType } from "./certificate-authority-enums"; export const CERTIFICATE_AUTHORITIES_TYPE_MAP: Record = { [CaType.INTERNAL]: "Internal", - [CaType.ACME]: "ACME" + [CaType.ACME]: "ACME", + [CaType.AZURE_AD_CS]: "Azure AD Certificate Service" +}; + +export const CERTIFICATE_AUTHORITIES_CAPABILITIES_MAP: Record = { + [CaType.INTERNAL]: [ + CaCapability.ISSUE_CERTIFICATES, + CaCapability.REVOKE_CERTIFICATES, + CaCapability.RENEW_CERTIFICATES + ], + [CaType.ACME]: [ + CaCapability.ISSUE_CERTIFICATES, + CaCapability.REVOKE_CERTIFICATES, + CaCapability.RENEW_CERTIFICATES + ], + [CaType.AZURE_AD_CS]: [ + CaCapability.ISSUE_CERTIFICATES, + CaCapability.RENEW_CERTIFICATES + // Note: REVOKE_CERTIFICATES intentionally omitted - not supported by ADCS connector + ] +}; + +/** + * Check if a certificate authority type supports a specific capability + */ +export const caSupportsCapability = (caType: CaType, capability: CaCapability): boolean => { + const capabilities = CERTIFICATE_AUTHORITIES_CAPABILITIES_MAP[caType] || []; + return capabilities.includes(capability); }; diff --git a/backend/src/services/certificate-authority/certificate-authority-queue.ts b/backend/src/services/certificate-authority/certificate-authority-queue.ts index afe17ec5f..0e015da03 100644 --- a/backend/src/services/certificate-authority/certificate-authority-queue.ts +++ b/backend/src/services/certificate-authority/certificate-authority-queue.ts @@ -21,6 +21,7 @@ import { TCertificateSecretDALFactory } from "../certificate/certificate-secret- 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 { AzureAdCsCertificateAuthorityFns } from "./azure-ad-cs/azure-ad-cs-certificate-authority-fns"; import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; import { CaType } from "./certificate-authority-enums"; import { keyAlgorithmToAlgCfg } from "./certificate-authority-fns"; @@ -33,7 +34,7 @@ import { type TCertificateAuthorityQueueFactoryDep = { certificateAuthorityDAL: TCertificateAuthorityDALFactory; - appConnectionDAL: Pick; + appConnectionDAL: Pick; appConnectionService: Pick; externalCertificateAuthorityDAL: Pick; keyStore: Pick; @@ -82,6 +83,19 @@ export const certificateAuthorityQueueFactory = ({ projectDAL }); + const azureAdCsFns = AzureAdCsCertificateAuthorityFns({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + pkiSubscriberDAL, + projectDAL + }); + // TODO 1: auto-periodic rotation // TODO 2: manual rotation @@ -158,6 +172,13 @@ export const certificateAuthorityQueueFactory = ({ lastOperationMessage: "Certificate ordered successfully", lastOperationAt: new Date() }); + } else if (caType === CaType.AZURE_AD_CS) { + await azureAdCsFns.orderSubscriberCertificate(subscriberId); + await pkiSubscriberDAL.updateById(subscriberId, { + lastOperationStatus: SubscriberOperationStatus.SUCCESS, + lastOperationMessage: "Certificate ordered successfully", + lastOperationAt: new Date() + }); } } catch (e: unknown) { if (e instanceof Error) { diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index fa0fe017f..f05df4f2a 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -2,7 +2,11 @@ import { ForbiddenError } from "@casl/ability"; import { ActionProjectType, TableName } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { + ProjectPermissionActions, + ProjectPermissionCertificateActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; @@ -22,6 +26,14 @@ import { TCreateAcmeCertificateAuthorityDTO, TUpdateAcmeCertificateAuthorityDTO } from "./acme/acme-certificate-authority-types"; +import { + AzureAdCsCertificateAuthorityFns, + castDbEntryToAzureAdCsCertificateAuthority +} from "./azure-ad-cs/azure-ad-cs-certificate-authority-fns"; +import { + TCreateAzureAdCsCertificateAuthorityDTO, + TUpdateAzureAdCsCertificateAuthorityDTO +} from "./azure-ad-cs/azure-ad-cs-certificate-authority-types"; import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; import { CaType } from "./certificate-authority-enums"; import { @@ -34,7 +46,7 @@ import { TInternalCertificateAuthorityServiceFactory } from "./internal/internal import { TCreateInternalCertificateAuthorityDTO } from "./internal/internal-certificate-authority-types"; type TCertificateAuthorityServiceFactoryDep = { - appConnectionDAL: Pick; + appConnectionDAL: Pick; appConnectionService: Pick; certificateAuthorityDAL: Pick< TCertificateAuthorityDALFactory, @@ -91,6 +103,19 @@ export const certificateAuthorityServiceFactory = ({ projectDAL }); + const azureAdCsFns = AzureAdCsCertificateAuthorityFns({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + pkiSubscriberDAL, + projectDAL + }); + const createCertificateAuthority = async ( { type, projectId, name, enableDirectIssuance, configuration, status }: TCreateCertificateAuthorityDTO, actor: OrgServiceActor @@ -146,6 +171,17 @@ export const certificateAuthorityServiceFactory = ({ }); } + if (type === CaType.AZURE_AD_CS) { + return azureAdCsFns.createCertificateAuthority({ + name, + projectId, + configuration: configuration as TCreateAzureAdCsCertificateAuthorityDTO["configuration"], + enableDirectIssuance, + status, + actor + }); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -205,6 +241,10 @@ export const certificateAuthorityServiceFactory = ({ return castDbEntryToAcmeCertificateAuthority(certificateAuthority); } + if (type === CaType.AZURE_AD_CS) { + return castDbEntryToAzureAdCsCertificateAuthority(certificateAuthority); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -249,6 +289,10 @@ export const certificateAuthorityServiceFactory = ({ return acmeFns.listCertificateAuthorities({ projectId }); } + if (type === CaType.AZURE_AD_CS) { + return azureAdCsFns.listCertificateAuthorities({ projectId }); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -323,6 +367,17 @@ export const certificateAuthorityServiceFactory = ({ }); } + if (type === CaType.AZURE_AD_CS) { + return azureAdCsFns.updateCertificateAuthority({ + id: certificateAuthority.id, + configuration: configuration as TUpdateAzureAdCsCertificateAuthorityDTO["configuration"], + enableDirectIssuance, + actor, + status, + name + }); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -384,14 +439,98 @@ export const certificateAuthorityServiceFactory = ({ return castDbEntryToAcmeCertificateAuthority(certificateAuthority); } + if (type === CaType.AZURE_AD_CS) { + return castDbEntryToAzureAdCsCertificateAuthority(certificateAuthority); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; + const orderSubscriberCertificate = async (subscriberId: string, actor: OrgServiceActor) => { + 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); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: ca.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Create, + ProjectPermissionSub.Certificates + ); + + if (!ca.externalCa && !ca.internalCa) { + throw new BadRequestError({ message: "Certificate authority configuration not found" }); + } + + if (ca.externalCa?.type === CaType.ACME) { + return acmeFns.orderSubscriberCertificate(subscriberId); + } + + if (ca.externalCa?.type === CaType.AZURE_AD_CS) { + return azureAdCsFns.orderSubscriberCertificate(subscriberId); + } + + if (ca.internalCa) { + // Handle internal CA certificate ordering - this would need to be implemented + throw new BadRequestError({ message: "Internal CA certificate ordering not yet supported" }); + } + + throw new BadRequestError({ message: "Unsupported certificate authority type" }); + }; + + const getAzureAdcsTemplates = async ({ + caId, + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: { + caId: string; + projectId: string; + actor: OrgServiceActor["type"]; + actorId: string; + actorAuthMethod: OrgServiceActor["authMethod"]; + actorOrgId?: string; + }) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Read, + ProjectPermissionSub.Certificates + ); + + return azureAdCsFns.getTemplates({ + caId, + projectId + }); + }; + return { createCertificateAuthority, findCertificateAuthorityByNameAndProjectId, listCertificateAuthoritiesByProjectId, updateCertificateAuthority, - deleteCertificateAuthority + deleteCertificateAuthority, + orderSubscriberCertificate, + getAzureAdcsTemplates }; }; diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index d76330bd8..14952ab72 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -1,13 +1,23 @@ import { TAcmeCertificateAuthority, TAcmeCertificateAuthorityInput } from "./acme/acme-certificate-authority-types"; +import { + TAzureAdCsCertificateAuthority, + TAzureAdCsCertificateAuthorityInput +} from "./azure-ad-cs/azure-ad-cs-certificate-authority-types"; import { CaType } from "./certificate-authority-enums"; import { TInternalCertificateAuthority, TInternalCertificateAuthorityInput } from "./internal/internal-certificate-authority-types"; -export type TCertificateAuthority = TInternalCertificateAuthority | TAcmeCertificateAuthority; +export type TCertificateAuthority = + | TInternalCertificateAuthority + | TAcmeCertificateAuthority + | TAzureAdCsCertificateAuthority; -export type TCertificateAuthorityInput = TInternalCertificateAuthorityInput | TAcmeCertificateAuthorityInput; +export type TCertificateAuthorityInput = + | TInternalCertificateAuthorityInput + | TAcmeCertificateAuthorityInput + | TAzureAdCsCertificateAuthorityInput; export type TCreateCertificateAuthorityDTO = Omit; diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts index 5671435f6..0621c20f8 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts @@ -8,6 +8,8 @@ import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; 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"; @@ -19,9 +21,12 @@ import { TAltNameMapping } 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 { TPkiSubscriberProperties } from "@app/services/pki-subscriber/pki-subscriber-types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; +import { AzureAdCsCertificateAuthorityFns } from "../azure-ad-cs/azure-ad-cs-certificate-authority-fns"; import { TCertificateAuthorityCertDALFactory } from "../certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "../certificate-authority-dal"; import { CaStatus } from "../certificate-authority-enums"; @@ -33,18 +38,102 @@ import { } from "../certificate-authority-fns"; import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority-secret-dal"; import { validateAndMapAltNameType } from "../certificate-authority-validators"; +import { TExternalCertificateAuthorityDALFactory } from "../external-certificate-authority-dal"; import { TIssueCertWithTemplateDTO } from "./internal-certificate-authority-types"; type TInternalCertificateAuthorityFnsDeps = { - certificateAuthorityDAL: Pick; + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + "findByIdWithAssociatedCa" | "findById" | "create" | "transaction" | "updateById" | "findWithAssociatedCa" + >; certificateAuthorityCertDAL: Pick; certificateAuthoritySecretDAL: Pick; certificateAuthorityCrlDAL: Pick; projectDAL: Pick; - kmsService: Pick; + kmsService: Pick< + TKmsServiceFactory, + "decryptWithKmsKey" | "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" + >; certificateDAL: Pick; certificateBodyDAL: Pick; certificateSecretDAL: Pick; + appConnectionDAL?: Pick; + appConnectionService?: Pick; + externalCertificateAuthorityDAL?: Pick; + pkiSubscriberDAL?: Pick; +}; + +const buildSubjectDN = (commonName: string, properties?: TPkiSubscriberProperties): string => { + // Validate and sanitize common name - it's required and cannot be empty + if (!commonName || !commonName.trim()) { + throw new BadRequestError({ message: "Common Name is required and cannot be empty" }); + } + + const sanitizedCN = commonName.trim().replace(/[,=+<>#;\\]/g, ""); // Remove problematic characters + if (!sanitizedCN) { + throw new BadRequestError({ message: "Common Name contains only invalid characters" }); + } + + let subject = `CN=${sanitizedCN}`; + + // Helper function to validate and sanitize DN component values + const sanitizeComponent = (value: string | undefined): string | null => { + if (!value || typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + + // Remove problematic characters for DN components + const sanitized = trimmed.replace(/[,=+<>#;\\"/\r\n\t]/g, "").trim(); + + // Additional validation to prevent empty components + if (sanitized.length === 0) return null; + + // Ensure the component doesn't start or end with spaces or problematic chars + const finalSanitized = sanitized.replace(/^[\s-_.]+|[\s-_.]+$/g, ""); + + return finalSanitized.length > 0 ? finalSanitized : null; + }; + + // Build DN components in proper X.500 ordering + const emailAddress = sanitizeComponent(properties?.emailAddress); + if (emailAddress) { + // Enhanced email validation for DN usage + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + if (emailRegex.test(emailAddress) && emailAddress.length > 5 && emailAddress.length < 64) { + subject += `,E=${emailAddress}`; + } + } + + const organizationalUnit = sanitizeComponent(properties?.organizationalUnit); + if (organizationalUnit && organizationalUnit.length <= 64) { + subject += `,OU=${organizationalUnit}`; + } + + const organization = sanitizeComponent(properties?.organization); + if (organization && organization.length <= 64) { + subject += `,O=${organization}`; + } + + const locality = sanitizeComponent(properties?.locality); + if (locality && locality.length <= 64) { + subject += `,L=${locality}`; + } + + const state = sanitizeComponent(properties?.state); + if (state && state.length <= 64) { + subject += `,ST=${state}`; + } + + const country = sanitizeComponent(properties?.country); + if (country) { + // Country code must be exactly 2 uppercase letters + const countryCode = country.toUpperCase().replace(/[^A-Z]/g, ""); + if (countryCode.length === 2) { + subject += `,C=${countryCode}`; + } + } + + return subject; }; export const InternalCertificateAuthorityFns = ({ @@ -56,7 +145,11 @@ export const InternalCertificateAuthorityFns = ({ certificateAuthorityCrlDAL, certificateDAL, certificateBodyDAL, - certificateSecretDAL + certificateSecretDAL, + appConnectionDAL, + appConnectionService, + externalCertificateAuthorityDAL, + pkiSubscriberDAL }: TInternalCertificateAuthorityFnsDeps) => { const issueCertificate = async ( subscriber: TPkiSubscribers, @@ -102,7 +195,7 @@ export const InternalCertificateAuthorityFns = ({ const leafKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ - name: `CN=${subscriber.commonName}`, + name: buildSubjectDN(subscriber.commonName, subscriber.properties as TPkiSubscriberProperties | undefined), keys: leafKeys, signingAlgorithm: alg, extensions: [ @@ -518,8 +611,30 @@ export const InternalCertificateAuthorityFns = ({ }; }; + const issueCertificateWithAzureAdCs = async (subscriberId: string) => { + if (!appConnectionDAL || !appConnectionService || !externalCertificateAuthorityDAL || !pkiSubscriberDAL) { + throw new BadRequestError({ message: "Azure AD CS dependencies not available" }); + } + + const azureAdCsFns = AzureAdCsCertificateAuthorityFns({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + pkiSubscriberDAL + }); + + return azureAdCsFns.orderSubscriberCertificate(subscriberId); + }; + return { issueCertificate, - issueCertificateWithTemplate + issueCertificateWithTemplate, + issueCertificateWithAzureAdCs }; }; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index de6da16ee..72e105781 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -10,10 +10,13 @@ import { } from "@app/ee/services/permission/project-permission"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; 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 { CaCapability, CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { caSupportsCapability } from "@app/services/certificate-authority/certificate-authority-maps"; 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"; @@ -49,6 +52,7 @@ type TCertificateServiceFactoryDep = { pkiCollectionDAL: Pick; pkiCollectionItemDAL: Pick; projectDAL: Pick; + appConnectionDAL: Pick; kmsService: Pick; permissionService: Pick; }; @@ -66,6 +70,7 @@ export const certificateServiceFactory = ({ pkiCollectionDAL, pkiCollectionItemDAL, projectDAL, + appConnectionDAL, kmsService, permissionService }: TCertificateServiceFactoryDep) => { @@ -184,9 +189,11 @@ export const certificateServiceFactory = ({ const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(cert.caId); - if (ca.externalCa?.id) { + // Check if the CA type supports revocation + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; + if (!caSupportsCapability(caType, CaCapability.REVOKE_CERTIFICATES)) { throw new BadRequestError({ - message: "Cannot revoke external certificates" + message: "Certificate revocation is not supported by this certificate authority type" }); } @@ -218,18 +225,37 @@ export const certificateServiceFactory = ({ } ); - // rebuild CRL (TODO: move to interval-based cron job) - await rebuildCaCrl({ - caId: ca.id, - certificateAuthorityDAL, - certificateAuthorityCrlDAL, - certificateAuthoritySecretDAL, - projectDAL, - certificateDAL, - kmsService - }); + // Note: External CA revocation handling would go here for supported CA types + // Currently, only internal CAs and ACME CAs support revocation - return { revokedAt, cert, ca: expandInternalCa(ca) }; + // rebuild CRL (TODO: move to interval-based cron job) + // Only rebuild CRL for internal CAs - external CAs manage their own CRLs + if (!ca.externalCa?.id) { + await rebuildCaCrl({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthorityCrlDAL, + certificateAuthoritySecretDAL, + projectDAL, + certificateDAL, + kmsService + }); + } + + // Return appropriate CA format based on CA type + const caResult = ca.externalCa?.id + ? { + id: ca.id, + name: ca.name, + projectId: ca.projectId, + status: ca.status, + enableDirectIssuance: ca.enableDirectIssuance, + type: ca.externalCa.type, + externalCa: ca.externalCa + } + : expandInternalCa(ca); + + return { revokedAt, cert, ca: caResult }; }; /** diff --git a/backend/src/services/pki-subscriber/pki-subscriber-schema.ts b/backend/src/services/pki-subscriber/pki-subscriber-schema.ts index 337f81d8c..7b22ab645 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-schema.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-schema.ts @@ -18,7 +18,8 @@ export const sanitizedPkiSubscriber = PkiSubscribersSchema.pick({ lastOperationAt: true, enableAutoRenewal: true, autoRenewalPeriodInDays: true, - lastAutoRenewAt: true + lastAutoRenewAt: true, + properties: 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 a3e6ec78c..391b5b2a8 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-service.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-service.ts @@ -109,6 +109,7 @@ export const pkiSubscriberServiceFactory = ({ extendedKeyUsages, enableAutoRenewal, autoRenewalPeriodInDays, + properties, projectId, actorId, actorAuthMethod, @@ -157,7 +158,8 @@ export const pkiSubscriberServiceFactory = ({ keyUsages, extendedKeyUsages, enableAutoRenewal, - autoRenewalPeriodInDays + autoRenewalPeriodInDays, + properties }); return newSubscriber; @@ -221,6 +223,7 @@ export const pkiSubscriberServiceFactory = ({ extendedKeyUsages, enableAutoRenewal, autoRenewalPeriodInDays, + properties, actorId, actorAuthMethod, actor, @@ -275,7 +278,8 @@ export const pkiSubscriberServiceFactory = ({ keyUsages, extendedKeyUsages, enableAutoRenewal, - autoRenewalPeriodInDays + autoRenewalPeriodInDays, + properties }); return updatedSubscriber; @@ -360,7 +364,7 @@ export const pkiSubscriberServiceFactory = ({ throw new BadRequestError({ message: "CA is disabled" }); } - if (ca.externalCa?.id && ca.externalCa.type === CaType.ACME) { + if (ca.externalCa?.id && (ca.externalCa.type === CaType.ACME || ca.externalCa.type === CaType.AZURE_AD_CS)) { await certificateAuthorityQueue.orderCertificateForSubscriber({ subscriberId: subscriber.id, caType: ca.externalCa.type diff --git a/backend/src/services/pki-subscriber/pki-subscriber-types.ts b/backend/src/services/pki-subscriber/pki-subscriber-types.ts index 6881eea74..472975d4d 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-types.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-types.ts @@ -18,6 +18,7 @@ export type TCreatePkiSubscriberDTO = { extendedKeyUsages: CertExtendedKeyUsage[]; enableAutoRenewal?: boolean; autoRenewalPeriodInDays?: number; + properties?: TPkiSubscriberProperties; } & TProjectPermission; export type TGetPkiSubscriberDTO = { @@ -36,6 +37,7 @@ export type TUpdatePkiSubscriberDTO = { extendedKeyUsages?: CertExtendedKeyUsage[]; enableAutoRenewal?: boolean; autoRenewalPeriodInDays?: number; + properties?: TPkiSubscriberProperties; } & TProjectPermission; export type TDeletePkiSubscriberDTO = { @@ -69,3 +71,13 @@ export enum SubscriberOperationStatus { SUCCESS = "success", FAILED = "failed" } + +export type TPkiSubscriberProperties = { + azureTemplateType?: string; + organization?: string; + organizationalUnit?: string; + country?: string; + state?: string; + locality?: string; + emailAddress?: string; +} diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/available.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/available.mdx new file mode 100644 index 000000000..6b23a07e9 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/azure-adcs/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/create.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/create.mdx new file mode 100644 index 000000000..132ee409c --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/azure-adcs" +--- + + + Azure ADCS Connections must be created through the Infisical UI. + Check out the configuration docs for [Azure ADCS Connections](/integrations/app-connections/azure-adcs) for a step-by-step + guide. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/delete.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/delete.mdx new file mode 100644 index 000000000..4d7037959 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/azure-adcs/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/get-by-id.mdx new file mode 100644 index 000000000..46a57d890 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/azure-adcs/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/get-by-name.mdx new file mode 100644 index 000000000..179ef9076 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/azure-adcs/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/list.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/list.mdx new file mode 100644 index 000000000..c0f679ee1 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/azure-adcs" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/update.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/update.mdx new file mode 100644 index 000000000..4c4dfc91d --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/azure-adcs/{connectionId}" +--- + + + Azure ADCS Connections must be updated through the Infisical UI. + Check out the configuration docs for [Azure ADCS Connections](/integrations/app-connections/azure-adcs) for a step-by-step + guide. + diff --git a/docs/docs.json b/docs/docs.json index 3b116f4db..4b8e62adf 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -106,6 +106,7 @@ "integrations/app-connections/auth0", "integrations/app-connections/aws", "integrations/app-connections/azure-app-configuration", + "integrations/app-connections/azure-adcs", "integrations/app-connections/azure-client-secrets", "integrations/app-connections/azure-devops", "integrations/app-connections/azure-key-vault", @@ -690,6 +691,7 @@ "documentation/platform/pki/subscribers", "documentation/platform/pki/certificates", "documentation/platform/pki/acme-ca", + "documentation/platform/pki/azure-adcs", "documentation/platform/pki/est", "documentation/platform/pki/alerting", { @@ -1396,6 +1398,18 @@ "api-reference/endpoints/app-connections/aws/delete" ] }, + { + "group": "Azure ADCS", + "pages": [ + "api-reference/endpoints/app-connections/azure-adcs/list", + "api-reference/endpoints/app-connections/azure-adcs/available", + "api-reference/endpoints/app-connections/azure-adcs/get-by-id", + "api-reference/endpoints/app-connections/azure-adcs/get-by-name", + "api-reference/endpoints/app-connections/azure-adcs/create", + "api-reference/endpoints/app-connections/azure-adcs/update", + "api-reference/endpoints/app-connections/azure-adcs/delete" + ] + }, { "group": "Azure App Configuration", "pages": [ diff --git a/docs/documentation/platform/pki/azure-adcs.mdx b/docs/documentation/platform/pki/azure-adcs.mdx new file mode 100644 index 000000000..3231b4cdf --- /dev/null +++ b/docs/documentation/platform/pki/azure-adcs.mdx @@ -0,0 +1,159 @@ +--- +title: "Certificates with Azure ADCS" +description: "Learn how to issue and manage certificates using Microsoft Active Directory Certificate Services (ADCS) with Infisical." +--- + +Issue and manage certificates using Microsoft Active Directory Certificate Services (ADCS) for enterprise-grade certificate management integrated with your existing Windows infrastructure. + +## Prerequisites + +Before setting up ADCS integration, ensure you have: + +- Microsoft Active Directory Certificate Services (ADCS) server running and accessible +- Domain administrator account with certificate management permissions +- ADCS web enrollment enabled on your server +- Network connectivity from Infisical to the ADCS server +- Azure ADCS app connection configured (see [Azure ADCS Connection](/integrations/app-connections/azure-adcs)) + +## Complete Workflow: From Setup to Certificate Issuance + +This section walks you through the complete end-to-end process of setting up Azure ADCS integration and issuing your first certificate. + + + + In your Infisical project, go to your **Certificate Project** → **Certificate Authority** to access the external CAs page. + + ![External CA Page](/images/platform/pki/azure-adcs/azure-adcs-external-ca-page.png) + + + + Click **Create CA** and configure: + - **Type**: Choose **Azure AD Certificate Service** + - **Name**: Friendly name for this CA (e.g., "Production ADCS CA") + - **App Connection**: Choose your ADCS connection from the dropdown + + ![External CA Form](/images/platform/pki/azure-adcs/azure-adcs-external-ca-form.png) + + + + Once created, your Azure ADCS Certificate Authority will appear in the list and be ready for use. + + ![External CA Created](/images/platform/pki/azure-adcs/azure-adcs-external-ca-created.png) + + + + Go to **Subscribers** to access the subscribers page. + + ![Subscribers Page](/images/platform/pki/azure-adcs/azure-adcs-subscribers-page.png) + + + + Click **Add Subscriber** and configure: + - **Name**: Unique subscriber name (e.g., "web-server-certs") + - **Certificate Authority**: Select your ADCS CA + - **Common Name**: Certificate CN (e.g., "api.example.com") + - **Certificate Template**: Select from dynamically loaded ADCS templates + - **Subject Alternative Names**: DNS names, IP addresses, or email addresses + - **TTL**: Certificate validity period (e.g., "1y" for 1 year) + - **Additional Subject Fields**: Organization, OU, locality, state, country, email (if required by template) + + ![Subscribers Form](/images/platform/pki/azure-adcs/azure-adcs-subscribers-form.png) + + + + Your subscriber is now created and ready to issue certificates. + + ![Subscriber Created](/images/platform/pki/azure-adcs/azure-adcs-subscribers-created.png) + + + + Click into your subscriber and click **Order Certificate** to generate a new certificate using your ADCS template. + + ![Issue New Certificate](/images/platform/pki/azure-adcs/azure-adcs-subscriber-issue-new-certificate.png) + + + + Your certificate has been successfully issued by the ADCS server and is ready for use. + + ![Certificate Created](/images/platform/pki/azure-adcs/azure-adcs-certificate-created.png) + + + + Navigate to **Certificates** to view detailed information about all issued certificates, including expiration dates, serial numbers, and certificate chains. + + ![Certificates Page](/images/platform/pki/azure-adcs/azure-adcs-certificates-page.png) + + + +## Certificate Templates + +Infisical automatically retrieves available certificate templates from your ADCS server, ensuring you can only select templates that are properly configured and accessible. The system dynamically discovers templates during the certificate authority setup and certificate issuance process. + +### Common Template Types + +ADCS templates you might see include: +- **Web Server**: For SSL/TLS certificates with server authentication +- **Computer**: For machine authentication certificates +- **User**: For client authentication certificates +- **Basic EFS**: For Encrypting File System certificates +- **EFS Recovery Agent**: For EFS data recovery +- **Administrator**: For administrative certificates +- **Subordinate Certification Authority**: For issuing CA certificates + +### Template Requirements + +Ensure your ADCS templates are configured with: +- **Enroll permissions** for your connection account +- **Auto-enroll permissions** if using automated workflows +- **Subject name requirements** matching your certificate requests +- **Key usage extensions** appropriate for your use case + + +**Dynamic Template Discovery**: Infisical queries your ADCS server in real-time to populate available templates. Only templates you have permission to use will be displayed during certificate issuance. + + +### Certificate Revocation + + +Certificate revocation is **not supported** by the Azure ADCS connector due to security and complexity considerations. + + +## Advanced Configuration + +### Custom Validity Periods + +Enable custom certificate validity periods on your ADCS server: + +```cmd +# Run on ADCS server as Administrator +certutil -setreg policy\EditFlags +EDITF_ATTRIBUTEENDDATE +net stop certsvc +net start certsvc +``` + +This allows Infisical to control certificate expiration dates directly. + +## Troubleshooting + +### Common Issues + +**Certificate Request Denied** +- Verify ADCS template permissions for your connection account +- Check template subject name requirements +- Ensure template allows the requested key algorithm and size + +**Revocation Service Unavailable** +- Verify IIS is running and the revocation endpoint is accessible +- Check IIS application pool permissions +- Test endpoint connectivity from Infisical + +**Template Not Found** +- Verify template exists on ADCS server and is published +- Check that your connection account has enrollment permissions for the template +- Ensure the template is properly configured and available in the ADCS web enrollment interface +- Templates are dynamically loaded - refresh the PKI Subscriber form if templates don't appear + +**Authentication Failures** +- Verify ADCS connection credentials +- Check domain account permissions +- Ensure network connectivity to ADCS server diff --git a/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-created.png b/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-created.png new file mode 100644 index 000000000..dff826130 Binary files /dev/null and b/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-created.png differ diff --git a/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-form.png b/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-form.png new file mode 100644 index 000000000..381ca8788 Binary files /dev/null and b/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-form.png differ diff --git a/docs/images/app-connections/azure-adcs/azure-adcs-select-connection.png b/docs/images/app-connections/azure-adcs/azure-adcs-select-connection.png new file mode 100644 index 000000000..16321c3dd Binary files /dev/null and b/docs/images/app-connections/azure-adcs/azure-adcs-select-connection.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-certificate-created.png b/docs/images/platform/pki/azure-adcs/azure-adcs-certificate-created.png new file mode 100644 index 000000000..e82b597eb Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-certificate-created.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-certificates-page.png b/docs/images/platform/pki/azure-adcs/azure-adcs-certificates-page.png new file mode 100644 index 000000000..b5666eaf0 Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-certificates-page.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-created.png b/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-created.png new file mode 100644 index 000000000..3326f6bac Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-created.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-form.png b/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-form.png new file mode 100644 index 000000000..3c5ccf079 Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-form.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-page.png b/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-page.png new file mode 100644 index 000000000..685b40492 Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-page.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-subscriber-issue-new-certificate.png b/docs/images/platform/pki/azure-adcs/azure-adcs-subscriber-issue-new-certificate.png new file mode 100644 index 000000000..8a007adcd Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-subscriber-issue-new-certificate.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-created.png b/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-created.png new file mode 100644 index 000000000..61bb140b8 Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-created.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-form.png b/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-form.png new file mode 100644 index 000000000..7e80c3763 Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-form.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-page.png b/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-page.png new file mode 100644 index 000000000..ce7cad5c8 Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-page.png differ diff --git a/docs/integrations/app-connections/azure-adcs.mdx b/docs/integrations/app-connections/azure-adcs.mdx new file mode 100644 index 000000000..adff38536 --- /dev/null +++ b/docs/integrations/app-connections/azure-adcs.mdx @@ -0,0 +1,42 @@ +--- +title: "Azure ADCS Connection" +description: "Learn how to configure an Azure ADCS Connection for Infisical certificate management." +--- + +Connect Infisical to Microsoft Active Directory Certificate Services (ADCS) for automated certificate issuance and management. + +## Prerequisites + +- Microsoft Active Directory Certificate Services (ADCS) server running and accessible +- Domain administrator account with certificate management permissions +- Network connectivity from Infisical to the ADCS server +- ADCS web enrollment enabled on your server + +## Connection Setup + + + + Navigate to the **App Connections** tab on the **Organization Settings** page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Select the **Azure ADCS Connection** option from the connection options modal. + ![Select Azure ADCS Connection](/images/app-connections/azure-adcs/azure-adcs-select-connection.png) + + + Fill in the following information: + - **Name**: Friendly name for this ADCS connection (e.g., "Production ADCS") + - **ADCS URL**: Your ADCS web enrollment URL (e.g., `https://adcs.yourdomain.com/certsrv`) + - **Username**: Domain administrator username (format: `DOMAIN\username` or `username@domain.com`) + - **Password**: Password for the domain administrator account + + And click **Connect to ADCS** to establish the connection. + ![Connect to ADCS](/images/app-connections/azure-adcs/azure-adcs-app-connection-form.png) + + + Your **Azure ADCS Connection** is now available for use in your Infisical + projects. ![Azure ADCS Connection + Created](/images/app-connections/azure-adcs/azure-adcs-app-connection-created.png) + + + diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 7ecc92811..88d9bb0d1 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -13,6 +13,7 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { Auth0ConnectionMethod, AwsConnectionMethod, + AzureADCSConnectionMethod, AzureAppConfigurationConnectionMethod, AzureClientSecretsConnectionMethod, AzureDevOpsConnectionMethod, @@ -76,6 +77,7 @@ export const APP_CONNECTION_MAP: Record< image: "Microsoft Azure.png" }, [AppConnection.AzureDevOps]: { name: "Azure DevOps", image: "Microsoft Azure.png" }, + [AppConnection.AzureADCS]: { name: "Azure ADCS", image: "Microsoft Azure.png" }, [AppConnection.Databricks]: { name: "Databricks", image: "Databricks.png" }, [AppConnection.Humanitec]: { name: "Humanitec", image: "Humanitec.png" }, [AppConnection.TerraformCloud]: { name: "Terraform Cloud", image: "Terraform Cloud.png" }, @@ -151,6 +153,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case MsSqlConnectionMethod.UsernameAndPassword: case MySqlConnectionMethod.UsernameAndPassword: case OracleDBConnectionMethod.UsernameAndPassword: + case AzureADCSConnectionMethod.UsernamePassword: return { name: "Username & Password", icon: faLock }; case HCVaultConnectionMethod.AccessToken: case TeamCityConnectionMethod.AccessToken: diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index eff3c9b62..7b041b797 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -7,6 +7,7 @@ export enum AppConnection { AzureAppConfiguration = "azure-app-configuration", AzureClientSecrets = "azure-client-secrets", AzureDevOps = "azure-devops", + AzureADCS = "azure-adcs", Databricks = "databricks", Humanitec = "humanitec", TerraformCloud = "terraform-cloud", diff --git a/frontend/src/hooks/api/appConnections/queries.tsx b/frontend/src/hooks/api/appConnections/queries.tsx index 0b55cb79b..dbccade88 100644 --- a/frontend/src/hooks/api/appConnections/queries.tsx +++ b/frontend/src/hooks/api/appConnections/queries.tsx @@ -54,13 +54,13 @@ export const useAppConnectionOptions = ( export const useGetAppConnectionOption = (app: T) => { const { data: options = [], isPending } = useAppConnectionOptions(); - return useMemo( - () => ({ - option: (options.find((opt) => opt.app === app) as TAppConnectionOptionMap[T]) ?? {}, + return useMemo(() => { + const foundOption = options.find((opt) => opt.app === app); + return { + option: (foundOption as TAppConnectionOptionMap[T]) ?? {}, isLoading: isPending - }), - [options, app, isPending] - ); + }; + }, [options, app, isPending]); }; export const useListAppConnections = ( diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 4191cb890..195e925da 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -32,7 +32,7 @@ export type TAzureKeyVaultConnectionOption = TAppConnectionOptionBase & { }; export type TAzureAppConfigurationConnectionOption = TAppConnectionOptionBase & { - app: AppConnection.AzureKeyVault; + app: AppConnection.AzureAppConfiguration; oauthClientId?: string; }; @@ -164,9 +164,14 @@ export type TOktaConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Okta; }; +export type TAzureAdCsConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.AzureADCS; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption + | TGitHubRadarConnectionOption | TGcpConnectionOption | TAzureAppConfigurationConnectionOption | TAzureKeyVaultConnectionOption @@ -184,6 +189,7 @@ export type TAppConnectionOption = | TWindmillConnectionOption | TAuth0ConnectionOption | THCVaultConnectionOption + | TLdapConnectionOption | TTeamCityConnectionOption | TOCIConnectionOption | TOnePassConnectionOption @@ -196,6 +202,7 @@ export type TAppConnectionOption = | TZabbixConnectionOption | TRailwayConnectionOption | TChecklyConnectionOption + | TSupabaseConnectionOption | TDigitalOceanConnectionOption | TNetlifyConnectionOption | TOktaConnectionOption; @@ -238,4 +245,5 @@ export type TAppConnectionOptionMap = { [AppConnection.DigitalOcean]: TDigitalOceanConnectionOption; [AppConnection.Netlify]: TNetlifyConnectionOption; [AppConnection.Okta]: TOktaConnectionOption; + [AppConnection.AzureADCS]: TAzureAdCsConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/azure-adcs-connection.ts b/frontend/src/hooks/api/appConnections/types/azure-adcs-connection.ts new file mode 100644 index 000000000..daed7e511 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/azure-adcs-connection.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +import { AppConnection } from "../enums"; +import { TRootAppConnection } from "./root-connection"; + +export enum AzureADCSConnectionMethod { + UsernamePassword = "username-password" +} + +export const CreateAzureADCSConnectionSchema = z.object({ + adcsUrl: z.string().url().min(1, "ADCS URL is required"), + username: z.string().min(1, "Username is required"), + password: z.string().min(1, "Password is required") +}); + +export type TCreateAzureADCSConnection = z.infer; + +export type TAzureADCSConnection = TRootAppConnection & { app: AppConnection.AzureADCS } & { + method: AzureADCSConnectionMethod.UsernamePassword; + credentials: { + username: string; + password: string; + adcsUrl: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index f07dcd283..e63979a8e 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -3,6 +3,7 @@ import { TOnePassConnection } from "./1password-connection"; import { TAppConnectionOption } from "./app-options"; import { TAuth0Connection } from "./auth0-connection"; import { TAwsConnection } from "./aws-connection"; +import { TAzureADCSConnection } from "./azure-adcs-connection"; import { TAzureAppConfigurationConnection } from "./azure-app-configuration-connection"; import { TAzureClientSecretsConnection } from "./azure-client-secrets-connection"; import { TAzureDevOpsConnection } from "./azure-devops-connection"; @@ -41,6 +42,7 @@ import { TZabbixConnection } from "./zabbix-connection"; export * from "./1password-connection"; export * from "./auth0-connection"; export * from "./aws-connection"; +export * from "./azure-adcs-connection"; export * from "./azure-app-configuration-connection"; export * from "./azure-client-secrets-connection"; export * from "./azure-devops-connection"; @@ -83,6 +85,7 @@ export type TAppConnection = | TAzureAppConfigurationConnection | TAzureClientSecretsConnection | TAzureDevOpsConnection + | TAzureADCSConnection | TDatabricksConnection | THumanitecConnection | TTerraformCloudConnection @@ -156,6 +159,7 @@ export type TAppConnectionMap = { [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnection; [AppConnection.AzureClientSecrets]: TAzureClientSecretsConnection; [AppConnection.AzureDevOps]: TAzureDevOpsConnection; + [AppConnection.AzureADCS]: TAzureADCSConnection; [AppConnection.Databricks]: TDatabricksConnection; [AppConnection.Humanitec]: THumanitecConnection; [AppConnection.TerraformCloud]: TTerraformCloudConnection; diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index fa59b3ae0..43941da41 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -1,7 +1,15 @@ import { AppConnection } from "../appConnections/enums"; import { SshCaStatus } from "../sshCa"; import { SshCertTemplateStatus } from "../sshCertificateTemplates"; -import { AcmeDnsProvider, CaStatus, InternalCaType } from "./enums"; +import { + AcmeDnsProvider, + AzureAdCsAuthMethod, + AzureAdCsTemplateType, + CaCapability, + CaStatus, + CaType, + InternalCaType +} from "./enums"; export const caTypeToNameMap: { [K in InternalCaType]: string } = { [InternalCaType.ROOT]: "Root", @@ -24,6 +32,45 @@ export const ACME_DNS_PROVIDER_APP_CONNECTION_MAP: Record = { + [AzureAdCsTemplateType.WEB_SERVER]: "Web Server", + [AzureAdCsTemplateType.COMPUTER]: "Computer", + [AzureAdCsTemplateType.USER]: "User", + [AzureAdCsTemplateType.DOMAIN_CONTROLLER]: "Domain Controller", + [AzureAdCsTemplateType.SUBORDINATE_CA]: "Subordinate CA" +}; + +export const AZURE_AD_CS_AUTH_METHOD_NAME_MAP: Record = { + [AzureAdCsAuthMethod.CLIENT_CERTIFICATE]: "Client Certificate", + [AzureAdCsAuthMethod.KERBEROS]: "Kerberos" +}; + +export const CA_TYPE_CAPABILITIES_MAP: Record = { + [CaType.INTERNAL]: [ + CaCapability.ISSUE_CERTIFICATES, + CaCapability.REVOKE_CERTIFICATES, + CaCapability.RENEW_CERTIFICATES + ], + [CaType.ACME]: [ + CaCapability.ISSUE_CERTIFICATES, + CaCapability.REVOKE_CERTIFICATES, + CaCapability.RENEW_CERTIFICATES + ], + [CaType.AZURE_AD_CS]: [ + CaCapability.ISSUE_CERTIFICATES, + CaCapability.RENEW_CERTIFICATES + // Note: REVOKE_CERTIFICATES intentionally omitted - not supported by ADCS connector + ] +}; + +/** + * Check if a certificate authority type supports a specific capability + */ +export const caSupportsCapability = (caType: CaType, capability: CaCapability): boolean => { + const capabilities = CA_TYPE_CAPABILITIES_MAP[caType] || []; + return capabilities.includes(capability); +}; + export const getCaStatusBadgeVariant = (status: CaStatus | SshCaStatus | SshCertTemplateStatus) => { switch (status) { case CaStatus.ACTIVE: diff --git a/frontend/src/hooks/api/ca/enums.tsx b/frontend/src/hooks/api/ca/enums.tsx index b010faa07..95a83ab28 100644 --- a/frontend/src/hooks/api/ca/enums.tsx +++ b/frontend/src/hooks/api/ca/enums.tsx @@ -1,6 +1,7 @@ export enum CaType { INTERNAL = "internal", - ACME = "acme" + ACME = "acme", + AZURE_AD_CS = "azure-ad-cs" } export enum InternalCaType { @@ -22,3 +23,22 @@ export enum AcmeDnsProvider { ROUTE53 = "route53", Cloudflare = "cloudflare" } + +export enum AzureAdCsTemplateType { + WEB_SERVER = "WebServer", + COMPUTER = "Computer", + USER = "User", + DOMAIN_CONTROLLER = "DomainController", + SUBORDINATE_CA = "SubordinateCA" +} + +export enum AzureAdCsAuthMethod { + CLIENT_CERTIFICATE = "client-certificate", + KERBEROS = "kerberos" +} + +export enum CaCapability { + ISSUE_CERTIFICATES = "issue-certificates", + REVOKE_CERTIFICATES = "revoke-certificates", + RENEW_CERTIFICATES = "renew-certificates" +} diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx index 82e9ea3be..92af5c2e0 100644 --- a/frontend/src/hooks/api/ca/index.tsx +++ b/frontend/src/hooks/api/ca/index.tsx @@ -1,4 +1,12 @@ -export { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from "./enums"; +export { + AcmeDnsProvider, + AzureAdCsAuthMethod, + AzureAdCsTemplateType, + CaRenewalType, + CaStatus, + CaType, + InternalCaType +} from "./enums"; export { useCreateCa, useCreateCertificate, @@ -9,6 +17,7 @@ export { useUpdateCa } from "./mutations"; export { + useGetAzureAdcsTemplates, useGetCa, useGetCaById, useGetCaCert, @@ -17,5 +26,6 @@ export { useGetCaCrls, useGetCaCsr, useListCasByProjectId, - useListCasByTypeAndProjectId + useListCasByTypeAndProjectId, + useListExternalCasByProjectId } from "./queries"; diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx index c05760beb..c0de85103 100644 --- a/frontend/src/hooks/api/ca/mutations.tsx +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -39,6 +39,10 @@ export const useUpdateCa = () => { queryClient.invalidateQueries({ queryKey: caKeys.getCaByNameAndProjectId(caName, projectId) }); + // Invalidate external CAs list + queryClient.invalidateQueries({ + queryKey: [`external-cas-${projectId}`] + }); } }); }; @@ -57,6 +61,10 @@ export const useCreateCa = () => { queryClient.invalidateQueries({ queryKey: caKeys.listCasByTypeAndProjectId(type, projectId) }); + // Invalidate external CAs list + queryClient.invalidateQueries({ + queryKey: [`external-cas-${projectId}`] + }); } }); }; @@ -79,6 +87,10 @@ export const useDeleteCa = () => { queryClient.invalidateQueries({ queryKey: caKeys.listCasByTypeAndProjectId(type, projectId) }); + // Invalidate external CAs list + queryClient.invalidateQueries({ + queryKey: [`external-cas-${projectId}`] + }); } }); }; diff --git a/frontend/src/hooks/api/ca/queries.tsx b/frontend/src/hooks/api/ca/queries.tsx index 4f10c6e84..b215fdcc1 100644 --- a/frontend/src/hooks/api/ca/queries.tsx +++ b/frontend/src/hooks/api/ca/queries.tsx @@ -17,7 +17,11 @@ export const caKeys = { getCaCsr: (caId: string) => [{ caId }, "ca-csr"], getCaCrl: (caId: string) => [{ caId }, "ca-crl"], getCaCertTemplates: (caId: string) => [{ caId }, "ca-cert-templates"], - getCaEstConfig: (caId: string) => [{ caId }, "ca-est-config"] + getCaEstConfig: (caId: string) => [{ caId }, "ca-est-config"], + getAzureAdcsTemplates: (caId: string, projectId: string) => [ + { caId, projectId }, + "azure-adcs-templates" + ] }; export const useGetCa = ({ @@ -67,6 +71,34 @@ export const useListCasByProjectId = (projectId: string) => { }); }; +export const useListExternalCasByProjectId = (projectId: string) => { + return useQuery({ + queryKey: [`external-cas-${projectId}`], + queryFn: async () => { + const [acmeResponse, azureAdCsResponse] = await Promise.allSettled([ + apiRequest.get( + `/api/v1/pki/ca/${CaType.ACME}?projectId=${projectId}` + ), + apiRequest.get( + `/api/v1/pki/ca/${CaType.AZURE_AD_CS}?projectId=${projectId}` + ) + ]); + + const allCas: TUnifiedCertificateAuthority[] = []; + + if (acmeResponse.status === "fulfilled") { + allCas.push(...acmeResponse.value.data); + } + + if (azureAdCsResponse.status === "fulfilled") { + allCas.push(...azureAdCsResponse.value.data); + } + + return allCas; + } + }); +}; + export const useGetCaById = (caId: string) => { return useQuery({ queryKey: caKeys.getCaById(caId), @@ -156,3 +188,22 @@ export const useGetCaCertTemplates = (caId: string) => { enabled: Boolean(caId) }); }; + +export const useGetAzureAdcsTemplates = ({ + caId, + projectId +}: { + caId: string; + projectId: string; +}) => { + return useQuery({ + queryKey: caKeys.getAzureAdcsTemplates(caId, projectId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + templates: { id: string; name: string; description?: string }[]; + }>(`/api/v1/pki/ca/azure-ad-cs/${caId}/templates?projectId=${projectId}`); + return data; + }, + enabled: Boolean(caId && projectId) + }); +}; diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index f9f812cb2..6151f15dd 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -1,5 +1,13 @@ import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "../certificates/enums"; -import { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from "./enums"; +import { + AcmeDnsProvider, + AzureAdCsAuthMethod, + AzureAdCsTemplateType, + CaRenewalType, + CaStatus, + CaType, + InternalCaType +} from "./enums"; export type TAcmeCertificateAuthority = { id: string; @@ -19,6 +27,20 @@ export type TAcmeCertificateAuthority = { }; }; +export type TAzureAdCsCertificateAuthority = { + id: string; + projectId: string; + type: CaType.AZURE_AD_CS; + status: CaStatus; + name: string; + enableDirectIssuance: boolean; + configuration: { + azureAdcsConnectionId: string; + templateName: AzureAdCsTemplateType; + authMethod: AzureAdCsAuthMethod; + }; +}; + export type TInternalCertificateAuthority = { id: string; projectId: string; @@ -48,6 +70,7 @@ export type TInternalCertificateAuthority = { export type TUnifiedCertificateAuthority = | TAcmeCertificateAuthority + | TAzureAdCsCertificateAuthority | TInternalCertificateAuthority; export type TCreateCertificateAuthorityDTO = Omit; diff --git a/frontend/src/hooks/api/pkiSubscriber/types.ts b/frontend/src/hooks/api/pkiSubscriber/types.ts index 628cfec0a..23ca6baf3 100644 --- a/frontend/src/hooks/api/pkiSubscriber/types.ts +++ b/frontend/src/hooks/api/pkiSubscriber/types.ts @@ -10,6 +10,16 @@ export enum SubscriberOperationStatus { FAILED = "failed" } +export type TPkiSubscriberProperties = { + azureTemplateType?: string; + organization?: string; + organizationalUnit?: string; + country?: string; + state?: string; + locality?: string; + emailAddress?: string; +}; + export type TPkiSubscriber = { id: string; projectId: string; @@ -27,6 +37,7 @@ export type TPkiSubscriber = { lastOperationStatus?: SubscriberOperationStatus; lastOperationMessage?: string; lastOperationAt?: string; + properties?: TPkiSubscriberProperties; }; export type TCreatePkiSubscriberDTO = { @@ -40,6 +51,7 @@ export type TCreatePkiSubscriberDTO = { extendedKeyUsages: CertExtendedKeyUsage[]; enableAutoRenewal?: boolean; autoRenewalPeriodInDays?: number; + properties?: TPkiSubscriberProperties; }; export type TUpdatePkiSubscriberDTO = { @@ -55,6 +67,7 @@ export type TUpdatePkiSubscriberDTO = { extendedKeyUsages?: CertExtendedKeyUsage[]; enableAutoRenewal?: boolean; autoRenewalPeriodInDays?: number; + properties?: TPkiSubscriberProperties; }; export type TDeletePkiSubscriberDTO = { diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx index e0bc547e0..6a1ebc247 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx @@ -42,29 +42,45 @@ import { 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 & Cloudflare 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() - }) +const baseSchema = z.object({ + type: z.nativeEnum(CaType), + name: slugSchema({ + field: "Name" + }), + enableDirectIssuance: z.boolean(), + status: z.nativeEnum(CaStatus) +}); + +const acmeConfigurationSchema = z.object({ + dnsAppConnection: z.object({ + id: z.string(), + name: z.string() + }), + dnsProviderConfig: z.object({ + provider: z.nativeEnum(AcmeDnsProvider), + hostedZoneId: z.string() + }), + directoryUrl: z.string(), + accountEmail: z.string() +}); + +const azureAdCsConfigurationSchema = z.object({ + azureAdcsConnection: z.object({ + id: z.string(), + name: z.string() }) - .required(); +}); + +const schema = z.discriminatedUnion("type", [ + baseSchema.extend({ + type: z.literal(CaType.ACME), + configuration: acmeConfigurationSchema + }), + baseSchema.extend({ + type: z.literal(CaType.AZURE_AD_CS), + configuration: azureAdCsConfigurationSchema + }) +]); export type FormData = z.infer; @@ -73,12 +89,15 @@ type Props = { handlePopUpToggle: (popUpName: keyof UsePopUpState<["ca"]>, state?: boolean) => void; }; -const caTypes = [{ label: "ACME", value: CaType.ACME }]; +const caTypes = [ + { label: "ACME", value: CaType.ACME }, + { label: "Azure AD Certificate Service", value: CaType.AZURE_AD_CS } +]; export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { const { currentWorkspace } = useWorkspace(); - const { data: ca } = useGetCa({ + const { data: ca, isLoading: isCaLoading } = useGetCa({ caName: (popUp?.ca?.data as { name: string })?.name || "", projectId: currentWorkspace?.id || "", type: (popUp?.ca?.data as { type: CaType })?.type || "" @@ -94,29 +113,54 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { 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: "" - } - } + resolver: zodResolver(schema) }); const caType = watch("type"); - const dnsProvider = watch("configuration.dnsProviderConfig.provider"); + const configuration = watch("configuration"); + const dnsProvider = + caType === CaType.ACME && configuration && "dnsProviderConfig" in configuration + ? configuration.dnsProviderConfig.provider + : undefined; + + useEffect(() => { + const initialType = (popUp?.ca?.data as { type: CaType })?.type; + if (!ca && popUp?.ca?.isOpen) { + if (initialType === CaType.AZURE_AD_CS) { + reset({ + type: CaType.AZURE_AD_CS, + name: "", + status: CaStatus.ACTIVE, + enableDirectIssuance: false, + configuration: { + azureAdcsConnection: { + id: "", + name: "" + } + } + }); + } else { + reset({ + type: CaType.ACME, + name: "", + status: CaStatus.ACTIVE, + enableDirectIssuance: true, + configuration: { + dnsAppConnection: { + id: "", + name: "" + }, + dnsProviderConfig: { + provider: AcmeDnsProvider.ROUTE53, + hostedZoneId: "" + }, + directoryUrl: "", + accountEmail: "" + } + }); + } + } + }, [popUp?.ca?.isOpen, popUp?.ca?.data, reset, ca]); const { data: availableRoute53Connections, isPending: isRoute53Pending } = useListAvailableAppConnections(AppConnection.AWS, { @@ -128,25 +172,44 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { enabled: caType === CaType.ACME }); - const availableConnections: TAvailableAppConnection[] = useMemo( - () => [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])], - [availableRoute53Connections, availableCloudflareConnections] - ); + const { data: availableAzureConnections, isPending: isAzurePending } = + useListAvailableAppConnections(AppConnection.AzureADCS, { + enabled: caType === CaType.AZURE_AD_CS + }); - const isPending = isRoute53Pending || isCloudflarePending; + const availableConnections: TAvailableAppConnection[] = useMemo(() => { + if (caType === CaType.ACME) { + return [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])]; + } + if (caType === CaType.AZURE_AD_CS) { + return availableAzureConnections || []; + } + return []; + }, [ + caType, + availableRoute53Connections, + availableCloudflareConnections, + availableAzureConnections + ]); - const dnsAppConnection = watch("configuration.dnsAppConnection"); + const isPending = isRoute53Pending || isCloudflarePending || isAzurePending; + + const dnsAppConnection = + caType === CaType.ACME && configuration && "dnsAppConnection" in configuration + ? configuration.dnsAppConnection + : { id: "", name: "" }; const { data: cloudflareZones = [], isPending: isZonesPending } = useCloudflareConnectionListZones(dnsAppConnection.id, { enabled: dnsProvider === AcmeDnsProvider.Cloudflare && !!dnsAppConnection.id }); + // Populate form with CA data when editing useEffect(() => { - if (ca) { - if (ca.type !== CaType.INTERNAL && availableConnections?.length) { + if (ca && !isCaLoading) { + if (ca.type === CaType.ACME && availableConnections?.length) { const selectedConnection = availableConnections?.find( - (connection) => connection.id === ca?.configuration.dnsAppConnectionId + (connection) => connection.id === ca.configuration.dnsAppConnectionId ); reset({ @@ -167,32 +230,61 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { accountEmail: ca.configuration.accountEmail } }); + } else if (ca.type === CaType.AZURE_AD_CS && availableConnections?.length) { + const selectedConnection = availableConnections?.find( + (connection) => connection.id === ca.configuration.azureAdcsConnectionId + ); + + reset({ + type: ca.type, + name: ca.name, + status: ca.status, + enableDirectIssuance: false, + configuration: { + azureAdcsConnection: { + id: ca.configuration.azureAdcsConnectionId, + name: selectedConnection?.name || "" + } + } + }); } } - }, [ca, availableConnections]); + }, [ca, availableConnections, reset, isCaLoading]); const onFormSubmit = async ({ type, name, enableDirectIssuance, status, - configuration + configuration: formConfiguration }: FormData) => { try { if (!currentWorkspace?.slug) return; - if (ca && type !== CaType.INTERNAL) { + let configPayload: any; + + if (type === CaType.ACME && "dnsAppConnection" in formConfiguration) { + configPayload = { + dnsProviderConfig: formConfiguration.dnsProviderConfig, + directoryUrl: formConfiguration.directoryUrl, + accountEmail: formConfiguration.accountEmail, + dnsAppConnectionId: formConfiguration.dnsAppConnection.id + }; + } else if (type === CaType.AZURE_AD_CS && "azureAdcsConnection" in formConfiguration) { + configPayload = { + azureAdcsConnectionId: formConfiguration.azureAdcsConnection.id + }; + } + + if (ca) { await updateMutateAsync({ caName: ca.name, projectId: currentWorkspace.id, name, type, status, - enableDirectIssuance, - configuration: { - ...configuration, - dnsAppConnectionId: configuration.dnsAppConnection.id - } + enableDirectIssuance: type === CaType.AZURE_AD_CS ? false : enableDirectIssuance, + configuration: configPayload }); } else { await createMutateAsync({ @@ -200,11 +292,8 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { name, type, status, - enableDirectIssuance, - configuration: { - ...configuration, - dnsAppConnectionId: configuration.dnsAppConnection.id - } + enableDirectIssuance: type === CaType.AZURE_AD_CS ? false : enableDirectIssuance, + configuration: configPayload }); } @@ -232,7 +321,7 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { handlePopUpToggle("ca", isOpen); }} > - +
{ca && ( @@ -307,7 +396,11 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { ( { /> )} - { - return ( - - field.onChange(value)} - isChecked={field.value} - > -

Enable Direct Issuance

-
+ {caType === CaType.AZURE_AD_CS && ( + ( + + { + onChange(newValue); + }} + isLoading={isPending} + options={availableConnections} + placeholder="Select connection..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> - ); - }} - /> + )} + control={control} + name="configuration.azureAdcsConnection" + /> + )} + {caType === CaType.ACME && ( + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Enable Direct Issuance

+
+
+ ); + }} + /> + )}