diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index 5e17cf2bb..b5f4f7ac2 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -49,9 +49,6 @@ RUN rm -fr ${SOFTHSM2_SOURCES} # Install pkcs11-tool RUN apt-get install -y opensc -RUN mkdir -p /etc/softhsm2/tokens && \ - softhsm2-util --init-token --slot 0 --label "auth-app" --pin 1234 --so-pin 0000 - # ? App setup # Install Infisical CLI @@ -64,10 +61,14 @@ WORKDIR /app COPY package.json package.json COPY package-lock.json package-lock.json +COPY dev-entrypoint.sh dev-entrypoint.sh +RUN chmod +x dev-entrypoint.sh + RUN npm install COPY . . ENV HOST=0.0.0.0 +ENTRYPOINT ["/app/dev-entrypoint.sh"] CMD ["npm", "run", "dev:docker"] diff --git a/backend/Dockerfile.dev.fips b/backend/Dockerfile.dev.fips index db5107985..4d5b84260 100644 --- a/backend/Dockerfile.dev.fips +++ b/backend/Dockerfile.dev.fips @@ -50,9 +50,6 @@ RUN rm -fr ${SOFTHSM2_SOURCES} # Install pkcs11-tool RUN apt-get install -y opensc -RUN mkdir -p /etc/softhsm2/tokens && \ - softhsm2-util --init-token --slot 0 --label "auth-app" --pin 1234 --so-pin 0000 - WORKDIR /openssl-build RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \ && tar -xf openssl-3.1.2.tar.gz \ @@ -77,6 +74,9 @@ WORKDIR /app COPY package.json package.json COPY package-lock.json package-lock.json +COPY dev-entrypoint.sh dev-entrypoint.sh +RUN chmod +x dev-entrypoint.sh + RUN npm install COPY . . @@ -87,4 +87,5 @@ ENV OPENSSL_MODULES=/usr/local/lib/ossl-modules # ENV NODE_OPTIONS=--force-fips # Note(Daniel): We can't set this on the node options because it may break for existing folks using the infisical/infisical-fips image. Instead we call crypto.setFips(true) at runtime. ENV FIPS_ENABLED=true +ENTRYPOINT ["/app/dev-entrypoint.sh"] CMD ["npm", "run", "dev:docker"] diff --git a/backend/dev-entrypoint.sh b/backend/dev-entrypoint.sh new file mode 100755 index 000000000..9cb3c0a5e --- /dev/null +++ b/backend/dev-entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +update-ca-certificates + +# Initialize SoftHSM token if it doesn't exist +if [ ! -f /etc/softhsm2/tokens/auth-app.db ]; then + echo "Initializing SoftHSM token..." + mkdir -p /etc/softhsm2/tokens + softhsm2-util --init-token --slot 0 --label "auth-app" --pin 1234 --so-pin 0000 + echo "SoftHSM token initialized" +else + echo "SoftHSM token already exists, skipping initialization" +fi + + +exec "$@" \ No newline at end of file diff --git a/backend/e2e-test/routes/v1/secret-folder.spec.ts b/backend/e2e-test/routes/v1/secret-folder.spec.ts index 4d4bd7ab4..e954179a7 100644 --- a/backend/e2e-test/routes/v1/secret-folder.spec.ts +++ b/backend/e2e-test/routes/v1/secret-folder.spec.ts @@ -40,7 +40,7 @@ describe("Secret Folder Router", async () => { { name: "folder1", path: "/" }, // one in root { name: "folder1", path: "/level1/level2" }, // then create a deep one creating intermediate ones { name: "folder2", path: "/" }, - { name: "folder1", path: "/level1/level2" } // this should not create folder return same thing + { name: "folder3", path: "/level1/level2" } ])("Create folder $name in $path", async ({ name, path }) => { const createdFolder = await createFolder({ path, name }); // check for default environments @@ -57,7 +57,7 @@ describe("Secret Folder Router", async () => { { path: "/", expected: { - folders: [{ name: "folder1" }, { name: "level1" }, { name: "folder2" }], + folders: [{ name: "folder4" }, { name: "level2" }, { name: "folder5" }], length: 3 } }, @@ -162,4 +162,25 @@ describe("Secret Folder Router", async () => { expect(updatedFolderList).toHaveProperty("folders"); expect(updatedFolderList.folders.length).toEqual(0); }); + test("Creating a duplicate folder should return a 400 error", async () => { + const newFolder = await createFolder({ name: "folder-duplicate", path: "/level1/level2" }); + + const res = await testServer.inject({ + method: "POST", + url: `/api/v1/folders`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + name: "folder-duplicate", + path: "/level1/level2" + } + }); + expect(res.statusCode).toBe(400); + const payload = JSON.parse(res.payload); + expect(payload).toHaveProperty("error"); + await deleteFolder({ path: "/level1/level2", id: newFolder.id }); + }); }); diff --git a/backend/e2e-test/routes/v2/secret-folder.spec.ts b/backend/e2e-test/routes/v2/secret-folder.spec.ts index 9cdad32fe..92bcc92e5 100644 --- a/backend/e2e-test/routes/v2/secret-folder.spec.ts +++ b/backend/e2e-test/routes/v2/secret-folder.spec.ts @@ -41,7 +41,7 @@ describe("Secret Folder Router", async () => { { name: "folder1", path: "/" }, // one in root { name: "folder1", path: "/level1/level2" }, // then create a deep one creating intermediate ones { name: "folder2", path: "/" }, - { name: "folder1", path: "/level1/level2" } // this should not create folder return same thing + { name: "folder3", path: "/level1/level2" } ])("Create folder $name in $path", async ({ name, path }) => { const createdFolder = await createFolder({ path, name }); // check for default environments @@ -58,7 +58,7 @@ describe("Secret Folder Router", async () => { { path: "/", expected: { - folders: [{ name: "folder1" }, { name: "level1" }, { name: "folder2" }], + folders: [{ name: "folder4" }, { name: "level2" }, { name: "folder5" }], length: 3 } }, @@ -163,4 +163,26 @@ describe("Secret Folder Router", async () => { expect(updatedFolderList).toHaveProperty("folders"); expect(updatedFolderList.folders.length).toEqual(0); }); + + test("Creating a duplicate folder should return a 400 error", async () => { + const newFolder = await createFolder({ name: "folder-duplicate", path: "/level1/level2" }); + + const res = await testServer.inject({ + method: "POST", + url: `/api/v2/folders`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + projectId: seedData1.project.id, + environment: seedData1.environment.slug, + name: "folder-duplicate", + path: "/level1/level2" + } + }); + expect(res.statusCode).toBe(400); + const payload = JSON.parse(res.payload); + expect(payload).toHaveProperty("error"); + await deleteFolder({ path: "/level1/level2", id: newFolder.id }); + }); }); diff --git a/backend/e2e-test/routes/v2/service-token.spec.ts b/backend/e2e-test/routes/v2/service-token.spec.ts index 4f72987cb..d3a8b0f67 100644 --- a/backend/e2e-test/routes/v2/service-token.spec.ts +++ b/backend/e2e-test/routes/v2/service-token.spec.ts @@ -146,7 +146,8 @@ describe("Service token secret ops", async () => { let folderId = ""; beforeAll(async () => { initLogger(); - await initEnvConfig(testSuperAdminDAL, logger); + + await initEnvConfig(testHsmService, testKmsRootConfigDAL, testSuperAdminDAL, logger); serviceToken = await createServiceToken( [{ secretPath: "/**", environment: seedData1.environment.slug }], diff --git a/backend/e2e-test/routes/v3/secrets.spec.ts b/backend/e2e-test/routes/v3/secrets.spec.ts index 1e58c7f4a..db5953f29 100644 --- a/backend/e2e-test/routes/v3/secrets.spec.ts +++ b/backend/e2e-test/routes/v3/secrets.spec.ts @@ -158,7 +158,7 @@ describe("Secret V3 Router", async () => { let folderId = ""; beforeAll(async () => { initLogger(); - await initEnvConfig(testSuperAdminDAL, logger); + await initEnvConfig(testHsmService, testKmsRootConfigDAL, testSuperAdminDAL, logger); const projectKeyRes = await testServer.inject({ method: "GET", diff --git a/backend/e2e-test/vitest-environment-knex.ts b/backend/e2e-test/vitest-environment-knex.ts index 085b8fe30..0f84dbee2 100644 --- a/backend/e2e-test/vitest-environment-knex.ts +++ b/backend/e2e-test/vitest-environment-knex.ts @@ -6,7 +6,7 @@ import { crypto } from "@app/lib/crypto/cryptography"; import path from "path"; import { seedData1 } from "@app/db/seed-data"; -import { getDatabaseCredentials, initEnvConfig } from "@app/lib/config/env"; +import { getDatabaseCredentials, getHsmConfig, initEnvConfig } from "@app/lib/config/env"; import { initLogger } from "@app/lib/logger"; import { main } from "@app/server/app"; import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; @@ -20,6 +20,8 @@ import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; import { buildRedisFromConfig } from "@app/lib/config/redis"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { bootstrapCheck } from "@app/server/boot-strap-check"; +import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; dotenv.config({ path: path.join(__dirname, "../../.env.test"), debug: true }); export default { @@ -28,6 +30,7 @@ export default { async setup() { const logger = initLogger(); const databaseCredentials = getDatabaseCredentials(logger); + const hsmConfig = getHsmConfig(logger); const db = initDbConnection({ dbConnectionUri: databaseCredentials.dbConnectionUri, @@ -35,7 +38,19 @@ export default { }); const superAdminDAL = superAdminDALFactory(db); - const envCfg = await initEnvConfig(superAdminDAL, logger); + const kmsRootConfigDAL = kmsRootConfigDALFactory(db); + + const hsmModule = initializeHsmModule(hsmConfig); + hsmModule.initialize(); + + const hsmService = hsmServiceFactory({ + hsmModule: hsmModule.getModule(), + envConfig: hsmConfig + }); + + await hsmService.startService(); + + const envCfg = await initEnvConfig(hsmService, kmsRootConfigDAL, superAdminDAL, logger); const redis = buildRedisFromConfig(envCfg); await redis.flushdb("SYNC"); @@ -68,16 +83,14 @@ export default { await queue.initialize(); - const hsmModule = initializeHsmModule(envCfg); - hsmModule.initialize(); - const server = await main({ db, smtp, logger, queue, keyStore, - hsmModule: hsmModule.getModule(), + hsmService, + kmsRootConfigDAL, superAdminDAL, redis, envConfig: envCfg @@ -92,6 +105,10 @@ export default { // @ts-expect-error type globalThis.testSuperAdminDAL = superAdminDAL; // @ts-expect-error type + globalThis.testKmsRootConfigDAL = kmsRootConfigDAL; + // @ts-expect-error type + globalThis.testHsmService = hsmService; + // @ts-expect-error type globalThis.jwtAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.ACCESS_TOKEN, diff --git a/backend/src/@types/fastify-zod.d.ts b/backend/src/@types/fastify-zod.d.ts index f0240d1a0..91cd00605 100644 --- a/backend/src/@types/fastify-zod.d.ts +++ b/backend/src/@types/fastify-zod.d.ts @@ -1,7 +1,9 @@ import { FastifyInstance, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault } from "fastify"; +import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { CustomLogger } from "@app/lib/logger/logger"; import { ZodTypeProvider } from "@app/server/plugins/fastify-zod"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; declare global { @@ -16,5 +18,7 @@ declare global { // used only for testing const testServer: FastifyZodProvider; const testSuperAdminDAL: TSuperAdminDALFactory; + const testKmsRootConfigDAL: TKmsRootConfigDALFactory; + const testHsmService: THsmServiceFactory; const jwtAuthToken: string; } diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index e3e8733f0..273e6d982 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -48,6 +48,7 @@ import { TSshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh import { TSshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service"; import { TSshHostServiceFactory } from "@app/ee/services/ssh-host/ssh-host-service"; import { TSshHostGroupServiceFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-service"; +import { TSubOrgServiceFactory } from "@app/ee/services/sub-org/sub-org-service"; import { TTrustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-types"; import { TAuthMode } from "@app/server/plugins/auth/inject-identity"; import { TAdditionalPrivilegeServiceFactory } from "@app/services/additional-privilege/additional-privilege-service"; @@ -61,7 +62,11 @@ import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-se import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +import { TCertificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service"; +import { TCertificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service"; import { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; +import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; +import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; import { TCmekServiceFactory } from "@app/services/cmek/cmek-service"; import { TConvertorServiceFactory } from "@app/services/convertor/convertor-service"; import { TExternalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; @@ -178,6 +183,8 @@ declare module "fastify" { type: ActorType; id: string; orgId: string; + parentOrgId: string; + rootOrgId: string; }; rateLimits: RateLimitConfiguration; // passport data @@ -262,7 +269,10 @@ declare module "fastify" { auditLog: TAuditLogServiceFactory; auditLogStream: TAuditLogStreamServiceFactory; certificate: TCertificateServiceFactory; + certificateV3: TCertificateV3ServiceFactory; certificateTemplate: TCertificateTemplateServiceFactory; + certificateTemplateV2: TCertificateTemplateV2ServiceFactory; + certificateProfile: TCertificateProfileServiceFactory; sshCertificateAuthority: TSshCertificateAuthorityServiceFactory; sshCertificateTemplate: TSshCertificateTemplateServiceFactory; sshHost: TSshHostServiceFactory; @@ -270,6 +280,7 @@ declare module "fastify" { certificateAuthority: TCertificateAuthorityServiceFactory; certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; certificateEst: TCertificateEstServiceFactory; + certificateEstV3: TCertificateEstV3ServiceFactory; pkiCollection: TPkiCollectionServiceFactory; pkiSubscriber: TPkiSubscriberServiceFactory; pkiSync: TPkiSyncServiceFactory; @@ -327,6 +338,7 @@ declare module "fastify" { additionalPrivilege: TAdditionalPrivilegeServiceFactory; role: TRoleServiceFactory; convertor: TConvertorServiceFactory; + subOrganization: TSubOrgServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 619f7f92d..bbc27ebc1 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -266,12 +266,24 @@ import { TPkiAlerts, TPkiAlertsInsert, TPkiAlertsUpdate, + TPkiApiEnrollmentConfigs, + TPkiApiEnrollmentConfigsInsert, + TPkiApiEnrollmentConfigsUpdate, + TPkiCertificateProfiles, + TPkiCertificateProfilesInsert, + TPkiCertificateProfilesUpdate, + TPkiCertificateTemplatesV2, + TPkiCertificateTemplatesV2Insert, + TPkiCertificateTemplatesV2Update, TPkiCollectionItems, TPkiCollectionItemsInsert, TPkiCollectionItemsUpdate, TPkiCollections, TPkiCollectionsInsert, TPkiCollectionsUpdate, + TPkiEstEnrollmentConfigs, + TPkiEstEnrollmentConfigsInsert, + TPkiEstEnrollmentConfigsUpdate, TPkiSubscribers, TPkiSubscribersInsert, TPkiSubscribersUpdate, @@ -674,6 +686,26 @@ declare module "knex/types/tables" { TCertificateTemplatesInsert, TCertificateTemplatesUpdate >; + [TableName.PkiCertificateTemplateV2]: KnexOriginal.CompositeTableType< + TPkiCertificateTemplatesV2, + TPkiCertificateTemplatesV2Insert, + TPkiCertificateTemplatesV2Update + >; + [TableName.PkiCertificateProfile]: KnexOriginal.CompositeTableType< + TPkiCertificateProfiles, + TPkiCertificateProfilesInsert, + TPkiCertificateProfilesUpdate + >; + [TableName.PkiEstEnrollmentConfig]: KnexOriginal.CompositeTableType< + TPkiEstEnrollmentConfigs, + TPkiEstEnrollmentConfigsInsert, + TPkiEstEnrollmentConfigsUpdate + >; + [TableName.PkiApiEnrollmentConfig]: KnexOriginal.CompositeTableType< + TPkiApiEnrollmentConfigs, + TPkiApiEnrollmentConfigsInsert, + TPkiApiEnrollmentConfigsUpdate + >; [TableName.CertificateTemplateEstConfig]: KnexOriginal.CompositeTableType< TCertificateTemplateEstConfigs, TCertificateTemplateEstConfigsInsert, diff --git a/backend/src/db/migrations/20250210101840_webhook-to-kms.ts b/backend/src/db/migrations/20250210101840_webhook-to-kms.ts index 09a346abb..2fbf68128 100644 --- a/backend/src/db/migrations/20250210101840_webhook-to-kms.ts +++ b/backend/src/db/migrations/20250210101840_webhook-to-kms.ts @@ -3,13 +3,14 @@ import { Knex } from "knex"; import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto } from "@app/lib/crypto/cryptography"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; export async function up(knex: Knex): Promise { @@ -25,10 +26,12 @@ export async function up(knex: Knex): Promise { if (hasUrl) t.string("url").nullable().alter(); }); } - initLogger(); + + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20250210101841_dynamic-secret-root-to-kms.ts b/backend/src/db/migrations/20250210101841_dynamic-secret-root-to-kms.ts index 94e30a7b8..179cb9bd6 100644 --- a/backend/src/db/migrations/20250210101841_dynamic-secret-root-to-kms.ts +++ b/backend/src/db/migrations/20250210101841_dynamic-secret-root-to-kms.ts @@ -4,13 +4,14 @@ import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto } from "@app/lib/crypto/cryptography"; import { selectAllTableCols } from "@app/lib/knex"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; export async function up(knex: Knex): Promise { @@ -30,8 +31,12 @@ export async function up(knex: Knex): Promise { } initLogger(); + + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20250210101841_secret-rotation-to-kms.ts b/backend/src/db/migrations/20250210101841_secret-rotation-to-kms.ts index bbda48dac..aef429ab9 100644 --- a/backend/src/db/migrations/20250210101841_secret-rotation-to-kms.ts +++ b/backend/src/db/migrations/20250210101841_secret-rotation-to-kms.ts @@ -4,13 +4,14 @@ import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto } from "@app/lib/crypto/cryptography"; import { selectAllTableCols } from "@app/lib/knex"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; export async function up(knex: Knex): Promise { @@ -24,8 +25,11 @@ export async function up(knex: Knex): Promise { } initLogger(); + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20250210101842_identity-k8-auth-to-kms.ts b/backend/src/db/migrations/20250210101842_identity-k8-auth-to-kms.ts index a24bfdf0c..f3fa63028 100644 --- a/backend/src/db/migrations/20250210101842_identity-k8-auth-to-kms.ts +++ b/backend/src/db/migrations/20250210101842_identity-k8-auth-to-kms.ts @@ -4,13 +4,14 @@ import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto, SymmetricKeySize } from "@app/lib/crypto/cryptography"; import { selectAllTableCols } from "@app/lib/knex"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName, TOrgBots } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; const reencryptIdentityK8sAuth = async (knex: Knex) => { @@ -55,9 +56,11 @@ const reencryptIdentityK8sAuth = async (knex: Knex) => { } initLogger(); - const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); const orgEncryptionRingBuffer = diff --git a/backend/src/db/migrations/20250210101842_identity-oidc-auth-to-kms.ts b/backend/src/db/migrations/20250210101842_identity-oidc-auth-to-kms.ts index 25db615fa..f970043f0 100644 --- a/backend/src/db/migrations/20250210101842_identity-oidc-auth-to-kms.ts +++ b/backend/src/db/migrations/20250210101842_identity-oidc-auth-to-kms.ts @@ -4,13 +4,14 @@ import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto, SymmetricKeySize } from "@app/lib/crypto/cryptography"; import { selectAllTableCols } from "@app/lib/knex"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName, TOrgBots } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; const reencryptIdentityOidcAuth = async (knex: Knex) => { @@ -35,8 +36,11 @@ const reencryptIdentityOidcAuth = async (knex: Knex) => { } initLogger(); + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20250210101845_directory-config-to-kms.ts b/backend/src/db/migrations/20250210101845_directory-config-to-kms.ts index 783693da6..62b4e8556 100644 --- a/backend/src/db/migrations/20250210101845_directory-config-to-kms.ts +++ b/backend/src/db/migrations/20250210101845_directory-config-to-kms.ts @@ -4,16 +4,18 @@ import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto, SymmetricKeySize } from "@app/lib/crypto/cryptography"; import { selectAllTableCols } from "@app/lib/knex"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; -const reencryptSamlConfig = async (knex: Knex) => { +const reencryptSamlConfig = async (knex: Knex, kmsService: TKmsServiceFactory) => { const hasEncryptedEntrypointColumn = await knex.schema.hasColumn(TableName.SamlConfig, "encryptedSamlEntryPoint"); const hasEncryptedIssuerColumn = await knex.schema.hasColumn(TableName.SamlConfig, "encryptedSamlIssuer"); const hasEncryptedCertificateColumn = await knex.schema.hasColumn(TableName.SamlConfig, "encryptedSamlCertificate"); @@ -28,10 +30,6 @@ const reencryptSamlConfig = async (knex: Knex) => { } initLogger(); - const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); - const keyStore = inMemoryKeyStore(); - const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); const orgEncryptionRingBuffer = createCircularCache>>(25); @@ -159,7 +157,7 @@ const reencryptSamlConfig = async (knex: Knex) => { } }; -const reencryptLdapConfig = async (knex: Knex) => { +const reencryptLdapConfig = async (knex: Knex, kmsService: TKmsServiceFactory) => { const hasEncryptedLdapBindDNColum = await knex.schema.hasColumn(TableName.LdapConfig, "encryptedLdapBindDN"); const hasEncryptedLdapBindPassColumn = await knex.schema.hasColumn(TableName.LdapConfig, "encryptedLdapBindPass"); const hasEncryptedCertificateColumn = await knex.schema.hasColumn(TableName.LdapConfig, "encryptedLdapCaCertificate"); @@ -194,10 +192,6 @@ const reencryptLdapConfig = async (knex: Knex) => { } initLogger(); - const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); - const keyStore = inMemoryKeyStore(); - const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); const orgEncryptionRingBuffer = createCircularCache>>(25); @@ -323,7 +317,7 @@ const reencryptLdapConfig = async (knex: Knex) => { } }; -const reencryptOidcConfig = async (knex: Knex) => { +const reencryptOidcConfig = async (knex: Knex, kmsService: TKmsServiceFactory) => { const hasEncryptedOidcClientIdColumn = await knex.schema.hasColumn(TableName.OidcConfig, "encryptedOidcClientId"); const hasEncryptedOidcClientSecretColumn = await knex.schema.hasColumn( TableName.OidcConfig, @@ -354,10 +348,6 @@ const reencryptOidcConfig = async (knex: Knex) => { } initLogger(); - const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); - const keyStore = inMemoryKeyStore(); - const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); const orgEncryptionRingBuffer = createCircularCache>>(25); @@ -462,9 +452,18 @@ const reencryptOidcConfig = async (knex: Knex) => { }; export async function up(knex: Knex): Promise { - await reencryptSamlConfig(knex); - await reencryptLdapConfig(knex); - await reencryptOidcConfig(knex); + initLogger(); + + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); + const keyStore = inMemoryKeyStore(); + const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); + + await reencryptSamlConfig(knex, kmsService); + await reencryptLdapConfig(knex, kmsService); + await reencryptOidcConfig(knex, kmsService); } const dropSamlConfigColumns = async (knex: Knex) => { diff --git a/backend/src/db/migrations/20250513081738_remove-gateway-project-link.ts b/backend/src/db/migrations/20250513081738_remove-gateway-project-link.ts index a0985471f..dd9ff2d6a 100644 --- a/backend/src/db/migrations/20250513081738_remove-gateway-project-link.ts +++ b/backend/src/db/migrations/20250513081738_remove-gateway-project-link.ts @@ -3,12 +3,13 @@ import { Knex } from "knex"; import { inMemoryKeyStore } from "@app/keystore/memory"; import { selectAllTableCols } from "@app/lib/knex"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; // Note(daniel): We aren't dropping tables or columns in this migrations so we can easily rollback if needed. // In the future we need to drop the projectGatewayId on the dynamic secrets table, and drop the project_gateways table entirely. @@ -40,8 +41,10 @@ export async function up(knex: Knex): Promise { ); initLogger(); + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20250711005900_github-app-connection-to-environments.ts b/backend/src/db/migrations/20250711005900_github-app-connection-to-environments.ts index 548d6207a..f2bc0a96a 100644 --- a/backend/src/db/migrations/20250711005900_github-app-connection-to-environments.ts +++ b/backend/src/db/migrations/20250711005900_github-app-connection-to-environments.ts @@ -2,19 +2,23 @@ import { Knex } from "knex"; import { inMemoryKeyStore } from "@app/keystore/memory"; import { selectAllTableCols } from "@app/lib/knex"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; export async function up(knex: Knex) { const existingSuperAdminsWithGithubConnection = await knex(TableName.SuperAdmin) .select(selectAllTableCols(TableName.SuperAdmin)) .whereNotNull(`${TableName.SuperAdmin}.encryptedGitHubAppConnectionClientId`); + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts b/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts index a70dcb8b9..82fa4a039 100644 --- a/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts +++ b/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts @@ -2,13 +2,14 @@ import { Knex } from "knex"; import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto } from "@app/lib/crypto/cryptography"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; export async function up(knex: Knex): Promise { @@ -25,8 +26,10 @@ export async function up(knex: Knex): Promise { }); if (!hasEncryptedCredentials) { + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); @@ -131,8 +134,11 @@ export async function down(knex: Knex): Promise { const hasEncryptedCredentials = await knex.schema.hasColumn(TableName.AuditLogStream, "encryptedCredentials"); if (hasEncryptedCredentials) { + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20251007133321_pki-v3-tables.ts b/backend/src/db/migrations/20251007133321_pki-v3-tables.ts new file mode 100644 index 000000000..713953f14 --- /dev/null +++ b/backend/src/db/migrations/20251007133321_pki-v3-tables.ts @@ -0,0 +1,117 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.PkiCertificateTemplateV2))) { + await knex.schema.createTable(TableName.PkiCertificateTemplateV2, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project); + + t.string("name").notNullable(); + t.string("description"); + + t.jsonb("subject"); + t.jsonb("sans"); + t.jsonb("keyUsages"); + t.jsonb("extendedKeyUsages"); + t.jsonb("algorithms"); + t.jsonb("validity"); + + t.timestamps(true, true, true); + + t.unique(["name", "projectId"]); + }); + + await createOnUpdateTrigger(knex, TableName.PkiCertificateTemplateV2); + } + + if (!(await knex.schema.hasTable(TableName.PkiEstEnrollmentConfig))) { + await knex.schema.createTable(TableName.PkiEstEnrollmentConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.boolean("disableBootstrapCaValidation").defaultTo(false); + t.text("hashedPassphrase").notNullable(); + t.binary("encryptedCaChain"); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiEstEnrollmentConfig); + } + + if (!(await knex.schema.hasTable(TableName.PkiApiEnrollmentConfig))) { + await knex.schema.createTable(TableName.PkiApiEnrollmentConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.boolean("autoRenew").defaultTo(false); + t.integer("autoRenewDays"); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiApiEnrollmentConfig); + } + + if (!(await knex.schema.hasTable(TableName.PkiCertificateProfile))) { + await knex.schema.createTable(TableName.PkiCertificateProfile, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + + t.uuid("caId").notNullable(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority); + + t.uuid("certificateTemplateId").notNullable(); + t.foreign("certificateTemplateId").references("id").inTable(TableName.PkiCertificateTemplateV2); + + t.string("slug").notNullable(); + t.string("description"); + t.string("enrollmentType").notNullable().checkIn(["api", "est"]); + + t.uuid("estConfigId"); + t.foreign("estConfigId").references("id").inTable(TableName.PkiEstEnrollmentConfig).onDelete("SET NULL"); + + t.uuid("apiConfigId"); + t.foreign("apiConfigId").references("id").inTable(TableName.PkiApiEnrollmentConfig).onDelete("SET NULL"); + + t.timestamps(true, true, true); + + t.unique(["slug", "projectId"]); + }); + + await createOnUpdateTrigger(knex, TableName.PkiCertificateProfile); + } + + if (!(await knex.schema.hasColumn(TableName.Certificate, "profileId"))) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.uuid("profileId"); + t.foreign("profileId").references("id").inTable(TableName.PkiCertificateProfile).onDelete("SET NULL"); + t.index("profileId"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Certificate, "profileId")) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.dropForeign(["profileId"]); + t.dropIndex("profileId"); + t.dropColumn("profileId"); + }); + } + + await knex.schema.dropTableIfExists(TableName.PkiCertificateProfile); + await dropOnUpdateTrigger(knex, TableName.PkiCertificateProfile); + + await knex.schema.dropTableIfExists(TableName.PkiApiEnrollmentConfig); + await dropOnUpdateTrigger(knex, TableName.PkiApiEnrollmentConfig); + + await knex.schema.dropTableIfExists(TableName.PkiEstEnrollmentConfig); + await dropOnUpdateTrigger(knex, TableName.PkiEstEnrollmentConfig); + + await knex.schema.dropTableIfExists(TableName.PkiCertificateTemplateV2); + await dropOnUpdateTrigger(knex, TableName.PkiCertificateTemplateV2); +} diff --git a/backend/src/db/migrations/20251015042917_pam-account-rotation.ts b/backend/src/db/migrations/20251015042917_pam-account-rotation.ts new file mode 100644 index 000000000..b83dae0ae --- /dev/null +++ b/backend/src/db/migrations/20251015042917_pam-account-rotation.ts @@ -0,0 +1,49 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.PamAccount, "rotationEnabled"))) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.boolean("rotationEnabled").notNullable().defaultTo(false); + }); + } + if (!(await knex.schema.hasColumn(TableName.PamAccount, "rotationIntervalSeconds"))) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.integer("rotationIntervalSeconds").nullable(); + }); + } + if (!(await knex.schema.hasColumn(TableName.PamAccount, "lastRotatedAt"))) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.timestamp("lastRotatedAt").nullable(); + }); + } + if (!(await knex.schema.hasColumn(TableName.PamResource, "encryptedRotationAccountCredentials"))) { + await knex.schema.alterTable(TableName.PamResource, (t) => { + t.binary("encryptedRotationAccountCredentials").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.PamResource, "encryptedRotationAccountCredentials")) { + await knex.schema.alterTable(TableName.PamResource, (t) => { + t.dropColumn("encryptedRotationAccountCredentials"); + }); + } + if (await knex.schema.hasColumn(TableName.PamAccount, "rotationEnabled")) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.dropColumn("rotationEnabled"); + }); + } + if (await knex.schema.hasColumn(TableName.PamAccount, "rotationIntervalSeconds")) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.dropColumn("rotationIntervalSeconds"); + }); + } + if (await knex.schema.hasColumn(TableName.PamAccount, "lastRotatedAt")) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.dropColumn("lastRotatedAt"); + }); + } +} diff --git a/backend/src/db/migrations/20251018061215_sub-org.ts b/backend/src/db/migrations/20251018061215_sub-org.ts new file mode 100644 index 000000000..6378fa66a --- /dev/null +++ b/backend/src/db/migrations/20251018061215_sub-org.ts @@ -0,0 +1,68 @@ +import { Knex } from "knex"; + +import { dropConstraintIfExists } from "@app/db/migrations/utils/dropConstraintIfExists"; + +import { AccessScope, TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasParentOrgId = await knex.schema.hasColumn(TableName.Organization, "parentOrgId"); + if (!hasParentOrgId) { + await knex.schema.alterTable(TableName.Organization, async (t) => { + // the one just above the chain + t.uuid("parentOrgId"); + t.foreign("parentOrgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + // this would root organization containing various informations like billing etc + t.uuid("rootOrgId"); + t.foreign("rootOrgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + + await dropConstraintIfExists(TableName.Organization, "organizations_slug_unique", knex); + t.unique(["rootOrgId", "parentOrgId", "slug"]); + }); + + // had to switch to raw for null not distinct + } + + const hasIdentityOrgCol = await knex.schema.hasColumn(TableName.Identity, "orgId"); + if (!hasIdentityOrgCol) { + await knex.schema.alterTable(TableName.Identity, (t) => { + t.uuid("orgId"); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + }); + + await knex.raw( + ` + UPDATE ?? AS identity + SET "orgId" = membership."scopeOrgId" + FROM ?? AS membership + WHERE + membership."actorIdentityId" = identity."id" + AND membership."scope" = ? +`, + [TableName.Identity, TableName.Membership, AccessScope.Organization] + ); + + await knex.raw(`DELETE FROM ?? WHERE "orgId" IS NULL`, [TableName.Identity]); + + await knex.schema.alterTable(TableName.Identity, (t) => { + t.uuid("orgId").notNullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasParentOrgId = await knex.schema.hasColumn(TableName.Organization, "parentOrgId"); + const hasRootOrgId = await knex.schema.hasColumn(TableName.Organization, "rootOrgId"); + if (hasParentOrgId || hasRootOrgId) { + await knex.schema.alterTable(TableName.Organization, (t) => { + if (hasParentOrgId) t.dropColumn("parentOrgId"); + if (hasRootOrgId) t.dropColumn("rootOrgId"); + }); + } + + const hasIdentityOrgCol = await knex.schema.hasColumn(TableName.Identity, "orgId"); + if (hasIdentityOrgCol) { + await knex.schema.alterTable(TableName.Identity, (t) => { + t.dropColumn("orgId"); + }); + } +} diff --git a/backend/src/db/migrations/20251023121055_fix-missing-group-memberships.ts b/backend/src/db/migrations/20251023121055_fix-missing-group-memberships.ts new file mode 100644 index 000000000..56fe82b2c --- /dev/null +++ b/backend/src/db/migrations/20251023121055_fix-missing-group-memberships.ts @@ -0,0 +1,60 @@ +import { Knex } from "knex"; + +import { AccessScope, TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasGroupsTable = await knex.schema.hasTable(TableName.Groups); + const hasMembershipTable = await knex.schema.hasTable(TableName.Membership); + const hasMembershipRoleTable = await knex.schema.hasTable(TableName.MembershipRole); + + if (!hasGroupsTable || !hasMembershipTable || !hasMembershipRoleTable) { + return; + } + + const groupsWithoutMembership = await knex + .select( + `${TableName.Groups}.id`, + `${TableName.Groups}.orgId`, + `${TableName.Groups}.role`, + `${TableName.Groups}.roleId` + ) + .from(TableName.Groups) + .leftJoin(TableName.Membership, `${TableName.Groups}.id`, `${TableName.Membership}.actorGroupId`) + .whereNull(`${TableName.Membership}.actorGroupId`); + + if (groupsWithoutMembership.length > 0) { + const membershipInserts = groupsWithoutMembership.map((group) => ({ + actorGroupId: group.id, + scope: AccessScope.Organization, + scopeOrgId: group.orgId, + isActive: true + })); + + const insertedMemberships = await knex(TableName.Membership).insert(membershipInserts).returning("*"); + + const membershipRoleInserts = insertedMemberships.map((membership, index) => { + const group = groupsWithoutMembership[index]; + return { + membershipId: membership.id, + role: group.role, + customRoleId: group.roleId + }; + }); + + await knex(TableName.MembershipRole).insert(membershipRoleInserts); + } + + await knex.schema.alterTable(TableName.Membership, (t) => { + t.check( + `("actorUserId" IS NOT NULL OR "actorIdentityId" IS NOT NULL OR "actorGroupId" IS NOT NULL)`, + undefined, + "at_least_one_actor" + ); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.Membership, (t) => { + t.dropChecks("at_least_one_actor"); + }); +} diff --git a/backend/src/db/migrations/utils/env-config.ts b/backend/src/db/migrations/utils/env-config.ts index de32f4db9..3a08f0123 100644 --- a/backend/src/db/migrations/utils/env-config.ts +++ b/backend/src/db/migrations/utils/env-config.ts @@ -1,7 +1,10 @@ import { z } from "zod"; +import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { crypto } from "@app/lib/crypto/cryptography"; +import { removeTrailingSlash } from "@app/lib/fn"; import { zpStr } from "@app/lib/zod"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; const envSchema = z @@ -22,13 +25,17 @@ const envSchema = z HSM_LIB_PATH: zpStr(z.string().optional()), HSM_PIN: zpStr(z.string().optional()), HSM_KEY_LABEL: zpStr(z.string().optional()), - HSM_SLOT: z.coerce.number().optional().default(0) + HSM_SLOT: z.coerce.number().optional().default(0), + + LICENSE_SERVER_URL: zpStr(z.string().optional().default("https://portal.infisical.com")), + LICENSE_SERVER_KEY: zpStr(z.string().optional()), + LICENSE_KEY: zpStr(z.string().optional()), + LICENSE_KEY_OFFLINE: zpStr(z.string().optional()), + INTERNAL_REGION: zpStr(z.enum(["us", "eu"]).optional()), + + SITE_URL: zpStr(z.string().transform((val) => (val ? removeTrailingSlash(val) : val))).optional() }) // To ensure that basic encryption is always possible. - .refine( - (data) => Boolean(data.ENCRYPTION_KEY) || Boolean(data.ROOT_ENCRYPTION_KEY), - "Either ENCRYPTION_KEY or ROOT_ENCRYPTION_KEY must be defined." - ) .transform((data) => ({ ...data, isHsmConfigured: @@ -37,7 +44,27 @@ const envSchema = z export type TMigrationEnvConfig = z.infer; -export const getMigrationEnvConfig = async (superAdminDAL: TSuperAdminDALFactory) => { +export const getMigrationHsmConfig = () => { + const parsedEnv = envSchema.safeParse(process.env); + if (!parsedEnv.success) { + console.error("Invalid environment variables. Check the error below"); + console.error(parsedEnv.error.issues); + process.exit(-1); + } + return { + isHsmConfigured: parsedEnv.data.isHsmConfigured, + HSM_PIN: parsedEnv.data.HSM_PIN, + HSM_SLOT: parsedEnv.data.HSM_SLOT, + HSM_LIB_PATH: parsedEnv.data.HSM_LIB_PATH, + HSM_KEY_LABEL: parsedEnv.data.HSM_KEY_LABEL + }; +}; + +export const getMigrationEnvConfig = async ( + superAdminDAL: TSuperAdminDALFactory, + hsmService: THsmServiceFactory, + kmsRootConfigDAL: TKmsRootConfigDALFactory +) => { const parsedEnv = envSchema.safeParse(process.env); if (!parsedEnv.success) { // eslint-disable-next-line no-console @@ -53,7 +80,7 @@ export const getMigrationEnvConfig = async (superAdminDAL: TSuperAdminDALFactory let envCfg = Object.freeze(parsedEnv.data); - const fipsEnabled = await crypto.initialize(superAdminDAL, envCfg); + const fipsEnabled = await crypto.initialize(superAdminDAL, hsmService, kmsRootConfigDAL, envCfg); // Fix for 128-bit entropy encryption key expansion issue: // In FIPS it is not ideal to expand a 128-bit key into 256-bit. We solved this issue in the past by creating the ROOT_ENCRYPTION_KEY. diff --git a/backend/src/db/migrations/utils/services.ts b/backend/src/db/migrations/utils/services.ts index 0e071e6fe..cd3e5ac23 100644 --- a/backend/src/db/migrations/utils/services.ts +++ b/backend/src/db/migrations/utils/services.ts @@ -1,28 +1,23 @@ import { Knex } from "knex"; -import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; +import { initializeHsmModule, isHsmActiveAndEnabled } from "@app/ee/services/hsm/hsm-fns"; import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; +import { licenseDALFactory } from "@app/ee/services/license/license-dal"; +import { licenseServiceFactory } from "@app/ee/services/license/license-service"; +import { permissionDALFactory } from "@app/ee/services/permission/permission-dal"; +import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TKeyStoreFactory } from "@app/keystore/keystore"; -import { folderCheckpointDALFactory } from "@app/services/folder-checkpoint/folder-checkpoint-dal"; -import { folderCheckpointResourcesDALFactory } from "@app/services/folder-checkpoint-resources/folder-checkpoint-resources-dal"; -import { folderCommitDALFactory } from "@app/services/folder-commit/folder-commit-dal"; -import { folderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service"; -import { folderCommitChangesDALFactory } from "@app/services/folder-commit-changes/folder-commit-changes-dal"; -import { folderTreeCheckpointDALFactory } from "@app/services/folder-tree-checkpoint/folder-tree-checkpoint-dal"; -import { folderTreeCheckpointResourcesDALFactory } from "@app/services/folder-tree-checkpoint-resources/folder-tree-checkpoint-resources-dal"; +import { BadRequestError } from "@app/lib/errors"; import { identityDALFactory } from "@app/services/identity/identity-dal"; import { internalKmsDALFactory } from "@app/services/kms/internal-kms-dal"; import { kmskeyDALFactory } from "@app/services/kms/kms-key-dal"; import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { kmsServiceFactory } from "@app/services/kms/kms-service"; +import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types"; import { orgDALFactory } from "@app/services/org/org-dal"; import { projectDALFactory } from "@app/services/project/project-dal"; -import { resourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal"; -import { secretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; -import { secretFolderVersionDALFactory } from "@app/services/secret-folder/secret-folder-version-dal"; -import { secretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; -import { secretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal"; -import { secretVersionV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-version-dal"; +import { roleDALFactory } from "@app/services/role/role-dal"; +import { serviceTokenDALFactory } from "@app/services/service-token/service-token-dal"; import { userDALFactory } from "@app/services/user/user-dal"; import { TMigrationEnvConfig } from "./env-config"; @@ -33,8 +28,11 @@ type TDependencies = { keyStore: TKeyStoreFactory; }; -export const getMigrationEncryptionServices = async ({ envConfig, db, keyStore }: TDependencies) => { - // eslint-disable-next-line no-param-reassign +type THsmServiceDependencies = { + envConfig: Pick; +}; + +export const getMigrationHsmService = async ({ envConfig }: THsmServiceDependencies) => { const hsmModule = initializeHsmModule(envConfig); hsmModule.initialize(); @@ -43,67 +41,72 @@ export const getMigrationEncryptionServices = async ({ envConfig, db, keyStore } envConfig }); - const orgDAL = orgDALFactory(db); - const kmsRootConfigDAL = kmsRootConfigDALFactory(db); - const kmsDAL = kmskeyDALFactory(db); - const internalKmsDAL = internalKmsDALFactory(db); - const projectDAL = projectDALFactory(db); - - const kmsService = kmsServiceFactory({ - kmsRootConfigDAL, - keyStore, - kmsDAL, - internalKmsDAL, - orgDAL, - projectDAL, - hsmService, - envConfig - }); - await hsmService.startService(); - await kmsService.startService(); - return { kmsService }; + return { hsmService }; }; -export const getMigrationPITServices = async ({ - db, - keyStore, - envConfig -}: { - db: Knex; - keyStore: TKeyStoreFactory; - envConfig: TMigrationEnvConfig; -}) => { +export const getMigrationEncryptionServices = async ({ envConfig, db, keyStore }: TDependencies) => { + // ----- DAL dependencies ----- + const orgDAL = orgDALFactory(db); + const licenseDAL = licenseDALFactory(db); + const permissionDAL = permissionDALFactory(db); const projectDAL = projectDALFactory(db); - const folderCommitDAL = folderCommitDALFactory(db); - const folderCommitChangesDAL = folderCommitChangesDALFactory(db); - const folderCheckpointDAL = folderCheckpointDALFactory(db); - const folderTreeCheckpointDAL = folderTreeCheckpointDALFactory(db); + const roleDAL = roleDALFactory(db); const userDAL = userDALFactory(db); const identityDAL = identityDALFactory(db); - const folderDAL = secretFolderDALFactory(db); - const folderVersionDAL = secretFolderVersionDALFactory(db); - const secretVersionV2BridgeDAL = secretVersionV2BridgeDALFactory(db); - const folderCheckpointResourcesDAL = folderCheckpointResourcesDALFactory(db); - const secretV2BridgeDAL = secretV2BridgeDALFactory({ db, keyStore }); - const folderTreeCheckpointResourcesDAL = folderTreeCheckpointResourcesDALFactory(db); - const secretTagDAL = secretTagDALFactory(db); - - const orgDAL = orgDALFactory(db); + const serviceTokenDAL = serviceTokenDALFactory(db); const kmsRootConfigDAL = kmsRootConfigDALFactory(db); const kmsDAL = kmskeyDALFactory(db); const internalKmsDAL = internalKmsDALFactory(db); - const resourceMetadataDAL = resourceMetadataDALFactory(db); - const hsmModule = initializeHsmModule(envConfig); - hsmModule.initialize(); + // ----- Service dependencies ----- + const permissionService = permissionServiceFactory({ + permissionDAL, + serviceTokenDAL, + projectDAL, + keyStore, + roleDAL, + userDAL, + identityDAL + }); - const hsmService = hsmServiceFactory({ - hsmModule: hsmModule.getModule(), + const licenseService = licenseServiceFactory({ + permissionService, + orgDAL, + licenseDAL, + keyStore, + projectDAL, envConfig }); + // ----- HSM startup ----- + + const { hsmService } = await getMigrationHsmService({ envConfig }); + + const hsmStatus = await isHsmActiveAndEnabled({ + hsmService, + kmsRootConfigDAL, + licenseService + }); + + // if the encryption strategy is software - user needs to provide an encryption key + // if the encryption strategy is null AND the hsm is not configured - user needs to provide an encryption key + const needsEncryptionKey = + hsmStatus.rootKmsConfigEncryptionStrategy === RootKeyEncryptionStrategy.Software || + (hsmStatus.rootKmsConfigEncryptionStrategy === null && !hsmStatus.isHsmConfigured); + + if (needsEncryptionKey) { + if (!envConfig.ROOT_ENCRYPTION_KEY && !envConfig.ENCRYPTION_KEY) { + throw new BadRequestError({ + message: + "Root KMS encryption strategy is set to software. Please set the ENCRYPTION_KEY environment variable and restart your deployment.\nYou can enable HSM encryption in the Server Console." + }); + } + } + + // ----- KMS startup ----- + const kmsService = kmsServiceFactory({ kmsRootConfigDAL, keyStore, @@ -115,27 +118,7 @@ export const getMigrationPITServices = async ({ envConfig }); - await hsmService.startService(); - await kmsService.startService(); + await kmsService.startService(hsmStatus); - const folderCommitService = folderCommitServiceFactory({ - folderCommitDAL, - folderCommitChangesDAL, - folderCheckpointDAL, - folderTreeCheckpointDAL, - userDAL, - identityDAL, - folderDAL, - folderVersionDAL, - secretVersionV2BridgeDAL, - projectDAL, - folderCheckpointResourcesDAL, - secretV2BridgeDAL, - folderTreeCheckpointResourcesDAL, - kmsService, - secretTagDAL, - resourceMetadataDAL - }); - - return { folderCommitService }; + return { kmsService, hsmService }; }; diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index 6bedf01ad..63122f662 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -26,7 +26,8 @@ export const CertificatesSchema = z.object({ keyUsages: z.string().array().nullable().optional(), extendedKeyUsages: z.string().array().nullable().optional(), projectId: z.string(), - pkiSubscriberId: z.string().uuid().nullable().optional() + pkiSubscriberId: z.string().uuid().nullable().optional(), + profileId: z.string().uuid().nullable().optional() }); export type TCertificates = z.infer; diff --git a/backend/src/db/schemas/identities.ts b/backend/src/db/schemas/identities.ts index a592e2480..06c37ff22 100644 --- a/backend/src/db/schemas/identities.ts +++ b/backend/src/db/schemas/identities.ts @@ -13,7 +13,8 @@ export const IdentitiesSchema = z.object({ authMethod: z.string().nullable().optional(), createdAt: z.date(), updatedAt: z.date(), - hasDeleteProtection: z.boolean().default(false) + hasDeleteProtection: z.boolean().default(false), + orgId: z.string().uuid() }); export type TIdentities = z.infer; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 6c718d097..4f0f221ff 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -92,8 +92,12 @@ export * from "./pam-folders"; export * from "./pam-resources"; export * from "./pam-sessions"; export * from "./pki-alerts"; +export * from "./pki-api-enrollment-configs"; +export * from "./pki-certificate-profiles"; +export * from "./pki-certificate-templates-v2"; export * from "./pki-collection-items"; export * from "./pki-collections"; +export * from "./pki-est-enrollment-configs"; export * from "./pki-subscribers"; export * from "./pki-syncs"; export * from "./project-bots"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index f7291a70f..86bc929b8 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -23,6 +23,10 @@ export enum TableName { CertificateBody = "certificate_bodies", CertificateSecret = "certificate_secrets", CertificateTemplate = "certificate_templates", + PkiCertificateTemplateV2 = "pki_certificate_templates_v2", + PkiCertificateProfile = "pki_certificate_profiles", + PkiEstEnrollmentConfig = "pki_est_enrollment_configs", + PkiApiEnrollmentConfig = "pki_api_enrollment_configs", PkiSubscriber = "pki_subscribers", PkiAlert = "pki_alerts", PkiCollection = "pki_collections", @@ -312,6 +316,12 @@ export enum ActionProjectType { Any = "any" } +export enum OrganizationActionScope { + ChildOrganization = "child-organization-only", + ParentOrganization = "parent-organization-only", + Any = "any" +} + export enum TemporaryPermissionMode { Relative = "relative" } diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index afc9e2b73..a1c01151f 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -38,7 +38,9 @@ export const OrganizationsSchema = z.object({ maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(), maxSharedSecretViewLimit: z.number().nullable().optional(), googleSsoAuthEnforced: z.boolean().default(false), - googleSsoAuthLastUsed: z.date().nullable().optional() + googleSsoAuthLastUsed: z.date().nullable().optional(), + parentOrgId: z.string().uuid().nullable().optional(), + rootOrgId: z.string().uuid().nullable().optional() }); export type TOrganizations = z.infer; diff --git a/backend/src/db/schemas/pam-accounts.ts b/backend/src/db/schemas/pam-accounts.ts index 5a9a45617..7e78e0874 100644 --- a/backend/src/db/schemas/pam-accounts.ts +++ b/backend/src/db/schemas/pam-accounts.ts @@ -18,7 +18,10 @@ export const PamAccountsSchema = z.object({ description: z.string().nullable().optional(), encryptedCredentials: zodBuffer, createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + rotationEnabled: z.boolean().default(false), + rotationIntervalSeconds: z.number().nullable().optional(), + lastRotatedAt: z.date().nullable().optional() }); export type TPamAccounts = z.infer; diff --git a/backend/src/db/schemas/pam-resources.ts b/backend/src/db/schemas/pam-resources.ts index d34017d0f..325f6eddc 100644 --- a/backend/src/db/schemas/pam-resources.ts +++ b/backend/src/db/schemas/pam-resources.ts @@ -17,7 +17,8 @@ export const PamResourcesSchema = z.object({ resourceType: z.string(), encryptedConnectionDetails: zodBuffer, createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + encryptedRotationAccountCredentials: zodBuffer.nullable().optional() }); export type TPamResources = z.infer; diff --git a/backend/src/db/schemas/pki-api-enrollment-configs.ts b/backend/src/db/schemas/pki-api-enrollment-configs.ts new file mode 100644 index 000000000..710b0dee4 --- /dev/null +++ b/backend/src/db/schemas/pki-api-enrollment-configs.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiApiEnrollmentConfigsSchema = z.object({ + id: z.string().uuid(), + autoRenew: z.boolean().default(false).nullable().optional(), + autoRenewDays: z.number().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiApiEnrollmentConfigs = z.infer; +export type TPkiApiEnrollmentConfigsInsert = Omit, TImmutableDBKeys>; +export type TPkiApiEnrollmentConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/pki-certificate-profiles.ts b/backend/src/db/schemas/pki-certificate-profiles.ts new file mode 100644 index 000000000..368770c3e --- /dev/null +++ b/backend/src/db/schemas/pki-certificate-profiles.ts @@ -0,0 +1,28 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiCertificateProfilesSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + caId: z.string().uuid(), + certificateTemplateId: z.string().uuid(), + slug: z.string(), + description: z.string().nullable().optional(), + enrollmentType: z.string(), + estConfigId: z.string().uuid().nullable().optional(), + apiConfigId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiCertificateProfiles = z.infer; +export type TPkiCertificateProfilesInsert = Omit, TImmutableDBKeys>; +export type TPkiCertificateProfilesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/pki-certificate-templates-v2.ts b/backend/src/db/schemas/pki-certificate-templates-v2.ts new file mode 100644 index 000000000..de4603887 --- /dev/null +++ b/backend/src/db/schemas/pki-certificate-templates-v2.ts @@ -0,0 +1,29 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiCertificateTemplatesV2Schema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + name: z.string(), + description: z.string().nullable().optional(), + subject: z.unknown().nullable().optional(), + sans: z.unknown().nullable().optional(), + keyUsages: z.unknown().nullable().optional(), + extendedKeyUsages: z.unknown().nullable().optional(), + algorithms: z.unknown().nullable().optional(), + validity: z.unknown().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiCertificateTemplatesV2 = z.infer; +export type TPkiCertificateTemplatesV2Insert = Omit, TImmutableDBKeys>; +export type TPkiCertificateTemplatesV2Update = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/pki-est-enrollment-configs.ts b/backend/src/db/schemas/pki-est-enrollment-configs.ts new file mode 100644 index 000000000..4a3b16eeb --- /dev/null +++ b/backend/src/db/schemas/pki-est-enrollment-configs.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiEstEnrollmentConfigsSchema = z.object({ + id: z.string().uuid(), + disableBootstrapCaValidation: z.boolean().default(false).nullable().optional(), + hashedPassphrase: z.string(), + encryptedCaChain: zodBuffer.nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiEstEnrollmentConfigs = z.infer; +export type TPkiEstEnrollmentConfigsInsert = Omit, TImmutableDBKeys>; +export type TPkiEstEnrollmentConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/seeds/1-user.ts b/backend/src/db/seeds/1-user.ts index 43ce4dadf..9f42ef12b 100644 --- a/backend/src/db/seeds/1-user.ts +++ b/backend/src/db/seeds/1-user.ts @@ -1,7 +1,10 @@ import { Knex } from "knex"; -import { initEnvConfig } from "@app/lib/config/env"; +import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; +import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; +import { getHsmConfig, initEnvConfig } from "@app/lib/config/env"; import { initLogger, logger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { AuthMethod } from "../../services/auth/auth-type"; @@ -17,7 +20,21 @@ export async function seed(knex: Knex): Promise { initLogger(); const superAdminDAL = superAdminDALFactory(knex); - await initEnvConfig(superAdminDAL, logger); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + + const hsmConfig = getHsmConfig(logger); + + const hsmModule = initializeHsmModule(hsmConfig); + hsmModule.initialize(); + + const hsmService = hsmServiceFactory({ + hsmModule: hsmModule.getModule(), + envConfig: hsmConfig + }); + + await hsmService.startService(); + + await initEnvConfig(hsmService, kmsRootConfigDAL, superAdminDAL, logger); await knex(TableName.SuperAdmin).insert([ // eslint-disable-next-line diff --git a/backend/src/db/seeds/3-project.ts b/backend/src/db/seeds/3-project.ts index d0294022f..99083ab94 100644 --- a/backend/src/db/seeds/3-project.ts +++ b/backend/src/db/seeds/3-project.ts @@ -1,11 +1,14 @@ import { Knex } from "knex"; -import { initEnvConfig } from "@app/lib/config/env"; +import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; +import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; +import { getHsmConfig, initEnvConfig } from "@app/lib/config/env"; import { crypto, SymmetricKeySize } from "@app/lib/crypto/cryptography"; import { generateUserSrpKeys } from "@app/lib/crypto/srp"; import { initLogger, logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { AuthMethod } from "@app/services/auth/auth-type"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { membershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; import { membershipUserDALFactory } from "@app/services/membership-user/membership-user-dal"; import { assignWorkspaceKeysToMembers, createProjectKey } from "@app/services/project/project-fns"; @@ -192,7 +195,21 @@ export async function seed(knex: Knex): Promise { initLogger(); const superAdminDAL = superAdminDALFactory(knex); - await initEnvConfig(superAdminDAL, logger); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + + const hsmConfig = getHsmConfig(logger); + + const hsmModule = initializeHsmModule(hsmConfig); + hsmModule.initialize(); + + const hsmService = hsmServiceFactory({ + hsmModule: hsmModule.getModule(), + envConfig: hsmConfig + }); + + await hsmService.startService(); + + await initEnvConfig(hsmService, kmsRootConfigDAL, superAdminDAL, logger); const [project] = await knex(TableName.Project) .insert({ diff --git a/backend/src/db/seeds/5-machine-identity.ts b/backend/src/db/seeds/5-machine-identity.ts index 333fc7e3a..4e4e3eb7f 100644 --- a/backend/src/db/seeds/5-machine-identity.ts +++ b/backend/src/db/seeds/5-machine-identity.ts @@ -1,8 +1,11 @@ import { Knex } from "knex"; -import { initEnvConfig } from "@app/lib/config/env"; +import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; +import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; +import { getHsmConfig, initEnvConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { initLogger, logger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { AccessScope, IdentityAuthMethod, OrgMembershipRole, ProjectMembershipRole, TableName } from "../schemas"; @@ -15,7 +18,20 @@ export async function seed(knex: Knex): Promise { initLogger(); const superAdminDAL = superAdminDALFactory(knex); - await initEnvConfig(superAdminDAL, logger); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const hsmConfig = getHsmConfig(logger); + + const hsmModule = initializeHsmModule(hsmConfig); + hsmModule.initialize(); + + const hsmService = hsmServiceFactory({ + hsmModule: hsmModule.getModule(), + envConfig: hsmConfig + }); + + await hsmService.startService(); + + await initEnvConfig(hsmService, kmsRootConfigDAL, superAdminDAL, logger); // Inserts seed entries await knex(TableName.Identity).insert([ @@ -24,7 +40,8 @@ export async function seed(knex: Knex): Promise { // @ts-ignore id: seedData1.machineIdentity.id, name: seedData1.machineIdentity.name, - authMethod: IdentityAuthMethod.UNIVERSAL_AUTH + authMethod: IdentityAuthMethod.UNIVERSAL_AUTH, + orgId: seedData1.organization.id } ]); const identityUa = await knex(TableName.IdentityUniversalAuth) diff --git a/backend/src/ee/routes/est/certificate-est-router.ts b/backend/src/ee/routes/est/certificate-est-router.ts index 33ebe910d..d5876782d 100644 --- a/backend/src/ee/routes/est/certificate-est-router.ts +++ b/backend/src/ee/routes/est/certificate-est-router.ts @@ -8,6 +8,35 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; export const registerCertificateEstRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); + const getIdentifierType = async (identifier: string): Promise<"template" | "profile" | null> => { + try { + // Try to find as profile first using internal access + await server.services.certificateProfile.getEstConfigurationByProfile({ + profileId: identifier, + isInternal: true + }); + return "profile"; + } catch (profileError) { + try { + await server.services.certificateTemplate.getEstConfiguration({ + isInternal: true, + certificateTemplateId: identifier + }); + return "template"; + } catch (templateError) { + server.log.debug( + { + identifier, + profileError: profileError instanceof Error ? profileError.message : "Unknown error", + templateError: templateError instanceof Error ? templateError.message : "Unknown error" + }, + "EST identifier not found as profile or template" + ); + return null; + } + } + }; + // add support for CSR bodies server.addContentTypeParser("application/pkcs10", { parseAs: "string" }, (_, body, done) => { try { @@ -59,11 +88,29 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = return; } - const certificateTemplateId = urlFragments.slice(-2)[0]; - const estConfig = await server.services.certificateTemplate.getEstConfiguration({ - isInternal: true, - certificateTemplateId - }); + const identifier = urlFragments.slice(-2)[0]; + + const identifierType = await getIdentifierType(identifier); + if (!identifierType) { + res.raw.statusCode = 404; + res.raw.setHeader("Content-Type", "text/plain"); + res.raw.write("Certificate template or profile not found"); + res.raw.flushHeaders(); + return; + } + + let estConfig; + if (identifierType === "profile") { + estConfig = await server.services.certificateProfile.getEstConfigurationByProfile({ + profileId: identifier, + isInternal: true + }); + } else { + estConfig = await server.services.certificateTemplate.getEstConfiguration({ + isInternal: true, + certificateTemplateId: identifier + }); + } if (!estConfig.isEnabled) { throw new BadRequestError({ @@ -95,14 +142,14 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = server.route({ method: "POST", - url: "/:certificateTemplateId/simpleenroll", + url: "/:identifier/simpleenroll", config: { rateLimit: writeLimit }, schema: { body: z.string().min(1), params: z.object({ - certificateTemplateId: z.string().min(1) + identifier: z.string().min(1) }), response: { 200: z.string() @@ -112,9 +159,23 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only"); void res.header("Content-Transfer-Encoding", "base64"); + const { identifier } = req.params; + const identifierType = await getIdentifierType(identifier); + + if (!identifierType) { + throw new BadRequestError({ message: "Certificate template or profile not found" }); + } + + if (identifierType === "profile") { + return server.services.certificateEstV3.simpleEnrollByProfile({ + csr: req.body, + profileId: identifier, + sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string + }); + } return server.services.certificateEst.simpleEnroll({ csr: req.body, - certificateTemplateId: req.params.certificateTemplateId, + certificateTemplateId: identifier, sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string }); } @@ -122,14 +183,14 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = server.route({ method: "POST", - url: "/:certificateTemplateId/simplereenroll", + url: "/:identifier/simplereenroll", config: { rateLimit: writeLimit }, schema: { body: z.string().min(1), params: z.object({ - certificateTemplateId: z.string().min(1) + identifier: z.string().min(1) }), response: { 200: z.string() @@ -139,9 +200,23 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only"); void res.header("Content-Transfer-Encoding", "base64"); + const { identifier } = req.params; + const identifierType = await getIdentifierType(identifier); + + if (!identifierType) { + throw new BadRequestError({ message: "Certificate template or profile not found" }); + } + + if (identifierType === "profile") { + return server.services.certificateEstV3.simpleReenrollByProfile({ + csr: req.body, + profileId: identifier, + sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string + }); + } return server.services.certificateEst.simpleReenroll({ csr: req.body, - certificateTemplateId: req.params.certificateTemplateId, + certificateTemplateId: identifier, sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string }); } @@ -149,13 +224,13 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = server.route({ method: "GET", - url: "/:certificateTemplateId/cacerts", + url: "/:identifier/cacerts", config: { rateLimit: readLimit }, schema: { params: z.object({ - certificateTemplateId: z.string().min(1) + identifier: z.string().min(1) }), response: { 200: z.string() @@ -165,8 +240,20 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only"); void res.header("Content-Transfer-Encoding", "base64"); + const { identifier } = req.params; + const identifierType = await getIdentifierType(identifier); + + if (!identifierType) { + throw new BadRequestError({ message: "Certificate template or profile not found" }); + } + + if (identifierType === "profile") { + return server.services.certificateEstV3.getCaCertsByProfile({ + profileId: identifier + }); + } return server.services.certificateEst.getCaCerts({ - certificateTemplateId: req.params.certificateTemplateId + certificateTemplateId: identifier }); } }); diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 42392ba55..31847b503 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -48,12 +48,14 @@ import { registerSshCertRouter } from "./ssh-certificate-router"; import { registerSshCertificateTemplateRouter } from "./ssh-certificate-template-router"; import { registerSshHostGroupRouter } from "./ssh-host-group-router"; import { registerSshHostRouter } from "./ssh-host-router"; +import { registerSubOrgRouter } from "./sub-org-router"; import { registerTrustedIpRouter } from "./trusted-ip-router"; import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router"; export const registerV1EERoutes = async (server: FastifyZodProvider) => { // org role starts with organization await server.register(registerOrgRoleRouter, { prefix: "/organization" }); + await server.register(registerSubOrgRouter, { prefix: "/sub-organizations" }); await server.register(registerLicenseRouter, { prefix: "/organizations" }); // depreciated in favour of infisical workspace diff --git a/backend/src/ee/routes/v1/license-router.ts b/backend/src/ee/routes/v1/license-router.ts index 17923975d..2ccdce93a 100644 --- a/backend/src/ee/routes/v1/license-router.ts +++ b/backend/src/ee/routes/v1/license-router.ts @@ -58,7 +58,7 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const plan = await server.services.license.getOrgPlan({ actorId: req.permission.id, actor: req.permission.type, - actorOrgId: req.permission.orgId, + actorOrgId: req.permission.rootOrgId, actorAuthMethod: req.permission.authMethod, orgId: req.params.organizationId, refreshCache: req.query.refreshCache diff --git a/backend/src/ee/routes/v1/org-role-router.ts b/backend/src/ee/routes/v1/org-role-router.ts index 5a8f03038..591458bb4 100644 --- a/backend/src/ee/routes/v1/org-role-router.ts +++ b/backend/src/ee/routes/v1/org-role-router.ts @@ -3,12 +3,35 @@ import { z } from "zod"; import { AccessScope, OrgMembershipRole, OrgRolesSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { OrgPermissionSchema } from "@app/ee/services/permission/org-permission"; +import { OrgPermissionSchema, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { BadRequestError } from "@app/lib/errors"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +const INVALID_SUBORG_PERMISSIONS = [ + OrgPermissionSubjects.Sso, + OrgPermissionSubjects.Ldap, + OrgPermissionSubjects.Scim, + OrgPermissionSubjects.GithubOrgSync, + OrgPermissionSubjects.GithubOrgSyncManual, + OrgPermissionSubjects.Billing, + OrgPermissionSubjects.SubOrganization +]; + +const validateSubOrganizationSubjects = (permissions: unknown) => { + const invalidPermissionSubjects = (permissions as { subject: OrgPermissionSubjects }[]) + .filter((el) => INVALID_SUBORG_PERMISSIONS.includes(el.subject)) + .map((el) => el.subject); + if (invalidPermissionSubjects.length) { + const deduplication = Array.from(new Set(invalidPermissionSubjects)); + throw new BadRequestError({ + message: `Suborganization contains invalid permission subjects: ${deduplication.join(",")}` + }); + } +}; + export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", @@ -37,6 +60,11 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { + const isSubOrganization = req.permission.rootOrgId !== req.permission.orgId; + if (isSubOrganization) { + validateSubOrganizationSubjects(req.body.permissions); + } + const stringifiedPermissions = JSON.stringify(packRules(req.body.permissions)); const role = await server.services.role.createRole({ permission: req.permission, @@ -133,6 +161,11 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { + const isSubOrganization = req.permission.rootOrgId !== req.permission.orgId; + if (isSubOrganization && req.body.permissions) { + validateSubOrganizationSubjects(req.body.permissions); + } + const stringifiedPermissions = req.body.permissions ? JSON.stringify(packRules(req.body.permissions)) : undefined; const role = await server.services.role.updateRole({ permission: req.permission, diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts index 0ed7e238a..44e2a5ea1 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts @@ -22,11 +22,15 @@ export const registerPamResourceEndpoints = ({ folderId?: C["folderId"]; name: C["name"]; description?: C["description"]; + rotationEnabled: C["rotationEnabled"]; + rotationIntervalSeconds?: C["rotationIntervalSeconds"]; }>; updateAccountSchema: z.ZodType<{ credentials?: C["credentials"]; name?: C["name"]; description?: C["description"]; + rotationEnabled?: C["rotationEnabled"]; + rotationIntervalSeconds?: C["rotationIntervalSeconds"]; }>; accountResponseSchema: z.ZodTypeAny; }) => { @@ -60,7 +64,9 @@ export const registerPamResourceEndpoints = ({ resourceType, folderId: req.body.folderId, name: req.body.name, - description: req.body.description + description: req.body.description, + rotationEnabled: req.body.rotationEnabled, + rotationIntervalSeconds: req.body.rotationIntervalSeconds } } }); @@ -108,7 +114,9 @@ export const registerPamResourceEndpoints = ({ resourceId: account.resourceId, resourceType, name: req.body.name, - description: req.body.description + description: req.body.description, + rotationEnabled: req.body.rotationEnabled, + rotationIntervalSeconds: req.body.rotationIntervalSeconds } } }); diff --git a/backend/src/ee/routes/v1/pam-resource-routers/index.ts b/backend/src/ee/routes/v1/pam-resource-routers/index.ts index a63b67d94..6b53781ae 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/index.ts @@ -1,7 +1,7 @@ import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { CreatePostgresResourceSchema, - PostgresResourceSchema, + SanitizedPostgresResourceSchema, UpdatePostgresResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; @@ -12,7 +12,7 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record({ connectionDetails: T["connectionDetails"]; gatewayId: T["gatewayId"]; name: T["name"]; + rotationAccountCredentials?: T["rotationAccountCredentials"]; }>; updateResourceSchema: z.ZodType<{ connectionDetails?: T["connectionDetails"]; gatewayId?: T["gatewayId"]; name?: T["name"]; + rotationAccountCredentials?: T["rotationAccountCredentials"]; }>; resourceResponseSchema: z.ZodTypeAny; }) => { diff --git a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts index c19c2030d..d42a73021 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts @@ -3,14 +3,14 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { PostgresResourceListItemSchema, - PostgresResourceSchema + SanitizedPostgresResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; // Use z.union([...]) when more resources are added -const ResourceSchema = PostgresResourceSchema; +const SanitizedResourceSchema = SanitizedPostgresResourceSchema; const ResourceOptionsSchema = z.discriminatedUnion("resource", [PostgresResourceListItemSchema]); @@ -50,7 +50,7 @@ export const registerPamResourceRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - resources: ResourceSchema.array() + resources: SanitizedResourceSchema.array() }) } }, diff --git a/backend/src/ee/routes/v1/sub-org-router.ts b/backend/src/ee/routes/v1/sub-org-router.ts new file mode 100644 index 000000000..200130488 --- /dev/null +++ b/backend/src/ee/routes/v1/sub-org-router.ts @@ -0,0 +1,163 @@ +import { z } from "zod"; + +import { OrganizationsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, SUB_ORGANIZATIONS } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const sanitizedSubOrganizationSchema = OrganizationsSchema.pick({ + id: true, + name: true, + slug: true, + createdAt: true, + updatedAt: true, + parentOrgId: true +}); + +export const registerSubOrgRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SubOrganizations], + description: "Create a sub organization", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + name: slugSchema().describe(SUB_ORGANIZATIONS.CREATE.name) + }), + response: { + 200: z.object({ + organization: sanitizedSubOrganizationSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { organization } = await server.services.subOrganization.createSubOrg({ + name: req.body.name, + permissionActor: req.permission + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.CREATE_SUB_ORGANIZATION, + metadata: { + name: req.body.name, + organizationId: organization.id + } + } + }); + + return { organization }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SubOrganizations], + description: "List of sub organizations", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + limit: z.coerce.number().min(1).max(1000).default(25).describe(SUB_ORGANIZATIONS.LIST.limit), + offset: z.coerce.number().min(0).default(0).describe(SUB_ORGANIZATIONS.LIST.offset), + isAccessible: z + .enum(["true", "false"]) + .optional() + .transform((value) => value === "true") + .describe(SUB_ORGANIZATIONS.LIST.isAccessible) + }), + response: { + 200: z.object({ + organizations: sanitizedSubOrganizationSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { organizations } = await server.services.subOrganization.listSubOrgs({ + permissionActor: req.permission, + data: { + limit: req.query.limit, + offset: req.query.offset, + isAccessible: req.query.isAccessible + } + }); + + return { organizations }; + } + }); + + server.route({ + method: "PATCH", + url: "/:subOrgId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SubOrganizations], + description: "Update a sub organization", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + subOrgId: z.string().trim().describe(SUB_ORGANIZATIONS.UPDATE.subOrgId) + }), + body: z.object({ + name: slugSchema().describe(SUB_ORGANIZATIONS.UPDATE.name) + }), + response: { + 200: z.object({ + organization: sanitizedSubOrganizationSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { organization } = await server.services.subOrganization.updateSubOrg({ + subOrgId: req.params.subOrgId, + name: req.body.name, + permissionActor: req.permission + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.UPDATE_SUB_ORGANIZATION, + metadata: { + name: req.body.name, + organizationId: organization.id + } + } + }); + + return { organization }; + } + }); +}; diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts index 5dd0fd4ba..b3cd34ac9 100644 --- a/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts @@ -1,7 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import { AxiosError } from "axios"; -import { TAuditLogs } from "@app/db/schemas"; +import { OrganizationActionScope, TAuditLogs } from "@app/db/schemas"; import { decryptLogStream, decryptLogStreamCredentials, @@ -45,13 +45,14 @@ export const auditLogStreamServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); @@ -94,13 +95,14 @@ export const auditLogStreamServiceFactory = ({ const logStream = await auditLogStreamDAL.findById(logStreamId); if (!logStream) throw new NotFoundError({ message: `Audit Log Stream with ID '${logStreamId}' not found` }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - logStream.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); @@ -160,13 +162,14 @@ export const auditLogStreamServiceFactory = ({ const logStream = await auditLogStreamDAL.findById(logStreamId); if (!logStream) throw new NotFoundError({ message: `Audit Log Stream with ID '${logStreamId}' not found` }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - logStream.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Settings); @@ -185,14 +188,14 @@ export const auditLogStreamServiceFactory = ({ const logStream = await auditLogStreamDAL.findById(logStreamId); if (!logStream) throw new NotFoundError({ message: `Audit log stream with ID '${logStreamId}' not found` }); - - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - logStream.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); @@ -206,13 +209,14 @@ export const auditLogStreamServiceFactory = ({ }; const list = async (actor: OrgServiceActor) => { - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); diff --git a/backend/src/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index ece5edaf9..eab7792f8 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -1,7 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; -import { ActionProjectType } from "@app/db/schemas"; +import { ActionProjectType, OrganizationActionScope } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { ActorType } from "@app/services/auth/auth-type"; @@ -47,13 +47,14 @@ export const auditLogServiceFactory = ({ ); } else { // Organization-wide logs - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionAuditLogsActions.Read, diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index bc50283d4..6ceaa7778 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -173,6 +173,9 @@ export enum EventType { UPDATE_TOKEN_IDENTITY_TOKEN_AUTH = "update-token-identity-token-auth", GET_TOKENS_IDENTITY_TOKEN_AUTH = "get-tokens-identity-token-auth", + CREATE_SUB_ORGANIZATION = "create-sub-organization", + UPDATE_SUB_ORGANIZATION = "update-sub-organization", + ADD_IDENTITY_TOKEN_AUTH = "add-identity-token-auth", UPDATE_IDENTITY_TOKEN_AUTH = "update-identity-token-auth", GET_IDENTITY_TOKEN_AUTH = "get-identity-token-auth", @@ -352,9 +355,18 @@ export enum EventType { UPDATE_CERTIFICATE_TEMPLATE = "update-certificate-template", DELETE_CERTIFICATE_TEMPLATE = "delete-certificate-template", GET_CERTIFICATE_TEMPLATE = "get-certificate-template", + LIST_CERTIFICATE_TEMPLATES = "list-certificate-templates", CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "create-certificate-template-est-config", UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config", GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config", + CREATE_CERTIFICATE_PROFILE = "create-certificate-profile", + UPDATE_CERTIFICATE_PROFILE = "update-certificate-profile", + DELETE_CERTIFICATE_PROFILE = "delete-certificate-profile", + GET_CERTIFICATE_PROFILE = "get-certificate-profile", + LIST_CERTIFICATE_PROFILES = "list-certificate-profiles", + ISSUE_CERTIFICATE_FROM_PROFILE = "issue-certificate-from-profile", + SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile", + ORDER_CERTIFICATE_FROM_PROFILE = "order-certificate-from-profile", ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration", ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration", GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", @@ -515,6 +527,8 @@ export enum EventType { PAM_ACCOUNT_CREATE = "pam-account-create", PAM_ACCOUNT_UPDATE = "pam-account-update", PAM_ACCOUNT_DELETE = "pam-account-delete", + PAM_ACCOUNT_CREDENTIAL_ROTATION = "pam-account-credential-rotation", + PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED = "pam-account-credential-rotation-failed", PAM_RESOURCE_LIST = "pam-resource-list", PAM_RESOURCE_GET = "pam-resource-get", PAM_RESOURCE_CREATE = "pam-resource-create", @@ -607,6 +621,22 @@ interface GetSecretsEvent { }; } +interface CreateSubOrganizationEvent { + type: EventType.CREATE_SUB_ORGANIZATION; + metadata: { + name: string; + organizationId: string; + }; +} + +interface UpdateSubOrganizationEvent { + type: EventType.UPDATE_SUB_ORGANIZATION; + metadata: { + name: string; + organizationId: string; + }; +} + type TSecretMetadata = { key: string; value: string }[]; interface GetSecretEvent { @@ -2512,46 +2542,6 @@ interface LoadProjectKmsBackupEvent { metadata: Record; // no metadata yet } -interface CreateCertificateTemplate { - type: EventType.CREATE_CERTIFICATE_TEMPLATE; - metadata: { - certificateTemplateId: string; - caId: string; - pkiCollectionId?: string; - name: string; - commonName: string; - subjectAlternativeName: string; - ttl: string; - }; -} - -interface GetCertificateTemplate { - type: EventType.GET_CERTIFICATE_TEMPLATE; - metadata: { - certificateTemplateId: string; - }; -} - -interface UpdateCertificateTemplate { - type: EventType.UPDATE_CERTIFICATE_TEMPLATE; - metadata: { - certificateTemplateId: string; - caId: string; - pkiCollectionId?: string; - name: string; - commonName: string; - subjectAlternativeName: string; - ttl: string; - }; -} - -interface DeleteCertificateTemplate { - type: EventType.DELETE_CERTIFICATE_TEMPLATE; - metadata: { - certificateTemplateId: string; - }; -} - interface OrgAdminAccessProjectEvent { type: EventType.ORG_ADMIN_ACCESS_PROJECT; metadata: { @@ -2598,6 +2588,138 @@ interface GetCertificateTemplateEstConfig { }; } +interface CreateCertificateTemplate { + type: EventType.CREATE_CERTIFICATE_TEMPLATE; + metadata: + | { + certificateTemplateId: string; + name: string; + projectId: string; + } + | { + certificateTemplateId: string; + caId: string; + pkiCollectionId: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + projectId: string; + }; +} + +interface UpdateCertificateTemplate { + type: EventType.UPDATE_CERTIFICATE_TEMPLATE; + metadata: + | { + certificateTemplateId: string; + name: string; + } + | { + certificateTemplateId: string; + caId: string; + pkiCollectionId: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + projectId: string; + }; +} + +interface DeleteCertificateTemplate { + type: EventType.DELETE_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + name: string; + }; +} + +interface GetCertificateTemplate { + type: EventType.GET_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + name: string; + }; +} + +interface ListCertificateTemplates { + type: EventType.LIST_CERTIFICATE_TEMPLATES; + metadata: { + projectId: string; + }; +} + +interface CreateCertificateProfile { + type: EventType.CREATE_CERTIFICATE_PROFILE; + metadata: { + certificateProfileId: string; + name: string; + projectId: string; + enrollmentType: string; + }; +} + +interface UpdateCertificateProfile { + type: EventType.UPDATE_CERTIFICATE_PROFILE; + metadata: { + certificateProfileId: string; + name: string; + }; +} + +interface DeleteCertificateProfile { + type: EventType.DELETE_CERTIFICATE_PROFILE; + metadata: { + certificateProfileId: string; + name: string; + }; +} + +interface GetCertificateProfile { + type: EventType.GET_CERTIFICATE_PROFILE; + metadata: { + certificateProfileId: string; + name: string; + }; +} + +interface ListCertificateProfiles { + type: EventType.LIST_CERTIFICATE_PROFILES; + metadata: { + projectId: string; + }; +} + +interface IssueCertificateFromProfile { + type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE; + metadata: { + certificateProfileId: string; + certificateId: string; + commonName: string; + profileName: string; + }; +} + +interface SignCertificateFromProfile { + type: EventType.SIGN_CERTIFICATE_FROM_PROFILE; + metadata: { + certificateProfileId: string; + certificateId: string; + profileName: string; + commonName: string; + }; +} + +interface OrderCertificateFromProfile { + type: EventType.ORDER_CERTIFICATE_FROM_PROFILE; + metadata: { + certificateProfileId: string; + orderId: string; + profileName: string; + }; +} + interface AttemptCreateSlackIntegration { type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION; metadata: { @@ -3795,6 +3917,8 @@ interface PamAccountCreateEvent { folderId?: string | null; name: string; description?: string | null; + rotationEnabled: boolean; + rotationIntervalSeconds?: number | null; }; } @@ -3806,6 +3930,8 @@ interface PamAccountUpdateEvent { resourceType: string; name?: string; description?: string | null; + rotationEnabled?: boolean; + rotationIntervalSeconds?: number | null; }; } @@ -3819,6 +3945,27 @@ interface PamAccountDeleteEvent { }; } +interface PamAccountCredentialRotationEvent { + type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION; + metadata: { + accountName: string; + accountId: string; + resourceId: string; + resourceType: string; + }; +} + +interface PamAccountCredentialRotationFailedEvent { + type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED; + metadata: { + accountName: string; + accountId: string; + resourceId: string; + resourceType: string; + errorMessage: string; + }; +} + interface PamResourceListEvent { type: EventType.PAM_RESOURCE_LIST; metadata: { @@ -3863,6 +4010,8 @@ interface PamResourceDeleteEvent { } export type Event = + | CreateSubOrganizationEvent + | UpdateSubOrganizationEvent | GetSecretsEvent | GetSecretEvent | CreateSecretEvent @@ -4051,13 +4200,22 @@ export type Event = | LoadProjectKmsBackupEvent | OrgAdminAccessProjectEvent | OrgAdminBypassSSOEvent - | CreateCertificateTemplate - | UpdateCertificateTemplate - | GetCertificateTemplate - | DeleteCertificateTemplate | CreateCertificateTemplateEstConfig | UpdateCertificateTemplateEstConfig | GetCertificateTemplateEstConfig + | CreateCertificateTemplate + | UpdateCertificateTemplate + | DeleteCertificateTemplate + | GetCertificateTemplate + | ListCertificateTemplates + | CreateCertificateProfile + | UpdateCertificateProfile + | DeleteCertificateProfile + | GetCertificateProfile + | ListCertificateProfiles + | IssueCertificateFromProfile + | SignCertificateFromProfile + | OrderCertificateFromProfile | GetAzureAdCsTemplatesEvent | AttemptCreateSlackIntegration | AttemptReinstallSlackIntegration @@ -4209,6 +4367,8 @@ export type Event = | PamAccountCreateEvent | PamAccountUpdateEvent | PamAccountDeleteEvent + | PamAccountCredentialRotationEvent + | PamAccountCredentialRotationFailedEvent | PamResourceListEvent | PamResourceGetEvent | PamResourceCreateEvent diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index 659e07bca..d2d683a84 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError, subject } from "@casl/ability"; -import { ActionProjectType } from "@app/db/schemas"; +import { ActionProjectType, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { @@ -134,13 +134,14 @@ export const dynamicSecretServiceFactory = ({ isGatewayV1 = false; } - const { permission: orgPermission } = await permissionService.getOrgPermission( + const { permission: orgPermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - gateway?.orgId ?? gatewayv2?.orgId, + orgId: gateway?.orgId || gatewayv2?.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(orgPermission).throwUnlessCan( OrgPermissionGatewayActions.AttachGateways, @@ -297,13 +298,14 @@ export const dynamicSecretServiceFactory = ({ isGatewayV1 = false; } - const { permission: orgPermission } = await permissionService.getOrgPermission( + const { permission: orgPermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: gateway?.orgId || gatewayv2?.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(orgPermission).throwUnlessCan( OrgPermissionGatewayActions.AttachGateways, diff --git a/backend/src/ee/services/external-kms/external-kms-service.ts b/backend/src/ee/services/external-kms/external-kms-service.ts index d515c5973..9614f3298 100644 --- a/backend/src/ee/services/external-kms/external-kms-service.ts +++ b/backend/src/ee/services/external-kms/external-kms-service.ts @@ -3,6 +3,7 @@ import { STSServiceException } from "@aws-sdk/client-sts"; import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; +import { OrganizationActionScope } from "@app/db/schemas"; import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TKmsKeyDALFactory } from "@app/services/kms/kms-key-dal"; @@ -51,13 +52,14 @@ export const externalKmsServiceFactory = ({ actorOrgId, actorAuthMethod }: TCreateExternalKmsDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Kms); const plan = await licenseService.getPlan(actorOrgId); @@ -154,13 +156,14 @@ export const externalKmsServiceFactory = ({ actorAuthMethod }: TUpdateExternalKmsDTO) => { const kmsDoc = await kmsDAL.findById(kmsId); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - kmsDoc.orgId, + orgId: kmsDoc.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Kms); const plan = await licenseService.getPlan(kmsDoc.orgId); @@ -257,13 +260,14 @@ export const externalKmsServiceFactory = ({ const deleteById = async ({ actor, id: kmsId, actorId, actorOrgId, actorAuthMethod }: TDeleteExternalKmsDTO) => { const kmsDoc = await kmsDAL.findById(kmsId); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - kmsDoc.orgId, + orgId: kmsDoc.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Kms); const externalKmsDoc = await externalKmsDAL.findOne({ kmsKeyId: kmsDoc.id }); @@ -278,13 +282,14 @@ export const externalKmsServiceFactory = ({ }; const list = async ({ actor, actorId, actorOrgId, actorAuthMethod }: TListExternalKmsDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Kms); const externalKmsDocs = await externalKmsDAL.find({ orgId: actorOrgId }); @@ -294,13 +299,14 @@ export const externalKmsServiceFactory = ({ const findById = async ({ actor, actorId, actorOrgId, actorAuthMethod, id: kmsId }: TGetExternalKmsByIdDTO) => { const kmsDoc = await kmsDAL.findById(kmsId); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - kmsDoc.orgId, + orgId: kmsDoc.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Kms); @@ -342,13 +348,14 @@ export const externalKmsServiceFactory = ({ name: kmsName }: TGetExternalKmsBySlugDTO) => { const kmsDoc = await kmsDAL.findOne({ name: kmsName, orgId: actorOrgId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - kmsDoc.orgId, + orgId: kmsDoc.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Kms); const externalKmsDoc = await externalKmsDAL.findOne({ kmsKeyId: kmsDoc.id }); diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts index a2d323790..fd4954a00 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -3,7 +3,7 @@ import net from "node:net"; import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; -import { OrgMembershipRole, TRelays } from "@app/db/schemas"; +import { OrganizationActionScope, OrgMembershipRole, TRelays } from "@app/db/schemas"; import { PgSqlLock } from "@app/keystore/keystore"; import { crypto } from "@app/lib/crypto"; import { DatabaseErrorCode } from "@app/lib/error-codes"; @@ -73,13 +73,14 @@ export const gatewayV2ServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, actorId, orgId, actorAuthMethod, - orgId - ); + actorOrgId: orgId + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionGatewayActions.CreateGateways, @@ -258,13 +259,14 @@ export const gatewayV2ServiceFactory = ({ }; const listGateways = async ({ orgPermission }: { orgPermission: OrgServiceActor }) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: orgPermission.type, + actorId: orgPermission.id, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionGatewayActions.ListGateways, @@ -815,13 +817,14 @@ export const gatewayV2ServiceFactory = ({ throw new NotFoundError({ message: `Gateway ${id} not found` }); } - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - gateway.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: orgPermission.type, + actorId: orgPermission.id, + orgId: gateway.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionGatewayActions.DeleteGateways, @@ -845,13 +848,14 @@ export const gatewayV2ServiceFactory = ({ }; const getPamSessionKey = async ({ orgPermission }: { orgPermission: OrgServiceActor }) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: orgPermission.type, + actorId: orgPermission.id, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionGatewayActions.CreateGateways, diff --git a/backend/src/ee/services/gateway/gateway-service.ts b/backend/src/ee/services/gateway/gateway-service.ts index 5c8ad80bf..261640cc9 100644 --- a/backend/src/ee/services/gateway/gateway-service.ts +++ b/backend/src/ee/services/gateway/gateway-service.ts @@ -2,6 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import { z } from "zod"; +import { OrganizationActionScope } from "@app/db/schemas"; import { KeyStorePrefixes, PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; @@ -68,13 +69,14 @@ export const gatewayServiceFactory = ({ "Gateway handshake failed due to organization plan restrictions. Please upgrade your instance to Infisical's Enterprise plan." }); } - const { permission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, + const { permission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, actorId, orgId, actorAuthMethod, - orgId - ); + actorOrgId: orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionGatewayActions.CreateGateways, OrgPermissionSubjects.Gateway @@ -480,13 +482,14 @@ export const gatewayServiceFactory = ({ }; const listGateways = async ({ orgPermission }: TListGatewaysDTO) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: orgPermission.type, + actorId: orgPermission.id, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionGatewayActions.ListGateways, OrgPermissionSubjects.Gateway @@ -501,13 +504,14 @@ export const gatewayServiceFactory = ({ }; const getGatewayById = async ({ orgPermission, id }: TGetGatewayByIdDTO) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: orgPermission.type, + actorId: orgPermission.id, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionGatewayActions.ListGateways, OrgPermissionSubjects.Gateway @@ -521,13 +525,14 @@ export const gatewayServiceFactory = ({ }; const updateGatewayById = async ({ orgPermission, id, name }: TUpdateGatewayByIdDTO) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: orgPermission.type, + actorId: orgPermission.id, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionGatewayActions.EditGateways, OrgPermissionSubjects.Gateway @@ -542,13 +547,14 @@ export const gatewayServiceFactory = ({ }; const deleteGatewayById = async ({ orgPermission, id }: TGetGatewayByIdDTO) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: orgPermission.type, + actorId: orgPermission.id, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionGatewayActions.DeleteGateways, OrgPermissionSubjects.Gateway diff --git a/backend/src/ee/services/github-org-sync/github-org-sync-service.ts b/backend/src/ee/services/github-org-sync/github-org-sync-service.ts index b2bcb4ef3..d2713f269 100644 --- a/backend/src/ee/services/github-org-sync/github-org-sync-service.ts +++ b/backend/src/ee/services/github-org-sync/github-org-sync-service.ts @@ -6,7 +6,7 @@ import { paginateGraphql } from "@octokit/plugin-paginate-graphql"; import { Octokit as OctokitRest } from "@octokit/rest"; import RE2 from "re2"; -import { AccessScope, OrgMembershipRole } from "@app/db/schemas"; +import { AccessScope, OrganizationActionScope, OrgMembershipRole } from "@app/db/schemas"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; @@ -104,13 +104,14 @@ export const githubOrgSyncServiceFactory = ({ githubOrgAccessToken, isActive }: TCreateGithubOrgSyncDTO) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor: orgPermission.type, + actorId: orgPermission.id, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.GithubOrgSync); const plan = await licenseService.getPlan(orgPermission.orgId); @@ -162,13 +163,14 @@ export const githubOrgSyncServiceFactory = ({ githubOrgAccessToken, isActive }: TUpdateGithubOrgSyncDTO) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: orgPermission.type, + scope: OrganizationActionScope.ParentOrganization, + actorId: orgPermission.id, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.GithubOrgSync); const plan = await licenseService.getPlan(orgPermission.orgId); @@ -226,13 +228,14 @@ export const githubOrgSyncServiceFactory = ({ }; const deleteGithubOrgSync = async ({ orgPermission }: TDeleteGithubOrgSyncDTO) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: orgPermission.type, + actorId: orgPermission.id, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.GithubOrgSync); @@ -256,13 +259,14 @@ export const githubOrgSyncServiceFactory = ({ }; const getGithubOrgSync = async ({ orgPermission }: TDeleteGithubOrgSyncDTO) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actorId: orgPermission.id, + actor: orgPermission.type, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.GithubOrgSync); @@ -422,13 +426,14 @@ export const githubOrgSyncServiceFactory = ({ }; const validateGithubToken = async ({ orgPermission, githubOrgAccessToken }: TValidateGithubTokenDTO) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actorId: orgPermission.id, + actor: orgPermission.type, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.GithubOrgSync); @@ -509,13 +514,14 @@ export const githubOrgSyncServiceFactory = ({ }; const syncAllTeams = async ({ orgPermission }: TSyncAllTeamsDTO): Promise => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor: orgPermission.type, + orgId: orgPermission.orgId, + actorId: orgPermission.id, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Edit, diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 075488488..956d7853a 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -1,7 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; -import { AccessScope, OrgMembershipRole, TRoles } from "@app/db/schemas"; +import { AccessScope, OrganizationActionScope, OrgMembershipRole, TRoles } from "@app/db/schemas"; import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; @@ -73,13 +73,14 @@ export const groupServiceFactory = ({ const createGroup = async ({ name, slug, role, actor, actorId, actorAuthMethod, actorOrgId }: TCreateGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Create, OrgPermissionSubjects.Groups); const plan = await licenseService.getPlan(actorOrgId); @@ -167,13 +168,14 @@ export const groupServiceFactory = ({ }: TUpdateGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); @@ -270,13 +272,14 @@ export const groupServiceFactory = ({ const deleteGroup = async ({ id, actor, actorId, actorAuthMethod, actorOrgId }: TDeleteGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Delete, OrgPermissionSubjects.Groups); const plan = await licenseService.getPlan(actorOrgId); @@ -297,17 +300,18 @@ export const groupServiceFactory = ({ const getGroupById = async ({ id, actor, actorId, actorAuthMethod, actorOrgId }: TGetGroupByIdDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); const group = await groupDAL.findById(id); - if (!group) { + if (!group || group.orgId !== actorOrgId) { throw new NotFoundError({ message: `Cannot find group with ID ${id}` }); @@ -330,13 +334,14 @@ export const groupServiceFactory = ({ }: TListGroupUsersDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); const group = await groupDAL.findOne({ @@ -365,13 +370,14 @@ export const groupServiceFactory = ({ const addUserToGroup = async ({ id, username, actor, actorId, actorAuthMethod, actorOrgId }: TAddUserToGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); // check if group with slug exists @@ -451,13 +457,14 @@ export const groupServiceFactory = ({ }: TRemoveUserFromGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); // check if group with slug exists diff --git a/backend/src/ee/services/hsm/hsm-fns.ts b/backend/src/ee/services/hsm/hsm-fns.ts index 1afccdafe..400fa31e9 100644 --- a/backend/src/ee/services/hsm/hsm-fns.ts +++ b/backend/src/ee/services/hsm/hsm-fns.ts @@ -1,8 +1,14 @@ import * as pkcs11js from "pkcs11js"; import { TEnvConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; +import { KMS_ROOT_CONFIG_UUID } from "@app/services/kms/kms-fns"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; +import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types"; +import { TLicenseServiceFactory } from "../license/license-service"; +import { THsmServiceFactory } from "./hsm-service"; import { HsmModule } from "./hsm-types"; export const initializeHsmModule = (envConfig: Pick) => { @@ -25,10 +31,9 @@ export const initializeHsmModule = (envConfig: Pick; + kmsRootConfigDAL: Pick; + licenseService?: Pick; +}) => { + const isHsmConfigured = await hsmService.isActive(); + + // null if the root kms config does not exist + let rootKmsConfigEncryptionStrategy: RootKeyEncryptionStrategy | null = null; + + const rootKmsConfig = await kmsRootConfigDAL.findById(KMS_ROOT_CONFIG_UUID).catch(() => null); + + rootKmsConfigEncryptionStrategy = (rootKmsConfig?.encryptionStrategy || null) as RootKeyEncryptionStrategy | null; + if ( + rootKmsConfigEncryptionStrategy === RootKeyEncryptionStrategy.HSM && + licenseService && + !licenseService.onPremFeatures.hsm + ) { + throw new BadRequestError({ + message: "Your license does not include HSM integration. Please upgrade to the Enterprise plan to use HSM." + }); + } + + return { + rootKmsConfigEncryptionStrategy, + isHsmConfigured + }; +}; diff --git a/backend/src/ee/services/hsm/hsm-service.ts b/backend/src/ee/services/hsm/hsm-service.ts index 0ed4c5faf..1207b1cd3 100644 --- a/backend/src/ee/services/hsm/hsm-service.ts +++ b/backend/src/ee/services/hsm/hsm-service.ts @@ -25,6 +25,8 @@ export const hsmServiceFactory = ({ hsmModule: { isInitialized, pkcs11 }, envCon const AES_KEY_SIZE = 256; const HMAC_KEY_SIZE = 256; + let pkcs11TestPassed = false; + const $withSession = async (callbackWithSession: SessionCallback): Promise => { const RETRY_INTERVAL = 200; // 200ms between attempts const MAX_TIMEOUT = 90_000; // 90 seconds maximum total time @@ -363,7 +365,9 @@ export const hsmServiceFactory = ({ hsmModule: { isInitialized, pkcs11 }, envCon return false; } - let pkcs11TestPassed = false; + if (pkcs11TestPassed) { + return true; + } try { pkcs11TestPassed = await $withSession($testPkcs11Module); @@ -371,7 +375,7 @@ export const hsmServiceFactory = ({ hsmModule: { isInitialized, pkcs11 }, envCon logger.error(err, "HSM: Error testing PKCS#11 module"); } - return envConfig.isHsmConfigured && isInitialized && pkcs11TestPassed; + return pkcs11TestPassed; }; const startService = async () => { @@ -460,10 +464,23 @@ export const hsmServiceFactory = ({ hsmModule: { isInitialized, pkcs11 }, envCon } }; + const randomBytes = async (length: number) => { + if (!pkcs11 || !isInitialized) { + throw new Error("PKCS#11 module is not initialized"); + } + + const randomData = await $withSession((sessionHandle) => + pkcs11.C_GenerateRandom(sessionHandle, Buffer.alloc(length)) + ); + + return randomData; + }; + return { encrypt, startService, isActive, - decrypt + decrypt, + randomBytes }; }; diff --git a/backend/src/ee/services/hsm/hsm-types.ts b/backend/src/ee/services/hsm/hsm-types.ts index b688147f5..ada527329 100644 --- a/backend/src/ee/services/hsm/hsm-types.ts +++ b/backend/src/ee/services/hsm/hsm-types.ts @@ -1,5 +1,7 @@ import pkcs11js from "pkcs11js"; +import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types"; + export type HsmModule = { pkcs11: pkcs11js.PKCS11; isInitialized: boolean; @@ -9,3 +11,8 @@ export enum HsmKeyType { AES = "AES", HMAC = "hmac" } + +export type THsmStatus = { + rootKmsConfigEncryptionStrategy: RootKeyEncryptionStrategy | null; + isHsmConfigured: boolean; +}; diff --git a/backend/src/ee/services/identity-auth-template/identity-auth-template-service.ts b/backend/src/ee/services/identity-auth-template/identity-auth-template-service.ts index ef071742d..10aa3b190 100644 --- a/backend/src/ee/services/identity-auth-template/identity-auth-template-service.ts +++ b/backend/src/ee/services/identity-auth-template/identity-auth-template-service.ts @@ -1,5 +1,6 @@ import { ForbiddenError } from "@casl/ability"; +import { OrganizationActionScope } from "@app/db/schemas"; import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { @@ -68,13 +69,14 @@ export const identityAuthTemplateServiceFactory = ({ templateFields: Record; } & Omit) => { await $checkPlan(actorOrgId); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate @@ -113,13 +115,14 @@ export const identityAuthTemplateServiceFactory = ({ throw new NotFoundError({ message: "Template not found" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - template.orgId, + orgId: template.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.EditTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate @@ -227,13 +230,14 @@ export const identityAuthTemplateServiceFactory = ({ throw new NotFoundError({ message: "Template not found" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - template.orgId, + orgId: template.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.DeleteTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate @@ -282,13 +286,14 @@ export const identityAuthTemplateServiceFactory = ({ throw new NotFoundError({ message: "Template not found" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - template.orgId, + orgId: template.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate @@ -316,13 +321,14 @@ export const identityAuthTemplateServiceFactory = ({ actorOrgId }: TListIdentityAuthTemplatesDTO) => { await $checkPlan(actorOrgId); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate @@ -352,13 +358,14 @@ export const identityAuthTemplateServiceFactory = ({ actorOrgId }: TGetTemplatesByAuthMethodDTO) => { await $checkPlan(actorOrgId); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate @@ -385,13 +392,14 @@ export const identityAuthTemplateServiceFactory = ({ actorOrgId }: TFindTemplateUsagesDTO) => { await $checkPlan(actorOrgId); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate @@ -415,13 +423,14 @@ export const identityAuthTemplateServiceFactory = ({ actorOrgId }: TUnlinkTemplateUsageDTO) => { await $checkPlan(actorOrgId); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate diff --git a/backend/src/ee/services/kmip/kmip-operation-service.ts b/backend/src/ee/services/kmip/kmip-operation-service.ts index 27f59a99f..b3eace6bc 100644 --- a/backend/src/ee/services/kmip/kmip-operation-service.ts +++ b/backend/src/ee/services/kmip/kmip-operation-service.ts @@ -1,5 +1,6 @@ import { ForbiddenError } from "@casl/ability"; +import { OrganizationActionScope } from "@app/db/schemas"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { TKmsKeyDALFactory } from "@app/services/kms/kms-key-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; @@ -46,13 +47,14 @@ export const kmipOperationServiceFactory = ({ actorAuthMethod, actorOrgId }: TKmipCreateDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); @@ -78,13 +80,14 @@ export const kmipOperationServiceFactory = ({ }; const destroy = async ({ projectId, id, clientId, actor, actorId, actorOrgId, actorAuthMethod }: TKmipDestroyDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); @@ -131,13 +134,14 @@ export const kmipOperationServiceFactory = ({ }; const get = async ({ projectId, id, clientId, actor, actorId, actorAuthMethod, actorOrgId }: TKmipGetDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); @@ -189,13 +193,14 @@ export const kmipOperationServiceFactory = ({ }; const activate = async ({ projectId, id, clientId, actor, actorId, actorAuthMethod, actorOrgId }: TKmipGetDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); @@ -226,13 +231,14 @@ export const kmipOperationServiceFactory = ({ }; const revoke = async ({ projectId, id, clientId, actor, actorId, actorAuthMethod, actorOrgId }: TKmipRevokeDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); @@ -287,13 +293,14 @@ export const kmipOperationServiceFactory = ({ actorAuthMethod, actorOrgId }: TKmipGetAttributesDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); @@ -339,13 +346,14 @@ export const kmipOperationServiceFactory = ({ }; const locate = async ({ projectId, clientId, actor, actorId, actorAuthMethod, actorOrgId }: TKmipLocateDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); @@ -377,13 +385,14 @@ export const kmipOperationServiceFactory = ({ actorOrgId, kmipMetadata }: TKmipRegisterDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); diff --git a/backend/src/ee/services/kmip/kmip-service.ts b/backend/src/ee/services/kmip/kmip-service.ts index 8daa5a37a..482eb41be 100644 --- a/backend/src/ee/services/kmip/kmip-service.ts +++ b/backend/src/ee/services/kmip/kmip-service.ts @@ -1,7 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; -import { ActionProjectType } from "@app/db/schemas"; +import { ActionProjectType, OrganizationActionScope } from "@app/db/schemas"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors"; import { isValidIp } from "@app/lib/ip"; @@ -401,13 +401,14 @@ export const kmipServiceFactory = ({ }; const setupOrgKmip = async ({ caKeyAlgorithm, actorOrgId, actor, actorId, actorAuthMethod }: TSetupOrgKmipDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Setup, OrgPermissionSubjects.Kmip); const kmipConfig = await kmipOrgConfigDAL.findOne({ @@ -566,7 +567,14 @@ export const kmipServiceFactory = ({ }; const getOrgKmip = async ({ actorOrgId, actor, actorId, actorAuthMethod }: TGetOrgKmipDTO) => { - await permissionService.getOrgPermission(actor, actorId, actorOrgId, actorAuthMethod, actorOrgId); + await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); const kmipConfig = await kmipOrgConfigDAL.findOne({ orgId: actorOrgId @@ -759,13 +767,14 @@ export const kmipServiceFactory = ({ keyAlgorithm, hostnamesOrIps }: TRegisterServerDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index 43ca5ab3d..86bfcc687 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -1,7 +1,14 @@ import { ForbiddenError } from "@casl/ability"; import { Knex } from "knex"; -import { AccessScope, OrgMembershipStatus, TableName, TLdapConfigsUpdate, TUsers } from "@app/db/schemas"; +import { + AccessScope, + OrganizationActionScope, + OrgMembershipStatus, + TableName, + TLdapConfigsUpdate, + TUsers +} from "@app/db/schemas"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; @@ -119,7 +126,14 @@ export const ldapConfigServiceFactory = ({ groupSearchFilter, caCert }: TCreateLdapCfgDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); const plan = await licenseService.getPlan(orgId); @@ -238,7 +252,14 @@ export const ldapConfigServiceFactory = ({ groupSearchFilter, caCert }: TUpdateLdapCfgDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap); const plan = await licenseService.getPlan(orgId); @@ -316,7 +337,14 @@ export const ldapConfigServiceFactory = ({ actorAuthMethod, actorOrgId }: TGetLdapCfgDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Ldap); return getLdapCfg({ orgId @@ -649,7 +677,14 @@ export const ldapConfigServiceFactory = ({ actorAuthMethod, actorOrgId }: TGetLdapGroupMapsDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Ldap); const ldapConfig = await ldapConfigDAL.findOne({ @@ -678,7 +713,14 @@ export const ldapConfigServiceFactory = ({ actorAuthMethod, actorOrgId }: TCreateLdapGroupMapDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); const plan = await licenseService.getPlan(orgId); @@ -732,7 +774,14 @@ export const ldapConfigServiceFactory = ({ actorAuthMethod, actorOrgId }: TDeleteLdapGroupMapDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Ldap); const plan = await licenseService.getPlan(orgId); @@ -771,7 +820,14 @@ export const ldapConfigServiceFactory = ({ caCert, url }: TTestLdapConnectionDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); const plan = await licenseService.getPlan(orgId); diff --git a/backend/src/ee/services/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts index f139ff2c1..d303859bb 100644 --- a/backend/src/ee/services/license/__mocks__/license-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -33,7 +33,8 @@ export const getDefaultOnPremFeatures = () => { enterpriseSecretSyncs: false, enterpriseCertificateSyncs: false, enterpriseAppConnections: true, - machineIdentityAuthTemplates: false + machineIdentityAuthTemplates: false, + pkiLegacyTemplates: false }; }; diff --git a/backend/src/ee/services/license/license-dal.ts b/backend/src/ee/services/license/license-dal.ts index a2bd7ec51..853f3a994 100644 --- a/backend/src/ee/services/license/license-dal.ts +++ b/backend/src/ee/services/license/license-dal.ts @@ -10,6 +10,7 @@ export const licenseDALFactory = (db: TDbClient) => { const countOfOrgMembers = async (orgId: string | null, tx?: Knex) => { try { const doc = await (tx || db.replicaNode())(TableName.Membership) + .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.Membership}.scopeOrgId`) .where({ status: OrgMembershipStatus.Accepted, scope: AccessScope.Organization }) .andWhere((bd) => { if (orgId) { @@ -18,6 +19,7 @@ export const licenseDALFactory = (db: TDbClient) => { }) .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) .where(`${TableName.Users}.isGhost`, false) + .whereNull(`${TableName.Organization}.rootOrgId`) .count(); return Number(doc?.[0]?.count ?? 0); } catch (error) { @@ -25,10 +27,31 @@ export const licenseDALFactory = (db: TDbClient) => { } }; + const countOfOrgIdentities = async (orgId: string | null, tx?: Knex) => { + try { + // count org identities + const identityDoc = await (tx || db.replicaNode())(TableName.Identity) + .join(TableName.Organization, `${TableName.Identity}.orgId`, `${TableName.Organization}.id`) + .where((bd) => { + if (orgId) { + void bd.where(`${TableName.Organization}.rootOrgId`, orgId).orWhere(`${TableName.Organization}.id`, orgId); + } + }) + .count(); + + const identityCount = Number(identityDoc?.[0].count); + + return identityCount; + } catch (error) { + throw new DatabaseError({ error, name: "Count of Org Users + Identities" }); + } + }; + const countOrgUsersAndIdentities = async (orgId: string | null, tx?: Knex) => { try { // count org users const userDoc = await (tx || db.replicaNode())(TableName.Membership) + .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.Membership}.scopeOrgId`) .where({ status: OrgMembershipStatus.Accepted, scope: AccessScope.Organization }) .whereNotNull(`${TableName.Membership}.actorUserId`) .andWhere((bd) => { @@ -38,17 +61,17 @@ export const licenseDALFactory = (db: TDbClient) => { }) .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) .where(`${TableName.Users}.isGhost`, false) + .whereNull(`${TableName.Organization}.rootOrgId`) .count(); const userCount = Number(userDoc?.[0].count); // count org identities - const identityDoc = await (tx || db.replicaNode())(TableName.Membership) - .where({ scope: AccessScope.Organization }) - .whereNotNull(`${TableName.Membership}.actorIdentityId`) + const identityDoc = await (tx || db.replicaNode())(TableName.Identity) + .join(TableName.Organization, `${TableName.Identity}.orgId`, `${TableName.Organization}.id`) .where((bd) => { if (orgId) { - void bd.where(`${TableName.Membership}.scopeOrgId`, orgId); + void bd.where(`${TableName.Organization}.rootOrgId`, orgId).orWhere(`${TableName.Organization}.id`, orgId); } }) .count(); @@ -61,5 +84,5 @@ export const licenseDALFactory = (db: TDbClient) => { } }; - return { countOfOrgMembers, countOrgUsersAndIdentities }; + return { countOfOrgMembers, countOrgUsersAndIdentities, countOfOrgIdentities }; }; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 2a3cf82cc..97061e3ca 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -28,6 +28,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ rbac: false, githubOrgSync: false, customRateLimits: false, + subOrganization: false, customAlerts: false, secretAccessInsights: false, auditLogs: false, @@ -67,6 +68,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ fips: false, eventSubscriptions: false, machineIdentityAuthTemplates: false, + pkiLegacyTemplates: false, pam: false }); diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index fba9d0cca..bb56d4df4 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -9,12 +9,12 @@ import { AxiosError } from "axios"; import { CronJob } from "cron"; import { Knex } from "knex"; +import { OrganizationActionScope } from "@app/db/schemas"; import { TKeyStoreFactory } from "@app/keystore/keystore"; -import { getConfig } from "@app/lib/config/env"; +import { TEnvConfig } from "@app/lib/config/env"; import { verifyOfflineLicense } from "@app/lib/crypto"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; -import { TIdentityOrgDALFactory } from "@app/services/identity/identity-org-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -45,11 +45,14 @@ import { } from "./license-types"; type TLicenseServiceFactoryDep = { - orgDAL: Pick; + envConfig: Pick< + TEnvConfig, + "LICENSE_SERVER_URL" | "LICENSE_SERVER_KEY" | "LICENSE_KEY" | "LICENSE_KEY_OFFLINE" | "INTERNAL_REGION" | "SITE_URL" + >; + orgDAL: Pick; permissionService: Pick; licenseDAL: TLicenseDALFactory; keyStore: Pick; - identityOrgMembershipDAL: TIdentityOrgDALFactory; projectDAL: TProjectDALFactory; }; @@ -66,27 +69,26 @@ export const licenseServiceFactory = ({ permissionService, licenseDAL, keyStore, - identityOrgMembershipDAL, - projectDAL + projectDAL, + envConfig }: TLicenseServiceFactoryDep) => { let isValidLicense = false; let instanceType = InstanceType.OnPrem; let onPremFeatures: TFeatureSet = getDefaultOnPremFeatures(); let selfHostedLicense: TOfflineLicense | null = null; - const appCfg = getConfig(); const licenseServerCloudApi = setupLicenseRequestWithStore( - appCfg.LICENSE_SERVER_URL || "", + envConfig.LICENSE_SERVER_URL || "", LICENSE_SERVER_CLOUD_LOGIN, - appCfg.LICENSE_SERVER_KEY || "", - appCfg.INTERNAL_REGION + envConfig.LICENSE_SERVER_KEY || "", + envConfig.INTERNAL_REGION ); const licenseServerOnPremApi = setupLicenseRequestWithStore( - appCfg.LICENSE_SERVER_URL || "", + envConfig.LICENSE_SERVER_URL || "", LICENSE_SERVER_ON_PREM_LOGIN, - appCfg.LICENSE_KEY || "", - appCfg.INTERNAL_REGION + envConfig.LICENSE_KEY || "", + envConfig.INTERNAL_REGION ); const syncLicenseKeyOnPremFeatures = async (shouldThrow: boolean = false) => { @@ -120,7 +122,7 @@ export const licenseServiceFactory = ({ const init = async () => { try { - if (appCfg.LICENSE_SERVER_KEY) { + if (envConfig.LICENSE_SERVER_KEY) { const token = await licenseServerCloudApi.refreshLicense(); if (token) instanceType = InstanceType.Cloud; logger.info(`Instance type: ${InstanceType.Cloud}`); @@ -128,7 +130,7 @@ export const licenseServiceFactory = ({ return; } - if (appCfg.LICENSE_KEY) { + if (envConfig.LICENSE_KEY) { const token = await licenseServerOnPremApi.refreshLicense(); if (token) { await syncLicenseKeyOnPremFeatures(true); @@ -139,10 +141,10 @@ export const licenseServiceFactory = ({ return; } - if (appCfg.LICENSE_KEY_OFFLINE) { + if (envConfig.LICENSE_KEY_OFFLINE) { let isValidOfflineLicense = true; const contents: TOfflineLicenseContents = JSON.parse( - Buffer.from(appCfg.LICENSE_KEY_OFFLINE, "base64").toString("utf8") + Buffer.from(envConfig.LICENSE_KEY_OFFLINE, "base64").toString("utf8") ); const isVerified = await verifyOfflineLicense(JSON.stringify(contents.license), contents.signature); @@ -181,7 +183,7 @@ export const licenseServiceFactory = ({ }; const initializeBackgroundSync = async () => { - if (appCfg.LICENSE_KEY) { + if (envConfig.LICENSE_KEY) { logger.info("Setting up background sync process for refresh onPremFeatures"); const job = new CronJob("*/10 * * * *", syncLicenseKeyOnPremFeatures); job.start(); @@ -199,22 +201,23 @@ export const licenseServiceFactory = ({ return JSON.parse(cachedPlan) as TFeatureSet; } - const org = await orgDAL.findOrgById(orgId); + const org = await orgDAL.findRootOrgDetails(orgId); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); + const rootOrgId = org.id; + const { data: { currentPlan } } = await licenseServerCloudApi.request.get<{ currentPlan: TFeatureSet }>( `/api/license-server/v1/customers/${org.customerId}/cloud-plan` ); - const workspacesUsed = await projectDAL.countOfOrgProjects(orgId); + const workspacesUsed = await projectDAL.countOfOrgProjects(rootOrgId); currentPlan.workspacesUsed = workspacesUsed; - const membersUsed = await licenseDAL.countOfOrgMembers(orgId); + const membersUsed = await licenseDAL.countOfOrgMembers(rootOrgId); currentPlan.membersUsed = membersUsed; - const identityUsed = await licenseDAL.countOrgUsersAndIdentities(orgId); - currentPlan.identitiesUsed = identityUsed; + const identityUsed = await licenseDAL.countOrgUsersAndIdentities(rootOrgId); - if (currentPlan.identityLimit && currentPlan.identityLimit !== identityUsed) { + if (currentPlan?.identitiesUsed && currentPlan.identitiesUsed !== identityUsed) { try { await licenseServerCloudApi.request.patch(`/api/license-server/v1/customers/${org.customerId}/cloud-plan`, { quantity: membersUsed, @@ -227,6 +230,7 @@ export const licenseServiceFactory = ({ ); } } + currentPlan.identitiesUsed = identityUsed; await keyStore.setItemWithExpiry( FEATURE_CACHE_KEY(org.id), @@ -284,19 +288,20 @@ export const licenseServiceFactory = ({ }; const updateSubscriptionOrgMemberCount = async (orgId: string, tx?: Knex) => { - if (instanceType === InstanceType.Cloud) { - const org = await orgDAL.findOrgById(orgId); - if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); + const org = await orgDAL.findRootOrgDetails(orgId, tx); + if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); - const quantity = await licenseDAL.countOfOrgMembers(orgId, tx); - const quantityIdentities = await licenseDAL.countOrgUsersAndIdentities(orgId, tx); + const rootOrgId = org.id; + if (instanceType === InstanceType.Cloud) { + const quantity = await licenseDAL.countOfOrgMembers(rootOrgId, tx); + const quantityIdentities = await licenseDAL.countOrgUsersAndIdentities(rootOrgId, tx); if (org?.customerId) { await licenseServerCloudApi.request.patch(`/api/license-server/v1/customers/${org.customerId}/cloud-plan`, { quantity, quantityIdentities }); } - await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); + await keyStore.deleteItem(FEATURE_CACHE_KEY(rootOrgId)); } else if (instanceType === InstanceType.EnterpriseOnPrem) { const usedSeats = await licenseDAL.countOfOrgMembers(null, tx); const usedIdentitySeats = await licenseDAL.countOrgUsersAndIdentities(null, tx); @@ -307,7 +312,7 @@ export const licenseServiceFactory = ({ usedIdentitySeats }); } - await refreshPlan(orgId); + await refreshPlan(rootOrgId); }; // below all are api calls @@ -319,7 +324,14 @@ export const licenseServiceFactory = ({ actorAuthMethod, billingCycle }: TOrgPlansTableDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); const { data } = await licenseServerCloudApi.request.get( `/api/license-server/v1/cloud-products?billing-cycle=${billingCycle}` @@ -336,7 +348,14 @@ export const licenseServiceFactory = ({ projectId, refreshCache }: TOrgPlanDTO) => { - await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); if (refreshCache) { await refreshPlan(orgId); } @@ -352,13 +371,20 @@ export const licenseServiceFactory = ({ actorAuthMethod, success_url }: TStartOrgTrialDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing ); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -384,13 +410,20 @@ export const licenseServiceFactory = ({ actorAuthMethod, actorOrgId }: TCreateOrgPortalSession) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing ); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: "Organization not found" @@ -411,8 +444,8 @@ export const licenseServiceFactory = ({ } = await licenseServerCloudApi.request.post( `/api/license-server/v1/customers/${organization.customerId}/billing-details/payment-methods`, { - success_url: `${appCfg.SITE_URL}/organization/billing`, - cancel_url: `${appCfg.SITE_URL}/organization/billing` + success_url: `${envConfig.SITE_URL}/organization/billing`, + cancel_url: `${envConfig.SITE_URL}/organization/billing` } ); @@ -425,7 +458,7 @@ export const licenseServiceFactory = ({ } = await licenseServerCloudApi.request.post( `/api/license-server/v1/customers/${organization.customerId}/billing-details/billing-portal`, { - return_url: `${appCfg.SITE_URL}/organization/billing` + return_url: `${envConfig.SITE_URL}/organization/billing` } ); @@ -433,10 +466,17 @@ export const licenseServiceFactory = ({ }; const getOrgBillingInfo = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -502,7 +542,7 @@ export const licenseServiceFactory = ({ const getUsageMetrics = async (orgId: string) => { const [orgMembersUsed, identityUsed, projectCount] = await Promise.all([ orgDAL.countAllOrgMembers(orgId), - identityOrgMembershipDAL.countAllOrgIdentities({ scopeOrgId: orgId }), + licenseDAL.countOfOrgIdentities(orgId), projectDAL.countOfOrgProjects(orgId) ]); @@ -516,10 +556,17 @@ export const licenseServiceFactory = ({ // returns org current plan feature table const getOrgPlanTable = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -553,10 +600,17 @@ export const licenseServiceFactory = ({ }; const getOrgBillingDetails = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -578,13 +632,20 @@ export const licenseServiceFactory = ({ name, email }: TUpdateOrgBillingDetailsDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing ); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -601,10 +662,17 @@ export const licenseServiceFactory = ({ }; const getOrgPmtMethods = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TOrgPmtMethodsDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -628,13 +696,20 @@ export const licenseServiceFactory = ({ success_url, cancel_url }: TAddOrgPmtMethodDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing ); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -660,13 +735,20 @@ export const licenseServiceFactory = ({ orgId, pmtMethodId }: TDelOrgPmtMethodDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing ); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -692,10 +774,17 @@ export const licenseServiceFactory = ({ }; const getOrgTaxIds = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgTaxIdDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -710,13 +799,20 @@ export const licenseServiceFactory = ({ }; const addOrgTaxId = async ({ actorId, actor, actorAuthMethod, actorOrgId, orgId, type, value }: TAddOrgTaxIdDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing ); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -734,13 +830,20 @@ export const licenseServiceFactory = ({ }; const delOrgTaxId = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId, taxId }: TDelOrgTaxIdDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing ); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -754,10 +857,17 @@ export const licenseServiceFactory = ({ }; const getOrgTaxInvoices = async ({ actorId, actor, actorOrgId, actorAuthMethod, orgId }: TOrgInvoiceDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -771,10 +881,17 @@ export const licenseServiceFactory = ({ }; const getOrgLicenses = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TOrgLicensesDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -819,7 +936,6 @@ export const licenseServiceFactory = ({ getLicenseId, invalidateGetPlan, updateSubscriptionOrgMemberCount, - refreshPlan, getOrgPlan, getOrgPlansTableByBillCycle, startOrgTrial, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 9cdcfcc3d..93f40ae6e 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -33,6 +33,7 @@ export type TFeatureSet = { membersUsed: number; identityLimit: null; identitiesUsed: number; + subOrganization: false; environmentLimit: null; environmentsUsed: 0; secretVersioning: true; @@ -78,6 +79,7 @@ export type TFeatureSet = { enterpriseCertificateSyncs: false; enterpriseAppConnections: false; machineIdentityAuthTemplates: false; + pkiLegacyTemplates: false; fips: false; eventSubscriptions: false; pam: false; diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index c2672a94e..e80ec7cf5 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import { Issuer, Issuer as OpenIdIssuer, Strategy as OpenIdStrategy, TokenSet } from "openid-client"; -import { AccessScope, OrgMembershipStatus, TableName, TUsers } from "@app/db/schemas"; +import { AccessScope, OrganizationActionScope, OrgMembershipStatus, TableName, TUsers } from "@app/db/schemas"; import { TOidcConfigsUpdate } from "@app/db/schemas/oidc-configs"; import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; @@ -118,13 +118,14 @@ export const oidcConfigServiceFactory = ({ } if (dto.type === "external") { - const { permission } = await permissionService.getOrgPermission( - dto.actor, - dto.actorId, - dto.organizationId, - dto.actorAuthMethod, - dto.actorOrgId - ); + const { permission } = await permissionService.getOrgPermission({ + actorId: dto.actorId, + actor: dto.actor, + orgId: dto.organizationId, + actorOrgId: dto.actorOrgId, + actorAuthMethod: dto.actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); } @@ -508,13 +509,14 @@ export const oidcConfigServiceFactory = ({ "Failed to update OIDC SSO configuration due to plan restriction. Upgrade plan to update SSO configuration." }); - const { permission } = await permissionService.getOrgPermission( - actor, + const { permission } = await permissionService.getOrgPermission({ actorId, - org.id, + actor, + orgId: org.id, + actorOrgId, actorAuthMethod, - actorOrgId - ); + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); if (org.googleSsoAuthEnforced && isActive) { @@ -602,13 +604,14 @@ export const oidcConfigServiceFactory = ({ "Failed to create OIDC SSO configuration due to plan restriction. Upgrade plan to update SSO configuration." }); - const { permission } = await permissionService.getOrgPermission( - actor, + const { permission } = await permissionService.getOrgPermission({ actorId, - org.id, + actor, + orgId: org.id, + actorOrgId, actorAuthMethod, - actorOrgId - ); + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Sso); if (org.googleSsoAuthEnforced && isActive) { @@ -764,7 +767,14 @@ export const oidcConfigServiceFactory = ({ }; const isOidcManageGroupMembershipsEnabled = async (orgId: string, actor: OrgServiceActor) => { - await permissionService.getOrgPermission(ActorType.USER, actor.id, orgId, actor.authMethod, actor.orgId); + await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: actor.id, + orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.ParentOrganization + }); const oidcConfig = await oidcConfigDAL.findOne({ orgId, diff --git a/backend/src/ee/services/pam-account/pam-account-dal.ts b/backend/src/ee/services/pam-account/pam-account-dal.ts index b62e940fe..6ef7df76e 100644 --- a/backend/src/ee/services/pam-account/pam-account-dal.ts +++ b/backend/src/ee/services/pam-account/pam-account-dal.ts @@ -18,7 +18,8 @@ export const pamAccountDALFactory = (db: TDbClient) => { .select( // resource db.ref("name").withSchema(TableName.PamResource).as("resourceName"), - db.ref("resourceType").withSchema(TableName.PamResource) + db.ref("resourceType").withSchema(TableName.PamResource), + db.ref("encryptedRotationAccountCredentials").withSchema(TableName.PamResource) ); if (filter) { @@ -28,16 +29,35 @@ export const pamAccountDALFactory = (db: TDbClient) => { const accounts = await query; - return accounts.map(({ resourceId, resourceName, resourceType, ...account }) => ({ - ...account, - resourceId, - resource: { - id: resourceId, - name: resourceName, - resourceType - } - })); + return accounts.map( + ({ resourceId, resourceName, resourceType, encryptedRotationAccountCredentials, ...account }) => ({ + ...account, + resourceId, + resource: { + id: resourceId, + name: resourceName, + resourceType, + encryptedRotationAccountCredentials + } + }) + ); }; - return { ...orm, findWithResourceDetails }; + const findAccountsDueForRotation = async (tx?: Knex) => { + const dbClient = tx || db.replicaNode(); + + const accounts = await dbClient(TableName.PamAccount) + .innerJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`) + .whereNotNull(`${TableName.PamResource}.encryptedRotationAccountCredentials`) + .whereNotNull(`${TableName.PamAccount}.rotationIntervalSeconds`) + .where(`${TableName.PamAccount}.rotationEnabled`, true) + .whereRaw( + `COALESCE("${TableName.PamAccount}"."lastRotatedAt", "${TableName.PamAccount}"."createdAt") + "${TableName.PamAccount}"."rotationIntervalSeconds" * interval '1 second' < NOW()` + ) + .select(selectAllTableCols(TableName.PamAccount)); + + return accounts; + }; + + return { ...orm, findWithResourceDetails, findAccountsDueForRotation }; }; diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts index e9ea76e8c..fd3615013 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError, subject } from "@casl/ability"; -import { ActionProjectType, TPamAccounts, TPamResources } from "@app/db/schemas"; +import { ActionProjectType, OrganizationActionScope, TPamAccounts, TPamResources } from "@app/db/schemas"; import { PAM_RESOURCE_FACTORY_MAP } from "@app/ee/services/pam-resource/pam-resource-factory"; import { decryptResource, decryptResourceConnectionDetails } from "@app/ee/services/pam-resource/pam-resource-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; @@ -11,12 +11,14 @@ import { } from "@app/ee/services/permission/project-permission"; import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; import { ActorType } from "@app/services/auth/auth-type"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types"; import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; import { TLicenseServiceFactory } from "../license/license-service"; import { TPamFolderDALFactory } from "../pam-folder/pam-folder-dal"; @@ -45,10 +47,12 @@ type TPamAccountServiceFactoryDep = { "getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId" >; userDAL: TUserDALFactory; + auditLogService: Pick; }; - export type TPamAccountServiceFactory = ReturnType; +const ROTATION_CONCURRENCY_LIMIT = 10; + export const pamAccountServiceFactory = ({ pamResourceDAL, pamSessionDAL, @@ -59,10 +63,19 @@ export const pamAccountServiceFactory = ({ permissionService, licenseService, kmsService, - gatewayV2Service + gatewayV2Service, + auditLogService }: TPamAccountServiceFactoryDep) => { const create = async ( - { credentials, resourceId, name, description, folderId }: TCreateAccountDTO, + { + credentials, + resourceId, + name, + description, + folderId, + rotationEnabled, + rotationIntervalSeconds + }: TCreateAccountDTO, actor: OrgServiceActor ) => { const orgLicensePlan = await licenseService.getPlan(actor.orgId); @@ -72,6 +85,12 @@ export const pamAccountServiceFactory = ({ }); } + if (rotationEnabled && (rotationIntervalSeconds === undefined || rotationIntervalSeconds === null)) { + throw new BadRequestError({ + message: "Rotation interval must be defined when rotation is enabled." + }); + } + const resource = await pamResourceDAL.findById(resourceId); if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` }); @@ -84,6 +103,10 @@ export const pamAccountServiceFactory = ({ actionProjectType: ActionProjectType.PAM }); + if (!resource.encryptedRotationAccountCredentials && rotationEnabled) { + throw new NotFoundError({ message: "Rotation credentials are not configured for this account's resource" }); + } + const accountPath = await getFullPamFolderPath({ pamFolderDAL, folderId, @@ -126,12 +149,19 @@ export const pamAccountServiceFactory = ({ encryptedCredentials, name, description, - folderId + folderId, + rotationEnabled, + rotationIntervalSeconds }); return { ...(await decryptAccount(account, resource.projectId, kmsService)), - resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } + resource: { + id: resource.id, + name: resource.name, + resourceType: resource.resourceType, + rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials + } }; } catch (err) { if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { @@ -145,7 +175,7 @@ export const pamAccountServiceFactory = ({ }; const updateById = async ( - { accountId, credentials, description, name }: TUpdateAccountDTO, + { accountId, credentials, description, name, rotationEnabled, rotationIntervalSeconds }: TUpdateAccountDTO, actor: OrgServiceActor ) => { const orgLicensePlan = await licenseService.getPlan(actor.orgId); @@ -195,6 +225,17 @@ export const pamAccountServiceFactory = ({ updateDoc.description = description; } + if (rotationEnabled !== undefined) { + if (!resource.encryptedRotationAccountCredentials && rotationEnabled) { + throw new NotFoundError({ message: "Rotation credentials are not configured for this account's resource" }); + } + updateDoc.rotationEnabled = rotationEnabled; + } + + if (rotationIntervalSeconds !== undefined) { + updateDoc.rotationIntervalSeconds = rotationIntervalSeconds; + } + if (credentials !== undefined) { const connectionDetails = await decryptResourceConnectionDetails({ projectId: account.projectId, @@ -211,7 +252,7 @@ export const pamAccountServiceFactory = ({ // Logic to prevent overwriting unedited censored values const finalCredentials = { ...credentials }; - if (credentials.password === "******") { + if (credentials.password === "__INFISICAL_UNCHANGED__") { const decryptedCredentials = await decryptAccountCredentials({ encryptedCredentials: account.encryptedCredentials, projectId: account.projectId, @@ -239,7 +280,12 @@ export const pamAccountServiceFactory = ({ return { ...(await decryptAccount(updatedAccount, account.projectId, kmsService)), - resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } + resource: { + id: resource.id, + name: resource.name, + resourceType: resource.resourceType, + rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials + } }; }; @@ -278,7 +324,12 @@ export const pamAccountServiceFactory = ({ return { ...(await decryptAccount(deletedAccount, account.projectId, kmsService)), - resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } + resource: { + id: resource.id, + name: resource.name, + resourceType: resource.resourceType, + rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials + } }; }; @@ -300,7 +351,7 @@ export const pamAccountServiceFactory = ({ const decryptedAndPermittedAccounts: Array< TPamAccounts & { - resource: Pick; + resource: Pick & { rotationCredentialsConfigured: boolean }; credentials: TPamAccountCredentials; } > = []; @@ -330,7 +381,8 @@ export const pamAccountServiceFactory = ({ resource: { id: account.resource.id, name: account.resource.name, - resourceType: account.resource.resourceType + resourceType: account.resource.resourceType, + rotationCredentialsConfigured: !!account.resource.encryptedRotationAccountCredentials } }); } @@ -459,13 +511,14 @@ export const pamAccountServiceFactory = ({ const project = await projectDAL.findById(session.projectId); if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - project.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: project.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionGatewayActions.CreateGateways, @@ -516,12 +569,116 @@ export const pamAccountServiceFactory = ({ }; }; + const rotateAllDueAccounts = async () => { + const accounts = await pamAccountDAL.findAccountsDueForRotation(); + + for (let i = 0; i < accounts.length; i += ROTATION_CONCURRENCY_LIMIT) { + const batch = accounts.slice(i, i + ROTATION_CONCURRENCY_LIMIT); + + const rotationPromises = batch.map(async (account) => + pamAccountDAL.transaction(async (tx) => { + let logResourceType = "unknown"; + try { + const resource = await pamResourceDAL.findById(account.resourceId, tx); + if (!resource || !resource.encryptedRotationAccountCredentials) return; + logResourceType = resource.resourceType; + + const { connectionDetails, rotationAccountCredentials, gatewayId, resourceType } = await decryptResource( + resource, + account.projectId, + kmsService + ); + + if (!rotationAccountCredentials) return; + + const accountCredentials = await decryptAccountCredentials({ + encryptedCredentials: account.encryptedCredentials, + projectId: account.projectId, + kmsService + }); + + const factory = PAM_RESOURCE_FACTORY_MAP[resourceType as PamResource]( + resourceType as PamResource, + connectionDetails, + gatewayId, + gatewayV2Service + ); + + const newCredentials = await factory.rotateAccountCredentials( + rotationAccountCredentials, + accountCredentials + ); + + const encryptedCredentials = await encryptAccountCredentials({ + credentials: newCredentials, + projectId: account.projectId, + kmsService + }); + + await pamAccountDAL.updateById( + account.id, + { + encryptedCredentials, + lastRotatedAt: new Date() + }, + tx + ); + + await auditLogService.createAuditLog({ + projectId: account.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION, + metadata: { + accountId: account.id, + accountName: account.name, + resourceId: resource.id, + resourceType: logResourceType + } + } + }); + } catch (error) { + logger.error(error, `Failed to rotate credentials for account [accountId=${account.id}]`); + + const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; + + await auditLogService.createAuditLog({ + projectId: account.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED, + metadata: { + accountId: account.id, + accountName: account.name, + resourceId: account.resourceId, + resourceType: logResourceType, + errorMessage + } + } + }); + throw error; // Rollback transaction + } + }) + ); + + // eslint-disable-next-line no-await-in-loop + await Promise.all(rotationPromises); + } + }; + return { create, updateById, deleteById, list, access, - getSessionCredentials + getSessionCredentials, + rotateAllDueAccounts }; }; diff --git a/backend/src/ee/services/pam-account/pam-account-types.ts b/backend/src/ee/services/pam-account/pam-account-types.ts index 514d7d780..4bbccc6fa 100644 --- a/backend/src/ee/services/pam-account/pam-account-types.ts +++ b/backend/src/ee/services/pam-account/pam-account-types.ts @@ -1,7 +1,10 @@ import { TPamAccount } from "../pam-resource/pam-resource-types"; // DTOs -export type TCreateAccountDTO = Pick; +export type TCreateAccountDTO = Pick< + TPamAccount, + "name" | "description" | "credentials" | "folderId" | "resourceId" | "rotationEnabled" | "rotationIntervalSeconds" +>; export type TUpdateAccountDTO = Partial> & { accountId: string; diff --git a/backend/src/ee/services/pam-resource/pam-resource-fns.ts b/backend/src/ee/services/pam-resource/pam-resource-fns.ts index 1d79e892e..9d7493e68 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-fns.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-fns.ts @@ -2,6 +2,7 @@ import { TPamResources } from "@app/db/schemas"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { decryptAccountCredentials } from "../pam-account/pam-account-fns"; import { TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types"; import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns"; @@ -63,6 +64,13 @@ export const decryptResource = async ( encryptedConnectionDetails: resource.encryptedConnectionDetails, projectId, kmsService - }) + }), + rotationAccountCredentials: resource.encryptedRotationAccountCredentials + ? await decryptAccountCredentials({ + encryptedCredentials: resource.encryptedRotationAccountCredentials, + projectId, + kmsService + }) + : null } as TPamResource; }; diff --git a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts index 80a50a9a4..7f6165d88 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts @@ -6,6 +6,7 @@ import { slugSchema } from "@app/server/lib/schemas"; // Resources export const BasePamResourceSchema = PamResourcesSchema.omit({ encryptedConnectionDetails: true, + encryptedRotationAccountCredentials: true, resourceType: true }); @@ -30,6 +31,8 @@ export const BasePamAccountSchemaWithResource = BasePamAccountSchema.extend({ id: true, name: true, resourceType: true + }).extend({ + rotationCredentialsConfigured: z.boolean() }) }); @@ -37,10 +40,14 @@ export const BaseCreatePamAccountSchema = z.object({ resourceId: z.string().uuid(), folderId: z.string().uuid().optional(), name: slugSchema({ field: "name" }), - description: z.string().max(512).nullable().optional() + description: z.string().max(512).nullable().optional(), + rotationEnabled: z.boolean(), + rotationIntervalSeconds: z.number().min(3600).nullable().optional() }); export const BaseUpdatePamAccountSchema = z.object({ name: slugSchema({ field: "name" }).optional(), - description: z.string().max(512).nullable().optional() + description: z.string().max(512).nullable().optional(), + rotationEnabled: z.boolean().optional(), + rotationIntervalSeconds: z.number().min(3600).nullable().optional() }); diff --git a/backend/src/ee/services/pam-resource/pam-resource-service.ts b/backend/src/ee/services/pam-resource/pam-resource-service.ts index 312795a50..d97905dbe 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-service.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-service.ts @@ -10,10 +10,16 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; import { TLicenseServiceFactory } from "../license/license-service"; +import { decryptAccountCredentials, encryptAccountCredentials } from "../pam-account/pam-account-fns"; import { TPamResourceDALFactory } from "./pam-resource-dal"; import { PamResource } from "./pam-resource-enums"; import { PAM_RESOURCE_FACTORY_MAP } from "./pam-resource-factory"; -import { decryptResource, encryptResourceConnectionDetails, listResourceOptions } from "./pam-resource-fns"; +import { + decryptResource, + decryptResourceConnectionDetails, + encryptResourceConnectionDetails, + listResourceOptions +} from "./pam-resource-fns"; import { TCreateResourceDTO, TUpdateResourceDTO } from "./pam-resource-types"; type TPamResourceServiceFactoryDep = { @@ -61,7 +67,7 @@ export const pamResourceServiceFactory = ({ }; const create = async ( - { resourceType, connectionDetails, gatewayId, name, projectId }: TCreateResourceDTO, + { resourceType, connectionDetails, gatewayId, name, projectId, rotationAccountCredentials }: TCreateResourceDTO, actor: OrgServiceActor ) => { const orgLicensePlan = await licenseService.getPlan(actor.orgId); @@ -88,26 +94,42 @@ export const pamResourceServiceFactory = ({ gatewayId, gatewayV2Service ); - const validatedConnectionDetails = await factory.validateConnection(); + const validatedConnectionDetails = await factory.validateConnection(); const encryptedConnectionDetails = await encryptResourceConnectionDetails({ connectionDetails: validatedConnectionDetails, projectId, kmsService }); + let encryptedRotationAccountCredentials: Buffer | null = null; + + if (rotationAccountCredentials) { + const validatedRotationAccountCredentials = await factory.validateAccountCredentials(rotationAccountCredentials); + + encryptedRotationAccountCredentials = await encryptAccountCredentials({ + credentials: validatedRotationAccountCredentials, + projectId, + kmsService + }); + } + const resource = await pamResourceDAL.create({ resourceType, encryptedConnectionDetails, gatewayId, name, - projectId + projectId, + encryptedRotationAccountCredentials }); return decryptResource(resource, projectId, kmsService); }; - const updateById = async ({ connectionDetails, resourceId, name }: TUpdateResourceDTO, actor: OrgServiceActor) => { + const updateById = async ( + { connectionDetails, resourceId, name, rotationAccountCredentials }: TUpdateResourceDTO, + actor: OrgServiceActor + ) => { const orgLicensePlan = await licenseService.getPlan(actor.orgId); if (!orgLicensePlan.pam) { throw new BadRequestError({ @@ -151,6 +173,60 @@ export const pamResourceServiceFactory = ({ updateDoc.encryptedConnectionDetails = encryptedConnectionDetails; } + if (rotationAccountCredentials !== undefined) { + updateDoc.encryptedRotationAccountCredentials = null; + + if (rotationAccountCredentials) { + const decryptedConnectionDetails = + connectionDetails ?? + (await decryptResourceConnectionDetails({ + encryptedConnectionDetails: resource.encryptedConnectionDetails, + projectId: resource.projectId, + kmsService + })); + + const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource]( + resource.resourceType as PamResource, + decryptedConnectionDetails, + resource.gatewayId, + gatewayV2Service + ); + + // Logic to prevent overwriting unedited censored values + const finalCredentials = { ...rotationAccountCredentials }; + if ( + resource.encryptedRotationAccountCredentials && + rotationAccountCredentials.password === "__INFISICAL_UNCHANGED__" + ) { + const decryptedCredentials = await decryptAccountCredentials({ + encryptedCredentials: resource.encryptedRotationAccountCredentials, + projectId: resource.projectId, + kmsService + }); + + finalCredentials.password = decryptedCredentials.password; + } + + try { + const validatedRotationAccountCredentials = await factory.validateAccountCredentials(finalCredentials); + + updateDoc.encryptedRotationAccountCredentials = await encryptAccountCredentials({ + credentials: validatedRotationAccountCredentials, + projectId: resource.projectId, + kmsService + }); + } catch (err) { + if (err instanceof BadRequestError) { + throw new BadRequestError({ + message: `Rotation Account Error: ${err.message}` + }); + } + + throw err; + } + } + } + // If nothing was updated, return the fetched resource if (Object.keys(updateDoc).length === 0) { return decryptResource(resource, resource.projectId, kmsService); diff --git a/backend/src/ee/services/pam-resource/pam-resource-types.ts b/backend/src/ee/services/pam-resource/pam-resource-types.ts index fb1b669ed..f2016420a 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-types.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-types.ts @@ -18,7 +18,7 @@ export type TPamAccountCredentials = TPostgresAccountCredentials; // Resource DTOs export type TCreateResourceDTO = Pick< TPamResource, - "name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId" + "name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId" | "rotationAccountCredentials" >; export type TUpdateResourceDTO = Partial> & { @@ -30,6 +30,10 @@ export type TPamResourceFactoryValidateConnection = ( credentials: C ) => Promise; +export type TPamResourceFactoryRotateAccountCredentials = ( + rotationAccountCredentials: C, + currentCredentials: C +) => Promise; export type TPamResourceFactory = ( resourceType: PamResource, @@ -39,4 +43,5 @@ export type TPamResourceFactory { validateConnection: TPamResourceFactoryValidateConnection; validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials; + rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials; }; diff --git a/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts b/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts index a97e3f2e7..bbe83a3a4 100644 --- a/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts @@ -15,13 +15,24 @@ import { BaseSqlResourceConnectionDetailsSchema } from "../shared/sql/sql-resource-schemas"; -// Resources export const PostgresResourceConnectionDetailsSchema = BaseSqlResourceConnectionDetailsSchema; +export const PostgresAccountCredentialsSchema = BaseSqlAccountCredentialsSchema; +// Resources const BasePostgresResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.Postgres) }); export const PostgresResourceSchema = BasePostgresResourceSchema.extend({ - connectionDetails: PostgresResourceConnectionDetailsSchema + connectionDetails: PostgresResourceConnectionDetailsSchema, + rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional() +}); + +export const SanitizedPostgresResourceSchema = BasePostgresResourceSchema.extend({ + connectionDetails: PostgresResourceConnectionDetailsSchema, + rotationAccountCredentials: PostgresAccountCredentialsSchema.pick({ + username: true + }) + .nullable() + .optional() }); export const PostgresResourceListItemSchema = z.object({ @@ -30,16 +41,16 @@ export const PostgresResourceListItemSchema = z.object({ }); export const CreatePostgresResourceSchema = BaseCreatePamResourceSchema.extend({ - connectionDetails: PostgresResourceConnectionDetailsSchema + connectionDetails: PostgresResourceConnectionDetailsSchema, + rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional() }); export const UpdatePostgresResourceSchema = BaseUpdatePamResourceSchema.extend({ - connectionDetails: PostgresResourceConnectionDetailsSchema.optional() + connectionDetails: PostgresResourceConnectionDetailsSchema.optional(), + rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional() }); // Accounts -export const PostgresAccountCredentialsSchema = BaseSqlAccountCredentialsSchema; - export const PostgresAccountSchema = BasePamAccountSchema.extend({ credentials: PostgresAccountCredentialsSchema }); diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts index 74a2c74ae..73defd6e6 100644 --- a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts @@ -6,9 +6,14 @@ import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2 import { BadRequestError } from "@app/lib/errors"; import { GatewayProxyProtocol } from "@app/lib/gateway"; import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; import { PamResource } from "../../pam-resource-enums"; -import { TPamResourceFactory, TPamResourceFactoryValidateAccountCredentials } from "../../pam-resource-types"; +import { + TPamResourceFactory, + TPamResourceFactoryRotateAccountCredentials, + TPamResourceFactoryValidateAccountCredentials +} from "../../pam-resource-types"; import { TSqlAccountCredentials, TSqlResourceConnectionDetails } from "./sql-resource-types"; const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; @@ -176,8 +181,66 @@ export const sqlResourceFactory: TPamResourceFactory = async ( + rotationAccountCredentials, + currentCredentials + ) => { + try { + const newPassword = alphaNumericNanoId(32); + + await executeWithGateway( + { + connectionDetails, + gatewayId, + resourceType, + username: rotationAccountCredentials.username, + password: rotationAccountCredentials.password + }, + gatewayV2Service, + async (client) => { + switch (resourceType) { + case PamResource.Postgres: + await client.raw(`ALTER USER ?? WITH PASSWORD '${newPassword}'`, [currentCredentials.username]); + break; + default: + throw new BadRequestError({ + message: `Password rotation for ${resourceType as PamResource} is not supported.` + }); + } + } + ); + + return { username: currentCredentials.username, password: newPassword }; + } catch (error) { + if (error instanceof BadRequestError) { + if (error.message === `password authentication failed for user "${rotationAccountCredentials.username}"`) { + throw new BadRequestError({ + message: "Management credentials invalid: Username or password incorrect" + }); + } + + if (error.message.includes("permission denied")) { + throw new BadRequestError({ + message: `Management credentials lack permission to rotate password for user "${currentCredentials.username}"` + }); + } + + if (error.message === "Connection terminated unexpectedly") { + throw new BadRequestError({ + message: "Connection terminated unexpectedly. Verify that host and port are correct" + }); + } + } + + throw new BadRequestError({ + message: `Unable to rotate account credentials for ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + return { validateConnection, - validateAccountCredentials + validateAccountCredentials, + rotateAccountCredentials }; }; diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-schemas.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-schemas.ts index cb3abf109..96b6a6a24 100644 --- a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-schemas.ts @@ -16,6 +16,6 @@ export const BaseSqlResourceConnectionDetailsSchema = z.object({ // Accounts export const BaseSqlAccountCredentialsSchema = z.object({ - username: z.string().trim().min(1), - password: z.string().trim().min(1) + username: z.string().trim().min(1).max(63), + password: z.string().trim().min(1).max(256) }); diff --git a/backend/src/ee/services/pam-session/pam-session-service.ts b/backend/src/ee/services/pam-session/pam-session-service.ts index 713383306..26ff7daa6 100644 --- a/backend/src/ee/services/pam-session/pam-session-service.ts +++ b/backend/src/ee/services/pam-session/pam-session-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { ActionProjectType } from "@app/db/schemas"; +import { ActionProjectType, OrganizationActionScope } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; @@ -102,13 +102,14 @@ export const pamSessionServiceFactory = ({ const project = await projectDAL.findById(session.projectId); if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - project.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: project.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionGatewayActions.CreateGateways, @@ -142,13 +143,14 @@ export const pamSessionServiceFactory = ({ const project = await projectDAL.findById(session.projectId); if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - project.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: project.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); if (actor.type === ActorType.IDENTITY) { ForbiddenError.from(permission).throwUnlessCan( diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index b9cabe022..34876f739 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -5,6 +5,7 @@ import { ProjectPermissionAppConnectionActions, ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, + ProjectPermissionCertificateProfileActions, ProjectPermissionCmekActions, ProjectPermissionCommitsActions, ProjectPermissionDynamicSecretActions, @@ -72,8 +73,8 @@ const buildAdminPermissionRules = () => { ProjectPermissionPkiTemplateActions.Edit, ProjectPermissionPkiTemplateActions.Create, ProjectPermissionPkiTemplateActions.Delete, - ProjectPermissionPkiTemplateActions.IssueCert, - ProjectPermissionPkiTemplateActions.ListCerts + ProjectPermissionPkiTemplateActions.IssueCert, // deprecated + ProjectPermissionPkiTemplateActions.ListCerts // deprecated ], ProjectPermissionSub.CertificateTemplates ); @@ -99,6 +100,17 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.Certificates ); + can( + [ + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionCertificateProfileActions.Edit, + ProjectPermissionCertificateProfileActions.Create, + ProjectPermissionCertificateProfileActions.Delete, + ProjectPermissionCertificateProfileActions.IssueCert + ], + ProjectPermissionSub.CertificateProfiles + ); + can( [ProjectPermissionCommitsActions.Read, ProjectPermissionCommitsActions.PerformRollback], ProjectPermissionSub.Commits @@ -443,6 +455,7 @@ const buildMemberPermissionRules = () => { // double check if all CRUD are needed for CA and Certificates can([ProjectPermissionActions.Read], ProjectPermissionSub.CertificateAuthorities); + can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates); can( [ @@ -454,7 +467,15 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.Certificates ); - can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates); + can( + [ + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionCertificateProfileActions.Edit, + ProjectPermissionCertificateProfileActions.Create, + ProjectPermissionCertificateProfileActions.Delete + ], + ProjectPermissionSub.CertificateProfiles + ); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index d4da8c98f..743dcd63f 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -15,6 +15,11 @@ export enum OrgPermissionActions { Delete = "delete" } +export enum OrgPermissionSubOrgActions { + Create = "create", + DirectAccess = "direct-access" +} + export enum OrgPermissionAppConnectionActions { Read = "read", Create = "create", @@ -117,7 +122,8 @@ export enum OrgPermissionSubjects { Kmip = "kmip", Gateway = "gateway", Relay = "relay", - SecretShare = "secret-share" + SecretShare = "secret-share", + SubOrganization = "sub-organization" } export type AppConnectionSubjectFields = { @@ -128,6 +134,7 @@ export type OrgPermissionSet = | [OrgPermissionActions.Create, OrgPermissionSubjects.Workspace] | [OrgPermissionActions.Create, OrgPermissionSubjects.Project] | [OrgPermissionActions, OrgPermissionSubjects.Role] + | [OrgPermissionSubOrgActions, OrgPermissionSubjects.SubOrganization] | [OrgPermissionActions, OrgPermissionSubjects.Member] | [OrgPermissionActions, OrgPermissionSubjects.Settings] | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] @@ -185,6 +192,12 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ subject: z.literal(OrgPermissionSubjects.Role).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") }), + z.object({ + subject: z.literal(OrgPermissionSubjects.SubOrganization).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionSubOrgActions).describe( + "Describe what action an entity can take." + ) + }), z.object({ subject: z.literal(OrgPermissionSubjects.Member).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") @@ -308,6 +321,10 @@ const buildAdminPermission = () => { // ws permissions can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); can(OrgPermissionActions.Create, OrgPermissionSubjects.Project); + + can(OrgPermissionSubOrgActions.Create, OrgPermissionSubjects.SubOrganization); + can(OrgPermissionSubOrgActions.DirectAccess, OrgPermissionSubjects.SubOrganization); + // role permission can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); can(OrgPermissionActions.Create, OrgPermissionSubjects.Role); diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index 49a375f8f..95480a54a 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -19,6 +19,7 @@ interface TPermissionDataReturn extends TMemberships { orgAuthEnforced?: boolean | null; orgGoogleSsoAuthEnforced?: boolean | null; shouldUseNewPrivilegeSystem?: boolean | null; + rootOrgId?: string | null; bypassOrgAuthEnabled?: boolean | null; roles: { id: string; @@ -273,7 +274,8 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { db.ref("shouldUseNewPrivilegeSystem").withSchema(TableName.Organization), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"), - db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled") + db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"), + db.ref("rootOrgId").withSchema(TableName.Organization).as("rootOrgId") ); const data = sqlNestRelationships({ @@ -283,6 +285,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { MembershipsSchema.extend({ orgAuthEnforced: z.boolean().optional().nullable(), shouldUseNewPrivilegeSystem: z.boolean().optional().nullable(), + rootOrgId: z.string().optional().nullable(), orgGoogleSsoAuthEnforced: z.boolean(), bypassOrgAuthEnabled: z.boolean() }).parse(el), diff --git a/backend/src/ee/services/permission/permission-service-types.ts b/backend/src/ee/services/permission/permission-service-types.ts index 1f0e00470..c69564b31 100644 --- a/backend/src/ee/services/permission/permission-service-types.ts +++ b/backend/src/ee/services/permission/permission-service-types.ts @@ -2,7 +2,7 @@ import { MongoAbility } from "@casl/ability"; import { MongoQuery } from "@ucast/mongo2js"; import { Knex } from "knex"; -import { ActionProjectType, TMemberships } from "@app/db/schemas"; +import { ActionProjectType, OrganizationActionScope, TMemberships } from "@app/db/schemas"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { OrgPermissionSet } from "./org-permission"; @@ -18,21 +18,6 @@ export type TBuildOrgPermissionDTO = { role: string; }[]; -export type TGetUserProjectPermissionArg = { - userId: string; - projectId: string; - authMethod: ActorAuthMethod; - actionProjectType: ActionProjectType; - userOrgId?: string; -}; - -export type TGetIdentityProjectPermissionArg = { - identityId: string; - projectId: string; - identityOrgId?: string; - actionProjectType: ActionProjectType; -}; - export type TGetServiceTokenProjectPermissionArg = { serviceTokenId: string; projectId: string; @@ -54,17 +39,12 @@ export type TGetOrgPermissionArg = { actorId: string; orgId: string; actorAuthMethod: ActorAuthMethod; - actorOrgId?: string; + actorOrgId: string; + scope: OrganizationActionScope; }; export type TPermissionServiceFactory = { - getOrgPermission: ( - type: ActorType, - id: string, - orgId: string, - authMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => Promise<{ + getOrgPermission: (arg: TGetOrgPermissionArg) => Promise<{ permission: MongoAbility; memberships: Array< TMemberships & { diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 2d879b4fd..48b78d980 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -7,6 +7,7 @@ import { Knex } from "knex"; import { AccessScope, ActionProjectType, + OrganizationActionScope, OrgMembershipRole, ProjectMembershipRole, ServiceTokenScopes @@ -179,14 +180,15 @@ export const permissionServiceFactory = ({ // return minTtl; // }; - const getOrgPermission: TPermissionServiceFactory["getOrgPermission"] = async ( - type, - id, + const getOrgPermission: TPermissionServiceFactory["getOrgPermission"] = async ({ + actor, + actorId, orgId, - authMethod, - actorOrgId - ) => { - if (type !== ActorType.USER && type !== ActorType.IDENTITY) { + actorOrgId, + scope, + actorAuthMethod + }) => { + if (actor !== ActorType.USER && actor !== ActorType.IDENTITY) { throw new BadRequestError({ message: "Invalid actor provided", name: "Get org permission" @@ -202,11 +204,19 @@ export const permissionServiceFactory = ({ scope: AccessScope.Organization, orgId }, - actorId: id, - actorType: type + actorId, + actorType: actor }); if (!permissionData?.length) throw new ForbiddenRequestError({ name: "You are not member of this organization" }); + const rootOrgId = permissionData?.[0]?.rootOrgId; + const isChild = Boolean(rootOrgId); + if (scope === OrganizationActionScope.ParentOrganization && isChild) { + throw new ForbiddenRequestError({ message: `Child organization cannot do this operation` }); + } else if (scope === OrganizationActionScope.ChildOrganization && !isChild) { + throw new ForbiddenRequestError({ message: `Parent organization cannot do this operation` }); + } + const permissionFromRoles = permissionData.flatMap((membership) => { const activeRoles = membership?.roles .filter( @@ -227,7 +237,7 @@ export const permissionServiceFactory = ({ permissionData.some((memberships) => memberships.roles.some((el) => role === (el.customRoleSlug || el.role))); validateOrgSSO( - authMethod, + actorAuthMethod, permissionData?.[0].orgAuthEnforced, Boolean(permissionData?.[0].orgGoogleSsoAuthEnforced), Boolean(permissionData?.[0].bypassOrgAuthEnabled), diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index d2c5b30db..bb62440c1 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -111,6 +111,14 @@ export enum ProjectPermissionPkiSubscriberActions { ListCerts = "list-certs" } +export enum ProjectPermissionCertificateProfileActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + IssueCert = "issue-cert" +} + export enum ProjectPermissionSecretSyncActions { Read = "read", Create = "create", @@ -249,7 +257,8 @@ export enum ProjectPermissionSub { PamFolders = "pam-folders", PamResources = "pam-resources", PamAccounts = "pam-accounts", - PamSessions = "pam-sessions" + PamSessions = "pam-sessions", + CertificateProfiles = "certificate-profiles" } export type SecretSubjectFields = { @@ -438,7 +447,8 @@ export type ProjectPermissionSet = ProjectPermissionPamAccountActions, ProjectPermissionSub.PamAccounts | (ForcedSubject & PamAccountSubjectFields) ] - | [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions]; + | [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions] + | [ProjectPermissionCertificateProfileActions, ProjectPermissionSub.CertificateProfiles]; const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'"; const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([ @@ -1109,6 +1119,13 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + z.object({ + subject: z.literal(ProjectPermissionSub.CertificateProfiles).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCertificateProfileActions).describe( + "Describe what action an entity can take." + ) + }), ...GeneralPermissionSchema ]); diff --git a/backend/src/ee/services/project-template/project-template-service.ts b/backend/src/ee/services/project-template/project-template-service.ts index f3fe07aa8..5a9f04d8d 100644 --- a/backend/src/ee/services/project-template/project-template-service.ts +++ b/backend/src/ee/services/project-template/project-template-service.ts @@ -1,7 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import { packRules } from "@casl/ability/extra"; -import { ProjectType, TProjectTemplates } from "@app/db/schemas"; +import { OrganizationActionScope, ProjectType, TProjectTemplates } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; @@ -59,13 +59,14 @@ export const projectTemplateServiceFactory = ({ message: "Failed to access project templates due to plan restriction. Upgrade plan to access project templates." }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.ProjectTemplates); @@ -97,13 +98,14 @@ export const projectTemplateServiceFactory = ({ if (!projectTemplate) throw new NotFoundError({ message: `Could not find project template with Name "${name}"` }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - projectTemplate.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: projectTemplate.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.ProjectTemplates); @@ -125,13 +127,14 @@ export const projectTemplateServiceFactory = ({ if (!projectTemplate) throw new NotFoundError({ message: `Could not find project template with ID ${id}` }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - projectTemplate.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: projectTemplate.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.ProjectTemplates); @@ -152,13 +155,14 @@ export const projectTemplateServiceFactory = ({ message: "Failed to create project template due to plan restriction. Upgrade plan to access project templates." }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.ProjectTemplates); @@ -213,13 +217,14 @@ export const projectTemplateServiceFactory = ({ if (!projectTemplate) throw new NotFoundError({ message: `Could not find project template with ID ${id}` }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - projectTemplate.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: projectTemplate.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.ProjectTemplates); if (projectTemplate.type !== ProjectType.SecretManager && environments) @@ -272,13 +277,14 @@ export const projectTemplateServiceFactory = ({ if (!projectTemplate) throw new NotFoundError({ message: `Could not find project template with ID ${id}` }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - projectTemplate.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: projectTemplate.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.ProjectTemplates); diff --git a/backend/src/ee/services/project-template/project-template-types.ts b/backend/src/ee/services/project-template/project-template-types.ts index 8d9e952a7..1815344a7 100644 --- a/backend/src/ee/services/project-template/project-template-types.ts +++ b/backend/src/ee/services/project-template/project-template-types.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { ProjectMembershipRole, ProjectType, TProjectEnvironments } from "@app/db/schemas"; import { TProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission"; -import { OrgServiceActor } from "@app/lib/types"; +import { ProjectServiceActor } from "@app/lib/types"; import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission"; export type TProjectTemplateEnvironment = Pick; @@ -31,7 +31,7 @@ export enum InfisicalProjectTemplate { export type TProjectTemplateServiceFactory = { listProjectTemplatesByOrg: ( - actor: OrgServiceActor, + actor: ProjectServiceActor, type?: ProjectType ) => Promise< ( @@ -85,7 +85,7 @@ export type TProjectTemplateServiceFactory = { >; createProjectTemplate: ( arg: TCreateProjectTemplateDTO, - actor: OrgServiceActor + actor: ProjectServiceActor ) => Promise<{ environments: TProjectTemplateEnvironment[]; roles: { @@ -109,7 +109,7 @@ export type TProjectTemplateServiceFactory = { updateProjectTemplateById: ( id: string, { roles, environments, ...params }: TUpdateProjectTemplateDTO, - actor: OrgServiceActor + actor: ProjectServiceActor ) => Promise<{ environments: TProjectTemplateEnvironment[]; roles: { @@ -132,7 +132,7 @@ export type TProjectTemplateServiceFactory = { }>; deleteProjectTemplateById: ( id: string, - actor: OrgServiceActor + actor: ProjectServiceActor ) => Promise<{ environments: TProjectTemplateEnvironment[]; roles: { @@ -155,7 +155,7 @@ export type TProjectTemplateServiceFactory = { }>; findProjectTemplateById: ( id: string, - actor: OrgServiceActor + actor: ProjectServiceActor ) => Promise<{ packedRoles: TProjectTemplateRole[]; environments: TProjectTemplateEnvironment[]; @@ -179,7 +179,7 @@ export type TProjectTemplateServiceFactory = { }>; findProjectTemplateByName: ( name: string, - actor: OrgServiceActor + actor: ProjectServiceActor ) => Promise<{ packedRoles: TProjectTemplateRole[]; environments: TProjectTemplateEnvironment[]; diff --git a/backend/src/ee/services/relay/relay-service.ts b/backend/src/ee/services/relay/relay-service.ts index d791e9919..b2eb932ed 100644 --- a/backend/src/ee/services/relay/relay-service.ts +++ b/backend/src/ee/services/relay/relay-service.ts @@ -3,7 +3,7 @@ import { isIP } from "node:net"; import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; -import { OrgMembershipRole, TRelays } from "@app/db/schemas"; +import { OrganizationActionScope, OrgMembershipRole, TRelays } from "@app/db/schemas"; import { PgSqlLock } from "@app/keystore/keystore"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; @@ -126,8 +126,8 @@ export const relayServiceFactory = ({ // generate instance relay CA const instanceRelayCaSerialNumber = createSerialNumber(); - const instanceRelayCaIssuedAt = new Date(); const instanceRelayCaExpiration = new Date(new Date().setFullYear(2045)); + const instanceRelayCaIssuedAt = new Date(); const instanceRelayCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); const instanceRelayCaSkObj = crypto.nativeCrypto.KeyObject.from(instanceRelayCaKeys.privateKey); const instanceRelayCaCert = await x509.X509CertificateGenerator.create({ @@ -972,13 +972,14 @@ export const relayServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityId, + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityId, orgId, - actorAuthMethod!, - orgId - ); + actorAuthMethod: actorAuthMethod!, + actorOrgId: orgId + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionRelayActions.CreateRelays, @@ -1102,13 +1103,14 @@ export const relayServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityId, + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityId, orgId, - actorAuthMethod!, - orgId - ); + actorAuthMethod: actorAuthMethod!, + actorOrgId: orgId + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionRelayActions.CreateRelays, OrgPermissionSubjects.Relay @@ -1155,13 +1157,14 @@ export const relayServiceFactory = ({ actorAuthMethod: ActorAuthMethod; actorOrgId: string; }) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, - actorAuthMethod, + orgId: actorOrgId, + actorAuthMethod: actorAuthMethod!, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionRelayActions.ListRelays, OrgPermissionSubjects.Relay); @@ -1189,13 +1192,14 @@ export const relayServiceFactory = ({ actorAuthMethod: ActorAuthMethod; actorOrgId: string; }) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionRelayActions.DeleteRelays, OrgPermissionSubjects.Relay); diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index ab84ebd39..c99ae8b28 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -5,6 +5,7 @@ import RE2 from "re2"; import { AccessScope, + OrganizationActionScope, OrgMembershipRole, OrgMembershipStatus, TableName, @@ -83,7 +84,7 @@ type TSamlConfigServiceFactoryDep = { projectDAL: Pick; projectBotDAL: Pick; projectKeyDAL: Pick; - membershipGroupDAL: Pick; + membershipGroupDAL: Pick; }; export const samlConfigServiceFactory = ({ @@ -182,6 +183,22 @@ export const samlConfigServiceFactory = ({ transaction ); orgGroupsMap.set(groupName, newGroup); + const orgMembership = await membershipGroupDAL.create( + { + actorGroupId: newGroup.id, + scope: AccessScope.Organization, + scopeOrgId: orgId + }, + transaction + ); + await membershipRoleDAL.create( + { + membershipId: orgMembership.id, + role: OrgMembershipRole.NoAccess, + customRoleId: null + }, + transaction + ); } } @@ -251,7 +268,14 @@ export const samlConfigServiceFactory = ({ authProvider, enableGroupSync }) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Sso); const plan = await licenseService.getPlan(orgId); @@ -317,7 +341,14 @@ export const samlConfigServiceFactory = ({ authProvider, enableGroupSync }) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); const plan = await licenseService.getPlan(orgId); if (!plan.samlSSO) @@ -393,7 +424,7 @@ export const samlConfigServiceFactory = ({ }); } } else if (dto.type === "orgSlug") { - const org = await orgDAL.findOne({ slug: dto.orgSlug }); + const org = await orgDAL.findOne({ slug: dto.orgSlug, rootOrgId: null }); if (!org) { throw new NotFoundError({ message: `Organization with slug '${dto.orgSlug}' not found` @@ -424,13 +455,14 @@ export const samlConfigServiceFactory = ({ // when dto is type id means it's internally used if (dto.type === "org") { - const { permission } = await permissionService.getOrgPermission( - dto.actor, - dto.actorId, - samlConfig.orgId, - dto.actorAuthMethod, - dto.actorOrgId - ); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor: dto.actor, + actorId: dto.actorId, + orgId: samlConfig.orgId, + actorAuthMethod: dto.actorAuthMethod, + actorOrgId: dto.actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); } const { decryptor } = await kmsService.createCipherPairWithDataKey({ diff --git a/backend/src/ee/services/saml-config/saml-config-types.ts b/backend/src/ee/services/saml-config/saml-config-types.ts index bdf65b988..983ec4db7 100644 --- a/backend/src/ee/services/saml-config/saml-config-types.ts +++ b/backend/src/ee/services/saml-config/saml-config-types.ts @@ -37,7 +37,7 @@ export type TGetSamlCfgDTO = actor: ActorType; actorId: string; actorAuthMethod: ActorAuthMethod; - actorOrgId: string | undefined; + actorOrgId: string; } | { type: "orgSlug"; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index a08cd5dbf..8b9256023 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -4,6 +4,7 @@ import { scimPatch } from "scim-patch"; import { AccessScope, + OrganizationActionScope, OrgMembershipRole, OrgMembershipStatus, TableName, @@ -56,6 +57,7 @@ type TScimServiceFactoryDep = { TOrgDALFactory, | "createMembership" | "findById" + | "find" | "findMembership" | "findMembershipWithScimFilter" | "deleteMembershipById" @@ -125,7 +127,14 @@ export const scimServiceFactory = ({ description, ttlDays }) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Scim); const plan = await licenseService.getPlan(orgId); @@ -160,7 +169,14 @@ export const scimServiceFactory = ({ actorAuthMethod, orgId }) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Scim); const plan = await licenseService.getPlan(orgId); @@ -183,13 +199,14 @@ export const scimServiceFactory = ({ let scimToken = await scimDAL.findById(scimTokenId); if (!scimToken) throw new NotFoundError({ message: `SCIM token with ID '${scimTokenId}' not found` }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, actor, actorId, - scimToken.orgId, + orgId: scimToken.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Scim); const plan = await licenseService.getPlan(scimToken.orgId); diff --git a/backend/src/ee/services/secret-scanning/secret-scanning-service.ts b/backend/src/ee/services/secret-scanning/secret-scanning-service.ts index 85a3cd5f2..a5fbe37a7 100644 --- a/backend/src/ee/services/secret-scanning/secret-scanning-service.ts +++ b/backend/src/ee/services/secret-scanning/secret-scanning-service.ts @@ -2,6 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import { WebhookEventMap } from "@octokit/webhooks-types"; import { ProbotOctokit } from "probot"; +import { OrganizationActionScope } from "@app/db/schemas"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { getConfig } from "@app/lib/config/env"; @@ -49,7 +50,14 @@ export const secretScanningServiceFactory = ({ }: TInstallAppSessionDTO) => { const appCfg = getConfig(); - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.SecretScanning); const sessionId = crypto.randomBytes(16).toString("hex"); @@ -68,13 +76,14 @@ export const secretScanningServiceFactory = ({ const session = await gitAppInstallSessionDAL.findOne({ sessionId }); if (!session) throw new NotFoundError({ message: "Session was not found" }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - session.orgId, + orgId: session.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.SecretScanning); const installatedApp = await gitAppOrgDAL.transaction(async (tx) => { await gitAppInstallSessionDAL.deleteById(session.id, tx); @@ -117,7 +126,14 @@ export const secretScanningServiceFactory = ({ actorAuthMethod, actorOrgId }: TGetOrgInstallStatusDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); const appInstallation = await gitAppOrgDAL.findOne({ orgId }); @@ -125,7 +141,14 @@ export const secretScanningServiceFactory = ({ }; const getRisksByOrg = async ({ actor, orgId, actorId, actorAuthMethod, actorOrgId, filter }: TGetOrgRisksDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); const results = await secretScanningDAL.findByOrgId(orgId, filter); @@ -134,7 +157,14 @@ export const secretScanningServiceFactory = ({ }; const getAllRisksByOrg = async ({ actor, orgId, actorId, actorAuthMethod, actorOrgId }: TGetAllOrgRisksDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); const risks = await secretScanningDAL.find({ orgId }, { sort: [["createdAt", "desc"]] }); @@ -150,7 +180,14 @@ export const secretScanningServiceFactory = ({ riskId, status }: TUpdateRiskStatusDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning); const isRiskResolved = Boolean( diff --git a/backend/src/ee/services/sub-org/sub-org-service.ts b/backend/src/ee/services/sub-org/sub-org-service.ts new file mode 100644 index 000000000..74bf784d5 --- /dev/null +++ b/backend/src/ee/services/sub-org/sub-org-service.ts @@ -0,0 +1,160 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope, OrganizationActionScope, OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas"; +import { BadRequestError } from "@app/lib/errors"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TMembershipDALFactory } from "@app/services/membership/membership-dal"; +import { TMembershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; + +import { TLicenseServiceFactory } from "../license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects, OrgPermissionSubOrgActions } from "../permission/org-permission"; +import { TPermissionServiceFactory } from "../permission/permission-service-types"; +import { TCreateSubOrgDTO, TListSubOrgDTO, TUpdateSubOrgDTO } from "./sub-org-types"; + +type TSubOrgServiceFactoryDep = { + orgDAL: Pick< + TOrgDALFactory, + "findOne" | "create" | "transaction" | "listSubOrganizations" | "updateById" | "findById" + >; + permissionService: Pick; + licenseService: Pick; + membershipDAL: Pick; + membershipRoleDAL: Pick; +}; + +export type TSubOrgServiceFactory = ReturnType; + +export const subOrgServiceFactory = ({ + orgDAL, + permissionService, + licenseService, + membershipDAL, + membershipRoleDAL +}: TSubOrgServiceFactoryDep) => { + const createSubOrg = async ({ name, permissionActor }: TCreateSubOrgDTO) => { + const { permission } = await permissionService.getOrgPermission({ + actorId: permissionActor.id, + actor: permissionActor.type, + orgId: permissionActor.orgId, + actorOrgId: permissionActor.orgId, + actorAuthMethod: permissionActor.authMethod, + scope: OrganizationActionScope.ParentOrganization + }); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionSubOrgActions.Create, + OrgPermissionSubjects.SubOrganization + ); + + const orgLicensePlan = await licenseService.getPlan(permissionActor.rootOrgId); + if (!orgLicensePlan.subOrganization) { + throw new BadRequestError({ + message: "Sub-organization creation failed. Please upgrade your instance to Infisical's Enterprise plan." + }); + } + + const existingSubOrg = await orgDAL.findOne({ + parentOrgId: permissionActor.orgId, + name + }); + if (existingSubOrg) { + throw new BadRequestError({ message: `Sub-organization with name ${name} already exists` }); + } + + const organization = await orgDAL.transaction(async (tx) => { + const org = await orgDAL.create( + { name, slug: name, rootOrgId: permissionActor.rootOrgId, parentOrgId: permissionActor.orgId }, + tx + ); + const membership = await membershipDAL.create( + { + scope: AccessScope.Organization, + [permissionActor.type === ActorType.IDENTITY ? "actorIdentityId" : "actorUserId"]: permissionActor.id, + scopeOrgId: org.id, + status: OrgMembershipStatus.Accepted, + isActive: true + }, + tx + ); + await membershipRoleDAL.create( + { + membershipId: membership.id, + role: OrgMembershipRole.Admin + }, + tx + ); + return org; + }); + + return { + organization + }; + }; + + const listSubOrgs = async ({ permissionActor, data }: TListSubOrgDTO) => { + await permissionService.getOrgPermission({ + actorId: permissionActor.id, + actor: permissionActor.type, + orgId: permissionActor.rootOrgId, + actorOrgId: permissionActor.rootOrgId, + actorAuthMethod: permissionActor.authMethod, + scope: OrganizationActionScope.Any + }); + + const organizations = await orgDAL.listSubOrganizations({ + actorId: permissionActor.id, + actorType: permissionActor.type, + orgId: permissionActor.rootOrgId, + isAccessible: data?.isAccessible, + limit: data?.limit, + offset: data?.offset + }); + + return { + organizations + }; + }; + + const updateSubOrg = async ({ subOrgId, name, permissionActor }: TUpdateSubOrgDTO) => { + const subOrg = await orgDAL.findOne({ + rootOrgId: permissionActor.rootOrgId, + id: subOrgId + }); + if (!subOrg) { + throw new BadRequestError({ message: "Sub-organization not found" }); + } + + const { permission } = await permissionService.getOrgPermission({ + actorId: permissionActor.id, + actor: permissionActor.type, + orgId: subOrgId, + actorOrgId: subOrgId, + actorAuthMethod: permissionActor.authMethod, + scope: OrganizationActionScope.ChildOrganization + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); + + const existingSubOrg = await orgDAL.findOne({ + parentOrgId: subOrg.parentOrgId, + slug: name + }); + + if (existingSubOrg && existingSubOrg.id !== subOrgId) { + throw new BadRequestError({ message: `Sub-organization with name ${name} already exists` }); + } + + const organization = await orgDAL.updateById(subOrgId, { name, slug: name }); + + return { + organization + }; + }; + + return { + createSubOrg, + listSubOrgs, + updateSubOrg + }; +}; diff --git a/backend/src/ee/services/sub-org/sub-org-types.ts b/backend/src/ee/services/sub-org/sub-org-types.ts new file mode 100644 index 000000000..a1af9878e --- /dev/null +++ b/backend/src/ee/services/sub-org/sub-org-types.ts @@ -0,0 +1,22 @@ +import { OrgServiceActor } from "@app/lib/types"; + +export type TCreateSubOrgDTO = { + name: string; + permissionActor: OrgServiceActor; +}; + +export type TListSubOrgDTO = { + permissionActor: OrgServiceActor; + data: Partial<{ + limit?: number; + offset?: number; + search?: string; + isAccessible?: boolean; + }>; +}; + +export type TUpdateSubOrgDTO = { + subOrgId: string; + name: string; + permissionActor: OrgServiceActor; +}; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 8d1ae45bf..dde9b4e35 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -33,6 +33,7 @@ export enum ApiDocsTags { LdapAuth = "LDAP Auth", Groups = "Groups", Organizations = "Organizations", + SubOrganizations = "Sub Organizations", Projects = "Projects", ProjectUsers = "Project Users", ProjectGroups = "Project Groups", @@ -57,6 +58,7 @@ export enum ApiDocsTags { PkiCertificateAuthorities = "PKI Certificate Authorities", PkiCertificates = "PKI Certificates", PkiCertificateTemplates = "PKI Certificate Templates", + PkiCertificateProfiles = "PKI Certificate Profiles", PkiCertificateCollections = "PKI Certificate Collections", PkiAlerting = "PKI Alerting", PkiSubscribers = "PKI Subscribers", @@ -716,6 +718,21 @@ export const ORGANIZATIONS = { } } as const; +export const SUB_ORGANIZATIONS = { + CREATE: { + name: "The name of the sub organization to create." + }, + UPDATE: { + name: "The name of the sub organization to update.", + subOrgId: "The id of the sub organization to update." + }, + LIST: { + limit: "The number of sub organizations to return.", + offset: "The offset to start from. If you enter 10, it will start from the 10th sub organization.", + isAccessible: "Filter to only return sub organizations that the actor has access to." + } +} as const; + export const PROJECTS = { CREATE: { organizationSlug: "The slug of the organization to create the project in.", diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 15f878323..2da7a245a 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { crypto } from "@app/lib/crypto/cryptography"; import { QueueWorkerProfile } from "@app/lib/types"; import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; @@ -8,6 +9,7 @@ import { BadRequestError } from "../errors"; import { removeTrailingSlash } from "../fn"; import { CustomLogger } from "../logger/logger"; import { zpStr } from "../zod"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; export const GITLAB_URL = "https://gitlab.com"; @@ -363,11 +365,6 @@ const envSchema = z /* INTERNAL ----------------------------------------------------------------------------- */ INTERNAL_REGION: zpStr(z.enum(["us", "eu"]).optional()) }) - // To ensure that basic encryption is always possible. - .refine( - (data) => Boolean(data.ENCRYPTION_KEY) || Boolean(data.ROOT_ENCRYPTION_KEY), - "Either ENCRYPTION_KEY or ROOT_ENCRYPTION_KEY must be defined." - ) .refine( (data) => Boolean(data.REDIS_URL) || Boolean(data.REDIS_SENTINEL_HOSTS) || Boolean(data.REDIS_CLUSTER_HOSTS), "Either REDIS_URL, REDIS_SENTINEL_HOSTS or REDIS_CLUSTER_HOSTS must be defined." @@ -453,7 +450,12 @@ export const getConfig = () => envCfg; export const getOriginalConfig = () => originalEnvConfig; // cannot import singleton logger directly as it needs config to load various transport -export const initEnvConfig = async (superAdminDAL?: TSuperAdminDALFactory, logger?: CustomLogger) => { +export const initEnvConfig = async ( + hsmService: THsmServiceFactory, + kmsRootConfigDAL: TKmsRootConfigDALFactory, + superAdminDAL?: TSuperAdminDALFactory, + logger?: CustomLogger +) => { const parsedEnv = envSchema.safeParse(process.env); if (!parsedEnv.success) { (logger ?? console).error("Invalid environment variables. Check the error below"); @@ -469,7 +471,7 @@ export const initEnvConfig = async (superAdminDAL?: TSuperAdminDALFactory, logge } if (superAdminDAL) { - const fipsEnabled = await crypto.initialize(superAdminDAL); + const fipsEnabled = await crypto.initialize(superAdminDAL, hsmService, kmsRootConfigDAL); if (fipsEnabled) { const newEnvCfg = { @@ -532,6 +534,22 @@ export const getDatabaseCredentials = (logger?: CustomLogger) => { }; }; +export const getHsmConfig = (logger?: CustomLogger) => { + const parsedEnv = envSchema.safeParse(process.env); + if (!parsedEnv.success) { + (logger ?? console).error("Invalid environment variables. Check the error below"); + (logger ?? console).error(parsedEnv.error.issues); + process.exit(-1); + } + return { + isHsmConfigured: parsedEnv.data.isHsmConfigured, + HSM_PIN: parsedEnv.data.HSM_PIN, + HSM_SLOT: parsedEnv.data.HSM_SLOT, + HSM_LIB_PATH: parsedEnv.data.HSM_LIB_PATH, + HSM_KEY_LABEL: parsedEnv.data.HSM_KEY_LABEL + }; +}; + // A list of environment variables that can be overwritten export const overwriteSchema: { [key: string]: { diff --git a/backend/src/lib/crypto/cryptography/crypto.ts b/backend/src/lib/crypto/cryptography/crypto.ts index 45c7a1986..6e2a15740 100644 --- a/backend/src/lib/crypto/cryptography/crypto.ts +++ b/backend/src/lib/crypto/cryptography/crypto.ts @@ -9,7 +9,11 @@ import nacl from "tweetnacl"; import naclUtils from "tweetnacl-util"; import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; +import { isHsmActiveAndEnabled } from "@app/ee/services/hsm/hsm-fns"; +import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; +import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types"; import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { ADMIN_CONFIG_DB_UUID } from "@app/services/super-admin/super-admin-service"; @@ -106,49 +110,73 @@ const cryptographyFactory = () => { } }; - const $setFipsModeEnabled = (enabled: boolean, envCfg?: Pick) => { + const $setFipsModeEnabled = async ( + enabled: boolean, + hsmService: THsmServiceFactory, + kmsRootConfigDAL: TKmsRootConfigDALFactory, + envCfg?: Pick + ) => { // If FIPS is enabled, we need to validate that the ENCRYPTION_KEY is in a base64 format, and is a 256-bit key. if (enabled) { crypto.setFips(true); const appCfg = envCfg || getConfig(); - if (appCfg.ENCRYPTION_KEY) { - // we need to validate that the ENCRYPTION_KEY is a base64 encoded 256-bit key + const hsmStatus = await isHsmActiveAndEnabled({ + hsmService, + kmsRootConfigDAL + }); - // note(daniel): for some reason this resolves as true for some hex-encoded strings. - if (!isBase64(appCfg.ENCRYPTION_KEY)) { + // if the encryption strategy is software - user needs to provide an encryption key + // if the encryption strategy is null AND the hsm is not configured - user needs to provide an encryption key + const needsEncryptionKey = + hsmStatus.rootKmsConfigEncryptionStrategy === RootKeyEncryptionStrategy.Software || + (hsmStatus.rootKmsConfigEncryptionStrategy === null && !hsmStatus.isHsmConfigured); + + // only perform encryption key validation if it's actually required. + if (needsEncryptionKey) { + if (appCfg.ENCRYPTION_KEY) { + // we need to validate that the ENCRYPTION_KEY is a base64 encoded 256-bit key + + // note(daniel): for some reason this resolves as true for some hex-encoded strings. + if (!isBase64(appCfg.ENCRYPTION_KEY)) { + throw new CryptographyError({ + message: + "FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not a base64 encoded 256-bit key.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`" + }); + } + + if (bytesToBits(Buffer.from(appCfg.ENCRYPTION_KEY, "base64").length) !== 256) { + throw new CryptographyError({ + message: + "FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not a 256-bit key.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`" + }); + } + } else { throw new CryptographyError({ message: - "FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not a base64 encoded 256-bit key.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`" + "FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not set.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`" }); } - - if (bytesToBits(Buffer.from(appCfg.ENCRYPTION_KEY, "base64").length) !== 256) { - throw new CryptographyError({ - message: - "FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not a 256-bit key.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`" - }); - } - } else { - throw new CryptographyError({ - message: - "FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not set.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`" - }); } } $fipsEnabled = enabled; $isInitialized = true; }; - const initialize = async (superAdminDAL: TSuperAdminDALFactory, envCfg?: Pick) => { + const initialize = async ( + superAdminDAL: TSuperAdminDALFactory, + hsmService: THsmServiceFactory, + kmsRootConfigDAL: TKmsRootConfigDALFactory, + envCfg?: Pick + ) => { if ($isInitialized) { return isFipsModeEnabled(); } if (process.env.FIPS_ENABLED !== "true") { logger.info("Cryptography module initialized in normal operation mode."); - $setFipsModeEnabled(false, envCfg); + await $setFipsModeEnabled(false, hsmService, kmsRootConfigDAL, envCfg); return false; } @@ -158,11 +186,11 @@ const cryptographyFactory = () => { if (serverCfg) { if (serverCfg.fipsEnabled) { logger.info("[FIPS]: Instance is configured for FIPS mode of operation. Continuing startup with FIPS enabled."); - $setFipsModeEnabled(true, envCfg); + await $setFipsModeEnabled(true, hsmService, kmsRootConfigDAL, envCfg); return true; } logger.info("[FIPS]: Instance age predates FIPS mode inception date. Continuing without FIPS."); - $setFipsModeEnabled(false, envCfg); + await $setFipsModeEnabled(false, hsmService, kmsRootConfigDAL, envCfg); return false; } @@ -171,7 +199,7 @@ const cryptographyFactory = () => { // TODO(daniel): check if it's an enterprise deployment // if there is no server cfg, and FIPS_MODE is `true`, its a fresh FIPS deployment. We need to set the fipsEnabled to true. - $setFipsModeEnabled(true, envCfg); + await $setFipsModeEnabled(true, hsmService, kmsRootConfigDAL, envCfg); return true; }; @@ -258,6 +286,13 @@ const cryptographyFactory = () => { const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY; const encryptionKey = appCfg.ENCRYPTION_KEY; + // Sanity check + if (!rootEncryptionKey && !encryptionKey) { + throw new CryptographyError({ + message: "Tried to encrypt with instance root encryption key, but no root encryption key is set." + }); + } + if (rootEncryptionKey) { const { iv, tag, ciphertext } = encrypt({ plaintext: data, @@ -303,6 +338,14 @@ const cryptographyFactory = () => { // the or gate is used used in migration const rootEncryptionKey = appCfg?.ROOT_ENCRYPTION_KEY || process.env.ROOT_ENCRYPTION_KEY; const encryptionKey = appCfg?.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY; + + // Sanity check + if (!rootEncryptionKey && !encryptionKey) { + throw new CryptographyError({ + message: "Tried to decrypt with instance root encryption key, but no root encryption key is set." + }); + } + if (rootEncryptionKey && keyEncoding === SecretKeyEncoding.BASE64) { const data = symmetric().decrypt({ key: rootEncryptionKey, diff --git a/backend/src/lib/types/index.ts b/backend/src/lib/types/index.ts index a7a60349f..5fbe8ca79 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -5,7 +5,7 @@ export type TGenericPermission = { actor: ActorType; actorId: string; actorAuthMethod: ActorAuthMethod; - actorOrgId: string | undefined; + actorOrgId: string; }; /** @@ -78,6 +78,15 @@ export type OrgServiceActor = { id: string; authMethod: ActorAuthMethod; orgId: string; + rootOrgId: string; + parentOrgId: string; +}; + +export type ProjectServiceActor = { + type: ActorType; + id: string; + authMethod: ActorAuthMethod; + orgId: string; }; export enum QueueWorkerProfile { diff --git a/backend/src/main.ts b/backend/src/main.ts index 7be9f43ec..400804804 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -9,14 +9,16 @@ import { keyValueStoreDALFactory } from "@app/keystore/key-value-store-dal"; import { runMigrations } from "./auto-start-migrations"; import { initAuditLogDbConnection, initDbConnection } from "./db"; +import { hsmServiceFactory } from "./ee/services/hsm/hsm-service"; import { keyStoreFactory } from "./keystore/keystore"; -import { formatSmtpConfig, getDatabaseCredentials, initEnvConfig } from "./lib/config/env"; +import { formatSmtpConfig, getDatabaseCredentials, getHsmConfig, initEnvConfig } from "./lib/config/env"; import { buildRedisFromConfig } from "./lib/config/redis"; import { removeTemporaryBaseDirectory } from "./lib/files"; import { initLogger } from "./lib/logger"; import { queueServiceFactory } from "./queue"; import { main } from "./server/app"; import { bootstrapCheck } from "./server/boot-strap-check"; +import { kmsRootConfigDALFactory } from "./services/kms/kms-root-config-dal"; import { smtpServiceFactory } from "./services/smtp/smtp-service"; import { superAdminDALFactory } from "./services/super-admin/super-admin-dal"; @@ -26,6 +28,18 @@ const run = async () => { const logger = initLogger(); await removeTemporaryBaseDirectory(); + const hsmConfig = getHsmConfig(logger); + + const hsmModule = initializeHsmModule(hsmConfig); + hsmModule.initialize(); + + const hsmService = hsmServiceFactory({ + hsmModule: hsmModule.getModule(), + envConfig: hsmConfig + }); + + await hsmService.startService(); + const databaseCredentials = getDatabaseCredentials(logger); const db = initDbConnection({ @@ -35,7 +49,8 @@ const run = async () => { }); const superAdminDAL = superAdminDALFactory(db); - const envConfig = await initEnvConfig(superAdminDAL, logger); + const kmsRootConfigDAL = kmsRootConfigDALFactory(db); + const envConfig = await initEnvConfig(hsmService, kmsRootConfigDAL, superAdminDAL, logger); const auditLogDb = envConfig.AUDIT_LOGS_DB_CONNECTION_URI ? initAuditLogDbConnection({ @@ -59,14 +74,12 @@ const run = async () => { const keyStore = keyStoreFactory(envConfig, keyValueStoreDAL); const redis = buildRedisFromConfig(envConfig); - const hsmModule = initializeHsmModule(envConfig); - hsmModule.initialize(); - const server = await main({ db, auditLogDb, superAdminDAL, - hsmModule: hsmModule.getModule(), + kmsRootConfigDAL, + hsmService, smtp, logger, queue, diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 7f45e3821..9d8c472f4 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -77,7 +77,8 @@ export enum QueueName { DailyReminders = "daily-reminders", SecretReminderMigration = "secret-reminder-migration", UserNotification = "user-notification", - HealthAlert = "health-alert" + HealthAlert = "health-alert", + PamAccountRotation = "pam-account-rotation" } export enum QueueJobs { @@ -126,7 +127,8 @@ export enum QueueJobs { DailyReminders = "daily-reminders", SecretReminderMigration = "secret-reminder-migration", UserNotification = "user-notification-job", - HealthAlert = "health-alert" + HealthAlert = "health-alert", + PamAccountRotation = "pam-account-rotation" } export type TQueueJobTypes = { @@ -357,6 +359,10 @@ export type TQueueJobTypes = { name: QueueJobs.HealthAlert; payload: undefined; }; + [QueueName.PamAccountRotation]: { + name: QueueJobs.PamAccountRotation; + payload: undefined; + }; }; const SECRET_SCANNING_JOBS = [ diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index 8cf23f703..f1176b932 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -15,12 +15,13 @@ import fastify from "fastify"; import { Cluster, Redis } from "ioredis"; import { Knex } from "knex"; -import { HsmModule } from "@app/ee/services/hsm/hsm-types"; +import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig, IS_PACKAGED, TEnvConfig } from "@app/lib/config/env"; import { CustomLogger } from "@app/lib/logger/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TQueueServiceFactory } from "@app/queue"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { TSmtpService } from "@app/services/smtp/smtp-service"; import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; @@ -42,16 +43,16 @@ type TMain = { logger?: CustomLogger; queue: TQueueServiceFactory; keyStore: TKeyStoreFactory; - hsmModule: HsmModule; redis: Redis | Cluster; envConfig: TEnvConfig; superAdminDAL: TSuperAdminDALFactory; + hsmService: THsmServiceFactory; + kmsRootConfigDAL: TKmsRootConfigDALFactory; }; // Run the server! export const main = async ({ db, - hsmModule, auditLogDb, smtp, logger, @@ -59,7 +60,9 @@ export const main = async ({ keyStore, redis, envConfig, - superAdminDAL + superAdminDAL, + hsmService, + kmsRootConfigDAL }: TMain) => { const appCfg = getConfig(); @@ -148,9 +151,10 @@ export const main = async ({ db, auditLogDb, keyStore, - hsmModule, + hsmService, envConfig, - superAdminDAL + superAdminDAL, + kmsRootConfigDAL }); await server.register(registerServeUI, { diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 1bff11879..b33f2fbe6 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -8,6 +8,7 @@ import { TScimTokenJwtPayload } from "@app/ee/services/scim/scim-types"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { BadRequestError } from "@app/lib/errors"; +import { slugSchema } from "@app/server/lib/schemas"; import { ActorType, AuthMethod, AuthMode, AuthModeJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type"; import { TIdentityAccessTokenJwtPayload } from "@app/services/identity-access-token/identity-access-token-types"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; @@ -20,6 +21,8 @@ export type TAuthMode = tokenVersionId: string; // the session id of token used user: TUsers; orgId: string; + rootOrgId: string; + parentOrgId: string; authMethod: AuthMethod; isMfaVerified?: boolean; token: AuthModeJwtTokenPayload; @@ -31,6 +34,8 @@ export type TAuthMode = userId: string; user: TUsers; orgId: string; + rootOrgId: string; + parentOrgId: string; token: string; } | { @@ -39,6 +44,8 @@ export type TAuthMode = actor: ActorType.SERVICE; serviceTokenId: string; orgId: string; + rootOrgId: string; + parentOrgId: string; authMethod: null; token: string; } @@ -48,6 +55,8 @@ export type TAuthMode = identityId: string; identityName: string; orgId: string; + rootOrgId: string; + parentOrgId: string; authMethod: null; isInstanceAdmin?: boolean; token: TIdentityAccessTokenJwtPayload; @@ -57,6 +66,8 @@ export type TAuthMode = actor: ActorType.SCIM_CLIENT; scimTokenId: string; orgId: string; + rootOrgId: string; + parentOrgId: string; authMethod: null; }; @@ -136,17 +147,26 @@ export const injectIdentity = fp( if (!authMode) return; + const subOrganizationSelector = req.headers?.["x-infisical-org"] as string | undefined; + if (subOrganizationSelector) { + await slugSchema().parseAsync(subOrganizationSelector); + } + switch (authMode) { case AuthMode.JWT: { - const { user, tokenVersionId, orgId } = await server.services.authToken.fnValidateJwtIdentity(token); + const { user, tokenVersionId, orgId, rootOrgId, parentOrgId } = + await server.services.authToken.fnValidateJwtIdentity(token, subOrganizationSelector); requestContext.set("orgId", orgId); + req.auth = { authMode: AuthMode.JWT, user, userId: user.id, tokenVersionId, actor, - orgId: orgId as string, + orgId, + rootOrgId, + parentOrgId, authMethod: token.authMethod, isMfaVerified: token.isMfaVerified, token @@ -154,13 +174,19 @@ export const injectIdentity = fp( break; } case AuthMode.IDENTITY_ACCESS_TOKEN: { - const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(token, req.realIp); + const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken( + token, + subOrganizationSelector, + req.realIp + ); const serverCfg = await getServerCfg(); requestContext.set("orgId", identity.orgId); req.auth = { authMode: AuthMode.IDENTITY_ACCESS_TOKEN, actor, orgId: identity.orgId, + rootOrgId: identity.rootOrgId, + parentOrgId: identity.parentOrgId, identityId: identity.identityId, identityName: identity.name, authMethod: null, @@ -190,8 +216,14 @@ export const injectIdentity = fp( case AuthMode.SERVICE_TOKEN: { const serviceToken = await server.services.serviceToken.fnValidateServiceToken(token); requestContext.set("orgId", serviceToken.orgId); + + if (subOrganizationSelector) + throw new BadRequestError({ message: `Service token doesn't support sub organization selector` }); + req.auth = { orgId: serviceToken.orgId, + rootOrgId: serviceToken.rootOrgId, + parentOrgId: serviceToken.parentOrgId, authMode: AuthMode.SERVICE_TOKEN as const, serviceToken, serviceTokenId: serviceToken.id, @@ -202,22 +234,27 @@ export const injectIdentity = fp( break; } case AuthMode.API_KEY: { - const user = await server.services.apiKey.fnValidateApiKey(token as string); - req.auth = { - authMode: AuthMode.API_KEY as const, - userId: user.id, - actor, - user, - orgId: "API_KEY", // We set the orgId to an arbitrary value, since we can't link an API key to a specific org. We have to deprecate API keys soon! - authMethod: null, - token: token as string - }; - break; + throw new BadRequestError({ + message: "API key authentication is not supported anymore. Please switch to identity authentication." + }); } case AuthMode.SCIM_TOKEN: { const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token); requestContext.set("orgId", orgId); - req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId, authMethod: null }; + + if (subOrganizationSelector) + throw new BadRequestError({ message: `SCIM token doesn't support sub organization selector` }); + + req.auth = { + authMode: AuthMode.SCIM_TOKEN, + actor, + scimTokenId, + orgId, + authMethod: null, + // scim cannot be done for sub organization + rootOrgId: orgId, + parentOrgId: orgId + }; break; } default: diff --git a/backend/src/server/plugins/auth/inject-permission.ts b/backend/src/server/plugins/auth/inject-permission.ts index 11a94657b..827a055d3 100644 --- a/backend/src/server/plugins/auth/inject-permission.ts +++ b/backend/src/server/plugins/auth/inject-permission.ts @@ -14,7 +14,9 @@ export const injectPermission = fp(async (server) => { type: ActorType.USER, id: req.auth.userId, orgId: req.auth.orgId, // if the req.auth.authMode is AuthMode.API_KEY, the orgId will be "API_KEY" - authMethod: req.auth.authMethod // if the req.auth.authMode is AuthMode.API_KEY, the authMethod will be null + authMethod: req.auth.authMethod, // if the req.auth.authMode is AuthMode.API_KEY, the authMethod will be null + rootOrgId: req.auth.rootOrgId, + parentOrgId: req.auth.parentOrgId }; logger.info( @@ -25,7 +27,9 @@ export const injectPermission = fp(async (server) => { type: ActorType.IDENTITY, id: req.auth.identityId, orgId: req.auth.orgId, - authMethod: null + authMethod: null, + rootOrgId: req.auth.rootOrgId, + parentOrgId: req.auth.parentOrgId }; logger.info( @@ -36,6 +40,8 @@ export const injectPermission = fp(async (server) => { type: ActorType.SERVICE, id: req.auth.serviceTokenId, orgId: req.auth.orgId, + rootOrgId: req.auth.rootOrgId, + parentOrgId: req.auth.parentOrgId, authMethod: null }; @@ -47,6 +53,8 @@ export const injectPermission = fp(async (server) => { type: ActorType.SCIM_CLIENT, id: req.auth.scimTokenId, orgId: req.auth.orgId, + rootOrgId: req.auth.rootOrgId, + parentOrgId: req.auth.parentOrgId, authMethod: null }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 4a73a650c..b42d01850 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -46,8 +46,8 @@ import { githubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/gi import { groupDALFactory } from "@app/ee/services/group/group-dal"; import { groupServiceFactory } from "@app/ee/services/group/group-service"; import { userGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; -import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; -import { HsmModule } from "@app/ee/services/hsm/hsm-types"; +import { isHsmActiveAndEnabled } from "@app/ee/services/hsm/hsm-fns"; +import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { identityAuthTemplateDALFactory } from "@app/ee/services/identity-auth-template/identity-auth-template-dal"; import { identityAuthTemplateServiceFactory } from "@app/ee/services/identity-auth-template/identity-auth-template-service"; import { kmipClientCertificateDALFactory } from "@app/ee/services/kmip/kmip-client-certificate-dal"; @@ -131,12 +131,14 @@ import { sshHostLoginUserDALFactory } from "@app/ee/services/ssh-host/ssh-login- import { sshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal"; import { sshHostGroupMembershipDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-membership-dal"; import { sshHostGroupServiceFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-service"; +import { subOrgServiceFactory } from "@app/ee/services/sub-org/sub-org-service"; import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal"; import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { keyValueStoreDALFactory } from "@app/keystore/key-value-store-dal"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig, TEnvConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TQueueServiceFactory } from "@app/queue"; import { readLimit } from "@app/server/config/rateLimiter"; @@ -167,11 +169,19 @@ import { externalCertificateAuthorityDALFactory } from "@app/services/certificat import { internalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-dal"; import { InternalCertificateAuthorityFns } from "@app/services/certificate-authority/internal/internal-certificate-authority-fns"; import { internalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +import { certificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service"; +import { certificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { certificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service"; import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal"; import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; +import { certificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal"; +import { certificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; +import { certificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; import { cmekServiceFactory } from "@app/services/cmek/cmek-service"; import { convertorServiceFactory } from "@app/services/convertor/convertor-service"; +import { apiEnrollmentConfigDALFactory } from "@app/services/enrollment-config/api-enrollment-config-dal"; +import { estEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal"; import { externalGroupOrgRoleMappingDALFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-dal"; import { externalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; import { externalMigrationQueueFactory } from "@app/services/external-migration/external-migration-queue"; @@ -227,8 +237,9 @@ import { integrationAuthDALFactory } from "@app/services/integration-auth/integr import { integrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; import { internalKmsDALFactory } from "@app/services/kms/internal-kms-dal"; import { kmskeyDALFactory } from "@app/services/kms/kms-key-dal"; -import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { kmsServiceFactory } from "@app/services/kms/kms-service"; +import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types"; import { membershipDALFactory } from "@app/services/membership/membership-dal"; import { membershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; import { membershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal"; @@ -246,11 +257,11 @@ import { userNotificationDALFactory } from "@app/services/notification/user-noti import { offlineUsageReportDALFactory } from "@app/services/offline-usage-report/offline-usage-report-dal"; import { offlineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; -import { orgBotDALFactory } from "@app/services/org/org-bot-dal"; import { orgDALFactory } from "@app/services/org/org-dal"; import { orgServiceFactory } from "@app/services/org/org-service"; import { orgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; +import { pamAccountRotationServiceFactory } from "@app/services/pam-account-rotation/pam-account-rotation-queue"; import { dailyExpiringPkiItemAlertQueueServiceFactory } from "@app/services/pki-alert/expiring-pki-item-alert-queue"; import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal"; import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; @@ -355,20 +366,22 @@ export const registerRoutes = async ( auditLogDb, superAdminDAL, db, - hsmModule, smtp: smtpService, queue: queueService, keyStore, - envConfig + envConfig, + hsmService, + kmsRootConfigDAL }: { auditLogDb?: Knex; superAdminDAL: TSuperAdminDALFactory; db: Knex; - hsmModule: HsmModule; smtp: TSmtpService; queue: TQueueServiceFactory; keyStore: TKeyStoreFactory; envConfig: TEnvConfig; + hsmService: THsmServiceFactory; + kmsRootConfigDAL: TKmsRootConfigDALFactory; } ) => { const appCfg = getConfig(); @@ -383,7 +396,6 @@ export const registerRoutes = async ( const authTokenDAL = tokenDALFactory(db); const orgDAL = orgDALFactory(db); const orgMembershipDAL = orgMembershipDALFactory(db); - const orgBotDAL = orgBotDALFactory(db); const incidentContactDAL = incidentContactDALFactory(db); const rateLimitDAL = rateLimitDALFactory(db); const apiKeyDAL = apiKeyDALFactory(db); @@ -500,7 +512,6 @@ export const registerRoutes = async ( const kmsDAL = kmskeyDALFactory(db); const internalKmsDAL = internalKmsDALFactory(db); const externalKmsDAL = externalKmsDALFactory(db); - const kmsRootConfigDAL = kmsRootConfigDALFactory(db); const slackIntegrationDAL = slackIntegrationDALFactory(db); const projectSlackConfigDAL = projectSlackConfigDALFactory(db); @@ -560,11 +571,11 @@ export const registerRoutes = async ( orgDAL, licenseDAL, keyStore, - identityOrgMembershipDAL, - projectDAL + projectDAL, + envConfig }); - const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL, membershipUserDAL }); + const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL, membershipUserDAL, orgDAL }); const membershipUserService = membershipUserServiceFactory({ licenseService, @@ -584,6 +595,7 @@ export const registerRoutes = async ( }); const membershipIdentityService = membershipIdentityServiceFactory({ + identityDAL, membershipIdentityDAL, membershipRoleDAL, orgDAL, @@ -615,11 +627,6 @@ export const registerRoutes = async ( permissionService }); - const hsmService = hsmServiceFactory({ - hsmModule, - envConfig - }); - const kmsService = kmsServiceFactory({ kmsRootConfigDAL, keyStore, @@ -892,7 +899,6 @@ export const registerRoutes = async ( smtpService, userDAL, groupDAL, - orgBotDAL, oidcConfigDAL, ldapConfigDAL, loginService, @@ -904,6 +910,15 @@ export const registerRoutes = async ( userGroupMembershipDAL, additionalPrivilegeDAL }); + + const subOrgService = subOrgServiceFactory({ + licenseService, + membershipDAL, + membershipRoleDAL, + orgDAL, + permissionService + }); + const signupService = authSignupServiceFactory({ tokenService, smtpService, @@ -1036,6 +1051,10 @@ export const registerRoutes = async ( const certificateAuthorityCrlDAL = certificateAuthorityCrlDALFactory(db); const certificateTemplateDAL = certificateTemplateDALFactory(db); const certificateTemplateEstConfigDAL = certificateTemplateEstConfigDALFactory(db); + const certificateTemplateV2DAL = certificateTemplateV2DALFactory(db); + const certificateProfileDAL = certificateProfileDALFactory(db); + const apiEnrollmentConfigDAL = apiEnrollmentConfigDALFactory(db); + const estEnrollmentConfigDAL = estEnrollmentConfigDALFactory(db); const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); @@ -1120,6 +1139,21 @@ export const registerRoutes = async ( licenseService }); + const certificateTemplateV2Service = certificateTemplateV2ServiceFactory({ + certificateTemplateV2DAL, + permissionService + }); + + const certificateProfileService = certificateProfileServiceFactory({ + certificateProfileDAL, + certificateTemplateV2DAL, + apiEnrollmentConfigDAL, + estEnrollmentConfigDAL, + permissionService, + kmsService, + projectDAL + }); + const pkiAlertService = pkiAlertServiceFactory({ pkiAlertDAL, pkiCollectionDAL, @@ -1567,10 +1601,12 @@ export const registerRoutes = async ( permissionService, projectDAL, accessTokenQueue, - smtpService + smtpService, + orgDAL }); const identityService = identityServiceFactory({ + additionalPrivilegeDAL, permissionService, identityDAL, identityOrgMembershipDAL, @@ -1601,10 +1637,12 @@ export const registerRoutes = async ( identityAccessTokenDAL, accessTokenQueue, identityDAL, - membershipIdentityDAL + membershipIdentityDAL, + orgDAL }); const identityTokenAuthService = identityTokenAuthServiceFactory({ + identityDAL, identityTokenAuthDAL, identityAccessTokenDAL, permissionService, @@ -1614,6 +1652,7 @@ export const registerRoutes = async ( }); const identityUaService = identityUaServiceFactory({ + identityDAL, permissionService, identityAccessTokenDAL, identityUaClientSecretDAL, @@ -1625,6 +1664,7 @@ export const registerRoutes = async ( }); const identityKubernetesAuthService = identityKubernetesAuthServiceFactory({ + identityDAL, identityKubernetesAuthDAL, identityAccessTokenDAL, permissionService, @@ -1638,6 +1678,7 @@ export const registerRoutes = async ( membershipIdentityDAL }); const identityGcpAuthService = identityGcpAuthServiceFactory({ + identityDAL, identityGcpAuthDAL, orgDAL, identityAccessTokenDAL, @@ -1647,6 +1688,7 @@ export const registerRoutes = async ( }); const identityAliCloudAuthService = identityAliCloudAuthServiceFactory({ + identityDAL, identityAccessTokenDAL, orgDAL, identityAliCloudAuthDAL, @@ -1656,6 +1698,7 @@ export const registerRoutes = async ( }); const identityTlsCertAuthService = identityTlsCertAuthServiceFactory({ + identityDAL, identityAccessTokenDAL, identityTlsCertAuthDAL, licenseService, @@ -1665,6 +1708,7 @@ export const registerRoutes = async ( }); const identityAwsAuthService = identityAwsAuthServiceFactory({ + identityDAL, identityAccessTokenDAL, orgDAL, identityAwsAuthDAL, @@ -1674,6 +1718,7 @@ export const registerRoutes = async ( }); const identityAzureAuthService = identityAzureAuthServiceFactory({ + identityDAL, identityAzureAuthDAL, orgDAL, identityAccessTokenDAL, @@ -1683,6 +1728,7 @@ export const registerRoutes = async ( }); const identityOciAuthService = identityOciAuthServiceFactory({ + identityDAL, identityAccessTokenDAL, orgDAL, identityOciAuthDAL, @@ -1706,6 +1752,7 @@ export const registerRoutes = async ( }); const identityOidcAuthService = identityOidcAuthServiceFactory({ + identityDAL, identityOidcAuthDAL, orgDAL, identityAccessTokenDAL, @@ -1716,6 +1763,7 @@ export const registerRoutes = async ( }); const identityJwtAuthService = identityJwtAuthServiceFactory({ + identityDAL, identityJwtAuthDAL, orgDAL, permissionService, @@ -2086,6 +2134,27 @@ export const registerRoutes = async ( pkiSyncQueue }); + const certificateV3Service = certificateV3ServiceFactory({ + certificateDAL, + certificateAuthorityDAL, + certificateProfileDAL, + certificateTemplateV2Service, + internalCaService: internalCertificateAuthorityService, + permissionService + }); + + const certificateEstV3Service = certificateEstV3ServiceFactory({ + internalCertificateAuthorityService, + certificateTemplateV2Service, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService, + licenseService, + certificateProfileDAL, + estEnrollmentConfigDAL + }); + const pkiSubscriberService = pkiSubscriberServiceFactory({ pkiSubscriberDAL, certificateAuthorityDAL, @@ -2190,7 +2259,13 @@ export const registerRoutes = async ( pamSessionDAL, permissionService, projectDAL, - userDAL + userDAL, + auditLogService + }); + + const pamAccountRotation = pamAccountRotationServiceFactory({ + queueService, + pamAccountService }); const pamSessionService = pamSessionServiceFactory({ @@ -2224,16 +2299,38 @@ export const registerRoutes = async ( // Start HSM service if it's configured/enabled. await hsmService.startService(); + const hsmStatus = await isHsmActiveAndEnabled({ + hsmService, + kmsRootConfigDAL, + licenseService + }); + + // if the encryption strategy is software - user needs to provide an encryption key + // if the encryption strategy is null AND the hsm is not configured - user needs to provide an encryption key + const needsEncryptionKey = + hsmStatus.rootKmsConfigEncryptionStrategy === RootKeyEncryptionStrategy.Software || + (hsmStatus.rootKmsConfigEncryptionStrategy === null && !hsmStatus.isHsmConfigured); + + if (needsEncryptionKey) { + if (!envConfig.ROOT_ENCRYPTION_KEY && !envConfig.ENCRYPTION_KEY) { + throw new BadRequestError({ + message: + "Root KMS encryption strategy is set to software. Please set the ENCRYPTION_KEY environment variable and restart your deployment.\nYou can enable HSM encryption in the Server Console." + }); + } + } + await telemetryQueue.startTelemetryCheck(); await telemetryQueue.startAggregatedEventsJob(); await dailyResourceCleanUp.init(); await healthAlert.init(); await pkiSyncCleanup.init(); + await pamAccountRotation.init(); await dailyReminderQueueService.startDailyRemindersJob(); await dailyReminderQueueService.startSecretReminderMigrationJob(); await dailyExpiringPkiItemAlert.startSendingAlerts(); await pkiSubscriberQueue.startDailyAutoRenewalJob(); - await kmsService.startService(); + await kmsService.startService(hsmStatus); await microsoftTeamsService.start(); await dynamicSecretQueueService.init(); await eventBusService.init(); @@ -2248,6 +2345,7 @@ export const registerRoutes = async ( groupProject: groupProjectService, permission: permissionService, org: orgService, + subOrganization: subOrgService, oidc: oidcService, apiKey: apiKeyService, authToken: tokenService, @@ -2296,6 +2394,8 @@ export const registerRoutes = async ( auditLog: auditLogService, auditLogStream: auditLogStreamService, certificate: certificateService, + certificateV3: certificateV3Service, + certificateEstV3: certificateEstV3Service, sshCertificateAuthority: sshCertificateAuthorityService, sshCertificateTemplate: sshCertificateTemplateService, sshHost: sshHostService, @@ -2303,6 +2403,8 @@ export const registerRoutes = async ( certificateAuthority: certificateAuthorityService, internalCertificateAuthority: internalCertificateAuthorityService, certificateTemplate: certificateTemplateService, + certificateTemplateV2: certificateTemplateV2Service, + certificateProfile: certificateProfileService, certificateAuthorityCrl: certificateAuthorityCrlService, certificateEst: certificateEstService, pit: pitService, diff --git a/backend/src/server/routes/v1/auth-router.ts b/backend/src/server/routes/v1/auth-router.ts index 911979b60..48939844e 100644 --- a/backend/src/server/routes/v1/auth-router.ts +++ b/backend/src/server/routes/v1/auth-router.ts @@ -94,6 +94,7 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { decodedToken.userId, decodedToken.organizationId, decodedToken.authMethod, + decodedToken.organizationId, decodedToken.organizationId ); if (org && org.userTokenExpiration) { diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts new file mode 100644 index 000000000..2292c3ba8 --- /dev/null +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -0,0 +1,506 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { PkiCertificateProfilesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { CertStatus } from "@app/services/certificate/certificate-types"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; + +export const registerCertificateProfilesRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + body: z + .object({ + projectId: z.string().min(1), + caId: z.string().uuid(), + certificateTemplateId: z.string().uuid(), + slug: z + .string() + .min(1) + .max(255) + .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"), + description: z.string().max(1000).optional(), + enrollmentType: z.nativeEnum(EnrollmentType), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().default(false), + passphrase: z.string().min(1), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().default(false), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + if (!data.estConfig) { + return false; + } + if (data.apiConfig) { + return false; + } + } + if (data.enrollmentType === EnrollmentType.API) { + if (!data.apiConfig) { + return false; + } + if (data.estConfig) { + return false; + } + } + return true; + }, + { + message: + "EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration." + } + ), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.createProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.body.projectId, + data: req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.body.projectId, + event: { + type: EventType.CREATE_CERTIFICATE_PROFILE, + metadata: { + certificateProfileId: certificateProfile.id, + name: certificateProfile.slug, + projectId: certificateProfile.projectId, + enrollmentType: certificateProfile.enrollmentType + } + } + }); + + return { certificateProfile }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + querystring: z.object({ + projectId: z.string().min(1), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + search: z.string().optional(), + enrollmentType: z.nativeEnum(EnrollmentType).optional(), + caId: z.string().uuid().optional(), + includeMetrics: z.coerce.boolean().optional().default(false), + expiringDays: z.coerce.number().min(1).max(365).optional().default(7) + }), + response: { + 200: z.object({ + certificateProfiles: PkiCertificateProfilesSchema.extend({ + metrics: z + .object({ + profileId: z.string(), + totalCertificates: z.number(), + activeCertificates: z.number(), + expiredCertificates: z.number(), + expiringCertificates: z.number(), + revokedCertificates: z.number() + }) + .optional(), + estConfig: z + .object({ + id: z.string(), + disableBootstrapCaValidation: z.boolean(), + passphrase: z.string().optional(), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + id: z.string(), + autoRenew: z.boolean(), + autoRenewDays: z.number().optional() + }) + .optional() + }).array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { profiles, totalCount } = await server.services.certificateProfile.listProfiles({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.LIST_CERTIFICATE_PROFILES, + metadata: { + projectId: req.query.projectId + } + } + }); + + return { certificateProfiles: profiles, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + id: z.string().uuid() + }), + querystring: z.object({ + includeMetrics: z.coerce.boolean().optional().default(false), + expiringDays: z.coerce.number().min(1).max(365).optional().default(7) + }), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema.extend({ + certificateAuthority: z + .object({ + id: z.string(), + projectId: z.string(), + status: z.string(), + name: z.string() + }) + .optional(), + certificateTemplate: z + .object({ + id: z.string(), + projectId: z.string(), + name: z.string(), + description: z.string().optional() + }) + .optional(), + estConfig: z + .object({ + id: z.string(), + disableBootstrapCaValidation: z.boolean(), + passphrase: z.string(), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + id: z.string(), + autoRenew: z.boolean(), + autoRenewDays: z.number().optional() + }) + .optional(), + metrics: z + .object({ + profileId: z.string(), + totalCertificates: z.number(), + activeCertificates: z.number(), + expiredCertificates: z.number(), + expiringCertificates: z.number(), + revokedCertificates: z.number() + }) + .optional() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.getProfileByIdWithConfigs({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id + }); + + let result = certificateProfile; + + if (req.query.includeMetrics) { + const metrics = await server.services.certificateProfile.getProfileMetrics({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id, + expiringDays: req.query.expiringDays + }); + result = { ...certificateProfile, metrics }; + } + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateProfile.projectId, + event: { + type: EventType.GET_CERTIFICATE_PROFILE, + metadata: { + certificateProfileId: certificateProfile.id, + name: certificateProfile.slug + } + } + }); + + return { certificateProfile: result }; + } + }); + + server.route({ + method: "GET", + url: "/slug/:slug", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + slug: z.string().min(1) + }), + querystring: z.object({ + projectId: z.string().min(1) + }), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.getProfileBySlug({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.query.projectId, + slug: req.params.slug + }); + + return { certificateProfile }; + } + }); + + server.route({ + method: "PATCH", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + id: z.string().uuid() + }), + body: z + .object({ + slug: z + .string() + .min(1) + .max(255) + .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens") + .optional(), + description: z.string().max(1000).optional(), + enrollmentType: z.nativeEnum(EnrollmentType).optional(), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().default(false), + passphrase: z.string().min(1).optional(), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().default(false), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + if (data.apiConfig) { + return false; + } + } + if (data.enrollmentType === EnrollmentType.API) { + if (data.estConfig) { + return false; + } + } + return true; + }, + { + message: "Cannot have EST config with API enrollment type or API config with EST enrollment type." + } + ), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.updateProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id, + data: req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateProfile.projectId, + event: { + type: EventType.UPDATE_CERTIFICATE_PROFILE, + metadata: { + certificateProfileId: certificateProfile.id, + name: certificateProfile.slug + } + } + }); + + return { certificateProfile }; + } + }); + + server.route({ + method: "DELETE", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + id: z.string().uuid() + }), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.deleteProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateProfile.projectId, + event: { + type: EventType.DELETE_CERTIFICATE_PROFILE, + metadata: { + certificateProfileId: certificateProfile.id, + name: certificateProfile.slug + } + } + }); + + return { certificateProfile }; + } + }); + + server.route({ + method: "GET", + url: "/:id/certificates", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + id: z.string().uuid() + }), + querystring: z.object({ + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + status: z.nativeEnum(CertStatus).optional(), + search: z.string().optional() + }), + response: { + 200: z.object({ + certificates: z.array( + z.object({ + id: z.string(), + serialNumber: z.string(), + cn: z.string(), + status: z.string(), + notBefore: z.date(), + notAfter: z.date(), + revokedAt: z.date().nullable().optional(), + createdAt: z.date() + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificates = await server.services.certificateProfile.getProfileCertificates({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id, + ...req.query + }); + + return { certificates }; + } + }); +}; diff --git a/backend/src/server/routes/v1/certificate-template-router.ts b/backend/src/server/routes/v1/certificate-template-router.ts index 17f564be4..5ff0e39c0 100644 --- a/backend/src/server/routes/v1/certificate-template-router.ts +++ b/backend/src/server/routes/v1/certificate-template-router.ts @@ -52,7 +52,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid event: { type: EventType.GET_CERTIFICATE_TEMPLATE, metadata: { - certificateTemplateId: certificateTemplate.id + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name } } }); @@ -121,7 +122,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid name: certificateTemplate.name, commonName: certificateTemplate.commonName, subjectAlternativeName: certificateTemplate.subjectAlternativeName, - ttl: certificateTemplate.ttl + ttl: certificateTemplate.ttl, + projectId: certificateTemplate.projectId } } }); @@ -184,9 +186,9 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid type: EventType.UPDATE_CERTIFICATE_TEMPLATE, metadata: { certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name, caId: certificateTemplate.caId, pkiCollectionId: certificateTemplate.pkiCollectionId as string, - name: certificateTemplate.name, commonName: certificateTemplate.commonName, subjectAlternativeName: certificateTemplate.subjectAlternativeName, ttl: certificateTemplate.ttl @@ -230,7 +232,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid event: { type: EventType.DELETE_CERTIFICATE_TEMPLATE, metadata: { - certificateTemplateId: certificateTemplate.id + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name } } }); diff --git a/backend/src/server/routes/v1/identity-alicloud-auth-router.ts b/backend/src/server/routes/v1/identity-alicloud-auth-router.ts index 3645a8bb6..8f64d3b23 100644 --- a/backend/src/server/routes/v1/identity-alicloud-auth-router.ts +++ b/backend/src/server/routes/v1/identity-alicloud-auth-router.ts @@ -73,12 +73,12 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi } }, handler: async (req) => { - const { identityAliCloudAuth, accessToken, identityAccessToken, identityMembershipOrg } = + const { identityAliCloudAuth, accessToken, identityAccessToken, identity } = await server.services.identityAliCloudAuth.login(req.body); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg.scopeOrgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_ALICLOUD_AUTH, metadata: { diff --git a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts index 59526899c..3cfb19895 100644 --- a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts +++ b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts @@ -40,12 +40,12 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) } }, handler: async (req) => { - const { identityAwsAuth, accessToken, identityAccessToken, identityMembershipOrg } = + const { identityAwsAuth, accessToken, identityAccessToken, identity } = await server.services.identityAwsAuth.login(req.body); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg.scopeOrgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_AWS_AUTH, metadata: { diff --git a/backend/src/server/routes/v1/identity-azure-auth-router.ts b/backend/src/server/routes/v1/identity-azure-auth-router.ts index 2649655bd..cdab7af02 100644 --- a/backend/src/server/routes/v1/identity-azure-auth-router.ts +++ b/backend/src/server/routes/v1/identity-azure-auth-router.ts @@ -35,12 +35,12 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider } }, handler: async (req) => { - const { identityAzureAuth, accessToken, identityAccessToken, identityMembershipOrg } = + const { identityAzureAuth, accessToken, identityAccessToken, identity } = await server.services.identityAzureAuth.login(req.body); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg.scopeOrgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_AZURE_AUTH, metadata: { diff --git a/backend/src/server/routes/v1/identity-gcp-auth-router.ts b/backend/src/server/routes/v1/identity-gcp-auth-router.ts index d65c46613..474999b2b 100644 --- a/backend/src/server/routes/v1/identity-gcp-auth-router.ts +++ b/backend/src/server/routes/v1/identity-gcp-auth-router.ts @@ -35,12 +35,12 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) } }, handler: async (req) => { - const { identityGcpAuth, accessToken, identityAccessToken, identityMembershipOrg } = + const { identityGcpAuth, accessToken, identityAccessToken, identity } = await server.services.identityGcpAuth.login(req.body); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg.scopeOrgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_GCP_AUTH, metadata: { diff --git a/backend/src/server/routes/v1/identity-jwt-auth-router.ts b/backend/src/server/routes/v1/identity-jwt-auth-router.ts index 2a882471d..5d71b3781 100644 --- a/backend/src/server/routes/v1/identity-jwt-auth-router.ts +++ b/backend/src/server/routes/v1/identity-jwt-auth-router.ts @@ -111,7 +111,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) } }, handler: async (req) => { - const { identityJwtAuth, accessToken, identityAccessToken, identityMembershipOrg } = + const { identityJwtAuth, accessToken, identityAccessToken, identity } = await server.services.identityJwtAuth.login({ identityId: req.body.identityId, jwt: req.body.jwt @@ -119,7 +119,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg.scopeOrgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_JWT_AUTH, metadata: { diff --git a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts index 0794cf00d..28f611aba 100644 --- a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -56,7 +56,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide } }, handler: async (req) => { - const { identityKubernetesAuth, accessToken, identityAccessToken, identityMembershipOrg } = + const { identityKubernetesAuth, accessToken, identityAccessToken, identity } = await server.services.identityKubernetesAuth.login({ identityId: req.body.identityId, jwt: req.body.jwt @@ -64,7 +64,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg.scopeOrgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_KUBERNETES_AUTH, metadata: { diff --git a/backend/src/server/routes/v1/identity-ldap-auth-router.ts b/backend/src/server/routes/v1/identity-ldap-auth-router.ts index caf5708e3..dade20ea3 100644 --- a/backend/src/server/routes/v1/identity-ldap-auth-router.ts +++ b/backend/src/server/routes/v1/identity-ldap-auth-router.ts @@ -162,13 +162,13 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) const { identityId, user } = req.passportMachineIdentity; - const { accessToken, identityLdapAuth, identityMembershipOrg } = await server.services.identityLdapAuth.login({ + const { accessToken, identityLdapAuth, identity } = await server.services.identityLdapAuth.login({ identityId }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg.scopeOrgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_LDAP_AUTH, metadata: { diff --git a/backend/src/server/routes/v1/identity-oci-auth-router.ts b/backend/src/server/routes/v1/identity-oci-auth-router.ts index 24d414286..003d9810b 100644 --- a/backend/src/server/routes/v1/identity-oci-auth-router.ts +++ b/backend/src/server/routes/v1/identity-oci-auth-router.ts @@ -52,12 +52,12 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) } }, handler: async (req) => { - const { identityOciAuth, accessToken, identityAccessToken, identityMembershipOrg } = + const { identityOciAuth, accessToken, identityAccessToken, identity } = await server.services.identityOciAuth.login(req.body); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg.scopeOrgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_OCI_AUTH, metadata: { diff --git a/backend/src/server/routes/v1/identity-oidc-auth-router.ts b/backend/src/server/routes/v1/identity-oidc-auth-router.ts index 48fa64bf4..6fad1f400 100644 --- a/backend/src/server/routes/v1/identity-oidc-auth-router.ts +++ b/backend/src/server/routes/v1/identity-oidc-auth-router.ts @@ -59,7 +59,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) } }, handler: async (req) => { - const { identityOidcAuth, accessToken, identityAccessToken, identityMembershipOrg, oidcTokenData } = + const { identityOidcAuth, accessToken, identityAccessToken, identity, oidcTokenData } = await server.services.identityOidcAuth.login({ identityId: req.body.identityId, jwt: req.body.jwt @@ -67,7 +67,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg.scopeOrgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_OIDC_AUTH, metadata: { diff --git a/backend/src/server/routes/v1/identity-org-membership-router.ts b/backend/src/server/routes/v1/identity-org-membership-router.ts new file mode 100644 index 000000000..c9b93965a --- /dev/null +++ b/backend/src/server/routes/v1/identity-org-membership-router.ts @@ -0,0 +1,137 @@ +import { z } from "zod"; + +import { AccessScope, TemporaryPermissionMode } from "@app/db/schemas"; +import { ApiDocsTags, PROJECT_IDENTITIES } from "@app/lib/api-docs"; +import { ms } from "@app/lib/ms"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const sanitizedOrgIdentityMembershipSchema = z.object({ + id: z.string().uuid(), + orgId: z.string(), + identityId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/identity-memberships/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: true, + // this is hidden so not updating tags + tags: [ApiDocsTags.ProjectIdentities], + description: "Create org identity membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + roles: z + .array( + z.union([ + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(false) + .default(false) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + }), + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryMode: z + .nativeEnum(TemporaryPermissionMode) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + }) + ]) + ) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.description) + .max(1) + }), + response: { + 200: z.object({ + identityMembership: sanitizedOrgIdentityMembershipSchema + }) + } + }, + handler: async (req) => { + const { membership } = await server.services.membershipIdentity.createMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + data: { + identityId: req.params.identityId, + roles: req.body.roles + } + }); + + return { + identityMembership: { ...membership, identityId: req.params.identityId, orgId: req.permission.orgId } + }; + } + }); + + server.route({ + method: "DELETE", + url: "/identity-memberships/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: true, + tags: [ApiDocsTags.ProjectIdentities], + description: "Delete org identity memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(PROJECT_IDENTITIES.DELETE_IDENTITY_MEMBERSHIP.identityId) + }), + response: { + 200: z.object({ + identityMembership: sanitizedOrgIdentityMembershipSchema + }) + } + }, + handler: async (req) => { + const { membership } = await server.services.membershipIdentity.deleteMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + selector: { + identityId: req.params.identityId + } + }); + + return { + identityMembership: { ...membership, identityId: req.params.identityId, orgId: req.permission.orgId } + }; + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index d6a42c4a2..f8e6c78ee 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -249,7 +249,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { permissions: true, description: true }).optional(), - identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true }).extend({ + identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true, orgId: true }).extend({ authMethods: z.array(z.string()), activeLockoutAuthMethods: z.array(z.string()) }) @@ -393,7 +393,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { permissions: true, description: true }).optional(), - identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true }).extend({ + identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true, orgId: true }).extend({ authMethods: z.array(z.string()) }) }).array(), diff --git a/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts index d549160db..b7a44c62c 100644 --- a/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts +++ b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts @@ -64,7 +64,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid throw new BadRequestError({ message: "Missing TLS certificate in header" }); } - const { identityTlsCertAuth, accessToken, identityAccessToken, identityMembershipOrg } = + const { identityTlsCertAuth, accessToken, identityAccessToken, identity } = await server.services.identityTlsCertAuth.login({ identityId: req.body.identityId, clientCertificate: clientCertificate as string @@ -72,7 +72,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg.scopeOrgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_TLS_CERT_AUTH, metadata: { diff --git a/backend/src/server/routes/v1/identity-token-auth-router.ts b/backend/src/server/routes/v1/identity-token-auth-router.ts index 9040d8909..aafffdfdb 100644 --- a/backend/src/server/routes/v1/identity-token-auth-router.ts +++ b/backend/src/server/routes/v1/identity-token-auth-router.ts @@ -319,7 +319,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider } }, handler: async (req) => { - const { identityTokenAuth, accessToken, identityAccessToken, identityMembershipOrg } = + const { identityTokenAuth, accessToken, identityAccessToken, identity } = await server.services.identityTokenAuth.createTokenAuthToken({ actor: req.permission.type, actorId: req.permission.id, @@ -332,7 +332,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg.scopeOrgId, + orgId: identity.orgId, event: { type: EventType.CREATE_TOKEN_IDENTITY_TOKEN_AUTH, metadata: { diff --git a/backend/src/server/routes/v1/identity-universal-auth-router.ts b/backend/src/server/routes/v1/identity-universal-auth-router.ts index 0443d35dd..88a4cb775 100644 --- a/backend/src/server/routes/v1/identity-universal-auth-router.ts +++ b/backend/src/server/routes/v1/identity-universal-auth-router.ts @@ -52,14 +52,14 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { accessToken, identityAccessToken, validClientSecretInfo, - identityMembershipOrg, + identity, accessTokenTTL, accessTokenMaxTTL } = await server.services.identityUa.login(req.body.clientId, req.body.clientSecret, req.realIp); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg.scopeOrgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH, metadata: { diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 89865b1a1..4300f5698 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -11,6 +11,7 @@ import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; import { registerCaRouter } from "./certificate-authority-router"; import { CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP } from "./certificate-authority-routers"; +import { registerCertificateProfilesRouter } from "./certificate-profiles-router"; import { registerCertRouter } from "./certificate-router"; import { registerCertificateTemplateRouter } from "./certificate-template-router"; import { registerDeprecatedProjectEnvRouter } from "./deprecated-project-env-router"; @@ -32,6 +33,7 @@ import { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-rou import { registerIdentityLdapAuthRouter } from "./identity-ldap-auth-router"; import { registerIdentityOciAuthRouter } from "./identity-oci-auth-router"; import { registerIdentityOidcAuthRouter } from "./identity-oidc-auth-router"; +import { registerOrgIdentityMembershipRouter } from "./identity-org-membership-router"; import { registerIdentityProjectRouter } from "./identity-project-router"; import { registerIdentityRouter } from "./identity-router"; import { registerIdentityTlsCertAuthRouter } from "./identity-tls-cert-auth-router"; @@ -89,6 +91,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { ); await server.register(registerPasswordRouter, { prefix: "/password" }); await server.register(registerOrgRouter, { prefix: "/organization" }); + await server.register(registerOrgIdentityMembershipRouter, { prefix: "/organization" }); await server.register(registerAdminRouter, { prefix: "/admin" }); await server.register(registerOrgAdminRouter, { prefix: "/organization-admin" }); await server.register(registerUserRouter, { prefix: "/user" }); @@ -146,6 +149,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { ); await pkiRouter.register(registerCertRouter, { prefix: "/certificates" }); await pkiRouter.register(registerCertificateTemplateRouter, { prefix: "/certificate-templates" }); + await pkiRouter.register(registerCertificateProfilesRouter, { prefix: "/certificate-profiles" }); await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" }); await pkiRouter.register(registerPkiCollectionRouter, { prefix: "/collections" }); await pkiRouter.register(registerPkiSubscriberRouter, { prefix: "/subscribers" }); diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 872b7b157..76b3eae51 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -2,6 +2,7 @@ import RE2 from "re2"; import { z } from "zod"; import { + AccessScope, AuditLogsSchema, GroupsSchema, IncidentContactsSchema, @@ -59,7 +60,14 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - organization: sanitizedOrganizationSchema + organization: sanitizedOrganizationSchema.extend({ + subOrganization: z + .object({ + id: z.string(), + name: z.string() + }) + .optional() + }) }) } }, @@ -69,6 +77,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { req.permission.id, req.params.organizationId, req.permission.authMethod, + req.permission.rootOrgId, req.permission.orgId ); return { organization }; @@ -467,4 +476,68 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { return { groups }; } }); + + server.route({ + method: "GET", + url: "/users/available", + schema: { + response: { + 200: z.object({ + users: z + .object({ + id: z.string().uuid(), + username: z.string(), + email: z.string().nullable().optional(), + firstName: z.string().nullable().optional(), + lastName: z.string().nullable().optional() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { users } = await server.services.membershipUser.listAvailableUsers({ + permission: req.permission, + scopeData: { + orgId: req.permission.orgId, + scope: AccessScope.Organization + }, + data: {} + }); + + return { users }; + } + }); + + server.route({ + method: "GET", + url: "/identities/available", + schema: { + response: { + 200: z.object({ + identities: z + .object({ + id: z.string().uuid(), + name: z.string(), + hasDeleteProtection: z.boolean() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { identities } = await server.services.membershipIdentity.listAvailableIdentities({ + permission: req.permission, + scopeData: { + orgId: req.permission.orgId, + scope: AccessScope.Organization + }, + data: {} + }); + + return { identities }; + } + }); }; diff --git a/backend/src/server/routes/v2/certificate-templates-v2-router.ts b/backend/src/server/routes/v2/certificate-templates-v2-router.ts new file mode 100644 index 000000000..8a7189727 --- /dev/null +++ b/backend/src/server/routes/v2/certificate-templates-v2-router.ts @@ -0,0 +1,367 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; +import { certificateTemplateV2ResponseSchema } from "@app/services/certificate-template-v2/certificate-template-v2-schemas"; + +const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType); +const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType); + +const templateV2SubjectSchema = z + .object({ + type: attributeTypeSchema, + allowed: z.array(z.string()).optional(), + required: z.array(z.string()).optional(), + denied: z.array(z.string()).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Subject attribute must have at least one allowed, required, or denied value" + } + ); + +const templateV2KeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2ExtendedKeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Extended key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2SanSchema = z + .object({ + type: sanTypeSchema, + allowed: z.array(z.string()).optional(), + required: z.array(z.string()).optional(), + denied: z.array(z.string()).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "SAN must have at least one allowed, required, or denied value" + } + ); + +const templateV2ValiditySchema = z.object({ + max: z + .string() + .refine( + (val) => { + if (!val) return true; + if (val.length < 2) return false; + const unit = val.slice(-1); + const number = val.slice(0, -1); + const digitRegex = new RE2("^\\d+$"); + return ["d", "h", "m", "y"].includes(unit) && digitRegex.test(number); + }, + { + message: "Max validity must be in format like '365d', '12m', '1y', or '24h'" + } + ) + .optional() +}); + +const templateV2AlgorithmsSchema = z.object({ + signature: z.array(z.string()).min(1, "At least one signature algorithm must be provided").optional(), + keyAlgorithm: z.array(z.string()).min(1, "At least one key algorithm must be provided").optional() +}); + +const createCertificateTemplateV2Schema = z.object({ + projectId: z.string().min(1), + name: z.string().min(1).max(255, "Name must be between 1 and 255 characters"), + description: z.string().max(1000).optional(), + subject: z.array(templateV2SubjectSchema).optional(), + sans: z.array(templateV2SanSchema).optional(), + keyUsages: templateV2KeyUsagesSchema.optional(), + extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), + algorithms: templateV2AlgorithmsSchema.optional(), + validity: templateV2ValiditySchema.optional() +}); + +const updateCertificateTemplateV2Schema = z.object({ + name: z.string().min(1).max(255, "Name must be between 1 and 255 characters").optional(), + description: z.string().max(1000).optional(), + subject: z.array(templateV2SubjectSchema).optional(), + sans: z.array(templateV2SanSchema).optional(), + keyUsages: templateV2KeyUsagesSchema.optional(), + extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), + algorithms: templateV2AlgorithmsSchema.optional(), + validity: templateV2ValiditySchema.optional() +}); + +export const registerCertificateTemplatesV2Router = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + body: createCertificateTemplateV2Schema, + response: { + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { projectId, ...data } = req.body; + const certificateTemplate = await server.services.certificateTemplateV2.createTemplateV2({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + projectId, + data + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.CREATE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name, + projectId: certificateTemplate.projectId + } + } + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + querystring: z.object({ + projectId: z.string().min(1), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + search: z.string().optional() + }), + response: { + 200: z.object({ + certificateTemplates: certificateTemplateV2ResponseSchema.array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { templates, totalCount } = await server.services.certificateTemplateV2.listTemplatesV2({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + ...req.query + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.LIST_CERTIFICATE_TEMPLATES, + metadata: { + projectId: req.query.projectId + } + } + }); + + return { certificateTemplates: templates, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + id: z.string().uuid() + }), + response: { + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplateV2.getTemplateV2ById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + templateId: req.params.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.GET_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name + } + } + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "PATCH", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + id: z.string().uuid() + }), + body: updateCertificateTemplateV2Schema, + response: { + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplateV2.updateTemplateV2({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + templateId: req.params.id, + data: req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.UPDATE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name + } + } + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "DELETE", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + id: z.string().uuid() + }), + response: { + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplateV2.deleteTemplateV2({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + templateId: req.params.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.DELETE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name + } + } + }); + + return { certificateTemplate }; + } + }); +}; diff --git a/backend/src/server/routes/v2/identity-org-router.ts b/backend/src/server/routes/v2/identity-org-router.ts index 8680a2dca..630e09dda 100644 --- a/backend/src/server/routes/v2/identity-org-router.ts +++ b/backend/src/server/routes/v2/identity-org-router.ts @@ -60,7 +60,7 @@ export const registerIdentityOrgRouter = async (server: FastifyZodProvider) => { permissions: true, description: true }).optional(), - identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + identity: IdentitiesSchema.pick({ name: true, id: true, orgId: true }).extend({ authMethods: z.array(z.string()) }) }) diff --git a/backend/src/server/routes/v2/index.ts b/backend/src/server/routes/v2/index.ts index aade29bb7..db4ebb176 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -1,4 +1,5 @@ import { registerCaRouter } from "./certificate-authority-router"; +import { registerCertificateTemplatesV2Router } from "./certificate-templates-v2-router"; import { registerDeprecatedGroupProjectRouter } from "./deprecated-group-project-router"; import { registerDeprecatedIdentityProjectRouter } from "./deprecated-identity-project-router"; import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-membership-router"; @@ -19,6 +20,8 @@ export const registerV2Routes = async (server: FastifyZodProvider) => { await server.register(registerServiceTokenRouter, { prefix: "/service-token" }); await server.register(registerPasswordRouter, { prefix: "/password" }); + await server.register(registerCertificateTemplatesV2Router, { prefix: "/certificate-templates" }); + await server.register( async (pkiRouter) => { await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts new file mode 100644 index 000000000..549310738 --- /dev/null +++ b/backend/src/server/routes/v3/certificates-router.ts @@ -0,0 +1,346 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { ms } from "@app/lib/ms"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { + ACMESANType, + CertificateOrderStatus, + CertKeyAlgorithm, + CertSignatureAlgorithm +} from "@app/services/certificate/certificate-types"; +import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType +} from "@app/services/certificate-common/certificate-constants"; +import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; +import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; + +interface CertificateRequestForService { + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + altNames?: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; + validity: { + ttl: string; + }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; +} + +const validateTtlAndDateFields = (data: { notBefore?: string; notAfter?: string; ttl?: string }) => { + const hasDateFields = data.notBefore || data.notAfter; + const hasTtl = data.ttl; + return !(hasDateFields && hasTtl); +}; + +const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => { + if (data.notBefore && data.notAfter) { + const notBefore = new Date(data.notBefore); + const notAfter = new Date(data.notAfter); + return notBefore < notAfter; + } + return true; +}; + +export const registerCertificatesRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/issue-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + body: z + .object({ + profileId: z.string().uuid(), + commonName: validateTemplateRegexField.optional(), + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .refine((val) => ms(val) > 0, "TTL must be a positive number"), + keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + altNames: z + .array( + z.object({ + type: z.nativeEnum(CertSubjectAlternativeNameType), + value: z.string().min(1, "SAN value cannot be empty") + }) + ) + .optional(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() + }) + .refine(validateTtlAndDateFields, { + message: + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + }) + .refine(validateDateOrder, { + message: "notBefore must be earlier than notAfter" + }), + response: { + 200: z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + privateKey: z.string().trim().optional(), + serialNumber: z.string().trim(), + certificateId: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateRequestForService: CertificateRequestForService = { + commonName: req.body.commonName, + keyUsages: req.body.keyUsages, + extendedKeyUsages: req.body.extendedKeyUsages, + altNames: req.body.altNames, + validity: { + ttl: req.body.ttl + }, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm + }; + + const mappedCertificateRequest = mapEnumsForValidation(certificateRequestForService); + + const data = await server.services.certificateV3.issueCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.body.profileId, + certificateRequest: mappedCertificateRequest + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: req.body.profileId, + certificateId: data.certificateId, + commonName: req.body.commonName || "", + profileName: data.profileName + } + } + }); + + return data; + } + }); + + server.route({ + method: "POST", + url: "/sign-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + body: z + .object({ + profileId: z.string().uuid(), + csr: z.string().trim().min(1, "CSR cannot be empty").max(4096, "CSR cannot exceed 4096 characters"), + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .refine((val) => ms(val) > 0, "TTL must be a positive number"), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() + }) + .refine(validateTtlAndDateFields, { + message: + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + }) + .refine(validateDateOrder, { + message: "notBefore must be earlier than notAfter" + }), + response: { + 200: z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + serialNumber: z.string().trim(), + certificateId: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.certificateV3.signCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.body.profileId, + csr: req.body.csr, + validity: { + ttl: req.body.ttl + }, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.SIGN_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: req.body.profileId, + certificateId: data.certificateId, + profileName: data.profileName, + commonName: "" + } + } + }); + + return data; + } + }); + + server.route({ + method: "POST", + url: "/order-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + body: z + .object({ + profileId: z.string().uuid(), + subjectAlternativeNames: z + .array( + z.object({ + type: z.nativeEnum(ACMESANType), + value: z + .string() + .trim() + .min(1, "SAN value cannot be empty") + .max(255, "SAN value must be less than 255 characters") + }) + ) + .min(1, "At least one subject alternative name must be provided"), + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .refine((val) => ms(val) > 0, "TTL must be a positive number"), + keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + commonName: validateTemplateRegexField.optional(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() + }) + .refine(validateTtlAndDateFields, { + message: + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + }) + .refine(validateDateOrder, { + message: "notBefore must be earlier than notAfter" + }), + response: { + 200: z.object({ + orderId: z.string(), + status: z.nativeEnum(CertificateOrderStatus), + subjectAlternativeNames: z.array( + z.object({ + type: z.nativeEnum(ACMESANType), + value: z.string(), + status: z.nativeEnum(CertificateOrderStatus) + }) + ), + authorizations: z.array( + z.object({ + identifier: z.object({ + type: z.nativeEnum(ACMESANType), + value: z.string() + }), + status: z.nativeEnum(CertificateOrderStatus), + expires: z.string().optional(), + challenges: z.array( + z.object({ + type: z.string(), + status: z.nativeEnum(CertificateOrderStatus), + url: z.string(), + token: z.string() + }) + ) + }) + ), + finalize: z.string(), + certificate: z.string().optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.certificateV3.orderCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.body.profileId, + certificateOrder: { + altNames: req.body.subjectAlternativeNames, + validity: { + ttl: req.body.ttl + }, + commonName: req.body.commonName, + keyUsages: req.body.keyUsages, + extendedKeyUsages: req.body.extendedKeyUsages, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm + } + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.ORDER_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: req.body.profileId, + orderId: data.orderId, + profileName: data.profileName + } + } + }); + + return data; + } + }); +}; diff --git a/backend/src/server/routes/v3/index.ts b/backend/src/server/routes/v3/index.ts index d7fa94d6b..47c3c2cb8 100644 --- a/backend/src/server/routes/v3/index.ts +++ b/backend/src/server/routes/v3/index.ts @@ -1,3 +1,4 @@ +import { registerCertificatesRouter } from "./certificates-router"; import { registerDeprecatedSecretRouter } from "./deprecated-secret-router"; import { registerExternalMigrationRouter } from "./external-migration-router"; import { registerLoginRouter } from "./login-router"; @@ -10,4 +11,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => { await server.register(registerUserRouter, { prefix: "/users" }); await server.register(registerDeprecatedSecretRouter, { prefix: "/secrets" }); await server.register(registerExternalMigrationRouter, { prefix: "/external-migration" }); + await server.register(registerCertificatesRouter, { prefix: "/certificates" }); }; diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 5e26ebdaf..d599568b3 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError, subject } from "@casl/ability"; -import { ActionProjectType, TAppConnections } from "@app/db/schemas"; +import { ActionProjectType, OrganizationActionScope, TAppConnections } from "@app/db/schemas"; import { ValidateOCIConnectionCredentialsSchema } from "@app/ee/services/app-connections/oci"; import { ociConnectionService } from "@app/ee/services/app-connections/oci/oci-connection-service"; import { ValidateOracleDBConnectionCredentialsSchema } from "@app/ee/services/app-connections/oracledb"; @@ -215,13 +215,14 @@ export const appConnectionServiceFactory = ({ ) ); } else { - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionAppConnectionActions.Read, @@ -268,13 +269,14 @@ export const appConnectionServiceFactory = ({ subject(ProjectPermissionSub.AppConnections, { connectionId }) ); } else { - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - appConnection.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: appConnection.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionAppConnectionActions.Read, @@ -318,13 +320,14 @@ export const appConnectionServiceFactory = ({ subject(ProjectPermissionSub.AppConnections, { connectionId: appConnection.id }) ); } else { - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - appConnection.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: appConnection.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionAppConnectionActions.Read, @@ -342,13 +345,14 @@ export const appConnectionServiceFactory = ({ { method, app, credentials, gatewayId, projectId, ...params }: TCreateAppConnectionDTO, actor: OrgServiceActor ) => { - const { permission: orgPermission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { permission: orgPermission } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); if (projectId) { const project = await projectDAL.findProjectById(projectId); @@ -477,13 +481,14 @@ export const appConnectionServiceFactory = ({ "Failed to update app connection due to plan restriction. Upgrade plan to access enterprise app connections." ); - const { permission: orgPermission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - appConnection.orgId, - actor.authMethod, - actor.orgId - ); + const { permission: orgPermission } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: appConnection.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); if (appConnection.projectId) { const { permission } = await permissionService.getProjectPermission({ @@ -635,13 +640,14 @@ export const appConnectionServiceFactory = ({ subject(ProjectPermissionSub.AppConnections, { connectionId }) ); } else { - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - appConnection.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: appConnection.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionAppConnectionActions.Delete, @@ -704,13 +710,14 @@ export const appConnectionServiceFactory = ({ subject(ProjectPermissionSub.AppConnections, { connectionId }) ); } else { - const { permission: orgPermission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - appConnection.orgId, - actor.authMethod, - actor.orgId - ); + const { permission: orgPermission } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: appConnection.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(orgPermission).throwUnlessCan( OrgPermissionAppConnectionActions.Connect, @@ -747,13 +754,14 @@ export const appConnectionServiceFactory = ({ }; const listAvailableAppConnectionsForUser = async (app: AppConnection, actor: OrgServiceActor, projectId?: string) => { - const { permission: orgPermission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { permission: orgPermission } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); let availableProjectConnections: TAppConnections[] = []; @@ -805,13 +813,14 @@ export const appConnectionServiceFactory = ({ if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - appConnection.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: appConnection.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionAppConnectionActions.Read, diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts index 38f97700c..425c9c4d2 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts @@ -25,6 +25,23 @@ import { THCVaultMountResponse } from "./hc-vault-connection-types"; +// HashiCorp Vault stores JSON data, so values can be any valid JSON type +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + +export const convertVaultValueToString = (value: JsonValue): string => { + if (value === null) { + return ""; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + // For objects and arrays, serialize as JSON + return JSON.stringify(value); +}; + // Concurrency limit for HC Vault API requests to avoid rate limiting const HC_VAULT_CONCURRENCY_LIMIT = 20; @@ -598,7 +615,7 @@ export const getHCVaultSecretsForPath = async ( // For KV v2: /v1/{mount}/data/{path} const { data } = await requestWithHCVaultGateway<{ data: { - data: Record; // KV v2 has nested data structure + data: Record; // KV v2 has nested data structure, supports all JSON types metadata: { created_time: string; deletion_time: string; @@ -620,7 +637,7 @@ export const getHCVaultSecretsForPath = async ( // For KV v1: /v1/{mount}/{path} const { data } = await requestWithHCVaultGateway<{ - data: Record; // KV v1 has flat data structure + data: Record; // KV v1 has flat data structure, supports all JSON types lease_duration: number; lease_id: string; renewable: boolean; diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 82df0dcb1..28a986fe8 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -3,10 +3,11 @@ import { Knex } from "knex"; import { AccessScope, TAuthTokens, TAuthTokenSessions } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { AuthModeJwtTokenPayload, AuthModeRefreshJwtTokenPayload, AuthTokenType } from "../auth/auth-type"; import { TMembershipUserDALFactory } from "../membership-user/membership-user-dal"; +import { TOrgDALFactory } from "../org/org-dal"; import { TUserDALFactory } from "../user/user-dal"; import { TTokenDALFactory } from "./auth-token-dal"; import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenForUserDTO } from "./auth-token-types"; @@ -14,6 +15,7 @@ import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenFo type TAuthTokenServiceFactoryDep = { tokenDAL: TTokenDALFactory; userDAL: Pick; + orgDAL: Pick; membershipUserDAL: Pick; }; @@ -80,7 +82,7 @@ export const getTokenConfig = (tokenType: TokenType) => { } }; -export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL }: TAuthTokenServiceFactoryDep) => { +export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL, orgDAL }: TAuthTokenServiceFactoryDep) => { const createTokenForUser = async ({ type, userId, orgId, aliasId, payload }: TCreateTokenForUserDTO) => { const { token, ...tkCfg } = getTokenConfig(type); const appCfg = getConfig(); @@ -194,7 +196,7 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL }: TA }; // to parse jwt identity in inject identity plugin - const fnValidateJwtIdentity = async (token: AuthModeJwtTokenPayload) => { + const fnValidateJwtIdentity = async (token: AuthModeJwtTokenPayload, subOrganizationSelector?: string) => { const session = await tokenDAL.findOneTokenSession({ id: token.tokenVersionId, userId: token.userId @@ -207,22 +209,56 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL }: TA const user = await userDAL.findById(session.userId); if (!user || !user.isAccepted) throw new NotFoundError({ message: `User with ID '${session.userId}' not found` }); + let orgId = ""; + let rootOrgId = ""; + let parentOrgId = ""; if (token.organizationId) { - const orgMembership = await membershipUserDAL.findOne({ - actorUserId: user.id, - scopeOrgId: token.organizationId, - scope: AccessScope.Organization - }); + if (subOrganizationSelector) { + const subOrganization = await orgDAL.findOne({ + rootOrgId: token.organizationId, + slug: subOrganizationSelector + }); + if (!subOrganization) + throw new BadRequestError({ message: `Sub organization ${subOrganizationSelector} not found` }); - if (!orgMembership) { - throw new ForbiddenRequestError({ message: "User not member of organization" }); - } - if (!orgMembership.isActive) { - throw new ForbiddenRequestError({ message: "User organization membership is inactive" }); + const orgMembership = await membershipUserDAL.findOne({ + actorUserId: user.id, + scopeOrgId: subOrganization.id, + scope: AccessScope.Organization + }); + + if (!orgMembership) { + throw new ForbiddenRequestError({ message: "User not member of organization" }); + } + + if (!orgMembership.isActive) { + throw new ForbiddenRequestError({ message: "User organization membership is inactive" }); + } + orgId = subOrganization.id; + rootOrgId = token.organizationId; + parentOrgId = subOrganization.parentOrgId as string; + } else { + const orgMembership = await membershipUserDAL.findOne({ + actorUserId: user.id, + scopeOrgId: token.organizationId, + scope: AccessScope.Organization + }); + + if (!orgMembership) { + throw new ForbiddenRequestError({ message: "User not member of organization" }); + } + + if (!orgMembership.isActive) { + throw new ForbiddenRequestError({ message: "User organization membership is inactive" }); + } + + orgId = token.organizationId; + rootOrgId = token.organizationId; + parentOrgId = token.organizationId; } } - return { user, tokenVersionId: token.tokenVersionId, orgId: token.organizationId }; + return { user, tokenVersionId: token.tokenVersionId, orgId, rootOrgId, parentOrgId }; }; return { diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index a2e426a2e..14f4387b9 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -258,7 +258,13 @@ export const authSignupServiceFactory = ({ let refreshTokenExpiresIn: string | number = appCfg.JWT_REFRESH_LIFETIME; if (organizationId) { - const org = await orgService.findOrganizationById(user.id, organizationId, authMethod, organizationId); + const org = await orgService.findOrganizationById( + user.id, + organizationId, + authMethod, + organizationId, + organizationId + ); if (org && org.userTokenExpiration) { tokenSessionExpiresIn = getMinExpiresIn(appCfg.JWT_AUTH_LIFETIME, org.userTokenExpiration); refreshTokenExpiresIn = org.userTokenExpiration; diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.test.ts b/backend/src/services/certificate-authority/certificate-authority-fns.test.ts new file mode 100644 index 000000000..870055711 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-fns.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; + +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +import { signatureAlgorithmToAlgCfg } from "./certificate-authority-fns"; + +describe("signatureAlgorithmToAlgCfg", () => { + describe("RSA algorithms", () => { + it("should handle RSA-SHA256 correctly", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA256", CertKeyAlgorithm.RSA_2048); + + expect(result).toEqual({ + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 2048 + }); + }); + + it("should handle RSA-SHA384 correctly", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA384", CertKeyAlgorithm.RSA_4096); + + expect(result).toEqual({ + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-384", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 4096 + }); + }); + + it("should handle RSA-SHA256 with RSA_3072 correctly", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA256", CertKeyAlgorithm.RSA_3072); + + expect(result).toEqual({ + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 3072 + }); + }); + + it("should handle RSA-SHA512 correctly", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA512", CertKeyAlgorithm.RSA_2048); + + expect(result).toEqual({ + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-512", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 2048 + }); + }); + }); + + describe("ECDSA algorithms", () => { + it("should handle ECDSA-SHA256 with P-256 curve", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", CertKeyAlgorithm.ECDSA_P256); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-256", + hash: "SHA-256" + }); + }); + + it("should handle ECDSA-SHA384 with P-384 curve", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", CertKeyAlgorithm.ECDSA_P384); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-384", + hash: "SHA-384" + }); + }); + + it("should handle ECDSA-SHA256 with EC_prime256v1 string format", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", "EC_prime256v1"); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-256", + hash: "SHA-256" + }); + }); + + it("should handle ECDSA-SHA384 with EC_secp384r1 string format", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", "EC_secp384r1"); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-384", + hash: "SHA-384" + }); + }); + }); + + describe("hash format normalization", () => { + it("should normalize SHA256 to SHA-256", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA256", CertKeyAlgorithm.RSA_2048); + expect(result.hash).toBe("SHA-256"); + }); + + it("should normalize SHA384 to SHA-384", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", CertKeyAlgorithm.ECDSA_P384); + expect(result.hash).toBe("SHA-384"); + }); + + it("should normalize SHA512 to SHA-512", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA512", CertKeyAlgorithm.RSA_4096); + expect(result.hash).toBe("SHA-512"); + }); + + it("should handle SHA1 format", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA1", CertKeyAlgorithm.RSA_2048); + expect(result.hash).toBe("SHA-1"); + }); + + it("should handle SHA224 format", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA224", CertKeyAlgorithm.ECDSA_P256); + expect(result.hash).toBe("SHA-224"); + }); + + it("should handle case insensitive hash normalization", () => { + const result = signatureAlgorithmToAlgCfg("RSA-sha256", CertKeyAlgorithm.RSA_2048); + expect(result.hash).toBe("SHA-256"); + }); + + it("should handle already normalized hash formats", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", CertKeyAlgorithm.ECDSA_P256); + expect(result.hash).toBe("SHA-256"); + }); + + it("should handle SHA-3 family hashes", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA3256", CertKeyAlgorithm.RSA_2048); + expect(result.hash).toBe("SHA3-256"); + }); + }); + + describe("dynamic key algorithm support", () => { + it("should support future RSA key sizes", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA256", "RSA_8192"); + + expect(result.name).toBe("RSASSA-PKCS1-v1_5"); + expect(result.hash).toBe("SHA-256"); + }); + + it("should support future EC curves", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", "EC_secp521r1"); + + expect(result.name).toBe("ECDSA"); + expect(result.namedCurve).toBe("P-521"); + expect(result.hash).toBe("SHA-256"); + }); + + it("should support EC_P384 string format", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", "EC_P384"); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-384", + hash: "SHA-384" + }); + }); + }); +}); diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts index 9991e462e..aff81dcc5 100644 --- a/backend/src/services/certificate-authority/certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-nested-ternary */ import * as x509 from "@peculiar/x509"; import { crypto } from "@app/lib/crypto/cryptography"; @@ -68,6 +69,13 @@ export const parseDistinguishedName = (dn: string): TDNParts => { export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => { switch (keyAlgorithm) { + case CertKeyAlgorithm.RSA_3072: + return { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 3072 + }; case CertKeyAlgorithm.RSA_4096: return { name: "RSASSA-PKCS1-v1_5", @@ -99,6 +107,73 @@ export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => { } }; +export const signatureAlgorithmToAlgCfg = (signatureAlgorithm: string, keyAlgorithm: CertKeyAlgorithm | string) => { + // Parse signature algorithm like "RSA-SHA256", "ECDSA-SHA256" etc. + if (!signatureAlgorithm || typeof signatureAlgorithm !== "string" || !signatureAlgorithm.includes("-")) { + throw new Error(`Invalid signature algorithm format: ${signatureAlgorithm}`); + } + + const [keyType, hashType] = signatureAlgorithm.split("-"); + + if (!keyType || !hashType) { + throw new Error(`Malformed signature algorithm: ${signatureAlgorithm}`); + } + + const normalizeHashType = (hash: string) => { + const upperHash = hash.toUpperCase(); + + if (upperHash === "SHA1" || upperHash === "SHA-1") return "SHA-1"; + + if (upperHash === "SHA224" || upperHash === "SHA-224") return "SHA-224"; + if (upperHash === "SHA256" || upperHash === "SHA-256") return "SHA-256"; + if (upperHash === "SHA384" || upperHash === "SHA-384") return "SHA-384"; + if (upperHash === "SHA512" || upperHash === "SHA-512") return "SHA-512"; + + if (upperHash === "SHA3224" || upperHash === "SHA3-224") return "SHA3-224"; + if (upperHash === "SHA3256" || upperHash === "SHA3-256") return "SHA3-256"; + if (upperHash === "SHA3384" || upperHash === "SHA3-384") return "SHA3-384"; + if (upperHash === "SHA3512" || upperHash === "SHA3-512") return "SHA3-512"; + + throw new Error(`Unsupported hash algorithm: ${hash}`); + }; + + const normalizedHash = hashType ? normalizeHashType(hashType) : undefined; + + switch (keyType) { + case "RSA": + return { + name: "RSASSA-PKCS1-v1_5", + hash: normalizedHash || "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: + keyAlgorithm === CertKeyAlgorithm.RSA_4096 ? 4096 : keyAlgorithm === CertKeyAlgorithm.RSA_3072 ? 3072 : 2048 + }; + case "ECDSA": + // eslint-disable-next-line no-case-declarations + const is384Curve = + keyAlgorithm === CertKeyAlgorithm.ECDSA_P384 || keyAlgorithm === "EC_secp384r1" || keyAlgorithm === "EC_P384"; + // eslint-disable-next-line no-case-declarations + const is521Curve = keyAlgorithm === "EC_secp521r1" || keyAlgorithm === "EC_P521"; + // eslint-disable-next-line no-case-declarations + let namedCurve: string; + if (is521Curve) { + namedCurve = "P-521"; + } else if (is384Curve) { + namedCurve = "P-384"; + } else { + namedCurve = "P-256"; + } + return { + name: "ECDSA", + namedCurve, + hash: normalizedHash || (namedCurve === "P-384" ? "SHA-384" : "SHA-256") + }; + default: + // Fallback to key algorithm default + return keyAlgorithmToAlgCfg(keyAlgorithm as CertKeyAlgorithm); + } +}; + /** * Return the public and private key of CA with id [caId] * Note: credentials are returned as crypto.webcrypto.CryptoKey @@ -111,7 +186,8 @@ export const getCaCredentials = async ({ certificateAuthorityDAL, certificateAuthoritySecretDAL, projectDAL, - kmsService + kmsService, + signatureAlgorithm }: TGetCaCredentialsDTO) => { const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); if (!ca?.internalCa?.id) throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); @@ -132,7 +208,7 @@ export const getCaCredentials = async ({ cipherTextBlob: caSecret.encryptedPrivateKey }); - const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + const alg = signatureAlgorithm || keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); const skObj = crypto.nativeCrypto.createPrivateKey({ key: decryptedPrivateKey, format: "der", type: "pkcs8" }); const caPrivateKey = await crypto.nativeCrypto.subtle.importKey( "pkcs8", diff --git a/backend/src/services/certificate-authority/certificate-authority-schemas.ts b/backend/src/services/certificate-authority/certificate-authority-schemas.ts index 61d620156..5ecc50a4b 100644 --- a/backend/src/services/certificate-authority/certificate-authority-schemas.ts +++ b/backend/src/services/certificate-authority/certificate-authority-schemas.ts @@ -18,7 +18,7 @@ export const BaseCertificateAuthoritySchema = CertificateAuthoritiesSchema.pick( export const GenericCreateCertificateAuthorityFieldsSchema = (type: CaType) => z.object({ name: slugSchema({ field: "name" }).describe(CertificateAuthorities.CREATE(type).name), - projectId: z.string().trim().min(1, "Project ID required").describe(CertificateAuthorities.CREATE(type).projectId), + projectId: z.string().uuid("Project ID must be valid").describe(CertificateAuthorities.CREATE(type).projectId), enableDirectIssuance: z.boolean().describe(CertificateAuthorities.CREATE(type).enableDirectIssuance), status: z.nativeEnum(CaStatus).describe(CertificateAuthorities.CREATE(type).status) }); @@ -26,7 +26,7 @@ export const GenericCreateCertificateAuthorityFieldsSchema = (type: CaType) => export const GenericUpdateCertificateAuthorityFieldsSchema = (type: CaType) => z.object({ name: slugSchema({ field: "name" }).optional().describe(CertificateAuthorities.UPDATE(type).name), - projectId: z.string().trim().min(1, "Project ID required").describe(CertificateAuthorities.UPDATE(type).projectId), + projectId: z.string().uuid("Project ID must be valid").describe(CertificateAuthorities.UPDATE(type).projectId), enableDirectIssuance: z.boolean().optional().describe(CertificateAuthorities.UPDATE(type).enableDirectIssuance), status: z.nativeEnum(CaStatus).optional().describe(CertificateAuthorities.UPDATE(type).status) }); diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts index ab89ea996..5b9cd78ee 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts @@ -32,6 +32,8 @@ import { CertExtendedKeyUsageOIDToName, CertKeyAlgorithm, CertKeyUsage, + CertSignatureAlgorithm, + CertSignatureType, CertStatus, TAltNameMapping } from "../../certificate/certificate-types"; @@ -48,7 +50,8 @@ import { getCaCertChains, getCaCredentials, keyAlgorithmToAlgCfg, - parseDistinguishedName + parseDistinguishedName, + signatureAlgorithmToAlgCfg } from "../certificate-authority-fns"; import { TCertificateAuthorityQueueFactory } from "../certificate-authority-queue"; import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority-secret-dal"; @@ -1174,7 +1177,10 @@ export const internalCertificateAuthorityServiceFactory = ({ actor, actorOrgId, keyUsages, - extendedKeyUsages + extendedKeyUsages, + signatureAlgorithm, + keyAlgorithm, + isFromProfile }: TIssueCertFromCaDTO) => { let ca: TCertificateAuthorityWithAssociatedCa | undefined; let certificateTemplate: TCertificateTemplates | undefined; @@ -1221,7 +1227,7 @@ export const internalCertificateAuthorityServiceFactory = ({ if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); if (!ca.internalCa.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); - if (!ca.enableDirectIssuance && !certificateTemplate) { + if (!isFromProfile && !ca.enableDirectIssuance && !certificateTemplate) { throw new BadRequestError({ message: "Certificate template or subscriber is required for issuance" }); } @@ -1277,13 +1283,43 @@ export const internalCertificateAuthorityServiceFactory = ({ throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); } - const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); - const leafKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const effectiveKeyAlgorithm = + (keyAlgorithm as CertKeyAlgorithm) || (ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + const keyGenAlg = keyAlgorithmToAlgCfg(effectiveKeyAlgorithm); + const leafKeys = await crypto.nativeCrypto.subtle.generateKey(keyGenAlg, true, ["sign", "verify"]); + + if (signatureAlgorithm) { + const caKeyAlgorithm = ca.internalCa.keyAlgorithm; + const requestedKeyType = signatureAlgorithm.split("-")[0]; + + const isRsaCa = caKeyAlgorithm.startsWith(CertKeyAlgorithm.RSA_2048.split("_")[0]); + const isEcdsaCa = caKeyAlgorithm.startsWith(CertKeyAlgorithm.ECDSA_P256.split("_")[0]); + + if ( + (requestedKeyType === CertSignatureAlgorithm.RSA_SHA256.split("-")[0] && !isRsaCa) || + (requestedKeyType === CertSignatureAlgorithm.ECDSA_SHA256.split("-")[0] && !isEcdsaCa) + ) { + // eslint-disable-next-line no-nested-ternary + const supportedType = isRsaCa + ? CertSignatureAlgorithm.RSA_SHA256.split("-")[0] + : isEcdsaCa + ? CertSignatureAlgorithm.ECDSA_SHA256.split("-")[0] + : "unknown"; + throw new BadRequestError({ + message: `Requested signature algorithm ${signatureAlgorithm} is not compatible with CA key algorithm ${caKeyAlgorithm}. CA can only sign with ${supportedType}-based signature algorithms.` + }); + } + } + + // Determine signing algorithm for certificate signing + const signingAlg = signatureAlgorithm + ? signatureAlgorithmToAlgCfg(signatureAlgorithm, ca.internalCa.keyAlgorithm as CertKeyAlgorithm) + : keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ name: `CN=${commonName}`, keys: leafKeys, - signingAlgorithm: alg, + signingAlgorithm: keyGenAlg, extensions: [ // eslint-disable-next-line no-bitwise new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment) @@ -1296,7 +1332,8 @@ export const internalCertificateAuthorityServiceFactory = ({ certificateAuthorityDAL, certificateAuthoritySecretDAL, projectDAL, - kmsService + kmsService, + signatureAlgorithm: signingAlg }); const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); @@ -1319,7 +1356,7 @@ export const internalCertificateAuthorityServiceFactory = ({ // handle key usages let selectedKeyUsages: CertKeyUsage[] = keyUsages ?? []; if (keyUsages === undefined && !certificateTemplate) { - selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; + selectedKeyUsages = isFromProfile ? [] : [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; } if (keyUsages === undefined && certificateTemplate) { @@ -1405,7 +1442,7 @@ export const internalCertificateAuthorityServiceFactory = ({ notAfter: notAfterDate, signingKey: caPrivateKey, publicKey: csrObj.publicKey, - signingAlgorithm: alg, + signingAlgorithm: signingAlg, extensions }); @@ -1517,7 +1554,9 @@ export const internalCertificateAuthorityServiceFactory = ({ notBefore, notAfter, keyUsages, - extendedKeyUsages + extendedKeyUsages, + signatureAlgorithm, + keyAlgorithm } = dto; let collectionId = pkiCollectionId; @@ -1563,7 +1602,7 @@ export const internalCertificateAuthorityServiceFactory = ({ if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); if (!ca.internalCa.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); - if (!ca.enableDirectIssuance && !certificateTemplate) { + if (!dto.isFromProfile && !ca.enableDirectIssuance && !certificateTemplate) { throw new BadRequestError({ message: "Certificate template or subscriber is required for issuance" }); } @@ -1622,7 +1661,29 @@ export const internalCertificateAuthorityServiceFactory = ({ throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); } - const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + if (signatureAlgorithm) { + const caKeyAlgorithm = ca.internalCa.keyAlgorithm; + const requestedKeyType = signatureAlgorithm.split("-")[0]; // Get the first part (RSA, ECDSA) + + const isRsaCa = caKeyAlgorithm.startsWith(CertSignatureType.RSA); + const isEcdsaCa = caKeyAlgorithm.startsWith(CertSignatureType.ECDSA); + + if ( + (requestedKeyType === CertSignatureType.RSA && !isRsaCa) || + (requestedKeyType === CertSignatureType.ECDSA && !isEcdsaCa) + ) { + // eslint-disable-next-line no-nested-ternary + const supportedType = isRsaCa ? CertSignatureType.RSA : isEcdsaCa ? CertSignatureType.ECDSA : "unknown"; + throw new BadRequestError({ + message: `Requested signature algorithm ${signatureAlgorithm} is not compatible with CA key algorithm ${caKeyAlgorithm}. CA can only sign with ${supportedType}-based signature algorithms.` + }); + } + } + + const effectiveKeyAlgorithm = (keyAlgorithm || ca.internalCa.keyAlgorithm) as CertKeyAlgorithm; + const alg = signatureAlgorithm + ? signatureAlgorithmToAlgCfg(signatureAlgorithm, effectiveKeyAlgorithm) + : keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); const csrObj = new x509.Pkcs10CertificateRequest(csr); @@ -1671,7 +1732,7 @@ export const internalCertificateAuthorityServiceFactory = ({ if (csrKeyUsageExtension) { selectedKeyUsages = csrKeyUsages; } else { - selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; + selectedKeyUsages = dto.isFromProfile ? [] : [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; } } diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts index fadd7b88d..22cb86d28 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts @@ -3,7 +3,12 @@ import { z } from "zod"; import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; import { TProjectPermission } from "@app/lib/types"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; -import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { + CertExtendedKeyUsage, + CertKeyAlgorithm, + CertKeyUsage, + CertSignatureAlgorithm +} from "@app/services/certificate/certificate-types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -131,6 +136,9 @@ export type TIssueCertFromCaDTO = { notAfter?: string; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; + signatureAlgorithm?: CertSignatureAlgorithm; + keyAlgorithm?: CertKeyAlgorithm; + isFromProfile?: boolean; } & Omit; export type TSignCertFromCaDTO = @@ -148,6 +156,9 @@ export type TSignCertFromCaDTO = notAfter?: string; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; + signatureAlgorithm?: string; + keyAlgorithm?: string; + isFromProfile?: boolean; } | ({ isInternal: false; @@ -163,6 +174,9 @@ export type TSignCertFromCaDTO = notAfter?: string; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; + signatureAlgorithm?: string; + keyAlgorithm?: string; + isFromProfile?: boolean; } & Omit); export type TGetCaCertificateTemplatesDTO = { @@ -184,6 +198,7 @@ export type TGetCaCredentialsDTO = { certificateAuthoritySecretDAL: Pick; projectDAL: Pick; kmsService: Pick; + signatureAlgorithm?: RsaHashedImportParams | EcKeyImportParams; }; export type TGetCaCertChainsDTO = { diff --git a/backend/src/services/certificate-common/certificate-constants.ts b/backend/src/services/certificate-common/certificate-constants.ts new file mode 100644 index 000000000..bbd589110 --- /dev/null +++ b/backend/src/services/certificate-common/certificate-constants.ts @@ -0,0 +1,187 @@ +export enum CertSubjectAlternativeNameType { + DNS_NAME = "dns_name", + IP_ADDRESS = "ip_address", + EMAIL = "email", + URI = "uri" +} + +export enum CertKeyUsageType { + DIGITAL_SIGNATURE = "digital_signature", + KEY_ENCIPHERMENT = "key_encipherment", + NON_REPUDIATION = "non_repudiation", + DATA_ENCIPHERMENT = "data_encipherment", + KEY_AGREEMENT = "key_agreement", + KEY_CERT_SIGN = "key_cert_sign", + CRL_SIGN = "crl_sign", + ENCIPHER_ONLY = "encipher_only", + DECIPHER_ONLY = "decipher_only" +} + +export enum CertExtendedKeyUsageType { + CLIENT_AUTH = "client_auth", + SERVER_AUTH = "server_auth", + CODE_SIGNING = "code_signing", + EMAIL_PROTECTION = "email_protection", + OCSP_SIGNING = "ocsp_signing", + TIME_STAMPING = "time_stamping" +} + +export enum CertIncludeType { + MANDATORY = "mandatory", + OPTIONAL = "optional", + PROHIBIT = "prohibit" +} + +export enum CertAttributeRule { + ALLOW = "allow", + DENY = "deny" +} + +export enum CertSanEffect { + ALLOW = "allow", + DENY = "deny", + REQUIRE = "require" +} + +export enum CertDurationUnit { + DAYS = "days", + MONTHS = "months", + YEARS = "years" +} + +export enum CertSubjectAttributeType { + COMMON_NAME = "common_name", + ORGANIZATION = "organization", + COUNTRY = "country" +} + +export const mapKeyUsageToLegacy = (usage: CertKeyUsageType): string => { + switch (usage) { + case CertKeyUsageType.DIGITAL_SIGNATURE: + return "digitalSignature"; + case CertKeyUsageType.KEY_ENCIPHERMENT: + return "keyEncipherment"; + case CertKeyUsageType.NON_REPUDIATION: + return "nonRepudiation"; + case CertKeyUsageType.DATA_ENCIPHERMENT: + return "dataEncipherment"; + case CertKeyUsageType.KEY_AGREEMENT: + return "keyAgreement"; + case CertKeyUsageType.KEY_CERT_SIGN: + return "keyCertSign"; + case CertKeyUsageType.CRL_SIGN: + return "cRLSign"; + case CertKeyUsageType.ENCIPHER_ONLY: + return "encipherOnly"; + case CertKeyUsageType.DECIPHER_ONLY: + return "decipherOnly"; + default: + return usage; + } +}; + +export const mapLegacyKeyUsageToStandard = (usage: string): CertKeyUsageType => { + switch (usage) { + case "digitalSignature": + case "digital_signature": + return CertKeyUsageType.DIGITAL_SIGNATURE; + case "keyEncipherment": + case "key_encipherment": + return CertKeyUsageType.KEY_ENCIPHERMENT; + case "nonRepudiation": + case "non_repudiation": + return CertKeyUsageType.NON_REPUDIATION; + case "dataEncipherment": + case "data_encipherment": + return CertKeyUsageType.DATA_ENCIPHERMENT; + case "keyAgreement": + case "key_agreement": + return CertKeyUsageType.KEY_AGREEMENT; + case "keyCertSign": + case "key_cert_sign": + return CertKeyUsageType.KEY_CERT_SIGN; + case "cRLSign": + case "crl_sign": + return CertKeyUsageType.CRL_SIGN; + case "encipherOnly": + case "encipher_only": + return CertKeyUsageType.ENCIPHER_ONLY; + case "decipherOnly": + case "decipher_only": + return CertKeyUsageType.DECIPHER_ONLY; + default: + throw new Error(`Unknown key usage: ${usage}`); + } +}; + +export const mapExtendedKeyUsageToLegacy = (usage: CertExtendedKeyUsageType): string => { + switch (usage) { + case CertExtendedKeyUsageType.CLIENT_AUTH: + return "clientAuth"; + case CertExtendedKeyUsageType.SERVER_AUTH: + return "serverAuth"; + case CertExtendedKeyUsageType.CODE_SIGNING: + return "codeSigning"; + case CertExtendedKeyUsageType.EMAIL_PROTECTION: + return "emailProtection"; + case CertExtendedKeyUsageType.OCSP_SIGNING: + return "ocspSigning"; + case CertExtendedKeyUsageType.TIME_STAMPING: + return "timeStamping"; + default: + return usage; + } +}; + +export const mapLegacyExtendedKeyUsageToStandard = (usage: string): CertExtendedKeyUsageType => { + switch (usage) { + case "clientAuth": + case "client_auth": + return CertExtendedKeyUsageType.CLIENT_AUTH; + case "serverAuth": + case "server_auth": + return CertExtendedKeyUsageType.SERVER_AUTH; + case "codeSigning": + case "code_signing": + return CertExtendedKeyUsageType.CODE_SIGNING; + case "emailProtection": + case "email_protection": + return CertExtendedKeyUsageType.EMAIL_PROTECTION; + case "ocspSigning": + case "ocsp_signing": + return CertExtendedKeyUsageType.OCSP_SIGNING; + case "timeStamping": + case "time_stamping": + return CertExtendedKeyUsageType.TIME_STAMPING; + default: + throw new Error(`Unknown extended key usage: ${usage}`); + } +}; + +export enum CertKeyAlgorithm { + RSA_2048 = "RSA_2048", + RSA_3072 = "RSA_3072", + RSA_4096 = "RSA_4096", + ECDSA_P256 = "EC_prime256v1", + ECDSA_P384 = "EC_secp384r1" +} + +export enum CertSignatureAlgorithm { + RSA_SHA256 = "RSA-SHA256", + RSA_SHA384 = "RSA-SHA384", + RSA_SHA512 = "RSA-SHA512", + ECDSA_SHA256 = "ECDSA-SHA256", + ECDSA_SHA384 = "ECDSA-SHA384", + ECDSA_SHA512 = "ECDSA-SHA512" +} + +export const SAN_TYPE_OPTIONS = Object.values(CertSubjectAlternativeNameType); +export const KEY_USAGE_OPTIONS = Object.values(CertKeyUsageType); +export const EXTENDED_KEY_USAGE_OPTIONS = Object.values(CertExtendedKeyUsageType); +export const INCLUDE_TYPE_OPTIONS = Object.values(CertIncludeType); +export const DURATION_UNIT_OPTIONS = Object.values(CertDurationUnit); +export const SUBJECT_ATTRIBUTE_TYPE_OPTIONS = Object.values(CertSubjectAttributeType); +export const ATTRIBUTE_RULE_OPTIONS = Object.values(CertAttributeRule); +export const SAN_EFFECT_OPTIONS = Object.values(CertSanEffect); +export const KEY_ALGORITHM_OPTIONS = Object.values(CertKeyAlgorithm); +export const SIGNATURE_ALGORITHM_OPTIONS = Object.values(CertSignatureAlgorithm); diff --git a/backend/src/services/certificate-common/certificate-utils.ts b/backend/src/services/certificate-common/certificate-utils.ts new file mode 100644 index 000000000..b88f183db --- /dev/null +++ b/backend/src/services/certificate-common/certificate-utils.ts @@ -0,0 +1,198 @@ +import RE2 from "re2"; + +import { CertExtendedKeyUsage, CertKeyUsage } from "../certificate/certificate-types"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + mapExtendedKeyUsageToLegacy, + mapKeyUsageToLegacy, + mapLegacyExtendedKeyUsageToStandard, + mapLegacyKeyUsageToStandard +} from "./certificate-constants"; + +interface CertificateRequestInput { + keyUsages?: string[]; + extendedKeyUsages?: string[]; +} + +export const mapEnumsForValidation = (request: T): T => { + const mapKeyUsage = (usage: string): string => { + try { + return mapLegacyKeyUsageToStandard(usage); + } catch { + return usage; + } + }; + + const mapExtendedKeyUsage = (usage: string): string => { + try { + return mapLegacyExtendedKeyUsageToStandard(usage); + } catch { + return usage; + } + }; + + return { + ...request, + keyUsages: request.keyUsages?.map(mapKeyUsage), + extendedKeyUsages: request.extendedKeyUsages?.map(mapExtendedKeyUsage) + } as T; +}; + +export const normalizeDateForApi = (date: Date | string | undefined): string | undefined => { + if (!date) return undefined; + return date instanceof Date ? date.toISOString() : date; +}; + +export const bufferToString = (data: Buffer | string): string => { + return String(data); +}; + +export const buildCertificateSubjectFromTemplate = ( + request: Record, + templateAttributes?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }> +): Record => { + const subject: Record = {}; + const attributeMap: Record = { + common_name: "commonName", + organization: "organization", + country: "country" + }; + + if (!templateAttributes || templateAttributes.length === 0) { + return subject; + } + + templateAttributes.forEach((attr) => { + const requestKey = attributeMap[attr.type]; + const value = request[requestKey]; + + if (value && typeof value === "string" && (attr.allowed || attr.required)) { + subject[attr.type] = value; + } + }); + + return subject; +}; + +const isWildcardPattern = (value: string): boolean => { + return value.includes("*"); +}; + +const createWildcardRegex = (pattern: string): RE2 => { + const escapeRegex = new RE2(/[.+?^${}()|[\]\\]/g); + const escaped = pattern.replace(escapeRegex, "\\$&"); + const wildcardRegex = new RE2(/\*/g); + const regexPattern = escaped.replace(wildcardRegex, ".*"); + return new RE2(`^${regexPattern}$`); +}; + +const validateValueAgainstPatterns = (value: string, patterns: string[]): boolean => { + if (!patterns || patterns.length === 0) { + return false; + } + + for (const pattern of patterns) { + if (isWildcardPattern(pattern)) { + try { + const regex = createWildcardRegex(pattern); + if (regex.test(value)) { + return true; + } + } catch { + if (pattern === value) { + return true; + } + } + } else if (pattern === value) { + return true; + } + } + + return false; +}; + +export const buildSubjectAlternativeNamesFromTemplate = ( + request: { subjectAlternativeNames?: Array<{ type: string; value: string }> }, + templateSans?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }> +): string => { + if (!request.subjectAlternativeNames || request.subjectAlternativeNames.length === 0) { + return ""; + } + + if (!templateSans || templateSans.length === 0) { + return request.subjectAlternativeNames.map((san) => san.value).join(","); + } + + const allowedSans: string[] = []; + + request.subjectAlternativeNames.forEach((san) => { + const templateSan = templateSans.find((template) => template.type === san.type); + + if (!templateSan) { + allowedSans.push(san.value); + return; + } + + if (templateSan.denied && validateValueAgainstPatterns(san.value, templateSan.denied)) { + throw new Error(`SAN value '${san.value}' is explicitly denied for type '${san.type}'`); + } + + const isRequired = templateSan.required && validateValueAgainstPatterns(san.value, templateSan.required); + const isAllowed = templateSan.allowed && validateValueAgainstPatterns(san.value, templateSan.allowed); + + if (isRequired || isAllowed || (!templateSan.allowed && !templateSan.required)) { + allowedSans.push(san.value); + } else { + throw new Error(`SAN value '${san.value}' is not allowed for type '${san.type}'`); + } + }); + + return allowedSans.join(","); +}; + +export const convertLegacyKeyUsage = (usage: CertKeyUsage): CertKeyUsageType => { + return mapLegacyKeyUsageToStandard(usage); +}; + +export const convertToLegacyKeyUsage = (usage: CertKeyUsageType): CertKeyUsage => { + return mapKeyUsageToLegacy(usage) as CertKeyUsage; +}; + +export const convertLegacyExtendedKeyUsage = (usage: CertExtendedKeyUsage): CertExtendedKeyUsageType => { + return mapLegacyExtendedKeyUsageToStandard(usage); +}; + +export const convertToLegacyExtendedKeyUsage = (usage: CertExtendedKeyUsageType): CertExtendedKeyUsage => { + return mapExtendedKeyUsageToLegacy(usage) as CertExtendedKeyUsage; +}; + +export const convertKeyUsageArrayFromLegacy = (usages?: CertKeyUsage[]): CertKeyUsageType[] | undefined => { + return usages?.map(convertLegacyKeyUsage); +}; + +export const convertKeyUsageArrayToLegacy = (usages?: CertKeyUsageType[]): CertKeyUsage[] | undefined => { + return usages?.map(convertToLegacyKeyUsage); +}; + +export const convertExtendedKeyUsageArrayFromLegacy = ( + usages?: CertExtendedKeyUsage[] +): CertExtendedKeyUsageType[] | undefined => { + return usages?.map(convertLegacyExtendedKeyUsage); +}; + +export const convertExtendedKeyUsageArrayToLegacy = ( + usages?: CertExtendedKeyUsageType[] +): CertExtendedKeyUsage[] | undefined => { + return usages?.map(convertToLegacyExtendedKeyUsage); +}; diff --git a/backend/src/services/certificate-est-v3/certificate-est-v3-service.test.ts b/backend/src/services/certificate-est-v3/certificate-est-v3-service.test.ts new file mode 100644 index 000000000..b6408a986 --- /dev/null +++ b/backend/src/services/certificate-est-v3/certificate-est-v3-service.test.ts @@ -0,0 +1,737 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-bitwise */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { BadRequestError, NotFoundError } from "@app/lib/errors"; + +import { EnrollmentType } from "../certificate-profile/certificate-profile-types"; +import { certificateEstV3ServiceFactory, TCertificateEstV3ServiceFactory } from "./certificate-est-v3-service"; + +// Mock the x509 module +vi.mock("@peculiar/x509", () => ({ + Pkcs10CertificateRequest: vi.fn(), + GeneralNames: vi.fn(), + KeyUsagesExtension: vi.fn(), + ExtendedKeyUsageExtension: vi.fn(), + X509Certificate: vi.fn(), + KeyUsageFlags: { + digitalSignature: 1, + nonRepudiation: 2, + keyEncipherment: 4, + dataEncipherment: 8, + keyAgreement: 16, + keyCertSign: 32, + cRLSign: 64, + encipherOnly: 128, + decipherOnly: 256 + } +})); + +// Mock other dependencies +vi.mock("@app/services/certificate-authority/certificate-authority-fns", () => ({ + parseDistinguishedName: vi.fn((subject: string) => { + const parts = subject.split(","); + const result: any = {}; + parts.forEach((part) => { + const [key, value] = part.split("="); + switch (key.trim()) { + case "CN": + result.commonName = value; + break; + case "O": + result.organization = value; + break; + case "OU": + result.ou = value; + break; + case "L": + result.locality = value; + break; + case "ST": + result.province = value; + break; + case "C": + result.country = value; + break; + default: + break; + } + }); + return result; + }) +})); + +vi.mock("@app/services/certificate-authority/certificate-authority-validators", () => ({ + validateAndMapAltNameType: vi.fn((value: string) => { + if (value.includes(".") && !value.match(/^\d+\.\d+\.\d+\.\d+$/)) { + return { type: "dns", value }; + } + if (value.match(/^\d+\.\d+\.\d+\.\d+$/)) { + return { type: "ip", value }; + } + return null; + }) +})); + +vi.mock("@app/services/certificate-common/certificate-constants", () => ({ + mapLegacyKeyUsageToStandard: vi.fn((usage: string) => { + const mapping: Record = { + digitalSignature: "digital_signature", + keyEncipherment: "key_encipherment", + keyCertSign: "key_cert_sign" + }; + return mapping[usage] || usage; + }), + mapLegacyExtendedKeyUsageToStandard: vi.fn((usage: string) => { + const mapping: Record = { + clientAuth: "client_auth", + serverAuth: "server_auth", + codeSigning: "code_signing" + }; + return mapping[usage] || usage; + }), + CertKeyUsageType: { + DIGITAL_SIGNATURE: "digital_signature", + KEY_ENCIPHERMENT: "key_encipherment", + KEY_CERT_SIGN: "key_cert_sign" + }, + CertExtendedKeyUsageType: { + CLIENT_AUTH: "client_auth", + SERVER_AUTH: "server_auth", + CODE_SIGNING: "code_signing" + }, + CertSubjectAlternativeNameType: { + DNS_NAME: "dns_name", + IP_ADDRESS: "ip_address", + RFC822_NAME: "rfc822_name", + UNIFORM_RESOURCE_IDENTIFIER: "uniform_resource_identifier" + } +})); + +vi.mock("@app/services/certificate/certificate-types", () => ({ + mapLegacyAltNameType: vi.fn((type: string) => { + const mapping: Record = { + dns: "dns_name", + ip: "ip_address", + email: "rfc822_name", + url: "uniform_resource_identifier" + }; + return mapping[type] || type; + }), + TAltNameType: { + EMAIL: "email", + DNS: "dns", + IP: "ip", + URL: "url" + }, + CertExtendedKeyUsageOIDToName: { + "1.3.6.1.5.5.7.3.1": "serverAuth", + "1.3.6.1.5.5.7.3.2": "clientAuth", + "1.3.6.1.5.5.7.3.3": "codeSigning" + }, + CertKeyUsage: { + DIGITAL_SIGNATURE: "digitalSignature", + KEY_ENCIPHERMENT: "keyEncipherment", + KEY_CERT_SIGN: "keyCertSign", + NON_REPUDIATION: "nonRepudiation", + DATA_ENCIPHERMENT: "dataEncipherment", + KEY_AGREEMENT: "keyAgreement", + CRL_SIGN: "cRLSign", + ENCIPHER_ONLY: "encipherOnly", + DECIPHER_ONLY: "decipherOnly" + }, + CertExtendedKeyUsage: { + CLIENT_AUTH: "clientAuth", + SERVER_AUTH: "serverAuth", + CODE_SIGNING: "codeSigning" + } +})); + +vi.mock("@app/services/certificate-common/certificate-utils", () => ({ + mapEnumsForValidation: vi.fn((req: any) => req) +})); + +vi.mock("../../ee/services/certificate-est/certificate-est-fns", () => ({ + convertRawCertsToPkcs7: vi.fn(() => "mocked-pkcs7-response") +})); + +describe("CertificateEstV3Service Security Fix", () => { + let service: TCertificateEstV3ServiceFactory; + + const mockInternalCertificateAuthorityService = { + signCertFromCa: vi.fn() + }; + + const mockCertificateTemplateV2Service = { + validateCertificateRequest: vi.fn() + }; + + const mockCertificateAuthorityDAL = { + findById: vi.fn(), + findByIdWithAssociatedCa: vi.fn() + }; + + const mockCertificateAuthorityCertDAL = { + find: vi.fn(), + findById: vi.fn() + }; + + const mockProjectDAL = { + findOne: vi.fn(), + updateById: vi.fn(), + transaction: vi.fn() + }; + + const mockKmsService = { + decryptWithKmsKey: vi.fn(), + generateKmsKey: vi.fn() + }; + + const mockLicenseService = { + getPlan: vi.fn() + }; + + const mockCertificateProfileDAL = { + findByIdWithConfigs: vi.fn() + }; + + const mockEstEnrollmentConfigDAL = { + findById: vi.fn() + }; + + const mockProfile = { + id: "profile-123", + projectId: "project-123", + caId: "ca-123", + certificateTemplateId: "template-v2-123", + enrollmentType: EnrollmentType.EST, + estConfigId: "est-config-123" + }; + + const mockEstConfig = { + id: "est-config-123", + disableBootstrapCaValidation: true + }; + + const mockProject = { + id: "project-123", + orgId: "org-123" + }; + + const mockPlan = { + pkiEst: true + }; + + beforeEach(async () => { + const { Pkcs10CertificateRequest, GeneralNames } = await import("@peculiar/x509"); + + service = certificateEstV3ServiceFactory({ + internalCertificateAuthorityService: mockInternalCertificateAuthorityService, + certificateTemplateV2Service: mockCertificateTemplateV2Service, + certificateAuthorityDAL: mockCertificateAuthorityDAL, + certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL, + projectDAL: mockProjectDAL, + kmsService: mockKmsService, + licenseService: mockLicenseService, + certificateProfileDAL: mockCertificateProfileDAL, + estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL + }); + + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile); + mockEstEnrollmentConfigDAL.findById.mockResolvedValue(mockEstConfig); + mockProjectDAL.findOne.mockResolvedValue(mockProject); + mockLicenseService.getPlan.mockResolvedValue(mockPlan); + + // Set up the default CSR parsing behavior + (Pkcs10CertificateRequest as any).mockImplementation((csr: string) => { + const parsed = JSON.parse(csr); + const mockExtensions: any[] = []; + + if (parsed.sans && parsed.sans.length > 0) { + mockExtensions.push({ type: "2.5.29.17", value: "mock-san-value" }); + } + + return { + subject: parsed.subject, + extensions: mockExtensions, + getExtension: vi.fn((oid: string) => { + if (oid === "2.5.29.15" && parsed.keyUsages && parsed.keyUsages.length > 0) { + // Calculate usages as bitwise OR of key usage flags + let usages = 0; + parsed.keyUsages.forEach((usage: string) => { + switch (usage) { + case "digital_signature": + usages |= 1; // KeyUsageFlags.digitalSignature + break; + case "key_encipherment": + usages |= 4; // KeyUsageFlags.keyEncipherment + break; + case "key_cert_sign": + usages |= 32; // KeyUsageFlags.keyCertSign + break; + default: + break; + } + }); + return { usages }; + } + if (oid === "2.5.29.37" && parsed.extendedKeyUsages && parsed.extendedKeyUsages.length > 0) { + const ekuOids = parsed.extendedKeyUsages.map((eku: string) => { + switch (eku) { + case "client_auth": + return "1.3.6.1.5.5.7.3.2"; + case "server_auth": + return "1.3.6.1.5.5.7.3.1"; + case "code_signing": + return "1.3.6.1.5.5.7.3.3"; + default: + return "1.3.6.1.5.5.7.3.1"; + } + }); + return { usages: ekuOids }; + } + return undefined; + }) + }; + }); + + (GeneralNames as any).mockImplementation(() => ({ + items: [ + { type: "dns", value: "test.example.com" }, + { type: "ip", value: "192.168.1.1" } + ] + })); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + const createMockCSR = ( + options: { + subject?: string; + keyUsages?: string[]; + extendedKeyUsages?: string[]; + sans?: Array<{ type: string; value: string }>; + } = {} + ) => { + const { + subject = "CN=test.example.com,O=Test Org,C=US", + keyUsages = [], + extendedKeyUsages = [], + sans = [] + } = options; + + return JSON.stringify({ + subject, + keyUsages, + extendedKeyUsages, + sans + }); + }; + + describe("CSR Extraction and Template Validation", () => { + it("should extract subject attributes from CSR", async () => { + const csr = createMockCSR({ + subject: "CN=test.example.com,O=Test Organization,OU=IT Department,L=San Francisco,ST=California,C=US" + }); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + commonName: "test.example.com", + organization: "Test Organization", + organizationUnit: "IT Department", + locality: "San Francisco", + state: "California", + country: "US" + }) + ); + }); + + it("should extract key usages from CSR", async () => { + const csr = createMockCSR({ + keyUsages: ["digital_signature", "key_encipherment"] + }); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + keyUsages: expect.arrayContaining(["digital_signature", "key_encipherment"]) + }) + ); + }); + + it("should extract extended key usages from CSR", async () => { + const csr = createMockCSR({ + extendedKeyUsages: ["client_auth", "server_auth"] + }); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + extendedKeyUsages: expect.arrayContaining(["client_auth", "server_auth"]) + }) + ); + }); + + it("should extract Subject Alternative Names from CSR", async () => { + const { GeneralNames } = await import("@peculiar/x509"); + + const csr = createMockCSR({ + sans: [ + { type: "dns", value: "test.example.com" }, + { type: "ip", value: "192.168.1.1" } + ] + }); + + (GeneralNames as any).mockImplementation(() => ({ + items: [ + { type: "dns", value: "test.example.com" }, + { type: "ip", value: "192.168.1.1" } + ] + })); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + subjectAlternativeNames: expect.arrayContaining([ + expect.objectContaining({ + type: "dns_name", + value: "test.example.com" + }), + expect.objectContaining({ + type: "ip_address", + value: "192.168.1.1" + }) + ]) + }) + ); + }); + }); + + describe("Template Validation Enforcement", () => { + const basicCSR = createMockCSR(); + + it("should enforce template validation and reject invalid requests", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["Common name 'test.example.com' is not allowed", "Key usage 'digital_signature' is denied"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockInternalCertificateAuthorityService.signCertFromCa).not.toHaveBeenCalled(); + }); + + it("should allow valid requests that pass template validation", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.any(Object) + ); + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith({ + isInternal: true, + caId: "ca-123", + csr: basicCSR, + isFromProfile: true + }); + }); + + it("should validate template for both simpleEnrollByProfile and simpleReenrollByProfile", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["SAN value 'evil.com' is denied"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalled(); + expect(mockInternalCertificateAuthorityService.signCertFromCa).not.toHaveBeenCalled(); + }); + }); + + describe("Policy Bypass Prevention", () => { + const maliciousCSR = createMockCSR({ + subject: "CN=evil.com,O=Evil Corp,C=XX", + keyUsages: ["key_cert_sign"], + sans: [ + { type: "dns", value: "*.example.com" }, + { type: "ip", value: "127.0.0.1" } + ] + }); + + it("should block attempts to bypass subject attribute policies", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["Organization 'Evil Corp' is denied", "Country 'XX' is not allowed"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: maliciousCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + commonName: "evil.com", + organization: "Evil Corp", + country: "XX" + }) + ); + }); + + it("should block attempts to bypass key usage policies", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["Key usage 'key_cert_sign' is denied - certificate authority privileges not allowed"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: maliciousCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + keyUsages: expect.arrayContaining(["key_cert_sign"]) + }) + ); + }); + + it("should block attempts to bypass SAN policies", async () => { + const { GeneralNames } = await import("@peculiar/x509"); + + (GeneralNames as any).mockImplementation(() => ({ + items: [ + { type: "dns", value: "*.example.com" }, + { type: "ip", value: "127.0.0.1" } + ] + })); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["SAN value '*.example.com' matches denied wildcard pattern", "SAN value '127.0.0.1' is denied"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: maliciousCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + }); + }); + + describe("Error Handling", () => { + const basicCSR = createMockCSR(); + + it("should handle profile not found", async () => { + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(null); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "nonexistent", + sslClientCert: "" + }) + ).rejects.toThrow(NotFoundError); + }); + + it("should handle non-EST enrollment type", async () => { + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue({ + ...mockProfile, + enrollmentType: EnrollmentType.API + }); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + }); + + it("should handle template validation service errors", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockRejectedValue( + new Error("Template validation service unavailable") + ); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow("Template validation service unavailable"); + }); + }); + + describe("Integration with existing flow", () => { + const basicCSR = createMockCSR(); + + beforeEach(() => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + }); + + it("should call internal CA service with correct parameters after validation", async () => { + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith({ + isInternal: true, + caId: "ca-123", + isFromProfile: true, + csr: basicCSR + }); + }); + + it("should use profile's CA ID instead of template ID to avoid v1/v2 mismatch", async () => { + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }); + + // Verify it uses caId from profile, not certificateTemplateId + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith( + expect.objectContaining({ + caId: "ca-123" + }) + ); + + // Verify it does NOT pass certificateTemplateId to avoid v1/v2 confusion + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith( + expect.not.objectContaining({ + certificateTemplateId: expect.anything() + }) + ); + }); + }); +}); diff --git a/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts new file mode 100644 index 000000000..f6dbfba52 --- /dev/null +++ b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts @@ -0,0 +1,408 @@ +import * as x509 from "@peculiar/x509"; + +import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; +import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { isCertChainValid } from "@app/services/certificate/certificate-fns"; +import { + CertExtendedKeyUsageOIDToName, + CertKeyUsage, + mapLegacyAltNameType, + TAltNameMapping, + TAltNameType +} from "@app/services/certificate/certificate-types"; +import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { + getCaCertChain, + getCaCertChains, + parseDistinguishedName +} from "@app/services/certificate-authority/certificate-authority-fns"; +import { validateAndMapAltNameType } from "@app/services/certificate-authority/certificate-authority-validators"; +import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +import { + mapLegacyExtendedKeyUsageToStandard, + mapLegacyKeyUsageToStandard +} from "@app/services/certificate-common/certificate-constants"; +import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; +import { TCertificateRequest } from "@app/services/certificate-template-v2/certificate-template-v2-types"; +import { TEstEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { convertRawCertsToPkcs7 } from "../../ee/services/certificate-est/certificate-est-fns"; +import { TLicenseServiceFactory } from "../../ee/services/license/license-service"; + +type TCertificateEstV3ServiceFactoryDep = { + internalCertificateAuthorityService: Pick; + certificateTemplateV2Service: Pick; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + licenseService: Pick; + certificateProfileDAL: Pick; + estEnrollmentConfigDAL: Pick; +}; + +export type TCertificateEstV3ServiceFactory = ReturnType; + +export const certificateEstV3ServiceFactory = ({ + internalCertificateAuthorityService, + certificateTemplateV2Service, + certificateAuthorityCertDAL, + certificateAuthorityDAL, + projectDAL, + kmsService, + licenseService, + certificateProfileDAL, + estEnrollmentConfigDAL +}: TCertificateEstV3ServiceFactoryDep) => { + const extractCertificateRequestFromCSR = (csr: string): TCertificateRequest => { + const csrObj = new x509.Pkcs10CertificateRequest(csr); + const subject = parseDistinguishedName(csrObj.subject); + + const certificateRequest: TCertificateRequest = { + commonName: subject.commonName, + organization: subject.organization, + organizationUnit: subject.ou, + locality: subject.locality, + state: subject.province, + country: subject.country + }; + + const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension; + if (csrKeyUsageExtension) { + const csrKeyUsages = Object.values(CertKeyUsage).filter( + // eslint-disable-next-line no-bitwise + (keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0 + ); + certificateRequest.keyUsages = csrKeyUsages.map(mapLegacyKeyUsageToStandard); + } + + const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension; + if (csrExtendedKeyUsageExtension) { + const csrExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map( + (ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string] + ); + certificateRequest.extendedKeyUsages = csrExtendedKeyUsages.map(mapLegacyExtendedKeyUsageToStandard); + } + + const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (sanExtension) { + const sanNames = new x509.GeneralNames(sanExtension.value); + const altNamesArray: TAltNameMapping[] = sanNames.items + .filter( + (value) => + value.type === TAltNameType.EMAIL || + value.type === TAltNameType.DNS || + value.type === TAltNameType.IP || + value.type === TAltNameType.URL + ) + .map((name): TAltNameMapping => { + const altNameType = validateAndMapAltNameType(name.value); + if (!altNameType) { + throw new BadRequestError({ message: `Invalid altName from CSR: ${name.value}` }); + } + return altNameType; + }); + + certificateRequest.subjectAlternativeNames = altNamesArray.map((altName) => ({ + type: mapLegacyAltNameType(altName.type), + value: altName.value + })); + } + + return certificateRequest; + }; + const simpleEnrollByProfile = async ({ + csr, + profileId, + sslClientCert + }: { + csr: string; + profileId: string; + sslClientCert: string; + }) => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== EnrollmentType.EST) { + throw new BadRequestError({ message: "Profile is not configured for EST enrollment" }); + } + + if (!profile.estConfigId) { + throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); + } + + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); + if (!estConfig) { + throw new NotFoundError({ message: "EST configuration not found" }); + } + + const project = await projectDAL.findOne({ id: profile.projectId }); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + + const plan = await licenseService.getPlan(project.orgId); + if (!plan.pkiEst) { + throw new BadRequestError({ + message: + "Failed to perform EST operation - simpleEnroll due to plan restriction. Upgrade to the Enterprise plan." + }); + } + + if (!estConfig.disableBootstrapCaValidation) { + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: profile.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaChain = estConfig.encryptedCaChain + ? ( + await kmsDecryptor({ + cipherTextBlob: estConfig.encryptedCaChain + }) + ).toString() + : ""; + + const caCerts = extractX509CertFromChain(decryptedCaChain)?.map((cert) => { + return new x509.X509Certificate(cert); + }); + + if (!caCerts) { + throw new BadRequestError({ message: "Failed to parse certificate chain" }); + } + + const leafCertificate = extractX509CertFromChain(decodeURIComponent(sslClientCert))?.[0]; + + if (!leafCertificate) { + throw new UnauthorizedError({ message: "Missing client certificate" }); + } + + const certObj = new x509.X509Certificate(leafCertificate); + if (!(await isCertChainValid([certObj, ...caCerts]))) { + throw new BadRequestError({ message: "Invalid certificate chain" }); + } + } + + const certificateRequest = extractCertificateRequestFromCSR(csr); + const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate request validation failed: ${validationResult.errors.join(", ")}` + }); + } + + const { certificate } = await internalCertificateAuthorityService.signCertFromCa({ + isInternal: true, + caId: profile.caId, + csr, + isFromProfile: true + }); + + return convertRawCertsToPkcs7([certificate.rawData]); + }; + + const simpleReenrollByProfile = async ({ + csr, + profileId, + sslClientCert + }: { + csr: string; + profileId: string; + sslClientCert: string; + }) => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== EnrollmentType.EST) { + throw new BadRequestError({ message: "Profile is not configured for EST enrollment" }); + } + + if (!profile.estConfigId) { + throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); + } + + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); + if (!estConfig) { + throw new NotFoundError({ message: "EST configuration not found" }); + } + + const project = await projectDAL.findOne({ id: profile.projectId }); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + + const plan = await licenseService.getPlan(project.orgId); + if (!plan.pkiEst) { + throw new BadRequestError({ + message: + "Failed to perform EST operation - simpleReenroll due to plan restriction. Upgrade to the Enterprise plan." + }); + } + + const leafCertificate = extractX509CertFromChain(decodeURIComponent(sslClientCert))?.[0]; + + if (!leafCertificate) { + throw new UnauthorizedError({ message: "Missing client certificate" }); + } + + const cert = new x509.X509Certificate(leafCertificate); + const caCertChains = await getCaCertChains({ + caId: profile.caId, + certificateAuthorityCertDAL, + certificateAuthorityDAL, + projectDAL, + kmsService + }); + + const verifiedChains = await Promise.all( + caCertChains.map((chain) => { + const caCert = new x509.X509Certificate(chain.certificate); + const caChain = extractX509CertFromChain(chain.certificateChain)?.map((c) => new x509.X509Certificate(c)) || []; + + return isCertChainValid([cert, caCert, ...caChain]); + }) + ); + + if (!verifiedChains.some(Boolean)) { + throw new BadRequestError({ + message: "Invalid client certificate: unable to build a valid certificate chain" + }); + } + + const csrObj = new x509.Pkcs10CertificateRequest(csr); + if (csrObj.subject !== cert.subject) { + throw new BadRequestError({ + message: "Subject mismatch" + }); + } + + let csrSanSet: Set = new Set(); + const csrSanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (csrSanExtension) { + const sanNames = new x509.GeneralNames(csrSanExtension.value); + csrSanSet = new Set([...sanNames.items.map((name) => `${name.type}-${name.value}`)]); + } + + let certSanSet: Set = new Set(); + const certSanExtension = cert.extensions.find((ext) => ext.type === "2.5.29.17"); + if (certSanExtension) { + const sanNames = new x509.GeneralNames(certSanExtension.value); + certSanSet = new Set([...sanNames.items.map((name) => `${name.type}-${name.value}`)]); + } + + if (csrSanSet.size !== certSanSet.size || ![...csrSanSet].every((element) => certSanSet.has(element))) { + throw new BadRequestError({ + message: "Subject alternative names mismatch" + }); + } + + const certificateRequest = extractCertificateRequestFromCSR(csr); + const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate request validation failed: ${validationResult.errors.join(", ")}` + }); + } + + const { certificate } = await internalCertificateAuthorityService.signCertFromCa({ + isInternal: true, + caId: profile.caId, + csr, + isFromProfile: true + }); + + return convertRawCertsToPkcs7([certificate.rawData]); + }; + + const getCaCertsByProfile = async ({ profileId }: { profileId: string }) => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== EnrollmentType.EST) { + throw new BadRequestError({ message: "Profile is not configured for EST enrollment" }); + } + + if (!profile.estConfigId) { + throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); + } + + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); + if (!estConfig) { + throw new NotFoundError({ message: "EST configuration not found" }); + } + + const project = await projectDAL.findOne({ id: profile.projectId }); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + + const plan = await licenseService.getPlan(project.orgId); + if (!plan.pkiEst) { + throw new BadRequestError({ + message: "Failed to perform EST operation - caCerts due to plan restriction. Upgrade to the Enterprise plan." + }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca?.internalCa?.id) { + throw new NotFoundError({ + message: `Internal Certificate Authority with ID '${profile.caId}' not found` + }); + } + + const { caCert, caCertChain } = await getCaCertChain({ + caCertId: ca.internalCa.activeCaCertId as string, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + let certificates: x509.X509Certificate[] = []; + if (caCertChain && caCertChain.trim()) { + try { + certificates = extractX509CertFromChain(caCertChain).map((cert) => new x509.X509Certificate(cert)); + } catch (error) { + certificates = []; + } + } + + const caCertificate = new x509.X509Certificate(caCert); + + return convertRawCertsToPkcs7([caCertificate.rawData, ...certificates.map((cert) => cert.rawData)]); + }; + + return { + simpleEnrollByProfile, + simpleReenrollByProfile, + getCaCertsByProfile + }; +}; diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts new file mode 100644 index 000000000..20cb9f3bc --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -0,0 +1,552 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +import { + EnrollmentType, + TCertificateProfile, + TCertificateProfileCertificate, + TCertificateProfileInsert, + TCertificateProfileMetrics, + TCertificateProfileUpdate, + TCertificateProfileWithConfigs, + TCertificateProfileWithRawMetrics +} from "./certificate-profile-types"; + +export type TCertificateProfileDALFactory = ReturnType; + +export const certificateProfileDALFactory = (db: TDbClient) => { + const certificateProfileOrm = ormify(db, TableName.PkiCertificateProfile); + + const create = async (data: TCertificateProfileInsert, tx?: Knex): Promise => { + try { + const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile).insert(data).returning("*")) as [ + TCertificateProfile + ]; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Create certificate profile" }); + } + }; + + const updateById = async (id: string, data: TCertificateProfileUpdate, tx?: Knex): Promise => { + try { + const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile) + .where({ id }) + .update(data) + .returning("*")) as [TCertificateProfile]; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Update certificate profile" }); + } + }; + + const deleteById = async (id: string, tx?: Knex): Promise => { + try { + const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile) + .where({ id }) + .del() + .returning("*")) as [TCertificateProfile]; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Delete certificate profile" }); + } + }; + + const findById = async (id: string, tx?: Knex): Promise => { + try { + const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile).where({ id }).first()) as + | TCertificateProfile + | undefined; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profile by id" }); + } + }; + + const findByIdWithConfigs = async (id: string, tx?: Knex): Promise => { + try { + const query = (tx || db)(TableName.PkiCertificateProfile) + .leftJoin( + TableName.CertificateAuthority, + `${TableName.PkiCertificateProfile}.caId`, + `${TableName.CertificateAuthority}.id` + ) + .leftJoin( + TableName.PkiCertificateTemplateV2, + `${TableName.PkiCertificateProfile}.certificateTemplateId`, + `${TableName.PkiCertificateTemplateV2}.id` + ) + .leftJoin( + TableName.PkiEstEnrollmentConfig, + `${TableName.PkiCertificateProfile}.estConfigId`, + `${TableName.PkiEstEnrollmentConfig}.id` + ) + .leftJoin( + TableName.PkiApiEnrollmentConfig, + `${TableName.PkiCertificateProfile}.apiConfigId`, + `${TableName.PkiApiEnrollmentConfig}.id` + ) + .select(selectAllTableCols(TableName.PkiCertificateProfile)) + .select( + db.ref("id").withSchema(TableName.CertificateAuthority).as("caId"), + db.ref("projectId").withSchema(TableName.CertificateAuthority).as("caProjectId"), + db.ref("status").withSchema(TableName.CertificateAuthority).as("caStatus"), + db.ref("name").withSchema(TableName.CertificateAuthority).as("caName"), + db.ref("id").withSchema(TableName.PkiCertificateTemplateV2).as("templateId"), + db.ref("projectId").withSchema(TableName.PkiCertificateTemplateV2).as("templateProjectId"), + db.ref("name").withSchema(TableName.PkiCertificateTemplateV2).as("templateName"), + db.ref("description").withSchema(TableName.PkiCertificateTemplateV2).as("templateDescription"), + db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigId"), + db + .ref("disableBootstrapCaValidation") + .withSchema(TableName.PkiEstEnrollmentConfig) + .as("estConfigDisableBootstrapCaValidation"), + db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigHashedPassphrase"), + db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigEncryptedCaChain"), + db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"), + db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"), + db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenewDays") + ) + .where(`${TableName.PkiCertificateProfile}.id`, id) + .first(); + + const result = await query; + + if (!result) return undefined; + + const estConfig = + result.estConfigId && result.estConfigHashedPassphrase + ? ({ + id: result.estConfigId, + disableBootstrapCaValidation: !!result.estConfigDisableBootstrapCaValidation, + passphrase: result.estConfigHashedPassphrase, + caChain: result.estConfigEncryptedCaChain ? result.estConfigEncryptedCaChain.toString("utf8") : "" + } as TCertificateProfileWithConfigs["estConfig"]) + : undefined; + + const apiConfig = result.apiConfigId + ? ({ + id: result.apiConfigId, + autoRenew: !!result.apiConfigAutoRenew, + autoRenewDays: result.apiConfigAutoRenewDays || undefined + } as TCertificateProfileWithConfigs["apiConfig"]) + : undefined; + + const certificateAuthority = + result.caId && result.caProjectId && result.caStatus && result.caName + ? ({ + id: result.caId, + projectId: result.caProjectId, + status: result.caStatus, + name: result.caName + } as TCertificateProfileWithConfigs["certificateAuthority"]) + : undefined; + + const certificateTemplate = + result.templateId && result.templateProjectId && result.templateName + ? ({ + id: result.templateId, + projectId: result.templateProjectId, + name: result.templateName, + description: result.templateDescription || undefined + } as TCertificateProfileWithConfigs["certificateTemplate"]) + : undefined; + + const transformedResult: TCertificateProfileWithConfigs = { + id: result.id, + projectId: result.projectId, + caId: result.caId, + certificateTemplateId: result.certificateTemplateId, + slug: result.slug, + description: result.description, + enrollmentType: result.enrollmentType as EnrollmentType, + estConfigId: result.estConfigId, + apiConfigId: result.apiConfigId, + createdAt: result.createdAt, + updatedAt: result.updatedAt, + estConfig, + apiConfig, + certificateAuthority, + certificateTemplate + }; + + return transformedResult; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profile by id with configs" }); + } + }; + + const findBySlugAndProjectId = async ( + slug: string, + projectId: string, + tx?: Knex + ): Promise => { + try { + const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile) + .where({ slug, projectId }) + .first()) as TCertificateProfile | undefined; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profile by slug and project id" }); + } + }; + + const findByProjectId = async ( + projectId: string, + options: { + offset?: number; + limit?: number; + search?: string; + enrollmentType?: EnrollmentType; + caId?: string; + includeMetrics?: boolean; + expiringDays?: number; + } = {}, + tx?: Knex + ): Promise => { + try { + const { + offset = 0, + limit = 20, + search, + enrollmentType, + caId, + includeMetrics = false, + expiringDays = 7 + } = options; + + let baseQuery = (tx || db)(TableName.PkiCertificateProfile).where( + `${TableName.PkiCertificateProfile}.projectId`, + projectId + ); + + if (search) { + baseQuery = baseQuery.where((builder) => { + void builder.where((qb) => { + void qb + .whereILike(`${TableName.PkiCertificateProfile}.slug`, `%${search}%`) + .orWhereILike(`${TableName.PkiCertificateProfile}.description`, `%${search}%`); + }); + }); + } + + if (enrollmentType) { + baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.enrollmentType`, enrollmentType); + } + + if (caId) { + baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.caId`, caId); + } + + let query = baseQuery + .leftJoin( + TableName.PkiEstEnrollmentConfig, + `${TableName.PkiCertificateProfile}.estConfigId`, + `${TableName.PkiEstEnrollmentConfig}.id` + ) + .leftJoin( + TableName.PkiApiEnrollmentConfig, + `${TableName.PkiCertificateProfile}.apiConfigId`, + `${TableName.PkiApiEnrollmentConfig}.id` + ) + .select(selectAllTableCols(TableName.PkiCertificateProfile)) + .select( + db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"), + db + .ref("disableBootstrapCaValidation") + .withSchema(TableName.PkiEstEnrollmentConfig) + .as("estDisableBootstrapCaValidation"), + db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estHashedPassphrase"), + db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), + db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), + db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), + db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays") + ); + + if (includeMetrics) { + query = query.leftJoin( + TableName.Certificate, + `${TableName.PkiCertificateProfile}.id`, + `${TableName.Certificate}.profileId` + ); + + const now = new Date(); + const expiringDate = new Date(); + expiringDate.setDate(now.getDate() + expiringDays); + + query = query + .select( + selectAllTableCols(TableName.PkiCertificateProfile), + db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"), + db + .ref("disableBootstrapCaValidation") + .withSchema(TableName.PkiEstEnrollmentConfig) + .as("estDisableBootstrapCaValidation"), + db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estHashedPassphrase"), + db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), + db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), + db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), + db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays"), + db.raw("COUNT(certificates.id) as total_certificates"), + db.raw( + 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? THEN 1 END) as active_certificates', + [expiringDate] + ), + db.raw( + 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" <= ? THEN 1 END) as expired_certificates', + [now] + ), + db.raw( + 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? AND certificates."notAfter" <= ? THEN 1 END) as expiring_certificates', + [now, expiringDate] + ), + db.raw('COUNT(CASE WHEN certificates."revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates') + ) + .groupBy( + `${TableName.PkiCertificateProfile}.id`, + `${TableName.PkiEstEnrollmentConfig}.id`, + `${TableName.PkiApiEnrollmentConfig}.id` + ); + } + + const results = (await query + .orderBy(`${TableName.PkiCertificateProfile}.createdAt`, "desc") + .offset(offset) + .limit(limit)) as Record[]; + + return results.map((result: Record) => { + const estConfig = + result.estId && result.estHashedPassphrase + ? { + id: result.estId as string, + disableBootstrapCaValidation: !!result.estDisableBootstrapCaValidation, + passphrase: result.estConfigHashedPassphrase, + caChain: result.estEncryptedCaChain ? (result.estEncryptedCaChain as Buffer).toString("utf8") : "" + } + : undefined; + + const apiConfig = result.apiId + ? { + id: result.apiId as string, + autoRenew: !!result.apiAutoRenew, + autoRenewDays: (result.apiAutoRenewDays as number) || undefined + } + : undefined; + + const baseProfile = { + id: result.id, + projectId: result.projectId, + caId: result.caId, + certificateTemplateId: result.certificateTemplateId, + slug: result.slug, + description: result.description, + enrollmentType: result.enrollmentType as EnrollmentType, + estConfigId: result.estConfigId, + apiConfigId: result.apiConfigId, + createdAt: result.createdAt, + updatedAt: result.updatedAt, + estConfig, + apiConfig + }; + + if (includeMetrics) { + return { + ...baseProfile, + total_certificates: result.total_certificates, + active_certificates: result.active_certificates, + expired_certificates: result.expired_certificates, + expiring_certificates: result.expiring_certificates, + revoked_certificates: result.revoked_certificates + } as TCertificateProfileWithRawMetrics & TCertificateProfileWithConfigs; + } + + return baseProfile as TCertificateProfileWithConfigs; + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profiles by project id" }); + } + }; + + const countByProjectId = async ( + projectId: string, + options: { + search?: string; + enrollmentType?: EnrollmentType; + caId?: string; + } = {}, + tx?: Knex + ): Promise => { + try { + const { search, enrollmentType, caId } = options; + + let query = (tx || db)(TableName.PkiCertificateProfile).where({ projectId }); + + if (search) { + query = query.where((builder) => { + void builder.where((qb) => { + void qb.whereILike("description", `%${search}%`).orWhereILike("slug", `%${search}%`); + }); + }); + } + + if (enrollmentType) { + query = query.where({ enrollmentType }); + } + + if (caId) { + query = query.where({ caId }); + } + + const result = await query.count("*").first(); + return parseInt((result as unknown as { count: string }).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count certificate profiles by project id" }); + } + }; + + const findByNameAndProjectId = async ( + name: string, + projectId: string, + tx?: Knex + ): Promise => { + try { + const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile) + .where({ slug: name, projectId }) + .first()) as TCertificateProfile | undefined; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profile by name and project id" }); + } + }; + + const getCertificatesByProfile = async ( + profileId: string, + options: { + offset?: number; + limit?: number; + status?: "active" | "expired" | "revoked"; + search?: string; + } = {}, + tx?: Knex + ): Promise => { + try { + const { offset = 0, limit = 20, status, search } = options; + const now = new Date(); + + let query = (tx || db)(TableName.Certificate).where("profileId", profileId); + + if (search) { + query = query.where((builder) => { + void builder.where((qb) => { + void qb.whereILike("cn", `%${search}%`).orWhereILike("serialNumber", `%${search}%`); + }); + }); + } + + if (status) { + switch (status) { + case "active": + query = query.where("notAfter", ">", now).whereNull("revokedAt"); + break; + case "expired": + query = query.where("notAfter", "<=", now).whereNull("revokedAt"); + break; + case "revoked": + query = query.whereNotNull("revokedAt"); + break; + default: + break; + } + } + + const certificates = await query + .select((tx || db).ref("id").withSchema(TableName.Certificate)) + .select((tx || db).ref("serialNumber").withSchema(TableName.Certificate)) + .select((tx || db).ref("cn").withSchema(TableName.Certificate)) + .select((tx || db).ref("status").withSchema(TableName.Certificate)) + .select((tx || db).ref("notBefore").withSchema(TableName.Certificate)) + .select((tx || db).ref("notAfter").withSchema(TableName.Certificate)) + .select((tx || db).ref("revokedAt").withSchema(TableName.Certificate)) + .select((tx || db).ref("createdAt").withSchema(TableName.Certificate)) + .orderBy("createdAt", "desc") + .offset(offset) + .limit(limit); + + return certificates.map((cert) => ({ + ...cert, + revokedAt: cert.revokedAt ?? null + })); + } catch (error) { + throw new DatabaseError({ error, name: "Get certificates by profile" }); + } + }; + + const getProfileMetrics = async ( + profileId: string, + expiringDays: number = 7, + tx?: Knex + ): Promise => { + try { + const now = new Date(); + const expiringDate = new Date(); + expiringDate.setDate(now.getDate() + expiringDays); + + const metrics = await (tx || db)(TableName.Certificate) + .where("profileId", profileId) + .select( + db.raw("COUNT(*) as total_certificates"), + db.raw('COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" > ? THEN 1 END) as active_certificates', [ + expiringDate + ]), + db.raw('COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" <= ? THEN 1 END) as expired_certificates', [now]), + db.raw( + 'COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" > ? AND "notAfter" <= ? THEN 1 END) as expiring_certificates', + [now, expiringDate] + ), + db.raw('COUNT(CASE WHEN "revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates') + ) + .first(); + + return { + profileId, + totalCertificates: parseInt(String((metrics as Record)?.total_certificates || 0), 10), + activeCertificates: parseInt(String((metrics as Record)?.active_certificates || 0), 10), + expiredCertificates: parseInt(String((metrics as Record)?.expired_certificates || 0), 10), + expiringCertificates: parseInt(String((metrics as Record)?.expiring_certificates || 0), 10), + revokedCertificates: parseInt(String((metrics as Record)?.revoked_certificates || 0), 10) + }; + } catch (error) { + throw new DatabaseError({ error, name: "Get certificate profile metrics" }); + } + }; + + const isProfileInUse = async (profileId: string, tx?: Knex) => { + try { + const doc = await (tx || db)(TableName.Certificate).where("profileId", profileId).count("*").first(); + + return parseInt((doc as unknown as { count: string }).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Check if certificate profile is in use" }); + } + }; + + return { + ...certificateProfileOrm, + create, + updateById, + deleteById, + findById, + findByIdWithConfigs, + findBySlugAndProjectId, + findByProjectId, + countByProjectId, + findByNameAndProjectId, + getCertificatesByProfile, + getProfileMetrics, + isProfileInUse + }; +}; diff --git a/backend/src/services/certificate-profile/certificate-profile-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-schemas.ts new file mode 100644 index 000000000..7b3e2cc57 --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts @@ -0,0 +1,134 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { EnrollmentType } from "./certificate-profile-types"; + +export const createCertificateProfileSchema = z + .object({ + projectId: z.string().uuid("Project ID must be valid"), + caId: z.string().uuid(), + certificateTemplateId: z.string().uuid(), + slug: z + .string() + .min(1) + .max(255) + .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"), + description: z.string().max(1000).optional(), + enrollmentType: z.nativeEnum(EnrollmentType), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().default(false), + passphrase: z.string().min(1), + encryptedCaChain: z.string() + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().default(false), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + if (!data.estConfig) { + return false; + } + if (data.apiConfig) { + return false; + } + } + if (data.enrollmentType === EnrollmentType.API) { + if (!data.apiConfig) { + return false; + } + if (data.estConfig) { + return false; + } + } + return true; + }, + { + message: + "EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration." + } + ); + +export const updateCertificateProfileSchema = z + .object({ + slug: z + .string() + .min(1) + .max(255) + .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens") + .optional(), + description: z.string().max(1000).optional(), + enrollmentType: z.nativeEnum(EnrollmentType).optional(), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().default(false), + passphrase: z.string().min(1), + encryptedCaChain: z.string() + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().default(false), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + if (data.apiConfig) { + return false; + } + } + if (data.enrollmentType === EnrollmentType.API) { + if (data.estConfig) { + return false; + } + } + return true; + }, + { + message: "Cannot have EST config with API enrollment type or API config with EST enrollment type." + } + ); + +export const getCertificateProfileByIdSchema = z.object({ + id: z.string().uuid() +}); + +export const getCertificateProfileBySlugSchema = z.object({ + projectId: z.string().uuid("Project ID must be valid"), + slug: z.string().min(1) +}); + +export const listCertificateProfilesSchema = z.object({ + projectId: z.string().uuid("Project ID must be valid"), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + search: z.string().optional(), + enrollmentType: z.nativeEnum(EnrollmentType).optional(), + caId: z.string().uuid().optional() +}); + +export const deleteCertificateProfileSchema = z.object({ + id: z.string().uuid() +}); + +export const listCertificatesByProfileSchema = z.object({ + profileId: z.string().uuid(), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + status: z.enum(["active", "expired", "revoked"]).optional(), + search: z.string().optional() +}); + +export const getCertificateProfileMetricsSchema = z.object({ + profileId: z.string().uuid(), + expiringDays: z.coerce.number().min(1).max(365).default(30) +}); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.test.ts b/backend/src/services/certificate-profile/certificate-profile-service.test.ts new file mode 100644 index 000000000..dd2d7d2d6 --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -0,0 +1,1181 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { ForbiddenError } from "@casl/ability"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; + +import { ActorType, AuthMethod } from "../auth/auth-type"; +import type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; +import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; +import type { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; +import type { TKmsServiceFactory } from "../kms/kms-service"; +import type { TProjectDALFactory } from "../project/project-dal"; +import type { TCertificateProfileDALFactory } from "./certificate-profile-dal"; +import { certificateProfileServiceFactory, TCertificateProfileServiceFactory } from "./certificate-profile-service"; +import { EnrollmentType, TCertificateProfile, TCertificateProfileWithConfigs } from "./certificate-profile-types"; + +vi.mock("@app/lib/crypto/cryptography", () => ({ + crypto: { + hashing: () => ({ + createHash: vi.fn().mockResolvedValue("mocked-hash") + }), + generateRandomPassword: vi.fn().mockReturnValue("mocked-password") + } +})); + +vi.mock("@app/lib/config/env", () => ({ + getConfig: () => ({ + SALT_ROUNDS: 12 + }) +})); + +describe("CertificateProfileService", () => { + let service: TCertificateProfileServiceFactory; + + const mockCertificateProfileDAL = { + create: vi.fn(), + findById: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + findBySlugAndProjectId: vi.fn(), + findByProjectId: vi.fn(), + countByProjectId: vi.fn(), + findByNameAndProjectId: vi.fn(), + findByIdWithConfigs: vi.fn(), + getCertificatesByProfile: vi.fn(), + getProfileMetrics: vi.fn(), + isProfileInUse: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TCertificateProfileDALFactory; + + const mockCertificateTemplateV2DAL = { + findById: vi.fn(), + create: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + findByProjectId: vi.fn(), + countByProjectId: vi.fn(), + isTemplateInUse: vi.fn(), + findByNameAndProjectId: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TCertificateTemplateV2DALFactory; + + const mockActor = { + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123" + }; + + const sampleProfile: TCertificateProfile = { + id: "profile-123", + projectId: "project-123", + description: "Test certificate profile", + slug: "test-profile", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfigId: "api-config-123", + estConfigId: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const sampleProfileWithConfigs: TCertificateProfileWithConfigs = { + ...sampleProfile, + certificateAuthority: { + id: "ca-123", + projectId: "project-123", + status: "active", + name: "Test CA" + }, + certificateTemplate: { + id: "template-123", + projectId: "project-123", + name: "Test Template", + description: "Test template" + }, + apiConfig: { + id: "api-config-123", + autoRenew: true, + autoRenewDays: 30 + } + }; + + const sampleTemplate = { + id: "template-123", + projectId: "project-123", + name: "Test Template" + }; + + const mockApiEnrollmentConfigDAL = { + create: vi.fn().mockResolvedValue({ id: "api-config-123" }), + findById: vi.fn(), + updateById: vi.fn(), + findProfilesForAutoRenewal: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TApiEnrollmentConfigDALFactory; + + const mockEstEnrollmentConfigDAL = { + create: vi.fn().mockResolvedValue({ id: "est-config-123" }), + findById: vi.fn(), + updateById: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TEstEnrollmentConfigDALFactory; + + const mockPermissionService = { + getProjectPermission: vi.fn().mockResolvedValue({ + permission: { + throwUnlessCan: vi.fn() + } + }) + } as unknown as Pick; + + const mockKmsService = { + encryptWithKmsKey: vi + .fn() + .mockResolvedValue(() => Promise.resolve({ cipherTextBlob: Buffer.from("encrypted-data") })), + decryptWithKmsKey: vi.fn().mockResolvedValue(() => Promise.resolve(Buffer.from("decrypted-ca-chain"))), + generateKmsKey: vi.fn() + } as unknown as Pick; + + const mockProjectDAL = { + findById: vi.fn(), + findOne: vi.fn(), + updateById: vi.fn(), + findProjectBySlug: vi.fn(), + transaction: vi.fn() + } as unknown as Pick; + + beforeEach(() => { + vi.spyOn(ForbiddenError, "from").mockReturnValue({ + throwUnlessCan: vi.fn() + } as any); + + // Mock the transaction method to execute the callback and return the result + (mockCertificateProfileDAL.transaction as any).mockImplementation(async (fn: any) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/return-await + return await fn(); + }); + + service = certificateProfileServiceFactory({ + certificateProfileDAL: mockCertificateProfileDAL, + certificateTemplateV2DAL: mockCertificateTemplateV2DAL, + apiEnrollmentConfigDAL: mockApiEnrollmentConfigDAL, + estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL, + permissionService: mockPermissionService, + kmsService: mockKmsService, + projectDAL: mockProjectDAL + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("createProfile", () => { + const validProfileData = { + slug: "new-profile", + description: "New test profile", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: true, + autoRenewDays: 30 + } + }; + + beforeEach(() => { + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockResolvedValue({ + ...sampleProfile, + enrollmentType: EnrollmentType.API // Ensure enrollmentType is explicitly included + }); + }); + + it("should create profile successfully", async () => { + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: validProfileData + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + expect(mockCertificateProfileDAL.findBySlugAndProjectId).toHaveBeenCalledWith("new-profile", "project-123"); + expect(mockCertificateProfileDAL.create).toHaveBeenCalledWith( + { + slug: "new-profile", + description: "New test profile", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfigId: "api-config-123", + estConfigId: null, + projectId: "project-123" + }, + undefined + ); + }); + + it("should throw NotFoundError when certificate template not found", async () => { + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(null); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: validProfileData + }) + ).rejects.toThrow(NotFoundError); + }); + + it("should throw ForbiddenRequestError when template belongs to different project", async () => { + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue({ + ...sampleTemplate, + projectId: "different-project" + }); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: validProfileData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should throw ForbiddenRequestError when profile slug already exists", async () => { + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(sampleProfile); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: validProfileData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should throw ForbiddenRequestError for EST enrollment without EST config", async () => { + const invalidData = { + ...validProfileData, + enrollmentType: EnrollmentType.EST, + estConfigId: null + }; + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: invalidData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should throw ForbiddenRequestError for API enrollment without API config", async () => { + const invalidData = { + slug: "invalid-profile", + description: "Invalid test profile", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123" + }; + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: invalidData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should create profile with API enrollment", async () => { + const apiProfileData = { + slug: "api-profile", + description: "Profile with API enrollment", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: true, + autoRenewDays: 30 + } + }; + + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: apiProfileData + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + }); + }); + + describe("updateProfile", () => { + const updateData = { + slug: "updated-profile", + description: "Updated description" + }; + + beforeEach(() => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.updateById as any).mockResolvedValue({ + ...sampleProfile, + ...updateData, + enrollmentType: EnrollmentType.API // Ensure enrollmentType is explicitly included + }); + }); + + it("should update profile successfully", async () => { + const result = await service.updateProfile({ + ...mockActor, + profileId: "profile-123", + data: updateData + }); + + expect(result.slug).toBe("updated-profile"); + expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); + expect(mockCertificateProfileDAL.updateById).toHaveBeenCalledWith("profile-123", updateData, undefined); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); + + await expect( + service.updateProfile({ + ...mockActor, + profileId: "profile-123", + data: updateData + }) + ).rejects.toThrow(NotFoundError); + }); + + it("should validate certificate template when updating", async () => { + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + + const updateWithTemplate = { + ...updateData, + certificateTemplateId: "template-123" + }; + + await service.updateProfile({ + ...mockActor, + profileId: "profile-123", + data: updateWithTemplate + }); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + }); + }); + + describe("getProfileById", () => { + it("should return profile successfully", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + + const result = await service.getProfileById({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); + + await expect( + service.getProfileById({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getProfileByIdWithConfigs", () => { + it("should return profile with configs successfully", async () => { + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(sampleProfileWithConfigs); + + const result = await service.getProfileByIdWithConfigs({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(sampleProfileWithConfigs); + expect(mockCertificateProfileDAL.findByIdWithConfigs).toHaveBeenCalledWith("profile-123"); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(null); + + await expect( + service.getProfileByIdWithConfigs({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getProfileBySlug", () => { + it("should return profile by slug successfully", async () => { + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(sampleProfile); + + const result = await service.getProfileBySlug({ + ...mockActor, + projectId: "project-123", + slug: "test-profile" + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateProfileDAL.findBySlugAndProjectId).toHaveBeenCalledWith("test-profile", "project-123"); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + + await expect( + service.getProfileBySlug({ + ...mockActor, + projectId: "project-123", + slug: "nonexistent" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("listProfiles", () => { + const mockProfiles = [sampleProfile]; + + beforeEach(() => { + (mockCertificateProfileDAL.findByProjectId as any).mockResolvedValue(mockProfiles); + (mockCertificateProfileDAL.countByProjectId as any).mockResolvedValue(1); + }); + + it("should list profiles successfully", async () => { + const result = await service.listProfiles({ + ...mockActor, + projectId: "project-123" + }); + + expect(result.profiles).toEqual(mockProfiles); + expect(result.totalCount).toBe(1); + expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 0, + limit: 20, + search: undefined, + enrollmentType: undefined, + caId: undefined, + includeMetrics: false, + expiringDays: 30 + }); + }); + + it("should list profiles with filters", async () => { + await service.listProfiles({ + ...mockActor, + projectId: "project-123", + offset: 10, + limit: 5, + search: "test", + enrollmentType: EnrollmentType.API, + caId: "ca-123" + }); + + expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 10, + limit: 5, + search: "test", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + includeMetrics: false, + expiringDays: 30 + }); + }); + + it("should list profiles with metrics when includeMetrics is true", async () => { + const mockProfilesWithMetrics = [ + { + ...sampleProfile, + total_certificates: 10, + active_certificates: 8, + expired_certificates: 1, + expiring_certificates: 1, + revoked_certificates: 0 + } + ]; + (mockCertificateProfileDAL.findByProjectId as any).mockResolvedValue(mockProfilesWithMetrics); + + const result = await service.listProfiles({ + ...mockActor, + projectId: "project-123", + includeMetrics: true, + expiringDays: 15 + }); + + expect(result.profiles).toHaveLength(1); + expect(result.profiles[0]).toHaveProperty("metrics"); + expect(result.profiles[0].metrics).toEqual({ + profileId: sampleProfile.id, + totalCertificates: 10, + activeCertificates: 8, + expiredCertificates: 1, + expiringCertificates: 1, + revokedCertificates: 0 + }); + + expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 0, + limit: 20, + search: undefined, + enrollmentType: undefined, + caId: undefined, + includeMetrics: true, + expiringDays: 15 + }); + }); + }); + + describe("deleteProfile", () => { + beforeEach(() => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.isProfileInUse as any).mockResolvedValue(false); + (mockCertificateProfileDAL.deleteById as any).mockResolvedValue(sampleProfile); + }); + + it("should delete profile successfully", async () => { + const result = await service.deleteProfile({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); + expect(mockCertificateProfileDAL.deleteById).toHaveBeenCalledWith("profile-123"); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); + + await expect( + service.deleteProfile({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getProfileCertificates", () => { + const mockCertificates = [ + { + id: "cert-123", + serialNumber: "123456", + cn: "example.com", + status: "active", + notBefore: new Date(), + notAfter: new Date(), + isRevoked: false, + createdAt: new Date() + } + ]; + + beforeEach(() => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getCertificatesByProfile as any).mockResolvedValue(mockCertificates); + }); + + it("should get profile certificates successfully", async () => { + const result = await service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(mockCertificates); + expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); + expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", { + offset: 0, + limit: 20, + status: undefined, + search: undefined + }); + }); + + it("should get profile certificates with filters", async () => { + await service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123", + offset: 10, + limit: 5, + status: "active", + search: "example" + }); + + expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", { + offset: 10, + limit: 5, + status: "active", + search: "example" + }); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); + + await expect( + service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getProfileMetrics", () => { + const mockMetrics = { + profileId: "profile-123", + totalCertificates: 10, + activeCertificates: 8, + expiredCertificates: 1, + expiringCertificates: 2, + revokedCertificates: 1 + }; + + beforeEach(() => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(mockMetrics); + }); + + it("should get profile metrics successfully", async () => { + const result = await service.getProfileMetrics({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(mockMetrics); + expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); + expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 30); + }); + + it("should get profile metrics with custom expiring days", async () => { + await service.getProfileMetrics({ + ...mockActor, + profileId: "profile-123", + expiringDays: 60 + }); + + expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 60); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); + + await expect( + service.getProfileMetrics({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("comprehensive certificate profile scenarios", () => { + describe("profile configuration validation", () => { + it("should validate EST enrollment configuration", async () => { + const estProfileData = { + slug: "est-profile", + description: "Profile with EST enrollment", + enrollmentType: EnrollmentType.EST, + caId: "ca-123", + certificateTemplateId: "template-123", + estConfig: { + disableBootstrapCaValidation: false, + passphrase: "secret-passphrase", + caChain: + "-----BEGIN CERTIFICATE-----\nMIIC+DCCAeCgAwIBAgIUBmCvLQ7l6CmNYjGeGXqIaS9LPuUwDQYJKoZIhvcNAQEL\nBQAwFDESMBAGA1UEChMJSW5maXNpY2FsMB4XDTI1MTAxNzE1MjczMFoXDTM1MTAx\nNzAwMDAwMFowFDESMBAGA1UEChMJSW5maXNpY2FsMIIBIjANBgkqhkiG9w0BAQEF\nAAOCAQ8AMIIBCgKCAQEAqRS0ZKh44Y1GHvD4/ryduaelVtfvqkdCmhxpCp7OTjIA\n/gPuVoBA31gxqMVcpDgIAk8dfqds0WFzFe2byhbBalNm3+FSYJkEKa1mdCnqM/mL\nt6O0V/dPv2dcepDluwWbHJIuFf5elH1F8eeyqZV5w6c980lOyDO0DVNqB6pjGlPq\njEVcvEdEtGSfIX3B2tmODilwUvl/lGjhnK6ghfots7i1Xno9VAY/YTqR0T+lyPx4\n23r+22gstJ7XCLA7aqfRyFyYaVKqubHPBwz2qKiBTc3Shc3ii/OHc5KjTpADNRDv\nvH7X5kOXYtdpGbMsJ1uY+MPwfbOVkxy4tg4HFejmyQIDAQABo0IwQDAPBgNVHRMB\nAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUpshrlfvvw+zkoLKf\nxNUYD92/YxIwDQYJKoZIhvcNAQELBQADggEBAAWDMNe8HnoOPHF1sIUcCvJjBeUz\neB++l5Er9P+UPpkSr7+KpD+9DQGWmaOT57Vp7nBYXd42828h+cq7KEG2w5Uf6fYD\nBuitrzj2IzNznvKwOMh/qAePC17tH4mnkSnsJCMg6cvG99GG+vQoMQW7+D6VshIH\nm5hNThNGSPznk+eNk+NlIIVzD4autRn+U5geYzDaZIWfmx95gwCPK2VVw1IDExA+\naQiZi4g1JviUB97E92rZzX+Ai4GYk+CKQTxAiZPZ2M9gRFLrjKIGbRu7FaL+9lwU\nWnax4HJZ/cdVUtVp8VgaAOy7qvl5WGZ4eLopLhMkW3RyPiFr4+M3vNJocqU=\n-----END CERTIFICATE-----" + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockResolvedValue({ + ...sampleProfile, + enrollmentType: EnrollmentType.EST, + estConfigId: "est-config-123" + }); + + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: estProfileData + }); + + expect(result.enrollmentType).toBe(EnrollmentType.EST); + expect(mockEstEnrollmentConfigDAL.create).toHaveBeenCalledWith( + { + disableBootstrapCaValidation: estProfileData.estConfig.disableBootstrapCaValidation, + hashedPassphrase: "mocked-hash", + encryptedCaChain: Buffer.from("encrypted-data") + }, + undefined + ); + }); + + it("should handle profile slug uniqueness validation", async () => { + vi.clearAllMocks(); + + const duplicateSlugData = { + slug: "different-profile-name", + description: "Profile with duplicate slug", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: true, + autoRenewDays: 30 + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(sampleProfile); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: duplicateSlugData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should validate auto-renewal configuration", async () => { + const autoRenewData = { + slug: "auto-renew-profile", + description: "Profile with auto-renewal", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: true, + autoRenewDays: 7 + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockResolvedValue({ + ...sampleProfile, + apiConfigId: "api-config-123", + enrollmentType: EnrollmentType.API + }); + + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: autoRenewData + }); + + expect(mockApiEnrollmentConfigDAL.create).toHaveBeenCalledWith( + { + autoRenew: true, + autoRenewDays: 7 + }, + undefined + ); + expect(result).toBeDefined(); + }); + }); + + describe("profile lifecycle management", () => { + it("should handle profile updates with enrollment type changes", async () => { + const currentProfile = { + ...sampleProfile, + enrollmentType: EnrollmentType.API, + apiConfigId: "api-config-123", + estConfigId: null + }; + + const updateToEst = { + enrollmentType: EnrollmentType.EST, + estConfigId: "est-config-123", + apiConfigId: null + }; + + (mockCertificateProfileDAL.findById as any).mockResolvedValue(currentProfile); + (mockCertificateProfileDAL.updateById as any).mockResolvedValue({ + ...currentProfile, + enrollmentType: EnrollmentType.EST, + estConfigId: "est-config-123", + apiConfigId: null + }); + + const result = await service.updateProfile({ + ...mockActor, + profileId: "profile-123", + data: updateToEst + }); + + expect(mockEstEnrollmentConfigDAL.create).not.toHaveBeenCalled(); + expect(result.enrollmentType).toBe(EnrollmentType.EST); + }); + + it("should allow deletion of profiles", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.deleteById as any).mockResolvedValue(sampleProfile); + + const result = await service.deleteProfile({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateProfileDAL.deleteById).toHaveBeenCalledWith("profile-123"); + }); + }); + + describe("certificate management", () => { + it("should filter certificates by status", async () => { + const activeCerts = [ + { + id: "cert-1", + serialNumber: "123456", + cn: "example.com", + status: "active", + notBefore: new Date(), + notAfter: new Date(), + isRevoked: false, + createdAt: new Date() + } + ]; + + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getCertificatesByProfile as any).mockResolvedValue(activeCerts); + + const result = await service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123", + status: "active" + }); + + expect(result).toEqual(activeCerts); + expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", { + offset: 0, + limit: 20, + status: "active", + search: undefined + }); + }); + + it("should search certificates by common name", async () => { + const searchResults = [ + { + id: "cert-1", + serialNumber: "123456", + cn: "api.example.com", + status: "active", + notBefore: new Date(), + notAfter: new Date(), + isRevoked: false, + createdAt: new Date() + } + ]; + + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getCertificatesByProfile as any).mockResolvedValue(searchResults); + + const result = await service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123", + search: "api.example" + }); + + expect(result).toEqual(searchResults); + expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", { + offset: 0, + limit: 20, + status: undefined, + search: "api.example" + }); + }); + }); + + describe("metrics and monitoring", () => { + it("should calculate profile metrics correctly", async () => { + const detailedMetrics = { + profileId: "profile-123", + totalCertificates: 50, + activeCertificates: 40, + expiredCertificates: 5, + expiringCertificates: 3, + revokedCertificates: 2 + }; + + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(detailedMetrics); + + const result = await service.getProfileMetrics({ + ...mockActor, + profileId: "profile-123", + expiringDays: 14 + }); + + expect(result).toEqual(detailedMetrics); + expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 14); + }); + + it("should handle zero certificate metrics", async () => { + const emptyMetrics = { + profileId: "profile-123", + totalCertificates: 0, + activeCertificates: 0, + expiredCertificates: 0, + expiringCertificates: 0, + revokedCertificates: 0 + }; + + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(emptyMetrics); + + const result = await service.getProfileMetrics({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result.totalCertificates).toBe(0); + expect(result.activeCertificates).toBe(0); + }); + }); + + describe("error scenarios", () => { + it("should handle database connection errors gracefully", async () => { + (mockCertificateProfileDAL.findById as any).mockRejectedValue(new Error("Database connection failed")); + + await expect( + service.getProfileById({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow("Database connection failed"); + }); + + it("should handle invalid template reference during profile creation", async () => { + const profileData = { + slug: "invalid-template-profile", + description: "Profile with invalid template", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "nonexistent-template", + apiConfig: { + autoRenew: false + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(null); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: profileData + }) + ).rejects.toThrow(NotFoundError); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("nonexistent-template"); + }); + + it("should handle concurrent profile creation conflicts", async () => { + const conflictingData = { + slug: "concurrent-profile", + description: "Profile created concurrently", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: false + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockRejectedValue(new Error("Unique constraint violation")); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: conflictingData + }) + ).rejects.toThrow("Unique constraint violation"); + }); + }); + + describe("permission and security", () => { + it("should validate project ownership for cross-project template access", async () => { + const crossProjectData = { + slug: "cross-project-profile", + description: "Profile using template from different project", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-456", + apiConfig: { + autoRenew: false + } + }; + + const foreignTemplate = { + id: "template-456", + projectId: "different-project-456", + slug: "foreign-template" + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(foreignTemplate); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: crossProjectData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should validate slug format constraints", async () => { + const invalidSlugData = { + slug: "invalid-slug-profile", + description: "Profile with invalid slug format", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: false + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockResolvedValue({ + ...sampleProfile, + slug: invalidSlugData.slug, + enrollmentType: EnrollmentType.API + }); + + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: invalidSlugData + }); + + expect(result.slug).toBe(invalidSlugData.slug); + }); + }); + }); + + describe("getEstConfigurationByProfile", () => { + it("should return EST configuration for valid EST profile", async () => { + const profileId = "profile-123"; + const mockProfile = { + ...sampleProfileWithConfigs, + id: profileId, + enrollmentType: EnrollmentType.EST, + estConfig: { + id: "est-config-123", + disableBootstrapCaValidation: false, + passphrase: "", + caChain: "mock-ca-chain" + } + } as TCertificateProfileWithConfigs; + + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(mockProfile); + + const result = await service.getEstConfigurationByProfile({ ...mockActor, profileId }); + + expect(result).toEqual({ + orgId: "project-123", + isEnabled: true, + caChain: "mock-ca-chain", + disableBootstrapCertValidation: false, + hashedPassphrase: "" + }); + }); + + it("should throw NotFoundError when profile doesn't exist", async () => { + const profileId = "non-existent-profile"; + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(null); + + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow(NotFoundError); + }); + + it("should throw ForbiddenRequestError when profile is not configured for EST enrollment", async () => { + const profileId = "profile-123"; + const mockProfile = { + ...sampleProfileWithConfigs, + id: profileId, + enrollmentType: EnrollmentType.API, // Wrong enrollment type + estConfig: { + id: "est-config-123", + disableBootstrapCaValidation: false, + passphrase: "", + caChain: "mock-ca-chain" + } + } as TCertificateProfileWithConfigs; + + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(mockProfile); + + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow( + ForbiddenRequestError + ); + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow( + "Profile is not configured for EST enrollment" + ); + }); + + it("should throw NotFoundError when EST configuration is missing", async () => { + const profileId = "profile-123"; + const mockProfile = { + ...sampleProfileWithConfigs, + id: profileId, + enrollmentType: EnrollmentType.EST, + estConfig: undefined // Missing EST config + } as TCertificateProfileWithConfigs; + + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(mockProfile); + + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow(NotFoundError); + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow( + "EST configuration not found for this profile" + ); + }); + }); +}); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts new file mode 100644 index 000000000..c43dee889 --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -0,0 +1,824 @@ +import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionCertificateProfileActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; +import { getConfig } from "@app/lib/config/env"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; + +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { isCertChainValid } from "../certificate/certificate-fns"; +import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; +import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; +import { TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types"; +import { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { TProjectDALFactory } from "../project/project-dal"; +import { getProjectKmsCertificateKeyId } from "../project/project-fns"; +import { TCertificateProfileDALFactory } from "./certificate-profile-dal"; +import { + EnrollmentType, + TCertificateProfile, + TCertificateProfileCertificate, + TCertificateProfileInsert, + TCertificateProfileMetrics, + TCertificateProfileUpdate, + TCertificateProfileWithConfigs, + TCertificateProfileWithRawMetrics +} from "./certificate-profile-types"; + +const validateAndEncryptPemCaChain = async ( + caChain: string, + projectId: string, + kmsService: Pick, + projectDAL: Pick +) => { + try { + const certificates = extractX509CertFromChain(caChain)?.map((cert) => new x509.X509Certificate(cert)); + + if (!certificates || certificates.length === 0) { + throw new BadRequestError({ message: "Failed to parse certificate chain" }); + } + + if (!(await isCertChainValid(certificates))) { + throw new BadRequestError({ message: "Invalid certificate chain" }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const { cipherTextBlob } = await kmsEncryptor({ + plainText: Buffer.from(caChain) + }); + + return { encryptedCaChain: cipherTextBlob }; + } catch (error) { + throw new BadRequestError({ message: `Failed to process certificate chain: ${(error as Error).message}` }); + } +}; + +const decryptCaChain = async ( + encryptedCaChain: Buffer, + projectId: string, + kmsService: Pick, + projectDAL: Pick +): Promise => { + try { + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaChain = await kmsDecryptor({ + cipherTextBlob: encryptedCaChain + }); + + return decryptedCaChain.toString(); + } catch (error) { + throw new BadRequestError({ message: `Failed to decrypt certificate chain: ${(error as Error).message}` }); + } +}; + +export type TCertificateProfileCreateData = Omit & { + estConfig?: TEstConfigData; + apiConfig?: TApiConfigData; +}; + +type TCertificateProfileServiceFactoryDep = { + certificateProfileDAL: TCertificateProfileDALFactory; + certificateTemplateV2DAL: TCertificateTemplateV2DALFactory; + apiEnrollmentConfigDAL: TApiEnrollmentConfigDALFactory; + estEnrollmentConfigDAL: TEstEnrollmentConfigDALFactory; + permissionService: Pick; + kmsService: Pick; + projectDAL: Pick; +}; + +export type TCertificateProfileServiceFactory = ReturnType; + +const convertDalToService = (dalResult: Record): TCertificateProfile => { + return { + ...dalResult, + enrollmentType: dalResult.enrollmentType as EnrollmentType + } as TCertificateProfile; +}; + +export const certificateProfileServiceFactory = ({ + certificateProfileDAL, + certificateTemplateV2DAL, + apiEnrollmentConfigDAL, + estEnrollmentConfigDAL, + permissionService, + kmsService, + projectDAL +}: TCertificateProfileServiceFactoryDep) => { + const createProfile = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + data + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + data: Omit; + }): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Create, + ProjectPermissionSub.CertificateProfiles + ); + + // Validate that certificate template exists and belongs to the same project + if (data.certificateTemplateId) { + const template = await certificateTemplateV2DAL.findById(data.certificateTemplateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + if (template.projectId !== projectId) { + throw new ForbiddenRequestError({ + message: "Certificate template must belong to the same project" + }); + } + } + + // Check for slug uniqueness within project + const existingSlugProfile = await certificateProfileDAL.findBySlugAndProjectId(data.slug, projectId); + if (existingSlugProfile) { + throw new ForbiddenRequestError({ + message: "Certificate profile with this name already exists in project" + }); + } + + // Validate enrollment configuration requirements + if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) { + throw new ForbiddenRequestError({ + message: "EST enrollment requires EST configuration" + }); + } + if (data.enrollmentType === EnrollmentType.API && !data.apiConfig) { + throw new ForbiddenRequestError({ + message: "API enrollment requires API configuration" + }); + } + + // Create enrollment configs and profile + const profile = await certificateProfileDAL.transaction(async (tx) => { + let estConfigId: string | null = null; + let apiConfigId: string | null = null; + + if (data.enrollmentType === EnrollmentType.EST && data.estConfig) { + const appCfg = getConfig(); + // Hash the passphrase + const hashedPassphrase = await crypto.hashing().createHash(data.estConfig.passphrase, appCfg.SALT_ROUNDS); + + let encryptedCaChainBuffer: Buffer | null = null; + if (!data.estConfig.disableBootstrapCaValidation && data.estConfig.caChain) { + const { encryptedCaChain } = await validateAndEncryptPemCaChain( + data.estConfig.caChain, + projectId, + kmsService, + projectDAL + ); + encryptedCaChainBuffer = encryptedCaChain; + } + + const estConfig = await estEnrollmentConfigDAL.create( + { + disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation, + hashedPassphrase, + encryptedCaChain: encryptedCaChainBuffer + }, + tx + ); + estConfigId = estConfig.id; + } else if (data.enrollmentType === EnrollmentType.API && data.apiConfig) { + const apiConfig = await apiEnrollmentConfigDAL.create( + { + autoRenew: data.apiConfig.autoRenew, + autoRenewDays: data.apiConfig.autoRenewDays + }, + tx + ); + apiConfigId = apiConfig.id; + } + + // Create the profile with the created config IDs + const { estConfig, apiConfig, ...profileData } = data; + const profileResult = await certificateProfileDAL.create( + { + ...profileData, + projectId, + estConfigId, + apiConfigId + }, + tx + ); + + return profileResult; + }); + + return convertDalToService(profile); + }; + + const updateProfile = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId, + data + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + data: TCertificateProfileUpdate; + }): Promise => { + const existingProfile = await certificateProfileDAL.findById(profileId); + if (!existingProfile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: existingProfile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Edit, + ProjectPermissionSub.CertificateProfiles + ); + + if (data.certificateTemplateId) { + const template = await certificateTemplateV2DAL.findById(data.certificateTemplateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + if (template.projectId !== existingProfile.projectId) { + throw new ForbiddenRequestError({ + message: "Certificate template must belong to the same project" + }); + } + } + + if (data.slug && data.slug !== existingProfile.slug) { + const conflictingProfile = await certificateProfileDAL.findBySlugAndProjectId( + data.slug, + existingProfile.projectId + ); + if (conflictingProfile && conflictingProfile.id !== profileId) { + throw new ForbiddenRequestError({ + message: "Certificate profile with this name already exists in project" + }); + } + } + + const { estConfig, apiConfig, ...profileUpdateData } = data; + + const updatedProfile = await certificateProfileDAL.transaction(async (tx) => { + if (estConfig && existingProfile.estConfigId) { + const updateData: { + disableBootstrapCaValidation: boolean; + hashedPassphrase?: string; + encryptedCaChain?: Buffer; + } = { + disableBootstrapCaValidation: estConfig.disableBootstrapCaValidation ?? false + }; + + if (estConfig.passphrase) { + updateData.hashedPassphrase = await crypto + .hashing() + .createHash(estConfig.passphrase, getConfig().SALT_ROUNDS); + } + + if (estConfig.caChain) { + const { encryptedCaChain } = await validateAndEncryptPemCaChain( + estConfig.caChain, + existingProfile.projectId, + kmsService, + projectDAL + ); + updateData.encryptedCaChain = encryptedCaChain; + } + + await estEnrollmentConfigDAL.updateById(existingProfile.estConfigId, updateData, tx); + } + + if (apiConfig && existingProfile.apiConfigId) { + await apiEnrollmentConfigDAL.updateById( + existingProfile.apiConfigId, + { + autoRenew: apiConfig.autoRenew, + autoRenewDays: apiConfig.autoRenewDays + }, + tx + ); + } + + const profileResult = await certificateProfileDAL.updateById(profileId, profileUpdateData, tx); + return profileResult; + }); + + return convertDalToService(updatedProfile); + }; + + const getProfileById = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId, + includeMetrics = false, + expiringDays = 30 + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + includeMetrics?: boolean; + expiringDays?: number; + }): Promise => { + const profile = await certificateProfileDAL.findById(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + const converted = convertDalToService(profile); + + if (includeMetrics) { + const metrics = await certificateProfileDAL.getProfileMetrics(profileId, expiringDays); + return { + ...converted, + metrics + }; + } + + return converted; + }; + + const getProfileByIdWithConfigs = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + }): Promise => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + if (profile.estConfig && profile.estConfig.caChain) { + try { + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId!); + if (estConfig && estConfig.encryptedCaChain) { + const decryptedCaChain = await decryptCaChain( + estConfig.encryptedCaChain, + profile.projectId, + kmsService, + projectDAL + ); + profile.estConfig.caChain = decryptedCaChain; + } else { + profile.estConfig.caChain = ""; + } + } catch (error) { + profile.estConfig.caChain = ""; + } + } + + return { + ...profile, + enrollmentType: profile.enrollmentType as EnrollmentType + }; + }; + + const getProfileBySlug = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + slug + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + slug: string; + }): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + const profile = await certificateProfileDAL.findBySlugAndProjectId(slug, projectId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + return convertDalToService(profile); + }; + + const listProfiles = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + offset = 0, + limit = 20, + search, + enrollmentType, + caId, + includeMetrics = false, + expiringDays = 30 + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + offset?: number; + limit?: number; + search?: string; + enrollmentType?: EnrollmentType; + caId?: string; + includeMetrics?: boolean; + expiringDays?: number; + }): Promise<{ + profiles: (TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics })[]; + totalCount: number; + }> => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + const profiles = await certificateProfileDAL.findByProjectId(projectId, { + offset, + limit, + search, + enrollmentType, + caId, + includeMetrics, + expiringDays + }); + + const totalCount = await certificateProfileDAL.countByProjectId(projectId, { + search, + enrollmentType, + caId + }); + + const convertedProfiles = await Promise.all( + profiles.map(async (profile) => { + const profileWithConfigs = profile as TCertificateProfileWithConfigs; + + let decryptedEstConfig = profileWithConfigs.estConfig; + if (decryptedEstConfig && profileWithConfigs.estConfigId) { + try { + const estConfig = await estEnrollmentConfigDAL.findById(profileWithConfigs.estConfigId); + if (estConfig && estConfig.encryptedCaChain) { + const decryptedCaChain = await decryptCaChain( + estConfig.encryptedCaChain, + projectId, + kmsService, + projectDAL + ); + decryptedEstConfig = { + ...decryptedEstConfig, + caChain: decryptedCaChain + }; + } else if (decryptedEstConfig) { + decryptedEstConfig = { + ...decryptedEstConfig, + caChain: "" + }; + } + } catch (error) { + if (decryptedEstConfig) { + decryptedEstConfig = { + ...decryptedEstConfig, + caChain: "" + }; + } + } + } + + const converted = convertDalToService(profileWithConfigs); + let result: TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics } = { + ...converted, + estConfig: decryptedEstConfig, + apiConfig: profileWithConfigs.apiConfig + }; + + if (includeMetrics) { + const profileWithMetrics = profile as TCertificateProfileWithRawMetrics; + result = { + ...result, + metrics: { + profileId: converted.id, + totalCertificates: parseInt(String(profileWithMetrics.total_certificates || 0), 10), + activeCertificates: parseInt(String(profileWithMetrics.active_certificates || 0), 10), + expiredCertificates: parseInt(String(profileWithMetrics.expired_certificates || 0), 10), + expiringCertificates: parseInt(String(profileWithMetrics.expiring_certificates || 0), 10), + revokedCertificates: parseInt(String(profileWithMetrics.revoked_certificates || 0), 10) + } + }; + } + + return result; + }) + ); + + return { + profiles: convertedProfiles, + totalCount + }; + }; + + const deleteProfile = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + }): Promise => { + const profile = await certificateProfileDAL.findById(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Delete, + ProjectPermissionSub.CertificateProfiles + ); + + const deletedProfile = await certificateProfileDAL.deleteById(profileId); + if (!deletedProfile) { + throw new NotFoundError({ message: "Failed to delete certificate profile" }); + } + return convertDalToService(deletedProfile); + }; + + const getProfileCertificates = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId, + offset = 0, + limit = 20, + status, + search + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + offset?: number; + limit?: number; + status?: "active" | "expired" | "revoked"; + search?: string; + }): Promise => { + const profile = await certificateProfileDAL.findById(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + const certificates = await certificateProfileDAL.getCertificatesByProfile(profileId, { + offset, + limit, + status, + search + }); + + return certificates; + }; + + const getProfileMetrics = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId, + expiringDays = 30 + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + expiringDays?: number; + }): Promise => { + const profile = await certificateProfileDAL.findById(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + const metrics = await certificateProfileDAL.getProfileMetrics(profileId, expiringDays); + return metrics; + }; + + const getEstConfigurationByProfile = async ( + params: + | { + profileId: string; + isInternal: true; + } + | { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string | undefined; + profileId: string; + isInternal?: false; + } + ) => { + const { profileId, isInternal = false } = params; + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (!isInternal) { + const { actor, actorId, actorAuthMethod, actorOrgId } = params as { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string | undefined; + }; + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + } + + if (profile.enrollmentType !== EnrollmentType.EST) { + throw new ForbiddenRequestError({ + message: "Profile is not configured for EST enrollment" + }); + } + + if (!profile.estConfig) { + throw new NotFoundError({ message: "EST configuration not found for this profile" }); + } + + return { + orgId: profile.projectId, + isEnabled: true, + caChain: profile.estConfig.caChain, + disableBootstrapCertValidation: profile.estConfig.disableBootstrapCaValidation, + hashedPassphrase: profile.estConfig.passphrase + }; + }; + + return { + createProfile, + updateProfile, + getProfileById, + getProfileByIdWithConfigs, + getProfileBySlug, + listProfiles, + deleteProfile, + getProfileCertificates, + getProfileMetrics, + getEstConfigurationByProfile + }; +}; diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts new file mode 100644 index 000000000..a6d53a0f3 --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -0,0 +1,86 @@ +import { + TPkiCertificateProfiles, + TPkiCertificateProfilesInsert, + TPkiCertificateProfilesUpdate +} from "@app/db/schemas/pki-certificate-profiles"; + +export enum EnrollmentType { + API = "api", + EST = "est" +} + +export type TCertificateProfile = Omit & { + enrollmentType: EnrollmentType; +}; + +export type TCertificateProfileInsert = Omit & { + enrollmentType: EnrollmentType; +}; + +export type TCertificateProfileUpdate = Omit & { + enrollmentType?: EnrollmentType; + estConfig?: { + disableBootstrapCaValidation?: boolean; + passphrase?: string; + caChain?: string; + }; + apiConfig?: { + autoRenew?: boolean; + autoRenewDays?: number; + }; +}; + +export type TCertificateProfileWithConfigs = TCertificateProfile & { + certificateAuthority?: { + id: string; + projectId: string; + status: string; + name: string; + }; + certificateTemplate?: { + id: string; + projectId: string; + name: string; + description?: string; + }; + estConfig?: { + id: string; + disableBootstrapCaValidation: boolean; + passphrase: string; + caChain: string; + }; + apiConfig?: { + id: string; + autoRenew: boolean; + autoRenewDays?: number; + }; + metrics?: TCertificateProfileMetrics; +}; + +export interface TCertificateProfileMetrics { + profileId: string; + totalCertificates: number; + activeCertificates: number; + expiredCertificates: number; + expiringCertificates: number; + revokedCertificates: number; +} + +export interface TCertificateProfileCertificate { + id: string; + serialNumber: string; + cn: string; + status: string; + notBefore: Date; + notAfter: Date; + revokedAt: Date | null; + createdAt: Date; +} + +export type TCertificateProfileWithRawMetrics = TCertificateProfile & { + total_certificates?: string; + active_certificates?: string; + expired_certificates?: string; + expiring_certificates?: string; + revoked_certificates?: string; +}; diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts new file mode 100644 index 000000000..3b935f26a --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts @@ -0,0 +1,244 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { TPkiCertificateTemplatesV2Insert } from "@app/db/schemas/pki-certificate-templates-v2"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +import { + TCertificateTemplateV2, + TCertificateTemplateV2Insert, + TCertificateTemplateV2Update +} from "./certificate-template-v2-types"; + +export type TCertificateTemplateV2DALFactory = ReturnType; + +interface CountResult { + count: string; +} + +export const certificateTemplateV2DALFactory = (db: TDbClient) => { + const certificateTemplateV2Orm = ormify(db, TableName.PkiCertificateTemplateV2); + + const serializeJsonFields = (data: TCertificateTemplateV2Insert | TCertificateTemplateV2Update) => { + const serialized = { ...data } as Record; + + const jsonFields = ["subject", "sans", "keyUsages", "extendedKeyUsages", "algorithms", "validity"]; + + jsonFields.forEach((field) => { + const value = serialized[field]; + if (value !== undefined && typeof value !== "string") { + serialized[field] = JSON.stringify(value); + } + }); + + return serialized; + }; + + const parseJsonFields = (raw: Record): TCertificateTemplateV2 => { + const jsonFields = ["subject", "sans", "keyUsages", "extendedKeyUsages", "algorithms", "validity"]; + const parsed = { ...raw } as Record; + + jsonFields.forEach((field) => { + const value = raw[field]; + if (value !== null && value !== undefined) { + if (typeof value === "string") { + try { + parsed[field] = JSON.parse(value); + } catch (error) { + throw new Error( + `Invalid JSON in field '${field}': ${error instanceof Error ? error.message : "Parse error"}` + ); + } + } else { + parsed[field] = value; + } + } else { + parsed[field] = undefined; + } + }); + + return parsed as TCertificateTemplateV2; + }; + + const create = async (data: TCertificateTemplateV2Insert, tx?: Knex) => { + try { + const serializedData = serializeJsonFields(data); + const [certificateTemplateV2] = (await (tx || db)(TableName.PkiCertificateTemplateV2) + .insert(serializedData as TPkiCertificateTemplatesV2Insert) + .returning("*")) as Record[]; + + if (!certificateTemplateV2) { + throw new Error("Failed to create certificate template v2"); + } + + return parseJsonFields(certificateTemplateV2); + } catch (error) { + throw new DatabaseError({ error, name: "Create certificate template v2" }); + } + }; + + const updateById = async (id: string, data: TCertificateTemplateV2Update, tx?: Knex) => { + try { + const serializedData = serializeJsonFields(data); + const [certificateTemplateV2] = (await (tx || db)(TableName.PkiCertificateTemplateV2) + .where({ id }) + .update(serializedData) + .returning("*")) as Record[]; + + if (!certificateTemplateV2) { + return null; + } + + return parseJsonFields(certificateTemplateV2); + } catch (error) { + throw new DatabaseError({ error, name: "Update certificate template v2" }); + } + }; + + const deleteById = async (id: string, tx?: Knex) => { + try { + const [certificateTemplateV2] = (await (tx || db)(TableName.PkiCertificateTemplateV2) + .where({ id }) + .del() + .returning("*")) as Record[]; + + return certificateTemplateV2; + } catch (error) { + throw new DatabaseError({ error, name: "Delete certificate template v2" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const certificateTemplateV2 = (await (tx || db)(TableName.PkiCertificateTemplateV2).where({ id }).first()) as + | Record + | undefined; + + if (!certificateTemplateV2) { + return null; + } + + return parseJsonFields(certificateTemplateV2); + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate template v2 by id" }); + } + }; + + const findByProjectId = async ( + projectId: string, + options: { + offset?: number; + limit?: number; + search?: string; + } = {}, + tx?: Knex + ) => { + try { + const { offset = 0, limit = 20, search } = options; + + let query = (tx || db)(TableName.PkiCertificateTemplateV2).where({ projectId }); + + if (search) { + query = query.where((builder) => { + void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`); + }); + } + + const certificateTemplatesV2 = await query.orderBy("createdAt", "desc").offset(offset).limit(limit); + + return certificateTemplatesV2.map((template: Record) => parseJsonFields(template)); + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate templates v2 by project id" }); + } + }; + + const countByProjectId = async ( + projectId: string, + options: { + search?: string; + } = {}, + tx?: Knex + ) => { + try { + const { search } = options; + + let query = (tx || db)(TableName.PkiCertificateTemplateV2).where({ projectId }); + + if (search) { + query = query.where((builder) => { + void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`); + }); + } + + const result = await query.count("*").first(); + return parseInt((result as unknown as { count: string }).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count certificate templates v2 by project id" }); + } + }; + + const findByNameAndProjectId = async (name: string, projectId: string, tx?: Knex) => { + try { + const certificateTemplateV2 = (await (tx || db)(TableName.PkiCertificateTemplateV2) + .where({ name, projectId }) + .first()) as Record | undefined; + + if (!certificateTemplateV2) { + return null; + } + + return parseJsonFields(certificateTemplateV2); + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate template v2 by name and project id" }); + } + }; + + const isTemplateInUse = async (templateId: string, tx?: Knex) => { + try { + const profileCount = await (tx || db)(TableName.PkiCertificateProfile) + .where({ certificateTemplateId: templateId }) + .count("*") + .first(); + + const profileUsage = parseInt((profileCount as unknown as CountResult).count || "0", 10) > 0; + + const certCount = await (tx || db)(TableName.Certificate) + .where({ certificateTemplateId: templateId }) + .count("*") + .first(); + + const certUsage = parseInt((certCount as unknown as CountResult).count || "0", 10) > 0; + + return profileUsage || certUsage; + } catch (error) { + throw new DatabaseError({ error, name: "Check if certificate template v2 is in use" }); + } + }; + + const getProfilesUsingTemplate = async (templateId: string, tx?: Knex) => { + try { + const profiles = await (tx || db)(TableName.PkiCertificateProfile) + .select("id", "slug", "description") + .where({ certificateTemplateId: templateId }); + + return profiles as Array<{ id: string; slug: string; description?: string }>; + } catch (error) { + throw new DatabaseError({ error, name: "Get profiles using certificate template v2" }); + } + }; + + return { + ...certificateTemplateV2Orm, + create, + updateById, + deleteById, + findById, + findByProjectId, + countByProjectId, + findByNameAndProjectId, + isTemplateInUse, + getProfilesUsingTemplate + }; +}; diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts new file mode 100644 index 000000000..9fcc51a57 --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts @@ -0,0 +1,181 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; + +const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType); +const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType); + +const templateV2SubjectSchema = z + .object({ + type: attributeTypeSchema, + allowed: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(), + required: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(), + denied: z.array(z.string().trim().min(1, "Value cannot be empty")).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Subject attribute must have at least one allowed, required, or denied value" + } + ); + +const templateV2KeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2ExtendedKeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Extended key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2SanSchema = z + .object({ + type: sanTypeSchema, + allowed: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(), + required: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(), + denied: z.array(z.string().trim().min(1, "Value cannot be empty")).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "SAN must have at least one allowed, required, or denied value" + } + ); + +const templateV2ValiditySchema = z.object({ + max: z + .string() + .regex(new RE2("^\\d+[dhmy]$"), { + message: "Max validity must be in format like '365d', '12m', '1y', or '24h'" + }) + .optional() +}); + +const templateV2AlgorithmsSchema = z.object({ + signature: z + .array(z.string().trim().min(1, "Algorithm cannot be empty")) + .min(1, "At least one signature algorithm must be provided") + .optional(), + keyAlgorithm: z + .array(z.string().trim().min(1, "Algorithm cannot be empty")) + .min(1, "At least one key algorithm must be provided") + .optional() +}); + +export const certificateTemplateV2ResponseSchema = z.object({ + id: z.string().uuid(), + projectId: z.string().uuid("Project ID must be valid"), + name: z + .string() + .trim() + .min(1, "Template name is required") + .max(255, "Template name must be less than 255 characters") + .regex(new RE2("^[a-zA-Z0-9-_]+$"), "Template name must contain only letters, numbers, hyphens, and underscores"), + description: z.string().trim().max(1000, "Description must be less than 1000 characters").nullable().optional(), + subject: z.array(templateV2SubjectSchema).optional(), + sans: z.array(templateV2SanSchema).optional(), + keyUsages: templateV2KeyUsagesSchema.optional(), + extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), + algorithms: templateV2AlgorithmsSchema.optional(), + validity: templateV2ValiditySchema.optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export const certificateRequestSchema = z.object({ + commonName: z + .string() + .trim() + .min(1, "Common name cannot be empty") + .max(64, "Common name must be less than 64 characters") + .optional(), + organization: z + .string() + .trim() + .min(1, "Organization cannot be empty") + .max(64, "Organization must be less than 64 characters") + .optional(), + country: z + .string() + .trim() + .min(2, "Country code must be 2 characters") + .max(2, "Country code must be 2 characters") + .optional(), + keyUsages: z.array(z.nativeEnum(CertKeyUsageType)).min(1, "At least one key usage must be provided").optional(), + extendedKeyUsages: z + .array(z.nativeEnum(CertExtendedKeyUsageType)) + .min(1, "At least one extended key usage must be provided") + .optional(), + subjectAlternativeNames: z + .array( + z.object({ + type: sanTypeSchema, + value: z + .string() + .trim() + .min(1, "SAN value cannot be empty") + .max(255, "SAN value must be less than 255 characters") + }) + ) + .min(1, "At least one SAN must be provided") + .optional(), + validity: z + .object({ + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .regex(new RE2("^\\d+[dhmy]$"), "TTL must be in format like '365d', '12m', '1y', or '24h'") + }) + .optional(), + signatureAlgorithm: z.string().trim().min(1, "Signature algorithm cannot be empty").optional(), + keyAlgorithm: z.string().trim().min(1, "Key algorithm cannot be empty").optional() +}); + +export const validateCertificateRequestSchema = z.object({ + templateId: z.string().uuid(), + request: certificateRequestSchema +}); diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts new file mode 100644 index 000000000..daacc5dde --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts @@ -0,0 +1,1745 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ForbiddenError } from "@casl/ability"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; + +import { ActorType, AuthMethod } from "../auth/auth-type"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "../certificate-common/certificate-constants"; +import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal"; +import { + certificateTemplateV2ServiceFactory, + TCertificateTemplateV2ServiceFactory +} from "./certificate-template-v2-service"; +import { + TCertificateRequest, + TCertificateTemplateV2, + TCertificateTemplateV2Insert, + TTemplateV2Policy +} from "./certificate-template-v2-types"; + +enum CertAttributeRule { + ALLOW = "allow", + DENY = "deny", + REQUIRE = "require" +} + +describe("CertificateTemplateV2Service", () => { + let service: TCertificateTemplateV2ServiceFactory; + + const mockCertificateTemplateV2DAL = { + findBySlugAndProjectId: vi.fn(), + create: vi.fn(), + findById: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + findByProjectId: vi.fn(), + countByProjectId: vi.fn(), + isTemplateInUse: vi.fn(), + getProfilesUsingTemplate: vi.fn(), + findByNameAndProjectId: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + insertMany: vi.fn(), + batchInsert: vi.fn(), + upsert: vi.fn(), + countDocuments: vi.fn() + } as any; + + const mockActor = { + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123" + }; + + const samplePolicy: TTemplateV2Policy = { + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.example.com", "example.com"] + }, + { + type: CertSubjectAttributeType.ORGANIZATION, + allowed: ["Example Inc", "Example Corp"], + denied: ["Malicious Corp"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.example.com", "*.api.example.com"], + required: ["api.example.com"] + }, + { + type: CertSubjectAlternativeNameType.EMAIL, + required: ["admin@example.com"], + denied: ["blocked@example.com"] + } + ], + keyUsages: { + required: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + allowed: [CertKeyUsageType.DATA_ENCIPHERMENT] + }, + extendedKeyUsages: { + required: [CertExtendedKeyUsageType.SERVER_AUTH], + allowed: [CertExtendedKeyUsageType.CLIENT_AUTH] + }, + validity: { + max: "90d" + }, + algorithms: { + signature: ["SHA256-RSA", "SHA256-ECDSA"], + keyAlgorithm: ["RSA-2048", "RSA-4096", "ECDSA-P256"] + } + }; + + const sampleTemplate: TCertificateTemplateV2 = { + id: "template-123", + projectId: "project-123", + name: "web-server-template", + description: "Template for web server certificates", + ...samplePolicy, + createdAt: new Date(), + updatedAt: new Date() + }; + + const mockPermission = { + can: vi.fn().mockReturnValue(true), + cannot: vi.fn().mockReturnValue(false), + relevantRuleFor: vi.fn().mockReturnValue(null), + rulesFor: vi.fn().mockReturnValue([]), + rules: [], + detectSubjectType: vi.fn().mockReturnValue("certificate-templates-v2"), + modelName: "certificate-templates-v2", + throwUnlessCan: vi.fn(), + unlessCan: vi.fn().mockReturnValue({ throwUnlessCan: vi.fn() }) + }; + + const mockPermissionService = { + getProjectPermission: vi.fn().mockResolvedValue({ + permission: mockPermission + }) + }; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.spyOn(ForbiddenError, "from").mockReturnValue({ + throwUnlessCan: vi.fn() + } as any); + + mockPermissionService.getProjectPermission.mockResolvedValue({ + permission: mockPermission + }); + + mockCertificateTemplateV2DAL.findByNameAndProjectId.mockResolvedValue(null); + mockCertificateTemplateV2DAL.findBySlugAndProjectId.mockResolvedValue(null); + + service = certificateTemplateV2ServiceFactory({ + certificateTemplateV2DAL: mockCertificateTemplateV2DAL as TCertificateTemplateV2DALFactory, + permissionService: mockPermissionService + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("createTemplateV2", () => { + const createData: Omit = { + name: "test-template", + description: "Test description", + ...samplePolicy + }; + + it("should create template with valid policy", async () => { + mockCertificateTemplateV2DAL.create.mockResolvedValue(sampleTemplate); + + const result = await service.createTemplateV2({ + ...mockActor, + projectId: "project-123", + data: createData + }); + + expect(mockCertificateTemplateV2DAL.create).toHaveBeenCalledWith({ + ...createData, + projectId: "project-123", + name: expect.any(String) + }); + expect(result).toEqual(sampleTemplate); + }); + + // Previously tested service-level validations that are now schema-level: + // - Missing attributes validation (now mandatory in schema) + // - Missing key usages validation (now mandatory in schema) + // - Default signature algorithm not in allowed list (now schema-level validation) + // - Default key algorithm not in allowed list (now schema-level validation) + }); + + describe("updateTemplateV2", () => { + it("should update template with valid data", async () => { + const updateData = { name: "updated-template-name" }; + const updatedTemplate = { ...sampleTemplate, ...updateData }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + mockCertificateTemplateV2DAL.updateById.mockResolvedValue(updatedTemplate); + + const result = await service.updateTemplateV2({ + ...mockActor, + templateId: "template-123", + data: updateData + }); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + expect(mockCertificateTemplateV2DAL.updateById).toHaveBeenCalledWith("template-123", { + ...updateData, + name: expect.any(String) + }); + expect(result).toEqual(updatedTemplate); + }); + + it("should throw NotFoundError when template does not exist", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(null); + + await expect( + service.updateTemplateV2({ + ...mockActor, + templateId: "nonexistent-template", + data: { name: "updated-name" } + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getTemplateV2ById", () => { + it("should return template when found", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + + const result = await service.getTemplateV2ById({ + ...mockActor, + templateId: "template-123" + }); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + expect(result).toEqual(sampleTemplate); + }); + + it("should throw NotFoundError when template does not exist", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(null); + + await expect( + service.getTemplateV2ById({ + ...mockActor, + templateId: "nonexistent-template" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("listTemplatesV2", () => { + it("should return templates list with pagination", async () => { + const templates = [sampleTemplate]; + const totalCount = 1; + + mockCertificateTemplateV2DAL.findByProjectId.mockResolvedValue(templates); + mockCertificateTemplateV2DAL.countByProjectId.mockResolvedValue(totalCount); + + const result = await service.listTemplatesV2({ + ...mockActor, + projectId: "project-123", + offset: 0, + limit: 20 + }); + + expect(mockCertificateTemplateV2DAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 0, + limit: 20, + search: undefined + }); + expect(mockCertificateTemplateV2DAL.countByProjectId).toHaveBeenCalledWith("project-123", { + search: undefined + }); + expect(result).toEqual({ templates, totalCount }); + }); + + it("should handle search parameter", async () => { + const templates = [sampleTemplate]; + const totalCount = 1; + + mockCertificateTemplateV2DAL.findByProjectId.mockResolvedValue(templates); + mockCertificateTemplateV2DAL.countByProjectId.mockResolvedValue(totalCount); + + await service.listTemplatesV2({ + ...mockActor, + projectId: "project-123", + search: "web server" + }); + + expect(mockCertificateTemplateV2DAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 0, + limit: 20, + search: "web server" + }); + expect(mockCertificateTemplateV2DAL.countByProjectId).toHaveBeenCalledWith("project-123", { + search: "web server" + }); + }); + }); + + describe("deleteTemplateV2", () => { + it("should delete template when not in use", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + mockCertificateTemplateV2DAL.isTemplateInUse.mockResolvedValue(false); + mockCertificateTemplateV2DAL.deleteById.mockResolvedValue(sampleTemplate); + + const result = await service.deleteTemplateV2({ + ...mockActor, + templateId: "template-123" + }); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + expect(mockCertificateTemplateV2DAL.isTemplateInUse).toHaveBeenCalledWith("template-123"); + expect(mockCertificateTemplateV2DAL.deleteById).toHaveBeenCalledWith("template-123"); + expect(result).toEqual(sampleTemplate); + }); + + it("should throw NotFoundError when template does not exist", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(null); + + await expect( + service.deleteTemplateV2({ + ...mockActor, + templateId: "nonexistent-template" + }) + ).rejects.toThrow(NotFoundError); + }); + + it("should throw ForbiddenRequestError when template is in use", async () => { + const mockProfiles = [ + { id: "profile-1", slug: "web-server-profile", description: "Web server certificate profile" }, + { id: "profile-2", slug: "api-gateway-profile", description: "API gateway certificate profile" } + ]; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + mockCertificateTemplateV2DAL.isTemplateInUse.mockResolvedValue(true); + mockCertificateTemplateV2DAL.getProfilesUsingTemplate.mockResolvedValue(mockProfiles); + + await expect( + service.deleteTemplateV2({ + ...mockActor, + templateId: "template-123" + }) + ).rejects.toThrow(ForbiddenRequestError); + + expect(mockCertificateTemplateV2DAL.getProfilesUsingTemplate).toHaveBeenCalledWith("template-123"); + expect(mockCertificateTemplateV2DAL.deleteById).not.toHaveBeenCalled(); + }); + }); + + describe("validateCertificateRequest", () => { + const validRequest: TCertificateRequest = { + commonName: "api.example.com", + organization: "Example Inc", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + validity: { ttl: "30d" }, + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048" + }; + + beforeEach(() => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + }); + + it("should validate valid certificate request", async () => { + const result = await service.validateCertificateRequest("template-123", validRequest); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + }); + + it("should throw NotFoundError when template does not exist", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(null); + + await expect(service.validateCertificateRequest("nonexistent-template", validRequest)).rejects.toThrow( + NotFoundError + ); + }); + + it("should validate allowed attribute values against pattern", async () => { + const result = await service.validateCertificateRequest("template-123", validRequest); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should detect attribute values that don't match allowed patterns", async () => { + const invalidRequest = { ...validRequest, commonName: "forbidden.com" }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "common_name value 'forbidden.com' does not match allowed patterns: *.example.com, example.com" + ); + }); + + it("should detect denied attribute values", async () => { + const templateWithDeny = { + ...sampleTemplate, + subject: [ + ...sampleTemplate.subject!, + { + type: CertSubjectAttributeType.ORGANIZATION, + denied: ["Forbidden Corp"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithDeny); + + const invalidRequest = { ...validRequest, organization: "Forbidden Corp" }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("organization value 'Forbidden Corp' is denied by template policy"); + }); + + it("should detect missing required key usages", async () => { + const invalidRequest = { ...validRequest, keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE] }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Missing required key usages: key_encipherment"); + }); + + it("should detect invalid key usages", async () => { + const invalidRequest = { + ...validRequest, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT, "invalid_usage"] as any + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Invalid key usages: invalid_usage"); + }); + + it("should detect missing required extended key usages", async () => { + const invalidRequest = { ...validRequest, extendedKeyUsages: [] }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Missing required extended key usages: server_auth"); + }); + + it("should detect invalid extended key usages", async () => { + const invalidRequest = { + ...validRequest, + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH, "invalid_eku"] as any + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Invalid extended key usages: invalid_eku"); + }); + + it("should detect missing required SAN entries", async () => { + const invalidRequest = { + ...validRequest, + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }] + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Required email SAN matching pattern 'admin@example.com' not found in request"); + }); + + it("should validate SAN values against allowed patterns", async () => { + const invalidRequest: TCertificateRequest = { + ...validRequest, + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "forbidden.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ] + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "dns_name SAN value 'forbidden.com' does not match allowed patterns: *.example.com, *.api.example.com" + ); + }); + + it("should detect denied SAN values", async () => { + const templateWithDenySan = { + ...sampleTemplate, + sans: [ + ...sampleTemplate.sans!, + { + type: CertSubjectAlternativeNameType.EMAIL, + denied: ["forbidden@example.com"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithDenySan); + + const invalidRequest: TCertificateRequest = { + ...validRequest, + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "forbidden@example.com" } + ] + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("email SAN matching denied pattern 'forbidden@example.com' found in request"); + }); + + it("should detect invalid signature algorithm", async () => { + const invalidRequest = { ...validRequest, signatureAlgorithm: "MD5-RSA" }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Signature algorithm 'MD5-RSA' is not allowed by template policy"); + }); + + it("should detect invalid key algorithm", async () => { + const invalidRequest = { ...validRequest, keyAlgorithm: "RSA-1024" }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Key algorithm 'RSA-1024' is not allowed by template policy"); + }); + + it("should detect TTL exceeding maximum duration", async () => { + const invalidRequest = { ...validRequest, validity: { ttl: "180d" } }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Requested validity period exceeds maximum allowed duration"); + }); + + it("should detect TTL exceeding maximum duration", async () => { + const templateWithMaxDuration = { + ...sampleTemplate, + validity: { + max: "90d" + } + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithMaxDuration); + + const invalidRequest = { ...validRequest, validity: { ttl: "100d" } }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Requested validity period exceeds maximum allowed duration"); + }); + + it("should handle various TTL formats", async () => { + const testCases = [ + { ttl: "24h", shouldBeValid: true }, + { ttl: "30d", shouldBeValid: true }, + { ttl: "90d", shouldBeValid: true }, + { ttl: "3m", shouldBeValid: true }, + { ttl: "1y", shouldBeValid: false }, + { ttl: "invalid", shouldThrow: true } + ]; + + await Promise.all( + testCases.map(async (testCase) => { + const request = { ...validRequest, validity: { ttl: testCase.ttl } }; + + if (testCase.shouldThrow) { + await expect(service.validateCertificateRequest("template-123", request)).rejects.toThrow( + `Invalid TTL format: ${testCase.ttl}` + ); + } else { + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + } + }) + ); + }); + + it("should allow optional key usages and extended key usages", async () => { + const requestWithOptionalUsages = { + ...validRequest, + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.KEY_ENCIPHERMENT, + CertKeyUsageType.DATA_ENCIPHERMENT + ], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.CLIENT_AUTH] + }; + + const result = await service.validateCertificateRequest("template-123", requestWithOptionalUsages); + + expect(result.isValid).toBe(true); + }); + + it("should handle camelCase key usage mapping correctly", async () => { + const templateWithOptionalUsages = { + ...sampleTemplate, + keyUsages: { + requiredUsages: { + all: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.NON_REPUDIATION, CertKeyUsageType.KEY_AGREEMENT] + }, + optionalUsages: { all: [CertKeyUsageType.CRL_SIGN, CertKeyUsageType.DECIPHER_ONLY] } + }, + extendedKeyUsages: { + requiredUsages: { all: [CertExtendedKeyUsageType.CLIENT_AUTH, CertExtendedKeyUsageType.CODE_SIGNING] }, + optionalUsages: { all: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.OCSP_SIGNING] } + } + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithOptionalUsages); + + const requestWithCamelCaseUsages = { + ...validRequest, + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.NON_REPUDIATION, + CertKeyUsageType.KEY_AGREEMENT, + CertKeyUsageType.CRL_SIGN, + CertKeyUsageType.DECIPHER_ONLY + ], + extendedKeyUsages: [CertExtendedKeyUsageType.CLIENT_AUTH, CertExtendedKeyUsageType.CODE_SIGNING] + }; + + const result = await service.validateCertificateRequest("template-123", requestWithCamelCaseUsages); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should validate wildcard patterns in allow attributes", async () => { + const wildcardTemplate = { + ...sampleTemplate, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + rule: CertAttributeRule.ALLOW, + value: "*.example.com" + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); + + const requestWithWildcard = { + commonName: "api.example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithWildcard); + expect(result.isValid).toBe(true); + }); + + it("should reject wildcard patterns that don't match", async () => { + const wildcardTemplate = { + ...sampleTemplate, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + rule: CertAttributeRule.ALLOW, + value: "*.example.com" + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); + + const requestWithNonMatchingWildcard = { + commonName: "api.notexample.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithNonMatchingWildcard); + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "common_name value 'api.notexample.com' does not match allowed patterns: *.example.com, example.com" + ); + }); + + it("should require attribute value when allow rule exists", async () => { + const emptyAllowTemplate = { + ...sampleTemplate, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + rule: CertAttributeRule.ALLOW, + value: "example.com" + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(emptyAllowTemplate); + + const requestWithoutCommonName = { + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithoutCommonName); + expect(result.isValid).toBe(true); + }); + + it("should prevent certificates from including denied SANs", async () => { + const denyTemplate = { + ...sampleTemplate, + sans: [ + ...sampleTemplate.sans!, + { + type: CertSubjectAlternativeNameType.EMAIL, + denied: ["*@example.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(denyTemplate); + + const requestWithProhibitedSan = { + ...validRequest, + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.EMAIL as const, value: "test@example.com" }] + }; + + const result = await service.validateCertificateRequest("template-123", requestWithProhibitedSan); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("email SAN matching denied pattern 'test@example.com' found in request"); + }); + + describe("comprehensive template validation scenarios", () => { + it("should handle template with minimal required fields only", async () => { + const minimalTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*"] + } + ], + keyUsages: { + required: [CertKeyUsageType.DIGITAL_SIGNATURE] + }, + extendedKeyUsages: { + allowed: [CertExtendedKeyUsageType.SERVER_AUTH] + }, + sans: [], + validity: { + max: "30d" + }, + algorithms: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(minimalTemplate); + + const minimalReq = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + validity: { ttl: "15d" } + }; + + const result = await service.validateCertificateRequest("template-123", minimalReq); + expect(result.isValid).toBe(true); + }); + + it("should handle template with all fields set to allow", async () => { + const allowTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*"] + }, + { + type: CertSubjectAttributeType.ORGANIZATION, + allowed: ["*"] + } + ], + keyUsages: { + allowed: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT] + }, + extendedKeyUsages: { + allowed: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.CLIENT_AUTH] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(allowTemplate); + + const emptyRequest = { + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", emptyRequest); + expect(result.isValid).toBe(true); + }); + + it("should handle template with SAN fields denied", async () => { + const denyTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["example.com"] + } + ], + keyUsages: { + required: [CertKeyUsageType.DIGITAL_SIGNATURE] + }, + extendedKeyUsages: { + required: [CertExtendedKeyUsageType.SERVER_AUTH] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.EMAIL, + denied: ["*"] + }, + { + type: CertSubjectAlternativeNameType.URI, + denied: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(denyTemplate); + + const requestWithProhibited = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.EMAIL as const, value: "test@example.com" }, + { type: CertSubjectAlternativeNameType.URI as const, value: "https://example.com" } + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithProhibited); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("email SAN matching denied pattern 'test@example.com' found in request"); + expect(result.errors).toContain("uri SAN matching denied pattern 'https://example.com' found in request"); + }); + + it("should validate complex attribute value constraints", async () => { + const constrainedTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["example.com", "test.com"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.example.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(constrainedTemplate); + + const validConstrainedRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const validResult = await service.validateCertificateRequest("template-123", validConstrainedRequest); + expect(validResult.isValid).toBe(true); + + const invalidConstrainedRequest = { + commonName: "forbidden.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const invalidResult = await service.validateCertificateRequest("template-123", invalidConstrainedRequest); + expect(invalidResult.isValid).toBe(false); + expect(invalidResult.errors).toContain("common_name value 'forbidden.com' is not in allowed values list"); + }); + + it("should validate SAN value constraints with multiple types", async () => { + const sanTemplate = { + ...sampleTemplate, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + required: ["*.example.com"] + }, + { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + allowed: ["192.168.1.*"] + }, + { + type: CertSubjectAlternativeNameType.EMAIL, + required: ["*@example.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sanTemplate); + + const validSanRequest = { + commonName: "api.example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.IP_ADDRESS as const, value: "192.168.1.100" }, + { type: CertSubjectAlternativeNameType.EMAIL as const, value: "admin@example.com" } + ], + validity: { ttl: "30d" } + }; + + const validResult = await service.validateCertificateRequest("template-123", validSanRequest); + expect(validResult.isValid).toBe(true); + + const missingSanRequest = { + commonName: "api.example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.example.com" } + ], + validity: { ttl: "30d" } + }; + + const missingResult = await service.validateCertificateRequest("template-123", missingSanRequest); + expect(missingResult.isValid).toBe(false); + expect(missingResult.errors).toContain( + "Required email SAN matching pattern '*@example.com' not found in request" + ); + }); + + it("should validate key usage combinations thoroughly", async () => { + const keyUsageTemplate = { + ...sampleTemplate, + keyUsages: { + required: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + allowed: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.KEY_ENCIPHERMENT, + CertKeyUsageType.DATA_ENCIPHERMENT, + CertKeyUsageType.KEY_AGREEMENT + ] + }, + extendedKeyUsages: { + required: [CertExtendedKeyUsageType.SERVER_AUTH], + allowed: [ + CertExtendedKeyUsageType.SERVER_AUTH, + CertExtendedKeyUsageType.CLIENT_AUTH, + CertExtendedKeyUsageType.EMAIL_PROTECTION + ] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.example.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(keyUsageTemplate); + + const minimalUsageRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const minimalResult = await service.validateCertificateRequest("template-123", minimalUsageRequest); + expect(minimalResult.isValid).toBe(true); + + const extendedUsageRequest = { + commonName: "example.com", + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.KEY_ENCIPHERMENT, + CertKeyUsageType.DATA_ENCIPHERMENT + ], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.CLIENT_AUTH], + validity: { ttl: "30d" } + }; + + const extendedResult = await service.validateCertificateRequest("template-123", extendedUsageRequest); + expect(extendedResult.isValid).toBe(true); + + const forbiddenUsageRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT, CertKeyUsageType.CRL_SIGN], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const forbiddenResult = await service.validateCertificateRequest("template-123", forbiddenUsageRequest); + expect(forbiddenResult.isValid).toBe(false); + expect(forbiddenResult.errors).toContain("Invalid key usages: crl_sign"); + }); + + it("should validate algorithm constraints thoroughly", async () => { + const algorithmTemplate = { + ...sampleTemplate, + algorithms: { + signature: ["RSA-SHA256", "RSA-SHA512"], + keyAlgorithm: ["RSA-2048", "RSA-4096"] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(algorithmTemplate); + + const validAlgoRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "RSA-SHA512", + keyAlgorithm: "RSA_4096", + validity: { ttl: "30d" } + }; + + const validResult = await service.validateCertificateRequest("template-123", validAlgoRequest); + expect(validResult.isValid).toBe(true); + + const invalidSigRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "ECDSA-SHA256", + keyAlgorithm: "RSA_2048", + validity: { ttl: "30d" } + }; + + const invalidSigResult = await service.validateCertificateRequest("template-123", invalidSigRequest); + expect(invalidSigResult.isValid).toBe(false); + expect(invalidSigResult.errors).toContain( + "Signature algorithm 'ECDSA-SHA256' is not allowed by template policy" + ); + + const invalidKeyRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "EC_prime256v1", + validity: { ttl: "30d" } + }; + + const invalidKeyResult = await service.validateCertificateRequest("template-123", invalidKeyRequest); + expect(invalidKeyResult.isValid).toBe(false); + expect(invalidKeyResult.errors).toContain("Key algorithm 'EC_prime256v1' is not allowed by template policy"); + }); + + it("should validate validity period edge cases", async () => { + const validityTemplate = { + ...sampleTemplate, + validity: { + max: "365d" + }, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(validityTemplate); + + const testCases = [ + { ttl: "1d", shouldBeValid: true, description: "minimum duration" }, + { ttl: "365d", shouldBeValid: true, description: "maximum duration" }, + { ttl: "366d", shouldBeValid: false, description: "exceeds maximum" }, + { ttl: "23h", shouldBeValid: true, description: "valid duration under max" }, + { ttl: "24h", shouldBeValid: true, description: "exactly 1 day in hours" }, + { ttl: "8760h", shouldBeValid: true, description: "exactly 365 days in hours" }, + { ttl: "12m", shouldBeValid: true, description: "exactly 365 days in months" }, + { ttl: "1y", shouldBeValid: true, description: "exactly 365 days in years" } + ]; + + await Promise.all( + testCases.map(async (testCase) => { + const request = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: testCase.ttl } + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + + if (!testCase.shouldBeValid) { + expect(result.errors.length).toBeGreaterThan(0); + } + }) + ); + }); + }); + + describe("unlisted field validation", () => { + it("should reject requests with unlisted subject attributes", async () => { + const templateWithLimitedAttributes = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithLimitedAttributes); + + const requestWithUnlistedKeyUsage = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.ENCIPHER_ONLY], // ENCIPHER_ONLY not allowed + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithUnlistedKeyUsage); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Invalid key usages: encipher_only"); + }); + + it("should reject requests with unlisted SAN types", async () => { + const templateWithLimitedSans = { + ...sampleTemplate, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithLimitedSans); + + const requestWithUnlistedSan = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.EMAIL as const, value: "test@example.com" } // This should be rejected + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithUnlistedSan); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("email SAN is not allowed by template policy (not defined in template)"); + }); + + it("should reject requests with unlisted key usages when template doesn't define any", async () => { + const templateWithoutKeyUsages = { + ...sampleTemplate, + keyUsages: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutKeyUsages); + + const requestWithKeyUsages = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], // This should be rejected + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithKeyUsages); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Key usages are not allowed by template policy (not defined in template)"); + }); + + it("should reject requests with algorithms when template doesn't define any", async () => { + const templateWithoutAlgorithms = { + ...sampleTemplate, + algorithms: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutAlgorithms); + + const requestWithAlgorithms = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "RSA-SHA256", // This should be rejected + keyAlgorithm: "RSA-2048", // This should be rejected + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithAlgorithms); + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "Signature algorithm 'RSA-SHA256' is not allowed by template policy (not defined in template)" + ); + expect(result.errors).toContain( + "Key algorithm 'RSA-2048' is not allowed by template policy (not defined in template)" + ); + }); + }); + + describe("comprehensive subject attribute validation", () => { + it("should validate all subject attribute types", async () => { + const comprehensiveTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + required: ["*"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(comprehensiveTemplate); + + const validComprehensiveRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const validResult = await service.validateCertificateRequest("template-123", validComprehensiveRequest); + expect(validResult.isValid).toBe(true); + + // Test missing mandatory field + const missingCommonNameRequest = { + ...validComprehensiveRequest, + commonName: undefined + }; + + const missingCommonNameResult = await service.validateCertificateRequest( + "template-123", + missingCommonNameRequest + ); + expect(missingCommonNameResult.isValid).toBe(false); + expect(missingCommonNameResult.errors).toContain("Missing required common_name attribute"); + }); + }); + + describe("improved wildcard pattern validation", () => { + it("should handle complex wildcard patterns", async () => { + const wildcardTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["v1.api.example.com", "service-auth.internal.com", "exact-match.com"] + } + ], + sans: [] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); + + const testCases = [ + // Valid patterns + { commonName: "v1.api.example.com", shouldBeValid: true }, + { commonName: "service-auth.internal.com", shouldBeValid: true }, + { commonName: "exact-match.com", shouldBeValid: true }, + // Invalid patterns + { commonName: "api.example.com", shouldBeValid: false }, // Missing subdomain for *.api.example.com + { commonName: "service.internal.com", shouldBeValid: false }, // Missing dash and wildcard part + { commonName: "not-exact-match.com", shouldBeValid: false } + ]; + + for (const testCase of testCases) { + const request = { + commonName: testCase.commonName, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + + if (!testCase.shouldBeValid) { + expect( + result.errors.some( + (error) => error.includes("does not match allowed patterns") || error.includes("not in allowed values") + ) + ).toBe(true); + } + } + }); + + it("should handle special regex characters in wildcard patterns", async () => { + const specialCharTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.test-site.com", "service[1-9].example.com", "api.{prod,staging}.com"] + } + ], + sans: [] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(specialCharTemplate); + + const testCases = [ + { commonName: "app.test-site.com", shouldBeValid: true }, + { commonName: "service[1-9].example.com", shouldBeValid: true }, // Should match exactly, not as regex + { commonName: "service1.example.com", shouldBeValid: false }, // Should not match as regex pattern + { commonName: "api.{prod,staging}.com", shouldBeValid: true }, // Should match exactly + { commonName: "api.prod.com", shouldBeValid: false } // Should not match as regex pattern + ]; + + for (const testCase of testCases) { + const request = { + commonName: testCase.commonName, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + } + }); + }); + + describe("algorithm validation", () => { + it("should validate signature algorithm constraints", async () => { + const algorithmTemplate = { + ...sampleTemplate, + algorithms: { + signature: ["RSA-SHA256", "RSA-SHA512", "ECDSA-SHA256"], + keyAlgorithm: ["RSA_2048", "RSA_4096", "EC_prime256v1"] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(algorithmTemplate); + + const testCases = [ + { + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048", + shouldBeValid: true, + description: "allowed algorithms" + }, + { + signatureAlgorithm: "RSA-SHA512", + keyAlgorithm: "RSA_4096", + shouldBeValid: true, + description: "different allowed algorithms" + }, + { + signatureAlgorithm: "ECDSA-SHA256", + keyAlgorithm: "EC_prime256v1", + shouldBeValid: true, + description: "ECDSA algorithms" + }, + { + signatureAlgorithm: "MD5-RSA", + keyAlgorithm: "RSA_2048", + shouldBeValid: false, + description: "disallowed signature algorithm" + }, + { + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_1024", + shouldBeValid: false, + description: "disallowed key algorithm" + }, + { + signatureAlgorithm: undefined, + keyAlgorithm: undefined, + shouldBeValid: true, + description: "no algorithms specified (should use defaults)" + } + ]; + + for (const testCase of testCases) { + const request = { + commonName: "example.com", + validity: { ttl: "30d" }, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.IP_ADDRESS as const, value: "192.168.1.1" } + ], + signatureAlgorithm: testCase.signatureAlgorithm, + keyAlgorithm: testCase.keyAlgorithm + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + + if (!testCase.shouldBeValid) { + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors.some((error) => error.includes("algorithm") || error.includes("Algorithm"))).toBe( + true + ); + } + } + }); + + it("should validate when no algorithm constraints are defined but no algorithms in request", async () => { + const templateWithoutAlgorithms = { + ...sampleTemplate, + algorithms: undefined, + sans: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutAlgorithms); + + const request = { + commonName: "example.com", + validity: { ttl: "30d" }, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH] + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should reject algorithms when template has no algorithm constraints", async () => { + const templateWithoutAlgorithms = { + ...sampleTemplate, + algorithms: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutAlgorithms); + + const requestWithAlgorithms = { + commonName: "example.com", + validity: { ttl: "30d" }, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.IP_ADDRESS as const, value: "192.168.1.1" }], + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048" + }; + + const result = await service.validateCertificateRequest("template-123", requestWithAlgorithms); + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "Signature algorithm 'RSA-SHA256' is not allowed by template policy (not defined in template)" + ); + expect(result.errors).toContain( + "Key algorithm 'RSA_2048' is not allowed by template policy (not defined in template)" + ); + }); + + it("should allow requests that match any of multiple attribute policies of same type", async () => { + const multipleAttributePoliciesTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.infisical.com", "*.infisical2.com"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.infisical.com", "*.infisical2.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(multipleAttributePoliciesTemplate); + + // Test case that matches first policy + const requestMatchingFirstPolicy = { + commonName: "test.infisical.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.infisical.com" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result1 = await service.validateCertificateRequest("template-123", requestMatchingFirstPolicy); + expect(result1.isValid).toBe(true); + + // Test case that matches second policy + const requestMatchingSecondPolicy = { + commonName: "test.infisical2.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.infisical2.com" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result2 = await service.validateCertificateRequest("template-123", requestMatchingSecondPolicy); + expect(result2.isValid).toBe(true); + + // Test case that matches neither policy + const requestMatchingNeitherPolicy = { + commonName: "test.example.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.example.com" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result3 = await service.validateCertificateRequest("template-123", requestMatchingNeitherPolicy); + expect(result3.isValid).toBe(false); + expect(result3.errors).toContain( + "common_name value 'test.example.com' does not match allowed patterns: *.infisical.com, *.infisical2.com" + ); + }); + }); + + describe("New validation logic with allow/deny/require", () => { + it("should validate complex attribute value constraints", async () => { + const complexTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.example.com"] + }, + { + type: CertSubjectAttributeType.ORGANIZATION, + allowed: ["Example*"] + }, + { + type: CertSubjectAttributeType.COUNTRY, + denied: ["XX"] + } + ], + sans: [] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(complexTemplate); + + const validComplexRequest = { + commonName: "api.example.com", + organization: "Example Corp", + country: "US", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result1 = await service.validateCertificateRequest("template-123", validComplexRequest); + expect(result1.isValid).toBe(true); + + const invalidCountryRequest = { ...validComplexRequest, country: "XX" }; + const result2 = await service.validateCertificateRequest("template-123", invalidCountryRequest); + expect(result2.isValid).toBe(false); + expect(result2.errors).toContain("country value 'XX' is denied by template policy"); + const invalidOrgRequest = { ...validComplexRequest, organization: "Different Corp" }; + const result3 = await service.validateCertificateRequest("template-123", invalidOrgRequest); + expect(result3.isValid).toBe(false); + expect(result3.errors).toContain( + "organization value 'Different Corp' does not match allowed patterns: Example*" + ); + }); + + it("should handle SAN allow/deny/require logic", async () => { + const sanTemplate = { + ...sampleTemplate, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.example.com"] + }, + { + type: CertSubjectAlternativeNameType.EMAIL, + required: ["*@example.com"] + }, + { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + denied: ["192.168.1.*"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sanTemplate); + + const validSanRequest = { + commonName: "api.example.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result1 = await service.validateCertificateRequest("template-123", validSanRequest); + expect(result1.isValid).toBe(true); + + const missingEmailRequest = { + ...validSanRequest, + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }] + }; + + const result2 = await service.validateCertificateRequest("template-123", missingEmailRequest); + expect(result2.isValid).toBe(false); + expect(result2.errors).toContain("Required email SAN matching pattern '*@example.com' not found in request"); + + const deniedIpRequest = { + ...validSanRequest, + subjectAlternativeNames: [ + ...validSanRequest.subjectAlternativeNames, + { type: CertSubjectAlternativeNameType.IP_ADDRESS, value: "192.168.1.100" } + ] + }; + + const result3 = await service.validateCertificateRequest("template-123", deniedIpRequest); + expect(result3.isValid).toBe(false); + expect(result3.errors).toContain("ip_address SAN matching denied pattern '192.168.1.100' found in request"); + }); + + it("should validate wildcard patterns correctly", async () => { + const wildcardTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.acme.com"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.api.acme.com"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); + + const testCases = [ + { cn: "api.acme.com", san: "v1.api.acme.com", shouldPass: true }, + { cn: "www.acme.com", san: "beta.api.acme.com", shouldPass: true }, + { cn: "acme.com", san: "api.acme.com", shouldPass: false }, // Missing subdomain + { cn: "api.notacme.com", san: "v1.api.acme.com", shouldPass: false }, // Wrong domain + { cn: "api.acme.com", san: "api.acme.com", shouldPass: false } // SAN missing required subdomain + ]; + + for (const testCase of testCases) { + const request = { + commonName: testCase.cn, + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME, value: testCase.san }], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldPass); + } + }); + + it("should enforce multiple required SAN types", async () => { + const multiRequiredTemplate = { + ...sampleTemplate, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + required: ["*.example.com"] + }, + { + type: CertSubjectAlternativeNameType.EMAIL, + required: ["*@example.com"] + }, + { + type: CertSubjectAlternativeNameType.URI, + required: ["https://*.example.com/*"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(multiRequiredTemplate); + + const completeRequest = { + commonName: "api.example.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" }, + { type: CertSubjectAlternativeNameType.URI, value: "https://api.example.com/webhook" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result1 = await service.validateCertificateRequest("template-123", completeRequest); + expect(result1.isValid).toBe(true); + + const incompleteRequest = { + ...completeRequest, + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ] + }; + + const result2 = await service.validateCertificateRequest("template-123", incompleteRequest); + expect(result2.isValid).toBe(false); + expect(result2.errors).toContain( + "Required uri SAN matching pattern 'https://*.example.com/*' not found in request" + ); + }); + }); + }); +}); diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts new file mode 100644 index 000000000..c1942b793 --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts @@ -0,0 +1,959 @@ +import { ForbiddenError } from "@casl/ability"; +import slugify from "@sindresorhus/slugify"; +import RE2 from "re2"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionPkiTemplateActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { CertSubjectAttributeType } from "../certificate-common/certificate-constants"; +import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal"; +import { + TCertificateRequest, + TCertificateTemplateV2, + TCertificateTemplateV2Insert, + TCertificateTemplateV2Update, + TTemplateValidationResult +} from "./certificate-template-v2-types"; + +type TCertificateTemplateV2ServiceFactoryDep = { + certificateTemplateV2DAL: TCertificateTemplateV2DALFactory; + permissionService: Pick; +}; + +export const certificateTemplateV2ServiceFactory = ({ + certificateTemplateV2DAL, + permissionService +}: TCertificateTemplateV2ServiceFactoryDep) => { + const consolidateAttributeArray = < + T extends { type: string; allowed?: string[]; required?: string[]; denied?: string[] } + >( + attributes: T[] + ): T[] => { + const consolidated = new Map(); + + attributes.forEach((attr) => { + const existing = consolidated.get(attr.type); + if (existing) { + throw new ForbiddenRequestError({ + message: `Duplicate attribute type '${attr.type}' found in request. Each attribute type must appear only once.` + }); + } else { + consolidated.set(attr.type, attr); + } + }); + + return Array.from(consolidated.values()); + }; + + const parseTTL = (ttl: string): number => { + const regex = new RE2("^(\\d+)([dmyh])$"); + const match = regex.exec(ttl); + if (!match) { + throw new Error(`Invalid TTL format: ${ttl}`); + } + + const value = parseInt(match[1], 10); + const unit = match[2]; + + switch (unit) { + case "h": + return value * 60 * 60 * 1000; + case "d": + return value * 24 * 60 * 60 * 1000; + case "m": + return value * 30 * 24 * 60 * 60 * 1000; + case "y": + return value * 365 * 24 * 60 * 60 * 1000; + default: + throw new Error(`Unsupported TTL unit: ${unit}`); + } + }; + + const validateSubjectAttributePolicy = ( + subject: Array<{ type: string; allowed?: string[]; required?: string[]; denied?: string[] }> + ) => { + if (!subject || subject.length === 0) return; + + // Validate each subject attribute policy + for (const attr of subject) { + // Ensure at least one field is provided + if (!attr.allowed && !attr.required && !attr.denied) { + throw new ForbiddenRequestError({ + message: `Subject attribute type '${attr.type}' must have at least one allowed, required, or denied value` + }); + } + + // Check for duplicate values within arrays + const arrays = [ + { name: "allowed", values: attr.allowed }, + { name: "required", values: attr.required }, + { name: "denied", values: attr.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} list for subject attribute type '${attr.type}'` + }); + } + } + } + } + }; + + const validateSanPolicy = ( + sans: Array<{ type: string; allowed?: string[]; required?: string[]; denied?: string[] }> + ) => { + if (!sans || sans.length === 0) return; + + // Validate each SAN policy + for (const san of sans) { + if (!san.allowed && !san.required && !san.denied) { + throw new ForbiddenRequestError({ + message: `SAN type '${san.type}' must have at least one allowed, required, or denied value` + }); + } + + const arrays = [ + { name: "allowed", values: san.allowed }, + { name: "required", values: san.required }, + { name: "denied", values: san.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} list for SAN type '${san.type}'` + }); + } + } + } + } + }; + + const generateTemplateSlug = (baseName?: string): string => { + if (baseName) { + return slugify(baseName); + } + return slugify(alphaNumericNanoId(12)); + }; + + const ensureUniqueSlug = async (projectId: string, desiredSlug: string, templateId?: string): Promise => { + const existingTemplate = await certificateTemplateV2DAL.findByNameAndProjectId(desiredSlug, projectId); + if (!existingTemplate || (templateId && existingTemplate.id === templateId)) { + return desiredSlug; + } + const alternativeSlug = `${desiredSlug}-${alphaNumericNanoId(8)}`; + const existingAlternative = await certificateTemplateV2DAL.findByNameAndProjectId(alternativeSlug, projectId); + if (!existingAlternative) { + return alternativeSlug; + } + + const randomSlug = slugify(alphaNumericNanoId(12)); + return randomSlug; + }; + + const isWildcardPattern = (value: string): boolean => { + return value.includes("*"); + }; + + const createWildcardRegex = (pattern: string): RegExp => { + const wildcardRegex = new RE2(/\*/g); + const withPlaceholder = pattern.replace(wildcardRegex, "__WILDCARD__"); + const escapeRegex = new RE2(/[.+?^${}()|[\]\\]/g); + const escaped = withPlaceholder.replace(escapeRegex, "\\$&"); + const placeholderRegex = new RE2(/__WILDCARD__/g); + const regexPattern = escaped.replace(placeholderRegex, ".*"); + return new RE2(`^${regexPattern}$`); + }; + + const mapTemplateSignatureAlgorithmToApi = (templateFormat: string): string => { + const mapping: Record = { + "SHA256-RSA": "RSA-SHA256", + "SHA384-RSA": "RSA-SHA384", + "SHA512-RSA": "RSA-SHA512", + "SHA256-ECDSA": "ECDSA-SHA256", + "SHA384-ECDSA": "ECDSA-SHA384", + "SHA512-ECDSA": "ECDSA-SHA512" + }; + return mapping[templateFormat] || templateFormat; + }; + + const mapTemplateKeyAlgorithmToApi = (templateFormat: string): string => { + const mapping: Record = { + "RSA-2048": "RSA_2048", + "RSA-3072": "RSA_3072", + "RSA-4096": "RSA_4096", + "ECDSA-P256": "EC_prime256v1", + "ECDSA-P384": "EC_secp384r1", + "ECDSA-P521": "EC_secp521r1" + }; + return mapping[templateFormat] || templateFormat; + }; + + const validateKeyUsagePolicy = (keyUsages: { allowed?: string[]; required?: string[]; denied?: string[] }) => { + if (!keyUsages) return; + + if (!keyUsages.allowed && !keyUsages.required && !keyUsages.denied) { + throw new ForbiddenRequestError({ + message: "Key usages must have at least one allowed, required, or denied value" + }); + } + + const arrays = [ + { name: "allowed", values: keyUsages.allowed }, + { name: "required", values: keyUsages.required }, + { name: "denied", values: keyUsages.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} key usages list` + }); + } + } + } + }; + + const validateExtendedKeyUsagePolicy = (extendedKeyUsages: { + allowed?: string[]; + required?: string[]; + denied?: string[]; + }) => { + if (!extendedKeyUsages) return; + + if (!extendedKeyUsages.allowed && !extendedKeyUsages.required && !extendedKeyUsages.denied) { + throw new ForbiddenRequestError({ + message: "Extended key usages must have at least one allowed, required, or denied value" + }); + } + + const arrays = [ + { name: "allowed", values: extendedKeyUsages.allowed }, + { name: "required", values: extendedKeyUsages.required }, + { name: "denied", values: extendedKeyUsages.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} extended key usages list` + }); + } + } + } + }; + + const validateValueAgainstConstraints = ( + value: string, + allowedValues: string[], + fieldName: string + ): { isValid: boolean; error?: string } => { + if (!allowedValues || allowedValues.length === 0) { + return { isValid: true }; + } + + const hasWildcards = allowedValues.some(isWildcardPattern); + + for (const allowedValue of allowedValues) { + if (isWildcardPattern(allowedValue)) { + try { + const regex = createWildcardRegex(allowedValue); + if (regex.test(value)) { + return { isValid: true }; + } + } catch (error) { + if (allowedValue === value) { + return { isValid: true }; + } + } + } else if (allowedValue === value) { + return { isValid: true }; + } + } + + if (hasWildcards) { + return { + isValid: false, + error: `${fieldName} value '${value}' does not match allowed patterns: ${allowedValues.join(", ")}` + }; + } + return { + isValid: false, + error: `${fieldName} value '${value}' is not in allowed values list` + }; + }; + + const validateRequestAgainstPolicy = ( + template: TCertificateTemplateV2, + request: TCertificateRequest + ): TTemplateValidationResult => { + const errors: string[] = []; + const warnings: string[] = []; + + // Validate subject attributes + const subjectPolicies = template.subject; + const requestAttributes = new Map(); + if (request.commonName) requestAttributes.set(CertSubjectAttributeType.COMMON_NAME, request.commonName); + if (request.organization) { + requestAttributes.set(CertSubjectAttributeType.ORGANIZATION, request.organization); + } + if (request.country) requestAttributes.set(CertSubjectAttributeType.COUNTRY, request.country); + + if (subjectPolicies && subjectPolicies.length > 0) { + for (const attrPolicy of subjectPolicies) { + const requestValue = requestAttributes.get(attrPolicy.type); + + if (attrPolicy.required && attrPolicy.required.length > 0) { + if (!requestValue) { + errors.push(`Missing required ${attrPolicy.type} attribute`); + } else { + // Validate that the request value matches the required pattern + const hasMatchingRequired = attrPolicy.required.some((requiredValue) => { + const validation = validateValueAgainstConstraints(requestValue, [requiredValue], attrPolicy.type); + return validation.isValid; + }); + if (!hasMatchingRequired) { + errors.push( + `${attrPolicy.type} value '${requestValue}' does not match any required patterns: ${attrPolicy.required.join(", ")}` + ); + } + } + } + + if (requestValue) { + let isValueDenied = false; + if (attrPolicy.denied && attrPolicy.denied.length > 0) { + const validation = validateValueAgainstConstraints(requestValue, attrPolicy.denied, attrPolicy.type); + if (validation.isValid) { + errors.push(`${attrPolicy.type} value '${requestValue}' is denied by template policy`); + isValueDenied = true; + } + } + + if (!isValueDenied && attrPolicy.allowed && attrPolicy.allowed.length > 0) { + let satisfiesRequired = false; + if (attrPolicy.required && attrPolicy.required.length > 0) { + satisfiesRequired = attrPolicy.required.some((requiredValue) => { + const validation = validateValueAgainstConstraints(requestValue, [requiredValue], attrPolicy.type); + return validation.isValid; + }); + } + + if (!satisfiesRequired) { + const allowedValidation = validateValueAgainstConstraints( + requestValue, + attrPolicy.allowed, + attrPolicy.type + ); + if (!allowedValidation.isValid && allowedValidation.error) { + errors.push(allowedValidation.error); + } + } + } + } + } + + // Check if any request attributes are not covered by template policies + for (const [attrType] of requestAttributes) { + const hasPolicy = subjectPolicies.some((policy) => policy.type === attrType); + if (!hasPolicy) { + errors.push(`${attrType} is not allowed by template policy (not defined in template)`); + } + } + } else if (requestAttributes.size > 0) { + // No subject policies defined but request has subject attributes - deny all + for (const [attrType] of requestAttributes) { + errors.push(`${attrType} is not allowed by template policy (no subject policies defined)`); + } + } + + // Validate Subject Alternative Names + const sansPolicies = template.sans; + if (sansPolicies && sansPolicies.length > 0) { + const requestSansByType = new Map(); + + // Group request SANs by type + if (request.subjectAlternativeNames) { + for (const san of request.subjectAlternativeNames) { + if (!requestSansByType.has(san.type)) { + requestSansByType.set(san.type, []); + } + requestSansByType.get(san.type)!.push(san.value); + } + } + + // Validate each SAN policy + for (const sanPolicy of sansPolicies) { + const requestSans = requestSansByType.get(sanPolicy.type) || []; + + // Check REQUIRED values - at least one SAN must match each required pattern + if (sanPolicy.required && sanPolicy.required.length > 0) { + for (const requiredValue of sanPolicy.required) { + const hasMatchingRequiredSan = requestSans.some((sanValue) => { + const validation = validateValueAgainstConstraints(sanValue, [requiredValue], `${sanPolicy.type} SAN`); + return validation.isValid; + }); + + if (!hasMatchingRequiredSan) { + errors.push(`Required ${sanPolicy.type} SAN matching pattern '${requiredValue}' not found in request`); + } + } + } + + // Check DENIED values - no SAN should match denied patterns + if (sanPolicy.denied && sanPolicy.denied.length > 0) { + for (const sanValue of requestSans) { + const validation = validateValueAgainstConstraints(sanValue, sanPolicy.denied, `${sanPolicy.type} SAN`); + if (validation.isValid) { + errors.push(`${sanPolicy.type} SAN matching denied pattern '${sanValue}' found in request`); + } + } + } + + // Check ALLOWED values - if present, all SANs must match at least one allowed pattern + if (sanPolicy.allowed && sanPolicy.allowed.length > 0 && requestSans.length > 0) { + for (const sanValue of requestSans) { + let satisfiesRequired = false; + if (sanPolicy.required && sanPolicy.required.length > 0) { + satisfiesRequired = sanPolicy.required.some((requiredValue) => { + const validation = validateValueAgainstConstraints(sanValue, [requiredValue], `${sanPolicy.type} SAN`); + return validation.isValid; + }); + } + + if (!satisfiesRequired) { + const validation = validateValueAgainstConstraints(sanValue, sanPolicy.allowed, `${sanPolicy.type} SAN`); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + } + } + } + } + + // Check if any request SANs are for types not covered by template policies + for (const [requestSanType] of requestSansByType) { + const hasPolicy = sansPolicies.some((policy) => policy.type === requestSanType); + if (!hasPolicy) { + errors.push(`${requestSanType} SAN is not allowed by template policy (not defined in template)`); + } + } + } else if (request.subjectAlternativeNames && request.subjectAlternativeNames.length > 0) { + // No SAN policies defined but request has SANs - deny all + for (const san of request.subjectAlternativeNames) { + errors.push(`${san.type} SAN is not allowed by template policy (no SAN policies defined)`); + } + } + + // Validate key usages + const keyUsagePolicy = template.keyUsages; + if (keyUsagePolicy) { + // Check REQUIRED key usages - must have all required usages + if (keyUsagePolicy.required && keyUsagePolicy.required.length > 0) { + const missingRequired = keyUsagePolicy.required.filter((usage) => !request.keyUsages?.includes(usage)); + if (missingRequired.length > 0) { + errors.push(`Missing required key usages: ${missingRequired.join(", ")}`); + } + } + + // Check DENIED key usages - must not have any denied usages + if (request.keyUsages && keyUsagePolicy.denied && keyUsagePolicy.denied.length > 0) { + const deniedUsages = request.keyUsages.filter((usage) => keyUsagePolicy?.denied?.includes(usage)); + if (deniedUsages.length > 0) { + errors.push(`Denied key usages found in request: ${deniedUsages.join(", ")}`); + } + } + + // Check ALLOWED key usages - if present, all usages must be in allowed list + if (request.keyUsages && keyUsagePolicy && keyUsagePolicy.allowed && keyUsagePolicy.allowed.length > 0) { + const allAllowedUsages = [...(keyUsagePolicy.required || []), ...(keyUsagePolicy.allowed || [])]; + const invalidUsages = request.keyUsages.filter((usage) => !allAllowedUsages.includes(usage)); + if (invalidUsages.length > 0) { + errors.push(`Invalid key usages: ${invalidUsages.join(", ")}`); + } + } + } else if (request.keyUsages && request.keyUsages.length > 0) { + errors.push(`Key usages are not allowed by template policy (not defined in template)`); + } + + // Validate extended key usages + const extendedKeyUsagePolicy = template.extendedKeyUsages; + if (extendedKeyUsagePolicy) { + // Check REQUIRED extended key usages - must have all required usages + if (extendedKeyUsagePolicy.required && extendedKeyUsagePolicy.required.length > 0) { + const missingRequired = extendedKeyUsagePolicy.required.filter( + (usage) => !request.extendedKeyUsages?.includes(usage) + ); + if (missingRequired.length > 0) { + errors.push(`Missing required extended key usages: ${missingRequired.join(", ")}`); + } + } + + // Check DENIED extended key usages - must not have any denied usages + if (request.extendedKeyUsages && extendedKeyUsagePolicy.denied && extendedKeyUsagePolicy.denied.length > 0) { + const deniedUsages = request.extendedKeyUsages.filter((usage) => + extendedKeyUsagePolicy?.denied?.includes(usage) + ); + if (deniedUsages.length > 0) { + errors.push(`Denied extended key usages found in request: ${deniedUsages.join(", ")}`); + } + } + + // Check ALLOWED extended key usages - if present, all usages must be in allowed list + if ( + request.extendedKeyUsages && + extendedKeyUsagePolicy && + extendedKeyUsagePolicy.allowed && + extendedKeyUsagePolicy.allowed.length > 0 + ) { + const allAllowedExtendedUsages = [ + ...(extendedKeyUsagePolicy.required || []), + ...(extendedKeyUsagePolicy.allowed || []) + ]; + const invalidExtendedUsages = request.extendedKeyUsages.filter( + (usage) => !allAllowedExtendedUsages.includes(usage) + ); + if (invalidExtendedUsages.length > 0) { + errors.push(`Invalid extended key usages: ${invalidExtendedUsages.join(", ")}`); + } + } + } else if (request.extendedKeyUsages && request.extendedKeyUsages.length > 0) { + errors.push(`Extended key usages are not allowed by template policy (not defined in template)`); + } + + // Validate algorithms with new structure + if (request.signatureAlgorithm) { + if (template.algorithms?.signature && template.algorithms.signature.length > 0) { + const mappedTemplateAlgorithms = template.algorithms.signature.map(mapTemplateSignatureAlgorithmToApi); + if (!mappedTemplateAlgorithms.includes(request.signatureAlgorithm)) { + errors.push(`Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy`); + } + } else if (!template.algorithms?.signature) { + errors.push( + `Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy (not defined in template)` + ); + } + } + + if (request.keyAlgorithm) { + if (template.algorithms?.keyAlgorithm && template.algorithms.keyAlgorithm.length > 0) { + const mappedTemplateKeyTypes = template.algorithms.keyAlgorithm.map(mapTemplateKeyAlgorithmToApi); + if (!mappedTemplateKeyTypes.includes(request.keyAlgorithm)) { + errors.push(`Key algorithm '${request.keyAlgorithm}' is not allowed by template policy`); + } + } else if (!template.algorithms?.keyAlgorithm) { + errors.push( + `Key algorithm '${request.keyAlgorithm}' is not allowed by template policy (not defined in template)` + ); + } + } + + // Validate validity with new structure + if (request.validity?.ttl && (request.notBefore || request.notAfter)) { + errors.push( + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + ); + } + + if (request.notBefore && request.notAfter && request.notBefore >= request.notAfter) { + errors.push("notBefore must be earlier than notAfter"); + } + + // Validate TTL against template validity constraints + if (request.validity?.ttl && template.validity) { + const requestDurationMs = parseTTL(request.validity.ttl); + + // Check maximum duration using max field + if (template.validity.max) { + const maxDurationMs = parseTTL(template.validity.max); + + if (requestDurationMs > maxDurationMs) { + errors.push("Requested validity period exceeds maximum allowed duration"); + } + } + } + // Validate explicit date range against max duration + if ((request.notBefore || request.notAfter) && template.validity?.max) { + const notBefore = request.notBefore || new Date(); + const { notAfter } = request; + + if (notAfter && notBefore && notAfter instanceof Date && notBefore instanceof Date) { + const requestDuration = notAfter.getTime() - notBefore.getTime(); + const maxDurationMs = parseTTL(template.validity.max); + + if (requestDuration > maxDurationMs) { + errors.push( + `Requested validity period (notBefore to notAfter) exceeds maximum allowed duration of ${template.validity.max}` + ); + } + } + } + + return { + isValid: errors.length === 0, + errors, + warnings + }; + }; + + const createTemplateV2 = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + data + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + data: Omit; + }): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Create, + ProjectPermissionSub.CertificateTemplates + ); + + if (!data) { + throw new Error("Template data is required"); + } + + const consolidatedData = { + ...data, + subject: data.subject ? consolidateAttributeArray(data.subject) : undefined, + sans: data.sans ? consolidateAttributeArray(data.sans) : undefined + }; + + if (consolidatedData.subject) { + validateSubjectAttributePolicy(consolidatedData.subject); + } + + if (consolidatedData.sans) { + validateSanPolicy(consolidatedData.sans); + } + + if (consolidatedData.keyUsages) { + validateKeyUsagePolicy(consolidatedData.keyUsages); + } + + if (consolidatedData.extendedKeyUsages) { + validateExtendedKeyUsagePolicy(consolidatedData.extendedKeyUsages); + } + + // Generate slug from name and ensure it's unique within project + if (!data.name) { + throw new ForbiddenRequestError({ message: "Template name is required" }); + } + + const slug = generateTemplateSlug(data.name); + const uniqueSlug = await ensureUniqueSlug(projectId, slug); + + const template = await certificateTemplateV2DAL.create({ + ...consolidatedData, + name: uniqueSlug, + projectId + }); + + return template; + }; + + const updateTemplateV2 = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId, + data + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + templateId: string; + data: TCertificateTemplateV2Update; + }): Promise => { + const existingTemplate = await certificateTemplateV2DAL.findById(templateId); + if (!existingTemplate) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: existingTemplate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Edit, + ProjectPermissionSub.CertificateTemplates + ); + + const consolidatedData = { + ...data, + subject: data.subject ? consolidateAttributeArray(data.subject) : undefined, + sans: data.sans ? consolidateAttributeArray(data.sans) : undefined + }; + + if (consolidatedData.subject) { + validateSubjectAttributePolicy(consolidatedData.subject); + } + + if (consolidatedData.sans) { + validateSanPolicy(consolidatedData.sans); + } + + if (consolidatedData.keyUsages) { + validateKeyUsagePolicy(consolidatedData.keyUsages); + } + + if (consolidatedData.extendedKeyUsages) { + validateExtendedKeyUsagePolicy(consolidatedData.extendedKeyUsages); + } + + const updateData = { ...consolidatedData }; + if (data.name && typeof data.name === "string") { + const newSlug = generateTemplateSlug(data.name); + if (newSlug !== existingTemplate.name) { + const uniqueSlug = await ensureUniqueSlug(existingTemplate.projectId, newSlug, templateId); + updateData.name = uniqueSlug; + } + } + + const updatedTemplate = await certificateTemplateV2DAL.updateById(templateId, updateData); + if (!updatedTemplate) { + throw new NotFoundError({ message: "Failed to update certificate template" }); + } + return updatedTemplate; + }; + + const getTemplateV2ById = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + templateId: string; + }): Promise => { + const template = await certificateTemplateV2DAL.findById(templateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: template.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + + return template; + }; + + const getTemplateV2BySlug = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + slug + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + slug: string; + }): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + + const template = await certificateTemplateV2DAL.findByNameAndProjectId(slug, projectId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + return template; + }; + + const listTemplatesV2 = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + offset = 0, + limit = 20, + search + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + offset?: number; + limit?: number; + search?: string; + }): Promise<{ + templates: TCertificateTemplateV2[]; + totalCount: number; + }> => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + + const templates = await certificateTemplateV2DAL.findByProjectId(projectId, { + offset, + limit, + search + }); + + const totalCount = await certificateTemplateV2DAL.countByProjectId(projectId, { search }); + + return { + templates, + totalCount + }; + }; + + const deleteTemplateV2 = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + templateId: string; + }): Promise => { + const template = await certificateTemplateV2DAL.findById(templateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: template.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Delete, + ProjectPermissionSub.CertificateTemplates + ); + + const isInUse = await certificateTemplateV2DAL.isTemplateInUse(templateId); + if (isInUse) { + const profilesUsingTemplate = await certificateTemplateV2DAL.getProfilesUsingTemplate(templateId); + const profileNames = profilesUsingTemplate + .map((profile: { slug?: string; id: string }) => profile.slug || profile.id) + .join(", "); + + throw new ForbiddenRequestError({ + message: + profilesUsingTemplate.length > 0 + ? `Cannot delete template '${template.name}' as it is currently in use by the following certificate profiles: ${profileNames}. Please remove this template from these profiles before deleting it.` + : `Cannot delete template '${template.name}' as it is currently in use by one or more certificates. Please ensure no certificates are using this template before deleting it.` + }); + } + + const deletedTemplate = await certificateTemplateV2DAL.deleteById(templateId); + if (!deletedTemplate) { + throw new NotFoundError({ message: "Failed to delete certificate template" }); + } + return deletedTemplate as TCertificateTemplateV2; + }; + + const validateCertificateRequest = async ( + templateId: string, + request: TCertificateRequest + ): Promise => { + const template = await certificateTemplateV2DAL.findById(templateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + return validateRequestAgainstPolicy(template, request); + }; + + return { + createTemplateV2, + updateTemplateV2, + getTemplateV2ById, + getTemplateV2BySlug, + listTemplatesV2, + deleteTemplateV2, + validateCertificateRequest + }; +}; + +export type TCertificateTemplateV2ServiceFactory = ReturnType; diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts new file mode 100644 index 000000000..de2691d9a --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts @@ -0,0 +1,98 @@ +import { + TPkiCertificateTemplatesV2, + TPkiCertificateTemplatesV2Insert +} from "@app/db/schemas/pki-certificate-templates-v2"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; + +export interface TTemplateV2Policy { + subject?: Array<{ + type: CertSubjectAttributeType; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + sans?: Array<{ + type: CertSubjectAlternativeNameType; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + keyUsages?: { + allowed?: CertKeyUsageType[]; + required?: CertKeyUsageType[]; + denied?: CertKeyUsageType[]; + }; + extendedKeyUsages?: { + allowed?: CertExtendedKeyUsageType[]; + required?: CertExtendedKeyUsageType[]; + denied?: CertExtendedKeyUsageType[]; + }; + algorithms?: { + signature?: string[]; + keyAlgorithm?: string[]; + }; + validity?: { + max?: string; + }; +} + +export type TCertificateTemplateV2 = TPkiCertificateTemplatesV2 & { + subject?: TTemplateV2Policy["subject"]; + sans?: TTemplateV2Policy["sans"]; + keyUsages?: TTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TTemplateV2Policy["algorithms"]; + validity?: TTemplateV2Policy["validity"]; +}; + +export type TCertificateTemplateV2Insert = TPkiCertificateTemplatesV2Insert & { + subject?: TTemplateV2Policy["subject"]; + sans?: TTemplateV2Policy["sans"]; + keyUsages?: TTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TTemplateV2Policy["algorithms"]; + validity?: TTemplateV2Policy["validity"]; +}; + +export type TCertificateTemplateV2Update = Partial< + Pick< + TCertificateTemplateV2, + "name" | "description" | "subject" | "sans" | "keyUsages" | "extendedKeyUsages" | "algorithms" | "validity" + > +>; + +export interface TCertificateRequest { + commonName?: string; + organization?: string; + organizationUnit?: string; + locality?: string; + state?: string; + country?: string; + email?: string; + streetAddress?: string; + postalCode?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + subjectAlternativeNames?: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; + validity?: { + ttl: string; + }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; +} + +export interface TTemplateValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; +} diff --git a/backend/src/services/certificate-template/certificate-template-service.ts b/backend/src/services/certificate-template/certificate-template-service.ts index 20c061bf7..20c0ffd88 100644 --- a/backend/src/services/certificate-template/certificate-template-service.ts +++ b/backend/src/services/certificate-template/certificate-template-service.ts @@ -420,7 +420,7 @@ export const certificateTemplateServiceFactory = ({ }; const getEstConfiguration = async (dto: TGetEstConfigurationDTO) => { - const { certificateTemplateId } = dto; + const { certificateTemplateId, isInternal } = dto; const certTemplate = await certificateTemplateDAL.getById(certificateTemplateId); if (!certTemplate) { @@ -429,7 +429,7 @@ export const certificateTemplateServiceFactory = ({ }); } - if (!dto.isInternal) { + if (!isInternal) { const { permission } = await permissionService.getProjectPermission({ actor: dto.actor, actorId: dto.actorId, @@ -440,7 +440,7 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionPkiTemplateActions.Edit, + ProjectPermissionPkiTemplateActions.Read, subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); } diff --git a/backend/src/services/certificate-template/certificate-template-validators.ts b/backend/src/services/certificate-template/certificate-template-validators.ts index 60694b598..e33ddb047 100644 --- a/backend/src/services/certificate-template/certificate-template-validators.ts +++ b/backend/src/services/certificate-template/certificate-template-validators.ts @@ -3,26 +3,40 @@ import z from "zod"; import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; -export const validateTemplateRegexField = z - .string() - .min(1) - .max(100) - .refine( - (val) => - characterValidator([ - CharacterType.AlphaNumeric, - CharacterType.Spaces, // (space) - CharacterType.Asterisk, // * - CharacterType.At, // @ - CharacterType.Hyphen, // - - CharacterType.Period, // . - CharacterType.Backslash // \ - ])(val), - { - message: "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed." - } - ) - // we ensure that the inputted pattern is computationally safe by limiting star height to 1 - .refine((v) => safe(v), { - message: "Unsafe REGEX pattern" - }); +export const createTemplateFieldValidator = (options?: { + minLength?: number; + maxLength?: number; + allowedCharacters?: CharacterType[]; + customMessage?: string; +}) => { + const { + minLength = 1, + maxLength = 100, + allowedCharacters = [ + CharacterType.AlphaNumeric, + CharacterType.Spaces, // (space) + CharacterType.Asterisk, // * + CharacterType.At, // @ + CharacterType.Hyphen, // - + CharacterType.Period, // . + CharacterType.Backslash // \ + ], + customMessage = "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed." + } = options || {}; + + return ( + z + .string() + .min(minLength) + .max(maxLength) + .refine((val) => characterValidator(allowedCharacters)(val), { + message: customMessage + }) + // we ensure that the inputted pattern is computationally safe by limiting star height to 1 + .refine((v) => safe(v), { + message: "Unsafe REGEX pattern" + }) + ); +}; + +export const validateTemplateRegexField = createTemplateFieldValidator(); diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts new file mode 100644 index 000000000..95f9a5077 --- /dev/null +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -0,0 +1,1463 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { ForbiddenError } from "@casl/ability"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { ACMESANType, CertificateOrderStatus } from "@app/services/certificate/certificate-types"; +import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +import { + CertExtendedKeyUsageType, + CertIncludeType, + CertKeyUsageType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; + +import { ActorType, AuthMethod } from "../auth/auth-type"; +import { certificateV3ServiceFactory, TCertificateV3ServiceFactory } from "./certificate-v3-service"; + +describe("CertificateV3Service", () => { + let service: TCertificateV3ServiceFactory; + + const mockCertificateDAL: Pick = { + findOne: vi.fn(), + updateById: vi.fn() + }; + + const mockCertificateAuthorityDAL: Pick = { + findByIdWithAssociatedCa: vi.fn() + }; + + const mockCertificateProfileDAL: Pick = { + findByIdWithConfigs: vi.fn() + }; + + const mockCertificateTemplateV2Service: Pick< + TCertificateTemplateV2ServiceFactory, + "validateCertificateRequest" | "getTemplateV2ById" + > = { + validateCertificateRequest: vi.fn(), + getTemplateV2ById: vi.fn() + }; + + const mockInternalCaService: Pick = + { + signCertFromCa: vi.fn(), + issueCertFromCa: vi.fn() + }; + + const mockPermissionService: Pick = { + getProjectPermission: vi.fn().mockResolvedValue({ + permission: { + throwUnlessCan: vi.fn(), + can: vi.fn().mockReturnValue(true), + cannot: vi.fn().mockReturnValue(false), + relevantRuleFor: vi.fn(), + rules: [] + } + }) + }; + + const mockActor = { + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123" + }; + + beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks(); + + // Mock ForbiddenError.from static method + vi.spyOn(ForbiddenError, "from").mockReturnValue({ + throwUnlessCan: vi.fn() + } as any); + + // Ensure the permission service mock is properly set up + (mockPermissionService.getProjectPermission as any).mockResolvedValue({ + permission: { + throwUnlessCan: vi.fn(), + can: vi.fn().mockReturnValue(true), + cannot: vi.fn().mockReturnValue(false), + relevantRuleFor: vi.fn(), + rules: [], + detectSubjectType: vi.fn() + } + }); + + service = certificateV3ServiceFactory({ + certificateDAL: mockCertificateDAL, + certificateAuthorityDAL: mockCertificateAuthorityDAL, + certificateProfileDAL: mockCertificateProfileDAL, + certificateTemplateV2Service: mockCertificateTemplateV2Service, + internalCaService: mockInternalCaService, + permissionService: mockPermissionService + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); // Ensure static method mocks are properly restored + }); + + describe("issueCertificateFromProfile", () => { + const mockCertificateRequest = { + commonName: "test.example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" }, + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048" + }; + + it("should issue certificate successfully for API enrollment profile", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile", + description: "Test profile" + }; + + const mockCA = { + id: "ca-123", + projectId: "project-123", + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-123" + }, + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true + }; + + const mockTemplate = { + id: "template-123", + name: "Test Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + description: "Test template", + signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, + keyAlgorithm: { defaultKeyType: "RSA_2048" }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ] + }; + + const mockCertificateResult = { + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "issuing-ca", + privateKey: "key", + serialNumber: "123456", + ca: { + id: "ca-123", + projectId: "project-123", + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: null, + notAfter: null, + activeCaCertId: "cert-123", + caId: "ca-123" + } + } + }; + + const mockCertRecord = { + id: "cert-123", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + commonName: "test.example.com", + friendlyName: "Test Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-123", + certificateTemplateId: "template-123", + revokedAt: null, + revokedBy: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResult as any); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + + const result = await service.issueCertificateFromProfile({ + profileId, + certificateRequest: mockCertificateRequest, + ...mockActor + }); + + expect(result).toHaveProperty("certificate"); + expect(result).toHaveProperty("issuingCaCertificate"); + expect(result).toHaveProperty("certificateChain"); + expect(result).toHaveProperty("privateKey"); + expect(result).toHaveProperty("serialNumber", "123456"); + expect(result).toHaveProperty("certificateId", "cert-123"); + }); + + it("should correctly map camelCase key usages to snake_case before validation", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-camel", + description: "Test camelCase profile", + estConfigId: null, + apiConfigId: null + }; + + const mockCA = { + id: "ca-123", + projectId: "project-123", + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-123" + }, + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true + }; + + const mockTemplate = { + id: "template-123", + name: "Test Template for CamelCase", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + description: "Test template for camelCase validation", + signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, + keyAlgorithm: { defaultKeyType: "RSA_2048" }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + const mockCertRecord = { + id: "cert-123", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + commonName: "test.example.com", + friendlyName: "Test Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-123", + certificateTemplateId: "template-123", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }; + + const camelCaseRequest = { + commonName: "test.example.com", + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.NON_REPUDIATION, + CertKeyUsageType.KEY_AGREEMENT, + CertKeyUsageType.CRL_SIGN, + CertKeyUsageType.DECIPHER_ONLY + ], + extendedKeyUsages: [ + CertExtendedKeyUsageType.CLIENT_AUTH, + CertExtendedKeyUsageType.CODE_SIGNING, + CertExtendedKeyUsageType.OCSP_SIGNING, + CertExtendedKeyUsageType.SERVER_AUTH + ], + validity: { ttl: "10d" } + }; + + const mockCertificateResultWithCa = { + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "issuing-ca", + privateKey: "key", + serialNumber: "123456", + ca: { + id: "ca-123", + projectId: "project-123", + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: null, + notAfter: null, + activeCaCertId: "cert-123", + caId: "ca-123" + } + } + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResultWithCa as any); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + + await service.issueCertificateFromProfile({ + profileId, + certificateRequest: camelCaseRequest, + ...mockActor + }); + + // Verify that the template validation service was called with mapped snake_case values + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-123", + expect.objectContaining({ + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.NON_REPUDIATION, + CertKeyUsageType.KEY_AGREEMENT, + CertKeyUsageType.CRL_SIGN, + CertKeyUsageType.DECIPHER_ONLY + ], + extendedKeyUsages: [ + CertExtendedKeyUsageType.CLIENT_AUTH, + CertExtendedKeyUsageType.CODE_SIGNING, + CertExtendedKeyUsageType.OCSP_SIGNING, + CertExtendedKeyUsageType.SERVER_AUTH + ] + }) + ); + }); + + it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.EST, // Wrong enrollment type + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-est", + description: "Test EST profile", + estConfigId: null, + apiConfigId: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + + await expect( + service.issueCertificateFromProfile({ + profileId, + certificateRequest: mockCertificateRequest, + ...mockActor + }) + ).rejects.toThrow(ForbiddenRequestError); + + await expect( + service.issueCertificateFromProfile({ + profileId, + certificateRequest: mockCertificateRequest, + ...mockActor + }) + ).rejects.toThrow("Profile is not configured for api enrollment"); + }); + + it("should throw NotFoundError when profile doesn't exist", async () => { + const profileId = "non-existent-profile"; + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(undefined); + + await expect( + service.issueCertificateFromProfile({ + profileId, + certificateRequest: mockCertificateRequest, + ...mockActor + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("signCertificateFromProfile", () => { + const mockCSR = "-----BEGIN CERTIFICATE REQUEST-----\nMIIC..."; + const mockValidity = { ttl: "30d" }; + + it("should sign certificate successfully for API enrollment profile", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-sign", + description: "Test signing profile", + estConfigId: null, + apiConfigId: null + }; + + const mockCA = { + id: "ca-123", + projectId: "project-123", + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-123" + }, + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true + }; + + const mockSignResult = { + certificate: "signed-cert", + certificateChain: "chain", + issuingCaCertificate: "issuing-ca", + serialNumber: "789012", + commonName: "test.example.com", + ca: { + id: "ca-123", + projectId: "project-123", + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: null, + notAfter: null, + activeCaCertId: "cert-123", + caId: "ca-123" + } + } + }; + + const mockCertRecord = { + id: "cert-456", + serialNumber: "789012", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + commonName: "test.example.com", + friendlyName: "Test Signing Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-123", + certificateTemplateId: "template-123", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }; + + const mockTemplate = { + id: "template-123", + name: "Test Signing Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + description: "Test template for signing certificates", + signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, + keyAlgorithm: { defaultKeyType: "RSA_2048" }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockInternalCaService.signCertFromCa).mockResolvedValue(mockSignResult as any); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + + const result = await service.signCertificateFromProfile({ + profileId, + csr: mockCSR, + validity: mockValidity, + ...mockActor + }); + + expect(result).toHaveProperty("certificate"); + expect(result).toHaveProperty("issuingCaCertificate"); + expect(result).toHaveProperty("certificateChain"); + expect(result).toHaveProperty("serialNumber", "789012"); + expect(result).toHaveProperty("certificateId", "cert-456"); + expect(result).not.toHaveProperty("privateKey"); + }); + + it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.EST, // Wrong enrollment type + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-est-sign", + description: "Test EST signing profile", + estConfigId: null, + apiConfigId: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + + await expect( + service.signCertificateFromProfile({ + profileId, + csr: mockCSR, + validity: mockValidity, + ...mockActor + }) + ).rejects.toThrow(ForbiddenRequestError); + + await expect( + service.signCertificateFromProfile({ + profileId, + csr: mockCSR, + validity: mockValidity, + ...mockActor + }) + ).rejects.toThrow("Profile is not configured for api enrollment"); + }); + }); + + describe("orderCertificateFromProfile", () => { + const mockCertificateOrder = { + altNames: [{ type: ACMESANType.DNS, value: "example.com" }], + validity: { ttl: "30d" }, + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048" + }; + + it("should create order successfully for API enrollment profile", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-order", + description: "Test order profile", + estConfigId: null, + apiConfigId: null + }; + + const mockCA = { + id: "ca-123", + projectId: "project-123", + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-123" + }, + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true + }; + + const mockTemplate = { + id: "template-123", + name: "Test Order Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + description: "Test template for ordering certificates", + signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, + keyAlgorithm: { defaultKeyType: "RSA_2048" }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + const mockCertificateResult = { + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "issuing-ca", + privateKey: "key", + serialNumber: "123456", + ca: { + id: "ca-123", + projectId: "project-123", + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: null, + notAfter: null, + activeCaCertId: "cert-123", + caId: "ca-123" + } + } + }; + + const mockCertRecord = { + id: "cert-123", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + commonName: "example.com", + friendlyName: "Test Order Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-123", + certificateTemplateId: "template-123", + revokedAt: null, + altNames: JSON.stringify([{ type: "DNS", value: "example.com" }]), + caCertId: null, + keyUsages: ["DIGITAL_SIGNATURE"], + extendedKeyUsages: ["SERVER_AUTH"], + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResult as any); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + + const result = await service.orderCertificateFromProfile({ + profileId, + certificateOrder: mockCertificateOrder, + ...mockActor + }); + + expect(result).toHaveProperty("orderId"); + expect(result).toHaveProperty("status", "valid"); + expect(result).toHaveProperty("certificate"); + expect(result.subjectAlternativeNames).toHaveLength(1); + expect(result.subjectAlternativeNames[0]).toEqual({ + type: ACMESANType.DNS, + value: "example.com", + status: CertificateOrderStatus.VALID + }); + }); + + it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.EST, // Wrong enrollment type + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-est-order", + description: "Test EST order profile", + estConfigId: null, + apiConfigId: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + + await expect( + service.orderCertificateFromProfile({ + profileId, + certificateOrder: mockCertificateOrder, + ...mockActor + }) + ).rejects.toThrow(ForbiddenRequestError); + + await expect( + service.orderCertificateFromProfile({ + profileId, + certificateOrder: mockCertificateOrder, + ...mockActor + }) + ).rejects.toThrow("Profile is not configured for api enrollment"); + }); + }); + + describe("algorithm compatibility (integration tests)", () => { + const mockProfile = { + id: "profile-1", + slug: "test-profile", + projectId: "project-1", + caId: "ca-1", + certificateTemplateId: "template-1", + enrollmentType: EnrollmentType.API, + createdAt: new Date(), + updatedAt: new Date(), + description: "Test profile for algorithm compatibility", + estConfigId: null, + apiConfigId: null + }; + + const mockCertificateRequest = { + commonName: "test.example.com", + validity: { ttl: "30d" }, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH] + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should successfully process RSA algorithms with RSA CAs", async () => { + const rsaCa = { + id: "ca-1", + projectId: "project-1", + status: "active", + name: "RSA Test CA", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-1", + parentCaId: null, + type: "ROOT", + friendlyName: "RSA Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "RSA Test CA", + dn: "CN=RSA Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-1" + } + }; + + const rsaTemplate = { + id: "template-1", + name: "RSA Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + description: "RSA template for algorithm compatibility", + signatureAlgorithm: { + allowedAlgorithms: ["SHA256-RSA", "SHA384-RSA"] + }, + keyAlgorithm: null, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(rsaCa); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(rsaTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "ca-cert", + privateKey: "key", + serialNumber: "123456", + ca: rsaCa as any + }); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + + // Should not throw - RSA CA is compatible with RSA signature algorithms + await expect( + service.issueCertificateFromProfile({ + profileId: mockProfile.id, + certificateRequest: { + ...mockCertificateRequest, + signatureAlgorithm: "RSA-SHA256" + }, + ...mockActor + }) + ).resolves.toBeDefined(); + }); + + it("should successfully process ECDSA algorithms with EC CAs", async () => { + const ecCa = { + id: "ca-1", + projectId: "project-1", + status: "active", + name: "EC Test CA", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-1", + parentCaId: null, + type: "ROOT", + friendlyName: "EC Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "EC Test CA", + dn: "CN=EC Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "EC_prime256v1", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-1" + } + }; + + const ecdsaTemplate = { + id: "template-1", + name: "ECDSA Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + description: "ECDSA template for algorithm compatibility", + signatureAlgorithm: { + allowedAlgorithms: ["SHA256-ECDSA", "SHA384-ECDSA"] + }, + keyAlgorithm: null, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(ecCa); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(ecdsaTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "ca-cert", + privateKey: "key", + serialNumber: "123456", + ca: ecCa as any + }); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + + // Should not throw - EC CA is compatible with ECDSA signature algorithms + await expect( + service.issueCertificateFromProfile({ + profileId: mockProfile.id, + certificateRequest: { + ...mockCertificateRequest, + signatureAlgorithm: "ECDSA-SHA256" + }, + ...mockActor + }) + ).resolves.toBeDefined(); + }); + + it("should dynamically support new RSA key sizes", async () => { + const rsa8192Ca = { + id: "ca-1", + projectId: "project-1", + status: "active", + name: "RSA 8192 Test CA", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-1", + parentCaId: null, + type: "ROOT", + friendlyName: "RSA 8192 Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "RSA 8192 Test CA", + dn: "CN=RSA 8192 Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_8192", // Future RSA key size + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-1" + } + }; + + const rsaTemplate = { + id: "template-1", + name: "RSA 8192 Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + description: "RSA 8192 template for future key sizes", + signatureAlgorithm: { + allowedAlgorithms: ["SHA256-RSA"] + }, + keyAlgorithm: null, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(rsa8192Ca); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(rsaTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "ca-cert", + privateKey: "key", + serialNumber: "123456", + ca: rsa8192Ca as any + }); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + + // Should not throw - dynamic check supports new RSA key sizes + await expect( + service.issueCertificateFromProfile({ + profileId: mockProfile.id, + certificateRequest: { + ...mockCertificateRequest, + signatureAlgorithm: "RSA-SHA256" + }, + ...mockActor + }) + ).resolves.toBeDefined(); + }); + + it("should dynamically support new EC curve types", async () => { + const newEcCa = { + id: "ca-1", + projectId: "project-1", + status: "active", + name: "EC secp521r1 Test CA", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-1", + parentCaId: null, + type: "ROOT", + friendlyName: "EC secp521r1 Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "EC secp521r1 Test CA", + dn: "CN=EC secp521r1 Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "EC_secp521r1", // Future EC curve + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-1" + } + }; + + const ecdsaTemplate = { + id: "template-1", + name: "ECDSA secp521r1 Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + description: "ECDSA secp521r1 template for future EC curves", + signatureAlgorithm: { + allowedAlgorithms: ["SHA384-ECDSA"] + }, + keyAlgorithm: null, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(newEcCa); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(ecdsaTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "ca-cert", + privateKey: "key", + serialNumber: "123456", + ca: newEcCa as any + }); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + + // Should not throw - dynamic check supports new EC curves + await expect( + service.issueCertificateFromProfile({ + profileId: mockProfile.id, + certificateRequest: { + ...mockCertificateRequest, + signatureAlgorithm: "ECDSA-SHA384" + }, + ...mockActor + }) + ).resolves.toBeDefined(); + }); + }); +}); diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts new file mode 100644 index 000000000..1c11b0a00 --- /dev/null +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -0,0 +1,487 @@ +import { ForbiddenError } from "@casl/ability"; +import { randomUUID } from "crypto"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionCertificateProfileActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { + CertificateOrderStatus, + CertKeyAlgorithm, + CertSignatureAlgorithm +} from "@app/services/certificate/certificate-types"; +import { + TCertificateAuthorityDALFactory, + TCertificateAuthorityWithAssociatedCa +} from "@app/services/certificate-authority/certificate-authority-dal"; +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; + +import { CertSubjectAlternativeNameType } from "../certificate-common/certificate-constants"; +import { + bufferToString, + buildCertificateSubjectFromTemplate, + buildSubjectAlternativeNamesFromTemplate, + convertExtendedKeyUsageArrayToLegacy, + convertKeyUsageArrayToLegacy, + mapEnumsForValidation, + normalizeDateForApi +} from "../certificate-common/certificate-utils"; +import { + TCertificateFromProfileResponse, + TCertificateOrderResponse, + TIssueCertificateFromProfileDTO, + TOrderCertificateFromProfileDTO, + TSignCertificateFromProfileDTO +} from "./certificate-v3-types"; + +type TCertificateV3ServiceFactoryDep = { + certificateDAL: Pick; + certificateAuthorityDAL: Pick; + certificateProfileDAL: Pick; + certificateTemplateV2Service: Pick< + TCertificateTemplateV2ServiceFactory, + "validateCertificateRequest" | "getTemplateV2ById" + >; + internalCaService: Pick; + permissionService: Pick; +}; + +export type TCertificateV3ServiceFactory = ReturnType; + +const validateProfileAndPermissions = async ( + profileId: string, + actor: ActorType, + actorId: string, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string, + certificateProfileDAL: Pick, + permissionService: Pick, + requiredEnrollmentType: EnrollmentType +) => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== requiredEnrollmentType) { + throw new ForbiddenRequestError({ + message: `Profile is not configured for ${requiredEnrollmentType} enrollment` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.IssueCert, + ProjectPermissionSub.CertificateProfiles + ); + + return profile; +}; + +const validateCaSupport = (ca: TCertificateAuthorityWithAssociatedCa, operation: string) => { + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; + if (caType !== CaType.INTERNAL) { + throw new BadRequestError({ message: `Only internal CAs support ${operation}` }); + } + return caType; +}; + +const validateAlgorithmCompatibility = ( + ca: TCertificateAuthorityWithAssociatedCa, + template: { + algorithms?: { + signature?: string[]; + }; + } +) => { + if (!template.algorithms?.signature || template.algorithms.signature.length === 0) { + return; + } + + const caKeyAlgorithm = ca.internalCa?.keyAlgorithm; + if (!caKeyAlgorithm) { + throw new BadRequestError({ message: "CA key algorithm not found" }); + } + + const compatibleAlgorithms = + template.algorithms?.signature?.filter((sigAlg: string) => { + const parts = sigAlg.split("-"); + if (parts.length === 0) { + return false; + } + const keyType = parts[parts.length - 1]; + + if (caKeyAlgorithm.startsWith("RSA")) { + return keyType === "RSA"; + } + + if (caKeyAlgorithm.startsWith("EC")) { + return keyType === "ECDSA"; + } + + return false; + }) || []; + + if (compatibleAlgorithms.length === 0) { + throw new BadRequestError({ + message: `Template signature algorithms (${template.algorithms?.signature?.join(", ") || "none"}) are not compatible with CA key algorithm (${caKeyAlgorithm})` + }); + } +}; + +const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | string): string => { + if (typeof certData === "string") return certData; + if (Buffer.isBuffer(certData)) return bufferToString(certData); + if (certData && typeof certData === "object" && "rawData" in certData && Buffer.isBuffer(certData.rawData)) { + return bufferToString(certData.rawData); + } + return bufferToString(certData as unknown as Buffer); +}; + +export const certificateV3ServiceFactory = ({ + certificateDAL, + certificateAuthorityDAL, + certificateProfileDAL, + certificateTemplateV2Service, + internalCaService, + permissionService +}: TCertificateV3ServiceFactoryDep) => { + const issueCertificateFromProfile = async ({ + profileId, + certificateRequest, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TIssueCertificateFromProfileDTO): Promise => { + const profile = await validateProfileAndPermissions( + profileId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + certificateProfileDAL, + permissionService, + EnrollmentType.API + ); + + if (certificateRequest.commonName && Array.isArray(certificateRequest.commonName)) { + throw new BadRequestError({ + message: "Common Name must be a single value, not an array" + }); + } + + const mappedCertificateRequest = mapEnumsForValidation({ + ...certificateRequest, + subjectAlternativeNames: certificateRequest.altNames + }); + + const template = await certificateTemplateV2Service.getTemplateV2ById({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId: profile.certificateTemplateId + }); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found for this profile" }); + } + + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate request validation failed: ${validationResult.errors.join(", ")}` + }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + validateCaSupport(ca, "direct certificate issuance"); + + if (!actorAuthMethod) { + throw new BadRequestError({ message: "Authentication method is required for certificate issuance" }); + } + + validateAlgorithmCompatibility(ca, template); + + const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm as CertSignatureAlgorithm | undefined; + const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm as CertKeyAlgorithm | undefined; + + if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) { + throw new BadRequestError({ + message: "Key algorithm is required by template policy but not provided in request" + }); + } + + if (template.algorithms?.signature && !effectiveSignatureAlgorithm) { + throw new BadRequestError({ + message: "Signature algorithm is required by template policy but not provided in request" + }); + } + + const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template.subject); + const subjectAlternativeNames = buildSubjectAlternativeNamesFromTemplate( + { subjectAlternativeNames: certificateRequest.altNames }, + template.sans + ); + + const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } = + await internalCaService.issueCertFromCa({ + caId: ca.id, + friendlyName: certificateSubject.common_name || "Certificate", + commonName: certificateSubject.common_name || "", + altNames: subjectAlternativeNames, + ttl: certificateRequest.validity.ttl, + keyUsages: convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || [], + extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages) || [], + notBefore: normalizeDateForApi(certificateRequest.notBefore), + notAfter: normalizeDateForApi(certificateRequest.notAfter), + signatureAlgorithm: effectiveSignatureAlgorithm, + keyAlgorithm: effectiveKeyAlgorithm, + actor, + actorId, + actorAuthMethod, + actorOrgId, + isFromProfile: true + }); + + const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); + if (!cert) { + throw new NotFoundError({ message: "Certificate was issued but could not be found in database" }); + } + + await certificateDAL.updateById(cert.id, { profileId }); + + return { + certificate: bufferToString(certificate), + issuingCaCertificate: bufferToString(issuingCaCertificate), + certificateChain: bufferToString(certificateChain), + privateKey: bufferToString(privateKey), + serialNumber, + certificateId: cert.id, + projectId: profile.projectId, + profileName: profile.slug + }; + }; + + const signCertificateFromProfile = async ({ + profileId, + csr, + validity, + notBefore, + notAfter, + signatureAlgorithm, + keyAlgorithm, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TSignCertificateFromProfileDTO): Promise> => { + const profile = await validateProfileAndPermissions( + profileId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + certificateProfileDAL, + permissionService, + EnrollmentType.API + ); + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + validateCaSupport(ca, "CSR signing"); + + if (!actorAuthMethod) { + throw new BadRequestError({ message: "Authentication method is required for certificate signing" }); + } + + const template = await certificateTemplateV2Service.getTemplateV2ById({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId: profile.certificateTemplateId + }); + + if (!template) { + throw new NotFoundError({ message: "Certificate template not found for this profile" }); + } + + validateAlgorithmCompatibility(ca, template); + + const effectiveSignatureAlgorithm = signatureAlgorithm; + const effectiveKeyAlgorithm = keyAlgorithm; + + if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) { + throw new BadRequestError({ + message: "Key algorithm is required by template policy but not provided in request" + }); + } + + if (template.algorithms?.signature && !effectiveSignatureAlgorithm) { + throw new BadRequestError({ + message: "Signature algorithm is required by template policy but not provided in request" + }); + } + + const { certificate, certificateChain, issuingCaCertificate, serialNumber } = + await internalCaService.signCertFromCa({ + isInternal: true, + caId: ca.id, + csr, + ttl: validity.ttl, + altNames: undefined, + notBefore: normalizeDateForApi(notBefore), + notAfter: normalizeDateForApi(notAfter), + signatureAlgorithm: effectiveSignatureAlgorithm, + keyAlgorithm: effectiveKeyAlgorithm, + isFromProfile: true + }); + + const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); + if (!cert) { + throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); + } + + await certificateDAL.updateById(cert.id, { profileId }); + + const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer); + const certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer); + + return { + certificate: certificateString, + issuingCaCertificate: extractCertificateFromBuffer(issuingCaCertificate as unknown as Buffer), + certificateChain: certificateChainString, + serialNumber, + certificateId: cert.id, + projectId: profile.projectId, + profileName: profile.slug + }; + }; + + const orderCertificateFromProfile = async ({ + profileId, + certificateOrder, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TOrderCertificateFromProfileDTO): Promise => { + const profile = await validateProfileAndPermissions( + profileId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + certificateProfileDAL, + permissionService, + EnrollmentType.API + ); + + const certificateRequest = { + commonName: certificateOrder.commonName, + keyUsages: certificateOrder.keyUsages, + extendedKeyUsages: certificateOrder.extendedKeyUsages, + subjectAlternativeNames: certificateOrder.altNames.map((san) => ({ + type: san.type === "dns" ? CertSubjectAlternativeNameType.DNS_NAME : CertSubjectAlternativeNameType.IP_ADDRESS, + value: san.value + })), + validity: certificateOrder.validity, + notBefore: certificateOrder.notBefore, + notAfter: certificateOrder.notAfter, + signatureAlgorithm: certificateOrder.signatureAlgorithm, + keyAlgorithm: certificateOrder.keyAlgorithm + }; + + const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate order validation failed: ${validationResult.errors.join(", ")}` + }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; + + if (caType === CaType.INTERNAL) { + const certificateResult = await issueCertificateFromProfile({ + profileId, + certificateRequest, + actor, + actorId, + actorAuthMethod, + actorOrgId + }); + + const orderId = randomUUID(); + + return { + orderId, + status: CertificateOrderStatus.VALID, + subjectAlternativeNames: certificateOrder.altNames.map((san) => ({ + type: san.type, + value: san.value, + status: CertificateOrderStatus.VALID + })), + authorizations: [], + finalize: `/api/v3/certificates/orders/${orderId}/completed`, + certificate: certificateResult.certificate, + projectId: certificateResult.projectId, + profileName: certificateResult.profileName + }; + } + + if (caType === CaType.ACME) { + throw new BadRequestError({ + message: "ACME certificate ordering via profiles is not yet implemented." + }); + } + + throw new BadRequestError({ + message: `Certificate ordering is not supported for CA type: ${caType}` + }); + }; + + return { + issueCertificateFromProfile, + signCertificateFromProfile, + orderCertificateFromProfile + }; +}; diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts new file mode 100644 index 000000000..b54042c5c --- /dev/null +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -0,0 +1,99 @@ +import { TProjectPermission } from "@app/lib/types"; + +import { ACMESANType, CertificateOrderStatus } from "../certificate/certificate-types"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType +} from "../certificate-common/certificate-constants"; + +export type TIssueCertificateFromProfileDTO = { + profileId: string; + certificateRequest: { + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + altNames?: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; + validity: { + ttl: string; + }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; + }; +} & Omit; + +export type TSignCertificateFromProfileDTO = { + profileId: string; + csr: string; + validity: { + ttl: string; + }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; +} & Omit; + +export type TOrderCertificateFromProfileDTO = { + profileId: string; + certificateOrder: { + altNames: Array<{ + type: ACMESANType; + value: string; + }>; + validity: { + ttl: string; + }; + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; + }; +} & Omit; + +export type TCertificateFromProfileResponse = { + certificate: string; + issuingCaCertificate: string; + certificateChain: string; + privateKey?: string; + serialNumber: string; + certificateId: string; + projectId: string; + profileName: string; +}; + +export type TCertificateOrderResponse = { + orderId: string; + status: CertificateOrderStatus; + subjectAlternativeNames: Array<{ + type: ACMESANType; + value: string; + status: CertificateOrderStatus; + }>; + authorizations: Array<{ + identifier: { + type: ACMESANType; + value: string; + }; + status: CertificateOrderStatus; + expires?: string; + challenges: Array<{ + type: string; + status: CertificateOrderStatus; + url: string; + token: string; + }>; + }>; + finalize: string; + certificate?: string; + projectId: string; + profileName: string; +}; diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts index 527df2a39..9da331be8 100644 --- a/backend/src/services/certificate/certificate-types.ts +++ b/backend/src/services/certificate/certificate-types.ts @@ -8,14 +8,26 @@ import { TCertificateSecretDALFactory } from "./certificate-secret-dal"; export enum CertStatus { ACTIVE = "active", + EXPIRED = "expired", REVOKED = "revoked" } export enum CertKeyAlgorithm { RSA_2048 = "RSA_2048", + RSA_3072 = "RSA_3072", RSA_4096 = "RSA_4096", ECDSA_P256 = "EC_prime256v1", - ECDSA_P384 = "EC_secp384r1" + ECDSA_P384 = "EC_secp384r1", + ECDSA_P521 = "EC_secp521r1" +} + +export enum CertSignatureAlgorithm { + RSA_SHA256 = "RSA-SHA256", + RSA_SHA384 = "RSA-SHA384", + RSA_SHA512 = "RSA-SHA512", + ECDSA_SHA256 = "ECDSA-SHA256", + ECDSA_SHA384 = "ECDSA-SHA384", + ECDSA_SHA512 = "ECDSA-SHA512" } export enum CertKeyUsage { @@ -39,6 +51,11 @@ export enum CertExtendedKeyUsage { OCSP_SIGNING = "ocspSigning" } +export enum CertSignatureType { + RSA = "RSA", + ECDSA = "ECDSA" +} + export const CertExtendedKeyUsageOIDToName: Record = { [x509.ExtendedKeyUsage.clientAuth]: CertExtendedKeyUsage.CLIENT_AUTH, [x509.ExtendedKeyUsage.serverAuth]: CertExtendedKeyUsage.SERVER_AUTH, @@ -105,13 +122,48 @@ export type TGetCertificateCredentialsDTO = { kmsService: Pick; }; +export enum CertSubjectAlternativeNameType { + DNS_NAME = "dns_name", + IP_ADDRESS = "ip_address", + EMAIL = "email", + URI = "uri" +} + export enum TAltNameType { EMAIL = "email", DNS = "dns", IP = "ip", URL = "url" } + +export const mapLegacyAltNameType = (legacyType: TAltNameType): CertSubjectAlternativeNameType => { + switch (legacyType) { + case TAltNameType.EMAIL: + return CertSubjectAlternativeNameType.EMAIL; + case TAltNameType.DNS: + return CertSubjectAlternativeNameType.DNS_NAME; + case TAltNameType.IP: + return CertSubjectAlternativeNameType.IP_ADDRESS; + case TAltNameType.URL: + return CertSubjectAlternativeNameType.URI; + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unknown legacy alt name type: ${legacyType}`); + } +}; export type TAltNameMapping = { type: TAltNameType; value: string; }; + +export enum ACMESANType { + DNS = "dns", + IP = "ip" +} + +export enum CertificateOrderStatus { + PENDING = "pending", + PROCESSING = "processing", + VALID = "valid", + INVALID = "invalid" +} diff --git a/backend/src/services/enrollment-config/api-enrollment-config-dal.ts b/backend/src/services/enrollment-config/api-enrollment-config-dal.ts new file mode 100644 index 000000000..1edfdae6c --- /dev/null +++ b/backend/src/services/enrollment-config/api-enrollment-config-dal.ts @@ -0,0 +1,118 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +import { TApiEnrollmentConfigInsert, TApiEnrollmentConfigUpdate } from "./enrollment-config-types"; + +export type TApiEnrollmentConfigDALFactory = ReturnType; + +export const apiEnrollmentConfigDALFactory = (db: TDbClient) => { + const apiEnrollmentConfigOrm = ormify(db, TableName.PkiApiEnrollmentConfig); + + const create = async (data: TApiEnrollmentConfigInsert, tx?: Knex) => { + try { + const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).insert(data).returning("*"); + + return apiConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Create API enrollment config" }); + } + }; + + const updateById = async (id: string, data: TApiEnrollmentConfigUpdate, tx?: Knex) => { + try { + const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).update(data).returning("*"); + + return apiConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Update API enrollment config" }); + } + }; + + const deleteById = async (id: string, tx?: Knex) => { + try { + const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).del().returning("*"); + + return apiConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Delete API enrollment config" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const apiConfig = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).first(); + + return apiConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Find API enrollment config by id" }); + } + }; + + const findProfilesForAutoRenewal = async (renewalThresholdDays: number = 30, projectId?: string, tx?: Knex) => { + try { + let query = (tx || db)(TableName.PkiCertificateProfile) + .join( + TableName.PkiApiEnrollmentConfig, + `${TableName.PkiCertificateProfile}.apiConfigId`, + `${TableName.PkiApiEnrollmentConfig}.id` + ) + .where(`${TableName.PkiApiEnrollmentConfig}.autoRenew`, true); + + if (projectId) { + query = query.where(`${TableName.PkiCertificateProfile}.projectId`, projectId); + } + + const profiles = await query + .where((qb) => { + void qb + .whereNull(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`) + .orWhere(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`, "<=", renewalThresholdDays); + }) + .select((tx || db).ref("id").withSchema(TableName.PkiCertificateProfile)) + .select((tx || db).ref("name").withSchema(TableName.PkiCertificateProfile)) + .select((tx || db).ref("projectId").withSchema(TableName.PkiCertificateProfile)) + .select((tx || db).ref("autoRenewDays").withSchema(TableName.PkiCertificateProfile)); + + return profiles as Array<{ id: string; name: string; projectId: string; autoRenewDays?: number }>; + } catch (error) { + throw new DatabaseError({ error, name: "Find profiles for auto renewal" }); + } + }; + + const isConfigInUse = async (configId: string, tx?: Knex) => { + try { + const doc = await (tx || db)(TableName.PkiCertificateProfile).where({ apiConfigId: configId }).count("*").first(); + + if (!doc || typeof doc !== "object") { + return 0; + } + + const countValue = (doc as Record).count; + if (typeof countValue === "number") { + return countValue; + } + if (typeof countValue === "string") { + const parsed = parseInt(countValue, 10); + return Number.isNaN(parsed) ? 0 : parsed; + } + + return 0; + } catch (error) { + throw new DatabaseError({ error, name: "Check if API enrollment config is in use" }); + } + }; + + return { + ...apiEnrollmentConfigOrm, + create, + updateById, + deleteById, + findById, + findProfilesForAutoRenewal, + isConfigInUse + }; +}; diff --git a/backend/src/services/enrollment-config/enrollment-config-types.ts b/backend/src/services/enrollment-config/enrollment-config-types.ts new file mode 100644 index 000000000..516f135fd --- /dev/null +++ b/backend/src/services/enrollment-config/enrollment-config-types.ts @@ -0,0 +1,29 @@ +import { + TPkiApiEnrollmentConfigs, + TPkiApiEnrollmentConfigsInsert, + TPkiApiEnrollmentConfigsUpdate +} from "@app/db/schemas/pki-api-enrollment-configs"; +import { + TPkiEstEnrollmentConfigs, + TPkiEstEnrollmentConfigsInsert, + TPkiEstEnrollmentConfigsUpdate +} from "@app/db/schemas/pki-est-enrollment-configs"; + +export type TEstEnrollmentConfig = TPkiEstEnrollmentConfigs; +export type TEstEnrollmentConfigInsert = TPkiEstEnrollmentConfigsInsert; +export type TEstEnrollmentConfigUpdate = TPkiEstEnrollmentConfigsUpdate; + +export type TApiEnrollmentConfig = TPkiApiEnrollmentConfigs; +export type TApiEnrollmentConfigInsert = TPkiApiEnrollmentConfigsInsert; +export type TApiEnrollmentConfigUpdate = TPkiApiEnrollmentConfigsUpdate; + +export interface TEstConfigData { + disableBootstrapCaValidation: boolean; + passphrase: string; + caChain?: string; +} + +export interface TApiConfigData { + autoRenew: boolean; + autoRenewDays?: number; +} diff --git a/backend/src/services/enrollment-config/est-enrollment-config-dal.ts b/backend/src/services/enrollment-config/est-enrollment-config-dal.ts new file mode 100644 index 000000000..b7520f290 --- /dev/null +++ b/backend/src/services/enrollment-config/est-enrollment-config-dal.ts @@ -0,0 +1,61 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +import { TEstEnrollmentConfigInsert, TEstEnrollmentConfigUpdate } from "./enrollment-config-types"; + +export type TEstEnrollmentConfigDALFactory = ReturnType; + +export const estEnrollmentConfigDALFactory = (db: TDbClient) => { + const estEnrollmentConfigOrm = ormify(db, TableName.PkiEstEnrollmentConfig); + + const create = async (data: TEstEnrollmentConfigInsert, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiEstEnrollmentConfig).insert(data).returning("*"); + const [estConfig] = result; + + if (!estConfig) { + throw new Error("Failed to create EST enrollment config"); + } + + return estConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Create EST enrollment config" }); + } + }; + + const updateById = async (id: string, data: TEstEnrollmentConfigUpdate, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).update(data).returning("*"); + const [estConfig] = result; + + if (!estConfig) { + return null; + } + + return estConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Update EST enrollment config" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const estConfig = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).first(); + + return estConfig || null; + } catch (error) { + throw new DatabaseError({ error, name: "Find EST enrollment config by id" }); + } + }; + + return { + ...estEnrollmentConfigOrm, + create, + updateById, + findById + }; +}; diff --git a/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-service.ts b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-service.ts index a072544ca..e51d25ce3 100644 --- a/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-service.ts +++ b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-service.ts @@ -1,5 +1,6 @@ import { ForbiddenError } from "@casl/ability"; +import { OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; @@ -26,13 +27,14 @@ export const externalGroupOrgRoleMappingServiceFactory = ({ roleDAL }: TExternalGroupOrgRoleMappingServiceFactoryDep) => { const listExternalGroupOrgRoleMappings = async (actor: OrgServiceActor) => { - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.ParentOrganization + }); // TODO: will need to change if we add support for ldap, oidc, etc. ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Scim); @@ -48,13 +50,14 @@ export const externalGroupOrgRoleMappingServiceFactory = ({ dto: TSyncExternalGroupOrgMembershipRoleMappingsDTO, actor: OrgServiceActor ) => { - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.ParentOrganization + }); // TODO: will need to change if we add support for ldap, oidc, etc. ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim); diff --git a/backend/src/services/external-migration/external-migration-service.ts b/backend/src/services/external-migration/external-migration-service.ts index 4192ffcd5..bc2a24c07 100644 --- a/backend/src/services/external-migration/external-migration-service.ts +++ b/backend/src/services/external-migration/external-migration-service.ts @@ -1,4 +1,4 @@ -import { OrgMembershipRole } from "@app/db/schemas"; +import { OrganizationActionScope, OrgMembershipRole } from "@app/db/schemas"; import { AuditLogInfo, EventType, @@ -16,6 +16,7 @@ import { AppConnection } from "../app-connection/app-connection-enums"; import { decryptAppConnectionCredentials } from "../app-connection/app-connection-fns"; import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service"; import { + convertVaultValueToString, getHCVaultAuthMounts, getHCVaultKubernetesAuthRoles, getHCVaultSecretsForPath, @@ -89,13 +90,14 @@ export const externalMigrationServiceFactory = ({ throw new BadRequestError({ message: "EnvKey migration is not supported when running in FIPS mode." }); } - const { hasRole } = await permissionService.getOrgPermission( - actor, + const { hasRole } = await permissionService.getOrgPermission({ actorId, + actor, + orgId: actorOrgId, actorOrgId, actorAuthMethod, - actorOrgId - ); + scope: OrganizationActionScope.Any + }); if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "Only admins can import data" }); } @@ -136,13 +138,14 @@ export const externalMigrationServiceFactory = ({ actorOrgId, actorAuthMethod }: TImportVaultDataDTO) => { - const { hasRole } = await permissionService.getOrgPermission( - actor, + const { hasRole } = await permissionService.getOrgPermission({ actorId, + actor, + orgId: actorOrgId, actorOrgId, actorAuthMethod, - actorOrgId - ); + scope: OrganizationActionScope.Any + }); if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "Only admins can import data" }); @@ -192,13 +195,14 @@ export const externalMigrationServiceFactory = ({ actorAuthMethod, provider }: THasCustomVaultMigrationDTO) => { - const { hasRole } = await permissionService.getOrgPermission( - actor, + const { hasRole } = await permissionService.getOrgPermission({ actorId, + actor, + orgId: actorOrgId, actorOrgId, actorAuthMethod, - actorOrgId - ); + scope: OrganizationActionScope.Any + }); if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "Only admins can check custom migration status" }); @@ -247,13 +251,14 @@ export const externalMigrationServiceFactory = ({ }; const createVaultExternalMigration = async ({ namespace, connectionId, actor }: TCreateVaultExternalMigrationDTO) => { - const { hasRole } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "Only admins can configure vault external migration" }); @@ -298,13 +303,14 @@ export const externalMigrationServiceFactory = ({ connectionId, actor }: TUpdateVaultExternalMigrationDTO) => { - const { hasRole } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "Only admins can update vault external migration" }); @@ -332,13 +338,14 @@ export const externalMigrationServiceFactory = ({ }; const getVaultExternalMigrationConfigs = async ({ actor }: { actor: OrgServiceActor }) => { - const { hasRole } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "Only admins can view vault external migration configs" }); @@ -352,13 +359,14 @@ export const externalMigrationServiceFactory = ({ }; const getVaultNamespaces = async ({ actor }: { actor: OrgServiceActor }) => { - const { hasRole } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "Only admins can view vault namespaces" }); @@ -380,13 +388,14 @@ export const externalMigrationServiceFactory = ({ }; const getVaultPolicies = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => { - const { hasRole } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "Only admins can view vault policies" }); @@ -422,13 +431,14 @@ export const externalMigrationServiceFactory = ({ }; const getVaultMounts = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => { - const { hasRole } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "Only admins can view vault mounts" }); @@ -472,13 +482,14 @@ export const externalMigrationServiceFactory = ({ namespace: string; mountPath: string; }) => { - const { hasRole } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "Only admins can view vault secret paths" }); @@ -531,13 +542,14 @@ export const externalMigrationServiceFactory = ({ vaultSecretPath: string; auditLogInfo: AuditLogInfo; }) => { - const { hasRole } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "Only admins can import vault secrets" }); @@ -581,7 +593,7 @@ export const externalMigrationServiceFactory = ({ projectId, secrets: Object.entries(vaultSecrets).map(([secretKey, secretValue]) => ({ secretKey, - secretValue + secretValue: convertVaultValueToString(secretValue) })) }); @@ -617,13 +629,14 @@ export const externalMigrationServiceFactory = ({ }; const deleteVaultExternalMigration = async ({ id, actor }: TDeleteVaultExternalMigrationDTO) => { - const { hasRole } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "Only admins can delete vault external migration configs" }); @@ -653,13 +666,14 @@ export const externalMigrationServiceFactory = ({ namespace: string; authType?: string; }) => { - const { hasRole } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "Only admins can view vault auth mounts" }); @@ -704,13 +718,14 @@ export const externalMigrationServiceFactory = ({ namespace: string; mountPath: string; }) => { - const { hasRole } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "Only admins can view vault Kubernetes auth roles" }); diff --git a/backend/src/services/identity-access-token/identity-access-token-dal.ts b/backend/src/services/identity-access-token/identity-access-token-dal.ts index 19de362d8..ffdb78645 100644 --- a/backend/src/services/identity-access-token/identity-access-token-dal.ts +++ b/backend/src/services/identity-access-token/identity-access-token-dal.ts @@ -19,6 +19,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityAccessToken}.identityId`) .select(selectAllTableCols(TableName.IdentityAccessToken)) .select(db.ref("name").withSchema(TableName.Identity)) + .select(db.ref("orgId").withSchema(TableName.Identity).as("identityScopeOrgId")) .first(); return doc; diff --git a/backend/src/services/identity-access-token/identity-access-token-service.ts b/backend/src/services/identity-access-token/identity-access-token-service.ts index 1f6e4616b..02660a0ae 100644 --- a/backend/src/services/identity-access-token/identity-access-token-service.ts +++ b/backend/src/services/identity-access-token/identity-access-token-service.ts @@ -8,6 +8,7 @@ import { TAccessTokenQueueServiceFactory } from "../access-token-queue/access-to import { AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TOrgDALFactory } from "../org/org-dal"; import { TIdentityAccessTokenDALFactory } from "./identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload, TRenewAccessTokenDTO } from "./identity-access-token-types"; @@ -19,6 +20,7 @@ type TIdentityAccessTokenServiceFactoryDep = { "updateIdentityAccessTokenStatus" | "getIdentityTokenDetailsInCache" >; membershipIdentityDAL: Pick; + orgDAL: Pick; }; export type TIdentityAccessTokenServiceFactory = ReturnType; @@ -27,7 +29,8 @@ export const identityAccessTokenServiceFactory = ({ identityAccessTokenDAL, accessTokenQueue, identityDAL, - membershipIdentityDAL + membershipIdentityDAL, + orgDAL }: TIdentityAccessTokenServiceFactoryDep) => { const validateAccessTokenExp = async (identityAccessToken: TIdentityAccessTokens) => { const { @@ -181,7 +184,11 @@ export const identityAccessTokenServiceFactory = ({ return { revokedToken }; }; - const fnValidateIdentityAccessToken = async (token: TIdentityAccessTokenJwtPayload, ipAddress?: string) => { + const fnValidateIdentityAccessToken = async ( + token: TIdentityAccessTokenJwtPayload, + subOrganizationSelector?: string, + ipAddress?: string + ) => { const identityAccessToken = await identityAccessTokenDAL.findOne({ [`${TableName.IdentityAccessToken}.id` as "id"]: token.identityAccessTokenId, isAccessTokenRevoked: false @@ -202,13 +209,40 @@ export const identityAccessTokenServiceFactory = ({ trustedIps: trustedIps as TIp[] }); } - const identityOrgMembership = await membershipIdentityDAL.findOne({ - scope: AccessScope.Organization, - actorIdentityId: identityAccessToken.identityId - }); + let orgId = ""; + let parentOrgId = ""; + const identityOrgDetails = await orgDAL.findOne({ id: identityAccessToken.identityScopeOrgId }); + const rootOrgId = identityOrgDetails.rootOrgId || identityOrgDetails.id; - if (!identityOrgMembership) { - throw new BadRequestError({ message: "Identity does not belong to any organization" }); + if (subOrganizationSelector) { + const subOrganization = await orgDAL.findOne({ rootOrgId, slug: subOrganizationSelector }); + if (!subOrganization) + throw new BadRequestError({ message: `Sub organization ${subOrganizationSelector} not found` }); + + const identityOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, + actorIdentityId: identityAccessToken.identityId, + scopeOrgId: subOrganization.id + }); + + if (!identityOrgMembership) { + throw new BadRequestError({ message: "Identity does not belong to any organization" }); + } + orgId = subOrganization.id; + parentOrgId = subOrganization.parentOrgId as string; + } else { + const identityOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, + actorIdentityId: identityAccessToken.identityId, + scopeOrgId: rootOrgId + }); + + if (!identityOrgMembership) { + throw new BadRequestError({ message: "Identity does not belong to any organization" }); + } + + orgId = rootOrgId; + parentOrgId = rootOrgId; } let { accessTokenNumUses } = identityAccessToken; @@ -219,7 +253,7 @@ export const identityAccessTokenServiceFactory = ({ await validateAccessTokenExp({ ...identityAccessToken, accessTokenNumUses }); await accessTokenQueue.updateIdentityAccessTokenStatus(identityAccessToken.id, Number(accessTokenNumUses) + 1); - return { ...identityAccessToken, orgId: identityOrgMembership.scopeOrgId }; + return { ...identityAccessToken, orgId, rootOrgId, parentOrgId }; }; return { renewAccessToken, revokeAccessToken, fnValidateIdentityAccessToken }; diff --git a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts index 43584a1af..c6f6f1376 100644 --- a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts +++ b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import { AxiosError } from "axios"; -import { AccessScope, IdentityAuthMethod } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -13,11 +13,18 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { logger } from "@app/lib/logger"; import { ActorType, AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; @@ -34,12 +41,13 @@ import { } from "./identity-alicloud-auth-types"; type TIdentityAliCloudAuthServiceFactoryDep = { + identityDAL: Pick; identityAccessTokenDAL: Pick; identityAliCloudAuthDAL: Pick< TIdentityAliCloudAuthDALFactory, "findOne" | "transaction" | "create" | "updateById" | "delete" >; - membershipIdentityDAL: Pick; + membershipIdentityDAL: Pick; licenseService: Pick; permissionService: Pick; orgDAL: Pick; @@ -48,6 +56,7 @@ type TIdentityAliCloudAuthServiceFactoryDep = { export type TIdentityAliCloudAuthServiceFactory = ReturnType; export const identityAliCloudAuthServiceFactory = ({ + identityDAL, identityAccessTokenDAL, identityAliCloudAuthDAL, membershipIdentityDAL, @@ -63,12 +72,8 @@ export const identityAliCloudAuthServiceFactory = ({ }); } - const identityMembershipOrg = await membershipIdentityDAL.findOne({ - actorIdentityId: identityAliCloudAuth.identityId, - scope: AccessScope.Organization - }); - - if (!identityMembershipOrg) throw new UnauthorizedError({ message: "Identity not attached to a organization" }); + const identity = await identityDAL.findById(identityAliCloudAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const requestUrl = new URL("https://sts.aliyuncs.com"); @@ -93,8 +98,8 @@ export const identityAliCloudAuthServiceFactory = ({ // Generate the token const identityAccessToken = await identityAliCloudAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.updateById( - identityMembershipOrg.id, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, { lastLoginAuthMethod: IdentityAuthMethod.ALICLOUD_AUTH, lastLoginTime: new Date() @@ -135,7 +140,7 @@ export const identityAliCloudAuthServiceFactory = ({ identityAliCloudAuth, accessToken, identityAccessToken, - identityMembershipOrg + identity }; }; @@ -162,6 +167,9 @@ export const identityAliCloudAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.ALICLOUD_AUTH)) { throw new BadRequestError({ @@ -173,13 +181,14 @@ export const identityAliCloudAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); @@ -238,6 +247,9 @@ export const identityAliCloudAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.ALICLOUD_AUTH)) { throw new NotFoundError({ @@ -255,13 +267,14 @@ export const identityAliCloudAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); @@ -304,6 +317,9 @@ export const identityAliCloudAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.ALICLOUD_AUTH)) { throw new BadRequestError({ @@ -313,13 +329,14 @@ export const identityAliCloudAuthServiceFactory = ({ const alicloudIdentityAuth = await identityAliCloudAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); return { ...alicloudIdentityAuth, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -339,27 +356,32 @@ export const identityAliCloudAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.ALICLOUD_AUTH)) { throw new BadRequestError({ message: "The identity does not have Alibaba Cloud auth" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identity.id, - identityMembershipOrg.scopeOrgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts index d0fb4d323..38944917e 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts @@ -2,13 +2,13 @@ interface PrincipalArnEntity { Partition: string; Service: "iam" | "sts"; AccountNumber: string; - Type: "user" | "role" | "instance-profile"; + Type: "user" | "role" | "instance-profile" | "assumed-role"; Path: string; FriendlyName: string; SessionInfo: string; // Only populated for assumed-role } -export const extractPrincipalArnEntity = (arn: string): PrincipalArnEntity => { +export const extractPrincipalArnEntity = (arn: string, formatAsIamRole: boolean = false): PrincipalArnEntity => { // split the ARN into parts using ":" as the delimiter const fullParts = arn.split(":"); if (fullParts.length !== 6) { @@ -49,7 +49,7 @@ export const extractPrincipalArnEntity = (arn: string): PrincipalArnEntity => { } // assumed roles use a special format where the friendly name is the role name const [roleName, sessionId] = rest; - finalType = "role"; // treat assumed role case as role + finalType = formatAsIamRole ? "role" : "assumed-role"; friendlyName = roleName; sessionInfo = sessionId; break; @@ -84,8 +84,8 @@ export const extractPrincipalArnEntity = (arn: string): PrincipalArnEntity => { * - arn:aws:iam::123456789012:user/MyUserName * - arn:aws:iam::123456789012:role/MyRoleName */ -export const extractPrincipalArn = (arn: string) => { - const entity = extractPrincipalArnEntity(arn); +export const extractPrincipalArn = (arn: string, formatAsIamRole: boolean = false) => { + const entity = extractPrincipalArnEntity(arn, formatAsIamRole); - return `arn:aws:iam::${entity.AccountNumber}:${entity.Type}/${entity.FriendlyName}`; + return `arn:aws:${formatAsIamRole ? "iam" : entity.Service}::${entity.AccountNumber}:${entity.Type}/${entity.FriendlyName}`; }; diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts index 8793c3a00..e61e8296b 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts @@ -3,7 +3,7 @@ import { ForbiddenError } from "@casl/ability"; import axios from "axios"; import RE2 from "re2"; -import { AccessScope, IdentityAuthMethod } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -13,10 +13,18 @@ import { import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { logger } from "@app/lib/logger"; import { ActorType, AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; @@ -35,9 +43,10 @@ import { } from "./identity-aws-auth-types"; type TIdentityAwsAuthServiceFactoryDep = { + identityDAL: Pick; identityAccessTokenDAL: Pick; identityAwsAuthDAL: Pick; - membershipIdentityDAL: Pick; + membershipIdentityDAL: Pick; licenseService: Pick; permissionService: Pick; orgDAL: Pick; @@ -80,6 +89,7 @@ function isValidAwsRegion(region: string | null): boolean { } export const identityAwsAuthServiceFactory = ({ + identityDAL, identityAccessTokenDAL, identityAwsAuthDAL, membershipIdentityDAL, @@ -93,11 +103,8 @@ export const identityAwsAuthServiceFactory = ({ throw new NotFoundError({ message: "AWS auth method not found for identity, did you configure AWS auth?" }); } - const identityMembershipOrg = await membershipIdentityDAL.findOne({ - actorIdentityId: identityAwsAuth.identityId, - scope: AccessScope.Organization - }); - if (!identityMembershipOrg) throw new UnauthorizedError({ message: "Identity not attached to a organization" }); + const identity = await identityDAL.findById(identityAwsAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString()); const body: string = Buffer.from(iamRequestBody, "base64").toString(); @@ -141,6 +148,8 @@ export const identityAwsAuthServiceFactory = ({ if (identityAwsAuth.allowedPrincipalArns) { // validate if Arn is in the list of allowed Principal ARNs + const formattedArn = extractPrincipalArn(Arn); + const isArnAllowed = identityAwsAuth.allowedPrincipalArns .split(",") .map((principalArn) => principalArn.trim()) @@ -149,18 +158,23 @@ export const identityAwsAuthServiceFactory = ({ // considers exact matches + wildcard matches // heavily validated in router const regex = new RE2(`^${principalArn.replaceAll("*", ".*")}$`); - return regex.test(extractPrincipalArn(Arn)); + return regex.test(formattedArn) || regex.test(extractPrincipalArn(Arn, true)); }); - if (!isArnAllowed) + if (!isArnAllowed) { + logger.error( + `AWS Auth Login: AWS principal ARN not allowed [principal-arn=${formattedArn}] [raw-arn=${Arn}] [identity-id=${identity.id}]` + ); + throw new UnauthorizedError({ - message: "Access denied: AWS principal ARN not allowed." + message: `Access denied: AWS principal ARN not allowed. [principal-arn=${formattedArn}]` }); + } } const identityAccessToken = await identityAwsAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.updateById( - identityMembershipOrg.id, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, { lastLoginAuthMethod: IdentityAuthMethod.AWS_AUTH, lastLoginTime: new Date() @@ -212,7 +226,7 @@ export const identityAwsAuthServiceFactory = ({ } ); - return { accessToken, identityAwsAuth, identityAccessToken, identityMembershipOrg }; + return { accessToken, identityAwsAuth, identityAccessToken, identity }; }; const attachAwsAuth = async ({ @@ -240,6 +254,9 @@ export const identityAwsAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AWS_AUTH)) { throw new BadRequestError({ @@ -251,13 +268,14 @@ export const identityAwsAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); @@ -320,6 +338,9 @@ export const identityAwsAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AWS_AUTH)) { throw new NotFoundError({ @@ -336,13 +357,14 @@ export const identityAwsAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); @@ -387,6 +409,9 @@ export const identityAwsAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AWS_AUTH)) { throw new BadRequestError({ @@ -396,13 +421,14 @@ export const identityAwsAuthServiceFactory = ({ const awsIdentityAuth = await identityAwsAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); return { ...awsIdentityAuth, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -422,27 +448,32 @@ export const identityAwsAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AWS_AUTH)) { throw new BadRequestError({ message: "The identity does not have aws auth" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identity.id, - identityMembershipOrg.scopeOrgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts index 098bdcf9a..4e3884e15 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts @@ -4,7 +4,10 @@ import { z } from "zod"; const twelveDigitRegex = new RE2(/^\d{12}$/); // akhilmhdh: change this to a normal function later. Checked no redosable at the moment -const arnRegex = new RE2(/^arn:aws:iam::\d{12}:(user\/[a-zA-Z0-9_.@+*/-]+|role\/[a-zA-Z0-9_.@+*/-]+|\*)$/); + +const arnRegex = new RE2( + /^arn:aws:(iam|sts)::\d{12}:(user\/[a-zA-Z0-9_.@+*/-]+|role\/[a-zA-Z0-9_.@+*/-]+|assumed-role\/[a-zA-Z0-9_.@+*/-]+|\*)$/ +); export const validateAccountIds = z .string() @@ -52,7 +55,7 @@ export const validatePrincipalArns = z }, { message: - "Each ARN must be in the format of 'arn:aws:iam::123456789012:user/UserName', 'arn:aws:iam::123456789012:role/RoleName', or 'arn:aws:iam::123456789012:*'." + "Each ARN must be in the format of 'arn:aws:iam::123456789012:user/UserName', 'arn:aws:iam::123456789012:role/RoleName', or 'arn:aws:iam::123456789012:*', 'arn:aws:sts::123456789012:assumed-role/RoleName'." } ) // Transform to normalize the spaces around commas diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts index b3250ed56..f75aeba4f 100644 --- a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { AccessScope, IdentityAuthMethod } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -10,10 +10,17 @@ import { import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { ActorType, AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; @@ -30,11 +37,12 @@ import { } from "./identity-azure-auth-types"; type TIdentityAzureAuthServiceFactoryDep = { + identityDAL: Pick; identityAzureAuthDAL: Pick< TIdentityAzureAuthDALFactory, "findOne" | "transaction" | "create" | "updateById" | "delete" >; - membershipIdentityDAL: Pick; + membershipIdentityDAL: Pick; identityAccessTokenDAL: Pick; permissionService: Pick; licenseService: Pick; @@ -44,6 +52,7 @@ type TIdentityAzureAuthServiceFactoryDep = { export type TIdentityAzureAuthServiceFactory = ReturnType; export const identityAzureAuthServiceFactory = ({ + identityDAL, identityAzureAuthDAL, membershipIdentityDAL, identityAccessTokenDAL, @@ -57,11 +66,8 @@ export const identityAzureAuthServiceFactory = ({ throw new NotFoundError({ message: "Azure auth method not found for identity, did you configure Azure Auth?" }); } - const identityMembershipOrg = await membershipIdentityDAL.findOne({ - actorIdentityId: identityAzureAuth.identityId, - scope: AccessScope.Organization - }); - if (!identityMembershipOrg) throw new UnauthorizedError({ message: "Identity not attached to a organization" }); + const identity = await identityDAL.findById(identityAzureAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const azureIdentity = await validateAzureIdentity({ tenantId: identityAzureAuth.tenantId, @@ -86,8 +92,8 @@ export const identityAzureAuthServiceFactory = ({ } const identityAccessToken = await identityAzureAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.updateById( - identityMembershipOrg.id, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, { lastLoginAuthMethod: IdentityAuthMethod.AZURE_AUTH, lastLoginTime: new Date() @@ -125,7 +131,7 @@ export const identityAzureAuthServiceFactory = ({ } ); - return { accessToken, identityAzureAuth, identityAccessToken, identityMembershipOrg }; + return { accessToken, identityAzureAuth, identityAccessToken, identity }; }; const attachAzureAuth = async ({ @@ -153,6 +159,9 @@ export const identityAzureAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AZURE_AUTH)) { throw new BadRequestError({ @@ -163,13 +172,14 @@ export const identityAzureAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); @@ -232,6 +242,9 @@ export const identityAzureAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AZURE_AUTH)) { throw new BadRequestError({ message: "Failed to update Azure Auth" @@ -247,13 +260,14 @@ export const identityAzureAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); @@ -301,6 +315,9 @@ export const identityAzureAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AZURE_AUTH)) { throw new BadRequestError({ message: "The identity does not have Azure Auth attached" @@ -309,13 +326,14 @@ export const identityAzureAuthServiceFactory = ({ const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); return { ...identityAzureAuth, orgId: identityMembershipOrg.scopeOrgId }; @@ -336,27 +354,32 @@ export const identityAzureAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AZURE_AUTH)) { throw new BadRequestError({ message: "The identity does not have azure auth" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identity.id, - identityMembershipOrg.scopeOrgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( shouldUseNewPrivilegeSystem, diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts index fe7b9b6d7..67adb6c1e 100644 --- a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { AccessScope, IdentityAuthMethod } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -10,10 +10,17 @@ import { import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { ActorType, AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; @@ -31,8 +38,9 @@ import { } from "./identity-gcp-auth-types"; type TIdentityGcpAuthServiceFactoryDep = { + identityDAL: Pick; identityGcpAuthDAL: Pick; - membershipIdentityDAL: Pick; + membershipIdentityDAL: Pick; identityAccessTokenDAL: Pick; permissionService: Pick; licenseService: Pick; @@ -42,6 +50,7 @@ type TIdentityGcpAuthServiceFactoryDep = { export type TIdentityGcpAuthServiceFactory = ReturnType; export const identityGcpAuthServiceFactory = ({ + identityDAL, identityGcpAuthDAL, membershipIdentityDAL, identityAccessTokenDAL, @@ -55,13 +64,8 @@ export const identityGcpAuthServiceFactory = ({ throw new NotFoundError({ message: "GCP auth method not found for identity, did you configure GCP auth?" }); } - const identityMembershipOrg = await membershipIdentityDAL.findOne({ - actorIdentityId: identityGcpAuth.identityId, - scope: AccessScope.Organization - }); - if (!identityMembershipOrg) { - throw new UnauthorizedError({ message: "Identity does not belong to any organization" }); - } + const identity = await identityDAL.findById(identityGcpAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); let gcpIdentityDetails: TGcpIdentityDetails; switch (identityGcpAuth.type) { @@ -125,8 +129,8 @@ export const identityGcpAuthServiceFactory = ({ } const identityAccessToken = await identityGcpAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.updateById( - identityMembershipOrg.id, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, { lastLoginAuthMethod: IdentityAuthMethod.GCP_AUTH, lastLoginTime: new Date() @@ -164,7 +168,7 @@ export const identityGcpAuthServiceFactory = ({ } ); - return { accessToken, identityGcpAuth, identityAccessToken, identityMembershipOrg }; + return { accessToken, identityGcpAuth, identityAccessToken, identity }; }; const attachGcpAuth = async ({ @@ -193,6 +197,9 @@ export const identityGcpAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.GCP_AUTH)) { throw new BadRequestError({ @@ -204,13 +211,14 @@ export const identityGcpAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); @@ -274,6 +282,9 @@ export const identityGcpAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.GCP_AUTH)) { throw new BadRequestError({ @@ -290,13 +301,14 @@ export const identityGcpAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); @@ -345,6 +357,9 @@ export const identityGcpAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.GCP_AUTH)) { throw new BadRequestError({ @@ -354,13 +369,14 @@ export const identityGcpAuthServiceFactory = ({ const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); return { ...identityGcpAuth, orgId: identityMembershipOrg.scopeOrgId }; @@ -381,28 +397,33 @@ export const identityGcpAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.GCP_AUTH)) { throw new BadRequestError({ message: "The identity does not have gcp auth" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identity.id, - identityMembershipOrg.scopeOrgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( shouldUseNewPrivilegeSystem, diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts index a99c8ad78..debd90933 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts @@ -3,7 +3,7 @@ import https from "https"; import jwt from "jsonwebtoken"; import { JwksClient } from "jwks-rsa"; -import { AccessScope, IdentityAuthMethod, TIdentityJwtAuthsUpdate } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope, TIdentityJwtAuthsUpdate } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -24,6 +24,7 @@ import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { getValueByDot } from "@app/lib/template/dot-access"; import { ActorType, AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TKmsServiceFactory } from "../kms/kms-service"; @@ -43,8 +44,9 @@ import { } from "./identity-jwt-auth-types"; type TIdentityJwtAuthServiceFactoryDep = { + identityDAL: Pick; identityJwtAuthDAL: TIdentityJwtAuthDALFactory; - membershipIdentityDAL: Pick; + membershipIdentityDAL: Pick; identityAccessTokenDAL: Pick; permissionService: Pick; licenseService: Pick; @@ -55,6 +57,7 @@ type TIdentityJwtAuthServiceFactoryDep = { export type TIdentityJwtAuthServiceFactory = ReturnType; export const identityJwtAuthServiceFactory = ({ + identityDAL, identityJwtAuthDAL, membershipIdentityDAL, permissionService, @@ -69,19 +72,12 @@ export const identityJwtAuthServiceFactory = ({ throw new NotFoundError({ message: "JWT auth method not found for identity, did you configure JWT auth?" }); } - const identityMembershipOrg = await membershipIdentityDAL.findOne({ - actorIdentityId: identityJwtAuth.identityId, - scope: AccessScope.Organization - }); - if (!identityMembershipOrg) { - throw new NotFoundError({ - message: `Identity organization membership for identity with ID '${identityJwtAuth.identityId}' not found` - }); - } + const identity = await identityDAL.findById(identityJwtAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityMembershipOrg.scopeOrgId + orgId: identity.orgId }); const decodedToken = crypto.jwt().decode(jwtValue, { complete: true }); @@ -211,12 +207,9 @@ export const identityJwtAuthServiceFactory = ({ } const identityAccessToken = await identityJwtAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.updateById( - identityMembershipOrg.id, - { - lastLoginAuthMethod: IdentityAuthMethod.JWT_AUTH, - lastLoginTime: new Date() - }, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.JWT_AUTH, lastLoginTime: new Date() }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -251,7 +244,7 @@ export const identityJwtAuthServiceFactory = ({ } ); - return { accessToken, identityJwtAuth, identityAccessToken, identityMembershipOrg }; + return { accessToken, identityJwtAuth, identityAccessToken, identity }; }; const attachJwtAuth = async ({ @@ -284,6 +277,9 @@ export const identityJwtAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { throw new BadRequestError({ message: "Failed to add JWT Auth to already configured identity" @@ -294,13 +290,14 @@ export const identityJwtAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); @@ -387,6 +384,9 @@ export const identityJwtAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { throw new BadRequestError({ @@ -403,13 +403,14 @@ export const identityJwtAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); @@ -491,6 +492,9 @@ export const identityJwtAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { throw new BadRequestError({ @@ -498,13 +502,14 @@ export const identityJwtAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); @@ -539,6 +544,9 @@ export const identityJwtAuthServiceFactory = ({ if (!identityMembershipOrg) { throw new NotFoundError({ message: "Failed to find identity" }); } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { throw new BadRequestError({ @@ -546,23 +554,25 @@ export const identityJwtAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identity.id, - identityMembershipOrg.scopeOrgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index 952b1e31d..49fb597f5 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -3,7 +3,12 @@ import axios, { AxiosError } from "axios"; import https from "https"; import RE2 from "re2"; -import { AccessScope, IdentityAuthMethod, TIdentityKubernetesAuthsUpdate } from "@app/db/schemas"; +import { + AccessScope, + IdentityAuthMethod, + OrganizationActionScope, + TIdentityKubernetesAuthsUpdate +} from "@app/db/schemas"; import { TGatewayDALFactory } from "@app/ee/services/gateway/gateway-dal"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TGatewayV2DALFactory } from "@app/ee/services/gateway-v2/gateway-v2-dal"; @@ -21,13 +26,20 @@ import { import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; import { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { logger } from "@app/lib/logger"; import { ActorType, AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TKmsServiceFactory } from "../kms/kms-service"; @@ -48,12 +60,13 @@ import { } from "./identity-kubernetes-auth-types"; type TIdentityKubernetesAuthServiceFactoryDep = { + identityDAL: Pick; identityKubernetesAuthDAL: Pick< TIdentityKubernetesAuthDALFactory, "create" | "findOne" | "transaction" | "updateById" | "delete" >; identityAccessTokenDAL: Pick; - membershipIdentityDAL: Pick; + membershipIdentityDAL: Pick; permissionService: Pick; licenseService: Pick; kmsService: Pick; @@ -69,6 +82,7 @@ export type TIdentityKubernetesAuthServiceFactory = ReturnType { - await membershipIdentityDAL.updateById( - identityMembershipOrg.id, - { - lastLoginAuthMethod: IdentityAuthMethod.KUBERNETES_AUTH, - lastLoginTime: new Date() - }, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.KUBERNETES_AUTH, lastLoginTime: new Date() }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -475,7 +479,7 @@ export const identityKubernetesAuthServiceFactory = ({ } ); - return { accessToken, identityKubernetesAuth, identityAccessToken, identityMembershipOrg }; + return { accessToken, identityKubernetesAuth, identityAccessToken, identity }; }; const attachKubernetesAuth = async ({ @@ -508,6 +512,9 @@ export const identityKubernetesAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.KUBERNETES_AUTH)) { throw new BadRequestError({ @@ -519,13 +526,14 @@ export const identityKubernetesAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); @@ -560,13 +568,14 @@ export const identityKubernetesAuthServiceFactory = ({ isGatewayV1 = false; } - const { permission: orgPermission } = await permissionService.getOrgPermission( + const { permission: orgPermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(orgPermission).throwUnlessCan( OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway @@ -633,6 +642,9 @@ export const identityKubernetesAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.KUBERNETES_AUTH)) { throw new BadRequestError({ @@ -650,13 +662,14 @@ export const identityKubernetesAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); @@ -692,13 +705,14 @@ export const identityKubernetesAuthServiceFactory = ({ isGatewayV1 = false; } - const { permission: orgPermission } = await permissionService.getOrgPermission( + const { permission: orgPermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(orgPermission).throwUnlessCan( OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway @@ -779,6 +793,9 @@ export const identityKubernetesAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); if (!identityKubernetesAuth) { @@ -791,13 +808,14 @@ export const identityKubernetesAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const { decryptor } = await kmsService.createCipherPairWithDataKey({ @@ -841,28 +859,33 @@ export const identityKubernetesAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.KUBERNETES_AUTH)) { throw new BadRequestError({ message: "The identity does not have kubernetes auth" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identity.id, - identityMembershipOrg.scopeOrgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( shouldUseNewPrivilegeSystem, diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts index 1a8ea3ed6..272e45c4e 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; -import { AccessScope, IdentityAuthMethod } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TIdentityAuthTemplateDALFactory } from "@app/ee/services/identity-auth-template"; import { testLDAPConfig } from "@app/ee/services/ldap-config/ldap-fns"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -21,6 +21,7 @@ import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, + ForbiddenRequestError, NotFoundError, PermissionBoundaryError, RateLimitError, @@ -56,11 +57,11 @@ type TIdentityLdapAuthServiceFactoryDep = { TIdentityLdapAuthDALFactory, "findOne" | "transaction" | "create" | "updateById" | "delete" >; - membershipIdentityDAL: Pick; + membershipIdentityDAL: Pick; licenseService: Pick; permissionService: Pick; kmsService: TKmsServiceFactory; - identityDAL: TIdentityDALFactory; + identityDAL: Pick; identityAuthTemplateDAL: TIdentityAuthTemplateDALFactory; keyStore: Pick< TKeyStoreFactory, @@ -150,17 +151,6 @@ export const identityLdapAuthServiceFactory = ({ }; const login = async ({ identityId }: TLoginLdapAuthDTO) => { - const identityMembershipOrg = await membershipIdentityDAL.findOne({ - actorIdentityId: identityId, - scope: AccessScope.Organization - }); - - if (!identityMembershipOrg) { - throw new UnauthorizedError({ - message: "Invalid credentials" - }); - } - const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId }); if (!identityLdapAuth) { @@ -169,7 +159,10 @@ export const identityLdapAuthServiceFactory = ({ }); } - const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); + const identity = await identityDAL.findById(identityLdapAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); + + const plan = await licenseService.getPlan(identity.orgId); if (!plan.ldap) { throw new BadRequestError({ message: @@ -178,12 +171,9 @@ export const identityLdapAuthServiceFactory = ({ } const identityAccessToken = await identityLdapAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.updateById( - identityMembershipOrg.id, - { - lastLoginAuthMethod: IdentityAuthMethod.LDAP_AUTH, - lastLoginTime: new Date() - }, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.LDAP_AUTH, lastLoginTime: new Date() }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -217,7 +207,7 @@ export const identityLdapAuthServiceFactory = ({ } ); - return { accessToken, identityLdapAuth, identityAccessToken, identityMembershipOrg }; + return { accessToken, identityLdapAuth, identityAccessToken, identity }; }; const attachLdapAuth = async ({ @@ -254,6 +244,9 @@ export const identityLdapAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.LDAP_AUTH)) { throw new BadRequestError({ @@ -265,13 +258,14 @@ export const identityLdapAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); if (templateId) { @@ -425,6 +419,9 @@ export const identityLdapAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.LDAP_AUTH)) { throw new NotFoundError({ @@ -441,13 +438,14 @@ export const identityLdapAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); if (templateId) { @@ -588,6 +586,9 @@ export const identityLdapAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.LDAP_AUTH)) { throw new BadRequestError({ @@ -597,13 +598,14 @@ export const identityLdapAuthServiceFactory = ({ const ldapIdentityAuth = await identityLdapAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, @@ -635,27 +637,32 @@ export const identityLdapAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.LDAP_AUTH)) { throw new BadRequestError({ message: "The identity does not have LDAP Auth attached" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identity.id, - identityMembershipOrg.scopeOrgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( @@ -785,13 +792,14 @@ export const identityLdapAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const deleted = await keyStore.deleteItems({ diff --git a/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts b/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts index bfac3d158..6d7f0c4d3 100644 --- a/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts +++ b/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts @@ -3,7 +3,7 @@ import { ForbiddenError } from "@casl/ability"; import { AxiosError } from "axios"; import RE2 from "re2"; -import { AccessScope, IdentityAuthMethod } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -14,11 +14,18 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { logger } from "@app/lib/logger"; import { ActorType, AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; @@ -35,9 +42,10 @@ import { } from "./identity-oci-auth-types"; type TIdentityOciAuthServiceFactoryDep = { + identityDAL: Pick; identityAccessTokenDAL: Pick; identityOciAuthDAL: Pick; - membershipIdentityDAL: Pick; + membershipIdentityDAL: Pick; licenseService: Pick; permissionService: Pick; orgDAL: Pick; @@ -46,6 +54,7 @@ type TIdentityOciAuthServiceFactoryDep = { export type TIdentityOciAuthServiceFactory = ReturnType; export const identityOciAuthServiceFactory = ({ + identityDAL, identityAccessTokenDAL, identityOciAuthDAL, membershipIdentityDAL, @@ -59,11 +68,8 @@ export const identityOciAuthServiceFactory = ({ throw new NotFoundError({ message: "OCI auth method not found for identity, did you configure OCI auth?" }); } - const identityMembershipOrg = await membershipIdentityDAL.findOne({ - actorIdentityId: identityOciAuth.identityId, - scope: AccessScope.Organization - }); - if (!identityMembershipOrg) throw new UnauthorizedError({ message: "Identity not attached to a organization" }); + const identity = await identityDAL.findById(identityOciAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); // Validate OCI host format. Ensures that the host is in "identity..oraclecloud.com" format. if (!headers.host || !new RE2("^identity\\.([a-z]{2}-[a-z]+-[1-9])\\.oraclecloud\\.com$").test(headers.host)) { @@ -98,12 +104,9 @@ export const identityOciAuthServiceFactory = ({ // Generate the token const identityAccessToken = await identityOciAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.updateById( - identityMembershipOrg.id, - { - lastLoginAuthMethod: IdentityAuthMethod.OCI_AUTH, - lastLoginTime: new Date() - }, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.OCI_AUTH, lastLoginTime: new Date() }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -140,7 +143,7 @@ export const identityOciAuthServiceFactory = ({ identityOciAuth, accessToken, identityAccessToken, - identityMembershipOrg + identity }; }; @@ -168,6 +171,9 @@ export const identityOciAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OCI_AUTH)) { throw new BadRequestError({ @@ -179,13 +185,14 @@ export const identityOciAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); @@ -246,6 +253,9 @@ export const identityOciAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OCI_AUTH)) { throw new NotFoundError({ @@ -262,13 +272,14 @@ export const identityOciAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); @@ -312,6 +323,9 @@ export const identityOciAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OCI_AUTH)) { throw new BadRequestError({ @@ -321,13 +335,14 @@ export const identityOciAuthServiceFactory = ({ const ociIdentityAuth = await identityOciAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); return { ...ociIdentityAuth, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -347,27 +362,32 @@ export const identityOciAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OCI_AUTH)) { throw new BadRequestError({ message: "The identity does not have OCI auth" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identity.id, - identityMembershipOrg.scopeOrgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); const permissionBoundary = validatePrivilegeChangeOperation( diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts index 1218d8e1c..628b69f14 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts @@ -4,7 +4,7 @@ import https from "https"; import jwt from "jsonwebtoken"; import { JwksClient } from "jwks-rsa"; -import { AccessScope, IdentityAuthMethod, TIdentityOidcAuthsUpdate } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope, TIdentityOidcAuthsUpdate } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -25,6 +25,7 @@ import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { getValueByDot } from "@app/lib/template/dot-access"; import { ActorType, AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TKmsServiceFactory } from "../kms/kms-service"; @@ -43,8 +44,9 @@ import { } from "./identity-oidc-auth-types"; type TIdentityOidcAuthServiceFactoryDep = { + identityDAL: Pick; identityOidcAuthDAL: TIdentityOidcAuthDALFactory; - membershipIdentityDAL: Pick; + membershipIdentityDAL: Pick; identityAccessTokenDAL: Pick; permissionService: Pick; licenseService: Pick; @@ -55,6 +57,7 @@ type TIdentityOidcAuthServiceFactoryDep = { export type TIdentityOidcAuthServiceFactory = ReturnType; export const identityOidcAuthServiceFactory = ({ + identityDAL, identityOidcAuthDAL, membershipIdentityDAL, permissionService, @@ -69,19 +72,12 @@ export const identityOidcAuthServiceFactory = ({ throw new NotFoundError({ message: "OIDC auth method not found for identity, did you configure OIDC auth?" }); } - const identityMembershipOrg = await membershipIdentityDAL.findOne({ - actorIdentityId: identityOidcAuth.identityId, - scope: AccessScope.Organization - }); - if (!identityMembershipOrg) { - throw new NotFoundError({ - message: `Identity organization membership for identity with ID '${identityOidcAuth.identityId}' not found` - }); - } + const identity = await identityDAL.findById(identityOidcAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityMembershipOrg.scopeOrgId + orgId: identity.orgId }); let caCert = ""; @@ -182,12 +178,9 @@ export const identityOidcAuthServiceFactory = ({ } const identityAccessToken = await identityOidcAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.updateById( - identityMembershipOrg.id, - { - lastLoginAuthMethod: IdentityAuthMethod.OIDC_AUTH, - lastLoginTime: new Date() - }, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.OIDC_AUTH, lastLoginTime: new Date() }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -226,7 +219,7 @@ export const identityOidcAuthServiceFactory = ({ } ); - return { accessToken, identityOidcAuth, identityAccessToken, identityMembershipOrg, oidcTokenData: tokenData }; + return { accessToken, identityOidcAuth, identityAccessToken, identity, oidcTokenData: tokenData }; }; const attachOidcAuth = async ({ @@ -259,6 +252,9 @@ export const identityOidcAuthServiceFactory = ({ if (!identityMembershipOrg) { throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OIDC_AUTH)) { throw new BadRequestError({ message: "Failed to add OIDC Auth to already configured identity" @@ -269,13 +265,14 @@ export const identityOidcAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); @@ -351,6 +348,9 @@ export const identityOidcAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OIDC_AUTH)) { throw new BadRequestError({ @@ -367,13 +367,14 @@ export const identityOidcAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); @@ -440,6 +441,9 @@ export const identityOidcAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OIDC_AUTH)) { throw new BadRequestError({ @@ -447,13 +451,14 @@ export const identityOidcAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const identityOidcAuth = await identityOidcAuthDAL.findOne({ identityId }); @@ -481,6 +486,9 @@ export const identityOidcAuthServiceFactory = ({ if (!identityMembershipOrg) { throw new NotFoundError({ message: "Failed to find identity" }); } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OIDC_AUTH)) { throw new BadRequestError({ @@ -488,23 +496,25 @@ export const identityOidcAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identity.id, - identityMembershipOrg.scopeOrgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( diff --git a/backend/src/services/identity-project/identity-project-dal.ts b/backend/src/services/identity-project/identity-project-dal.ts index 3dba6210d..adcdd8be8 100644 --- a/backend/src/services/identity-project/identity-project-dal.ts +++ b/backend/src/services/identity-project/identity-project-dal.ts @@ -25,11 +25,12 @@ import { buildAuthMethods } from "../identity/identity-fns"; export type TIdentityProjectDALFactory = ReturnType; export const identityProjectDALFactory = (db: TDbClient) => { - const findByIdentityId = async (identityId: string, tx?: Knex) => { + const findByIdentityId = async (identityId: string, orgId: string, tx?: Knex) => { try { const docs = await (tx || db.replicaNode())(TableName.Membership) .where(`${TableName.Membership}.actorIdentityId`, identityId) .where(`${TableName.Membership}.scope`, AccessScope.Project) + .where(`${TableName.Membership}.scopeOrgId`, orgId) .whereNotNull(`${TableName.Membership}.actorIdentityId`) .join(TableName.Project, `${TableName.Membership}.scopeProjectId`, `${TableName.Project}.id`) .join(TableName.Identity, `${TableName.Membership}.actorIdentityId`, `${TableName.Identity}.id`) diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts index 625b9b328..24c82ccac 100644 --- a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts +++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { AccessScope, IdentityAuthMethod } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -11,10 +11,17 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { ActorType, AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TKmsServiceFactory } from "../kms/kms-service"; @@ -25,12 +32,13 @@ import { TIdentityTlsCertAuthDALFactory } from "./identity-tls-cert-auth-dal"; import { TIdentityTlsCertAuthServiceFactory } from "./identity-tls-cert-auth-types"; type TIdentityTlsCertAuthServiceFactoryDep = { + identityDAL: Pick; identityAccessTokenDAL: Pick; identityTlsCertAuthDAL: Pick< TIdentityTlsCertAuthDALFactory, "findOne" | "transaction" | "create" | "updateById" | "delete" >; - membershipIdentityDAL: Pick; + membershipIdentityDAL: Pick; licenseService: Pick; permissionService: Pick; kmsService: Pick; @@ -46,6 +54,7 @@ const parseSubjectDetails = (data: string) => { }; export const identityTlsCertAuthServiceFactory = ({ + identityDAL, identityAccessTokenDAL, identityTlsCertAuthDAL, membershipIdentityDAL, @@ -61,20 +70,12 @@ export const identityTlsCertAuthServiceFactory = ({ }); } - const identityMembershipOrg = await membershipIdentityDAL.findOne({ - actorIdentityId: identityTlsCertAuth.identityId, - scope: AccessScope.Organization - }); - - if (!identityMembershipOrg) { - throw new NotFoundError({ - message: `Identity organization membership for identity with ID '${identityTlsCertAuth.identityId}' not found` - }); - } + const identity = await identityDAL.findById(identityTlsCertAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityMembershipOrg.scopeOrgId + orgId: identity.orgId }); const caCertificate = decryptor({ @@ -119,12 +120,9 @@ export const identityTlsCertAuthServiceFactory = ({ // Generate the token const identityAccessToken = await identityTlsCertAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.updateById( - identityMembershipOrg.id, - { - lastLoginAuthMethod: IdentityAuthMethod.TLS_CERT_AUTH, - lastLoginTime: new Date() - }, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.TLS_CERT_AUTH, lastLoginTime: new Date() }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -161,7 +159,7 @@ export const identityTlsCertAuthServiceFactory = ({ identityTlsCertAuth, accessToken, identityAccessToken, - identityMembershipOrg + identity }; }; @@ -189,6 +187,9 @@ export const identityTlsCertAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) { throw new BadRequestError({ @@ -200,13 +201,14 @@ export const identityTlsCertAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); @@ -271,6 +273,9 @@ export const identityTlsCertAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) { throw new NotFoundError({ @@ -288,13 +293,14 @@ export const identityTlsCertAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); @@ -350,6 +356,9 @@ export const identityTlsCertAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) { throw new BadRequestError({ @@ -359,13 +368,14 @@ export const identityTlsCertAuthServiceFactory = ({ const identityAuth = await identityTlsCertAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, @@ -394,28 +404,32 @@ export const identityTlsCertAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) { throw new BadRequestError({ message: "The identity does not have TLS Certificate auth" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission, memberships } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identity.id, - identityMembershipOrg.scopeOrgId, + const { permission: rolePermission, memberships } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); - + actorOrgId, + scope: OrganizationActionScope.Any + }); const shouldUseNewPrivilegeSystem = Boolean(memberships?.[0]?.shouldUseNewPrivilegeSystem); const permissionBoundary = validatePrivilegeChangeOperation( shouldUseNewPrivilegeSystem, diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts index eb9f4ab5d..cf35bb5ee 100644 --- a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts +++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts @@ -1,4 +1,4 @@ -import { TIdentityAccessTokens, TIdentityTlsCertAuths, TMemberships } from "@app/db/schemas"; +import { TIdentities, TIdentityAccessTokens, TIdentityTlsCertAuths } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; export type TLoginTlsCertAuthDTO = { @@ -40,7 +40,7 @@ export type TIdentityTlsCertAuthServiceFactory = { identityTlsCertAuth: TIdentityTlsCertAuths; accessToken: string; identityAccessToken: TIdentityAccessTokens; - identityMembershipOrg: TMemberships; + identity: TIdentities; }>; attachTlsCertAuth: (dto: TAttachTlsCertAuthDTO) => Promise; updateTlsCertAuth: (dto: TUpdateTlsCertAuthDTO) => Promise; diff --git a/backend/src/services/identity-token-auth/identity-token-auth-service.ts b/backend/src/services/identity-token-auth/identity-token-auth-service.ts index 2ae05cb97..2d3e11cd8 100644 --- a/backend/src/services/identity-token-auth/identity-token-auth-service.ts +++ b/backend/src/services/identity-token-auth/identity-token-auth-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { AccessScope, IdentityAuthMethod, TableName } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope, TableName } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -10,10 +10,17 @@ import { import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { ActorType, AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; @@ -32,11 +39,12 @@ import { } from "./identity-token-auth-types"; type TIdentityTokenAuthServiceFactoryDep = { + identityDAL: Pick; identityTokenAuthDAL: Pick< TIdentityTokenAuthDALFactory, "transaction" | "create" | "findOne" | "updateById" | "delete" >; - membershipIdentityDAL: Pick; + membershipIdentityDAL: Pick; identityAccessTokenDAL: Pick< TIdentityAccessTokenDALFactory, "create" | "find" | "update" | "findById" | "findOne" | "updateById" | "delete" @@ -49,8 +57,8 @@ type TIdentityTokenAuthServiceFactoryDep = { export type TIdentityTokenAuthServiceFactory = ReturnType; export const identityTokenAuthServiceFactory = ({ + identityDAL, identityTokenAuthDAL, - // identityDAL, membershipIdentityDAL, identityAccessTokenDAL, permissionService, @@ -79,6 +87,9 @@ export const identityTokenAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TOKEN_AUTH)) { throw new BadRequestError({ @@ -90,13 +101,14 @@ export const identityTokenAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); @@ -155,6 +167,9 @@ export const identityTokenAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TOKEN_AUTH)) { throw new BadRequestError({ @@ -172,13 +187,14 @@ export const identityTokenAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); @@ -223,6 +239,9 @@ export const identityTokenAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TOKEN_AUTH)) { throw new BadRequestError({ @@ -232,13 +251,14 @@ export const identityTokenAuthServiceFactory = ({ const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); return { ...identityTokenAuth, orgId: identityMembershipOrg.scopeOrgId }; @@ -262,28 +282,33 @@ export const identityTokenAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TOKEN_AUTH)) { throw new BadRequestError({ message: "The identity does not have Token Auth" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identity.id, - identityMembershipOrg.scopeOrgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( @@ -341,22 +366,26 @@ export const identityTokenAuthServiceFactory = ({ message: "The identity does not have Token Auth" }); } - const { permission } = await permissionService.getOrgPermission( + + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identity.id, - identityMembershipOrg.scopeOrgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( @@ -379,13 +408,13 @@ export const identityTokenAuthServiceFactory = ({ const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId }); + const identity = await identityDAL.findById(identityTokenAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); + const identityAccessToken = await identityTokenAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.updateById( - identityMembershipOrg.id, - { - lastLoginAuthMethod: IdentityAuthMethod.TOKEN_AUTH, - lastLoginTime: new Date() - }, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.TOKEN_AUTH, lastLoginTime: new Date() }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -420,7 +449,7 @@ export const identityTokenAuthServiceFactory = ({ } ); - return { accessToken, identityTokenAuth, identityAccessToken, identityMembershipOrg }; + return { accessToken, identityTokenAuth, identityAccessToken, identity }; }; const getTokenAuthTokens = async ({ @@ -449,13 +478,14 @@ export const identityTokenAuthServiceFactory = ({ message: "The identity does not have Token Auth" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const tokens = await identityAccessTokenDAL.find( @@ -501,22 +531,24 @@ export const identityTokenAuthServiceFactory = ({ message: "The identity does not have Token Auth" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identity.id, - identityMembershipOrg.scopeOrgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( shouldUseNewPrivilegeSystem, @@ -580,13 +612,14 @@ export const identityTokenAuthServiceFactory = ({ throw new NotFoundError({ message: `Failed to find identity with ID ${identityAccessToken.identityId}` }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityOrgMembership.scopeOrgId, + orgId: identityOrgMembership.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const [revokedToken] = await identityAccessTokenDAL.update( diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 563a3f897..00ab1610d 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { AccessScope, IdentityAuthMethod } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -13,6 +13,7 @@ import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, + ForbiddenRequestError, NotFoundError, PermissionBoundaryError, RateLimitError, @@ -22,6 +23,7 @@ import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from import { logger } from "@app/lib/logger"; import { ActorType, AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; @@ -42,6 +44,7 @@ import { } from "./identity-ua-types"; type TIdentityUaServiceFactoryDep = { + identityDAL: Pick; identityUaDAL: TIdentityUaDALFactory; identityUaClientSecretDAL: TIdentityUaClientSecretDALFactory; identityAccessTokenDAL: TIdentityAccessTokenDALFactory; @@ -70,7 +73,8 @@ export const identityUaServiceFactory = ({ permissionService, licenseService, orgDAL, - keyStore + keyStore, + identityDAL }: TIdentityUaServiceFactoryDep) => { const login = async (clientId: string, clientSecret: string, ip: string) => { const identityUa = await identityUaDAL.findOne({ clientId }); @@ -100,16 +104,6 @@ export const identityUaServiceFactory = ({ }); } - const identityMembershipOrg = await membershipIdentityDAL.findOne({ - actorIdentityId: identityUa.identityId, - scope: AccessScope.Organization - }); - if (!identityMembershipOrg) { - throw new UnauthorizedError({ - message: "Invalid credentials" - }); - } - const clientSecretPrefix = clientSecret.slice(0, 4); const clientSecretInfo = await identityUaClientSecretDAL.find({ identityUAId: identityUa.id, @@ -227,10 +221,11 @@ export const identityUaServiceFactory = ({ accessTokenMaxTTL: 1000000000 }; + const identity = await identityDAL.findById(identityUa.identityId); const identityAccessToken = await identityUaDAL.transaction(async (tx) => { const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx); - await membershipIdentityDAL.updateById( - identityMembershipOrg.id, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, { lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH, lastLoginTime: new Date() @@ -276,7 +271,7 @@ export const identityUaServiceFactory = ({ identityUa, validClientSecretInfo, identityAccessToken, - identityMembershipOrg, + identity, ...accessTokenTTLParams }; }; @@ -315,18 +310,23 @@ export const identityUaServiceFactory = ({ message: "Failed to add universal auth to already configured identity" }); } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); @@ -423,6 +423,10 @@ export const identityUaServiceFactory = ({ }); } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } + if ( (accessTokenMaxTTL || uaIdentityAuth.accessTokenMaxTTL) > 0 && (accessTokenTTL || uaIdentityAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || uaIdentityAuth.accessTokenMaxTTL) @@ -430,13 +434,14 @@ export const identityUaServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); @@ -512,14 +517,18 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); return { ...uaIdentityAuth, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -545,22 +554,27 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } - const { permission } = await permissionService.getOrgPermission( + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identity.id, - identityMembershipOrg.scopeOrgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( shouldUseNewPrivilegeSystem, @@ -611,23 +625,28 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identity.id, - identityMembershipOrg.scopeOrgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( shouldUseNewPrivilegeSystem, @@ -692,23 +711,28 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } - const { permission } = await permissionService.getOrgPermission( + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } + + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identity.id, - identityMembershipOrg.scopeOrgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); - + actorOrgId, + scope: OrganizationActionScope.Any + }); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( shouldUseNewPrivilegeSystem, @@ -761,6 +785,9 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } const identityUa = await identityUaDAL.findOne({ identityId }); if (!identityUa) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); @@ -768,22 +795,24 @@ export const identityUaServiceFactory = ({ const clientSecret = await identityUaClientSecretDAL.findOne({ id: clientSecretId, identityUAId: identityUa.id }); if (!clientSecret) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identity.id, - identityMembershipOrg.scopeOrgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( shouldUseNewPrivilegeSystem, @@ -828,6 +857,9 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } const identityUa = await identityUaDAL.findOne({ identityId }); if (!identityUa) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); @@ -835,22 +867,24 @@ export const identityUaServiceFactory = ({ const clientSecret = await identityUaClientSecretDAL.findOne({ id: clientSecretId, identityUAId: identityUa.id }); if (!clientSecret) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identity.id, - identityMembershipOrg.scopeOrgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( @@ -900,14 +934,18 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.scopeOrgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const deleted = await keyStore.deleteItems({ diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index 65aee561c..66556f5fa 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -163,7 +163,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { .select( selectAllTableCols(TableName.Membership), db.ref("name").withSchema(TableName.Identity).as("identityName"), - db.ref("hasDeleteProtection").withSchema(TableName.Identity) + db.ref("hasDeleteProtection").withSchema(TableName.Identity), + db.ref("orgId").withSchema(TableName.Identity) ) .where(filter) .as("paginatedIdentity"); @@ -257,6 +258,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("customRoleId").withSchema(TableName.MembershipRole).as("roleId"), db.ref("scopeOrgId").withSchema("paginatedIdentity").as("orgId"), db.ref("lastLoginAuthMethod").withSchema("paginatedIdentity"), + db.ref("orgId").withSchema("paginatedIdentity").as("identityOrgId"), db.ref("lastLoginTime").withSchema("paginatedIdentity"), db.ref("createdAt").withSchema("paginatedIdentity"), db.ref("updatedAt").withSchema("paginatedIdentity"), @@ -309,6 +311,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { roleId, id, orgId, + identityOrgId, uaId, alicloudId, awsId, @@ -348,6 +351,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { id: identityId as string, name: identityName, hasDeleteProtection, + orgId: identityOrgId, authMethods: buildAuthMethods({ uaId, alicloudId, @@ -515,6 +519,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("actorIdentityId").withSchema(TableName.Membership).as("identityId"), db.ref("name").withSchema(TableName.Identity).as("identityName"), db.ref("hasDeleteProtection").withSchema(TableName.Identity), + db.ref("orgId").withSchema(TableName.Identity).as("identityOrgId"), db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), @@ -566,6 +571,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { crPermission, crName, identityId, + identityOrgId, identityName, hasDeleteProtection, role, @@ -611,6 +617,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { id: identityId as string, name: identityName, hasDeleteProtection, + orgId: identityOrgId, authMethods: buildAuthMethods({ uaId, alicloudId, diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index 721844070..f6ec60e9e 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { AccessScope, OrgMembershipRole, TableName, TRoles } from "@app/db/schemas"; +import { AccessScope, OrganizationActionScope, OrgMembershipRole, TableName, TRoles } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -12,6 +12,7 @@ import { TKeyStoreFactory } from "@app/keystore/keystore"; import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; +import { TAdditionalPrivilegeDALFactory } from "../additional-privilege/additional-privilege-dal"; import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; import { TOrgDALFactory } from "../org/org-dal"; @@ -40,6 +41,7 @@ type TIdentityServiceFactoryDep = { licenseService: Pick; keyStore: Pick; orgDAL: Pick; + additionalPrivilegeDAL: Pick; }; export type TIdentityServiceFactory = ReturnType; @@ -54,7 +56,8 @@ export const identityServiceFactory = ({ keyStore, orgDAL, membershipIdentityDAL, - membershipRoleDAL + membershipRoleDAL, + additionalPrivilegeDAL }: TIdentityServiceFactoryDep) => { const createIdentity = async ({ name, @@ -67,7 +70,14 @@ export const identityServiceFactory = ({ actorOrgId, metadata }: TCreateIdentityDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const [rolePermissionDetails] = await permissionService.getOrgPermissionByRoles([role], orgId); @@ -104,7 +114,7 @@ export const identityServiceFactory = ({ } const identity = await identityDAL.transaction(async (tx) => { - const newIdentity = await identityDAL.create({ name, hasDeleteProtection }, tx); + const newIdentity = await identityDAL.create({ name, hasDeleteProtection, orgId }, tx); const membership = await membershipIdentityDAL.create( { scope: AccessScope.Organization, @@ -172,13 +182,14 @@ export const identityServiceFactory = ({ }); if (!identityOrgMembership) throw new NotFoundError({ message: `Failed to find identity with id ${id}` }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityOrgMembership.scopeOrgId, + orgId: identityOrgMembership.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); let customRole: TRoles | undefined; @@ -208,11 +219,12 @@ export const identityServiceFactory = ({ if (isCustomRole) customRole = rolePermissionDetails?.role; } + const identityDetails = await identityDAL.findById(id); const identity = await identityDAL.transaction(async (tx) => { const newIdentity = - name || hasDeleteProtection + identityDetails.orgId === actorOrgId && (name || hasDeleteProtection) ? await identityDAL.updateById(id, { name, hasDeleteProtection }, tx) - : await identityDAL.findById(id, tx); + : identityDetails; if (role) { await membershipRoleDAL.delete({ membershipId: identityOrgMembership.id }, tx); @@ -264,16 +276,16 @@ export const identityServiceFactory = ({ const identity = doc[0]; if (!identity) throw new NotFoundError({ message: `Failed to find identity with id ${id}` }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identity.orgId, + orgId: identity.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - // TODO(namespace): check this in identity service const activeLockouts = await keyStore.getKeysByPattern(`lockout:identity:${id}:*`); const activeLockoutAuthMethods = new Set(); @@ -314,23 +326,56 @@ export const identityServiceFactory = ({ }); if (!identityOrgMembership) throw new NotFoundError({ message: `Failed to find identity with id ${id}` }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityOrgMembership.scopeOrgId, + orgId: identityOrgMembership.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); if (identityOrgMembership.identity.hasDeleteProtection) throw new BadRequestError({ message: "Identity has delete protection" }); - const deletedIdentity = await identityDAL.deleteById(id); + if (identityOrgMembership.identity.identityOrgId === actorOrgId) { + const deletedIdentity = await identityDAL.deleteById(id); + await licenseService.updateSubscriptionOrgMemberCount(identityOrgMembership.scopeOrgId); + return { ...deletedIdentity, orgId: identityOrgMembership.scopeOrgId }; + } - await licenseService.updateSubscriptionOrgMemberCount(identityOrgMembership.scopeOrgId); + await membershipIdentityDAL.transaction(async (tx) => { + await identityMetadataDAL.delete( + { + identityId: id, + orgId: actorOrgId + }, + tx + ); + const identityProjectMembership = await membershipIdentityDAL.find( + { + actorIdentityId: id, + scope: AccessScope.Project, + scopeOrgId: actorOrgId + }, + { tx } + ); + await additionalPrivilegeDAL.delete( + { + actorIdentityId: id, + $in: { + projectId: identityProjectMembership.map((el) => el.scopeProjectId) + } + }, + tx + ); + const doc = await membershipIdentityDAL.delete({ actorIdentityId: id, scopeOrgId: actorOrgId }, tx); + return doc; + }); + const deletedIdentity = await identityDAL.findById(id); return { ...deletedIdentity, orgId: identityOrgMembership.scopeOrgId }; }; @@ -346,7 +391,14 @@ export const identityServiceFactory = ({ orderDirection, search }: TListOrgIdentitiesByOrgIdDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const identityMemberships = await identityOrgMembershipDAL.find({ @@ -379,7 +431,14 @@ export const identityServiceFactory = ({ orderDirection, searchFilter = {} }: TSearchOrgIdentitiesByOrgIdDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const { totalCount, docs } = await identityOrgMembershipDAL.searchIdentities({ @@ -408,16 +467,17 @@ export const identityServiceFactory = ({ }); if (!identityOrgMembership) throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityOrgMembership.scopeOrgId, + orgId: identityOrgMembership.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - const identityMemberships = await identityProjectDAL.findByIdentityId(identityId); + const identityMemberships = await identityProjectDAL.findByIdentityId(identityId, actorOrgId); return identityMemberships; }; diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index 248488e9f..1d1f2cbb6 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -112,7 +112,7 @@ export const integrationAuthServiceFactory = ({ }; const listOrgIntegrationAuth = async ({ actorId, actor, actorOrgId, actorAuthMethod }: TGenericPermission) => { - const authorizations = await integrationAuthDAL.getByOrg(actorOrgId as string); + const authorizations = await integrationAuthDAL.getByOrg(actorOrgId); const filteredAuthorizations = await Promise.all( authorizations.map(async (auth) => { diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 4e5b48006..b665df4ed 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -12,6 +12,7 @@ import { TExternalKmsProviderFns } from "@app/ee/services/external-kms/providers/model"; import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; +import { THsmStatus } from "@app/ee/services/hsm/hsm-types"; import { KeyStorePrefixes, PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { TEnvConfig } from "@app/lib/config/env"; import { symmetricCipherService, SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; @@ -1077,17 +1078,22 @@ export const kmsServiceFactory = ({ return { id, name, orgId, isExternal }; }; - const startService = async () => { + const startService = async (hsmStatus: THsmStatus) => { const kmsRootConfig = await kmsRootConfigDAL.transaction(async (tx) => { await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.KmsRootKeyInit]); // check if KMS root key was already generated and saved in DB const existingRootConfig = await kmsRootConfigDAL.findById(KMS_ROOT_CONFIG_UUID); if (existingRootConfig) return existingRootConfig; - logger.info("KMS: Generating new ROOT Key"); - const newRootKey = crypto.randomBytes(32); - const encryptedRootKey = await $encryptRootKey(newRootKey, RootKeyEncryptionStrategy.Software).catch((err) => { - logger.error({ hsmEnabled: hsmService.isActive() }, "KMS: Failed to encrypt ROOT Key"); + const isHsmActive = hsmStatus.isHsmConfigured; + + logger.info(`KMS: Generating new ROOT Key with ${isHsmActive ? "HSM" : "software"} encryption`); + const newRootKey = isHsmActive ? await hsmService.randomBytes(32) : crypto.randomBytes(32); + + const encryptionStrategy = isHsmActive ? RootKeyEncryptionStrategy.HSM : RootKeyEncryptionStrategy.Software; + + const encryptedRootKey = await $encryptRootKey(newRootKey, encryptionStrategy).catch((err) => { + logger.error({ hsmEnabled: isHsmActive, encryptionStrategy }, "KMS: Failed to encrypt ROOT Key"); throw err; }); @@ -1095,7 +1101,7 @@ export const kmsServiceFactory = ({ // @ts-expect-error id is kept as fixed for idempotence and to avoid race condition id: KMS_ROOT_CONFIG_UUID, encryptedRootKey, - encryptionStrategy: RootKeyEncryptionStrategy.Software + encryptionStrategy }); return newRootConfig; }); @@ -1117,6 +1123,15 @@ export const kmsServiceFactory = ({ return; } + if (strategy === RootKeyEncryptionStrategy.Software) { + if (!envConfig.ROOT_ENCRYPTION_KEY && !envConfig.ENCRYPTION_KEY) { + throw new BadRequestError({ + message: + "Root KMS encryption strategy is set to software. Please set the ENCRYPTION_KEY environment variable and restart your deployment before trying to update the encryption strategy to software mode." + }); + } + } + const decryptedRootKey = await $decryptRootKey(kmsRootConfig); const encryptedRootKey = await $encryptRootKey(decryptedRootKey, strategy); diff --git a/backend/src/services/membership-group/org/org-membership-group-factory.ts b/backend/src/services/membership-group/org/org-membership-group-factory.ts index 1e87ee3ca..d69db9c08 100644 --- a/backend/src/services/membership-group/org/org-membership-group-factory.ts +++ b/backend/src/services/membership-group/org/org-membership-group-factory.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { AccessScope, OrgMembershipRole } from "@app/db/schemas"; +import { AccessScope, OrganizationActionScope, OrgMembershipRole } from "@app/db/schemas"; import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { constructPermissionErrorMessage, @@ -45,13 +45,14 @@ export const newOrgMembershipGroupFactory = ({ }; const onUpdateMembershipGroupGuard: TMembershipGroupScopeFactory["onUpdateMembershipGroupGuard"] = async (dto) => { - const { permission } = await permissionService.getOrgPermission( - dto.permission.type, - dto.permission.id, - dto.permission.orgId, - dto.permission.authMethod, - dto.permission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); const permissionRoles = await permissionService.getOrgPermissionByRoles( dto.data.roles.map((el) => el.role), @@ -89,26 +90,28 @@ export const newOrgMembershipGroupFactory = ({ }; const onListMembershipGroupGuard: TMembershipGroupScopeFactory["onListMembershipGroupGuard"] = async (dto) => { - const { permission } = await permissionService.getOrgPermission( - dto.permission.type, - dto.permission.id, - dto.permission.orgId, - dto.permission.authMethod, - dto.permission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); }; const onGetMembershipGroupByGroupIdGuard: TMembershipGroupScopeFactory["onGetMembershipGroupByGroupIdGuard"] = async ( dto ) => { - const { permission } = await permissionService.getOrgPermission( - dto.permission.type, - dto.permission.id, - dto.permission.orgId, - dto.permission.authMethod, - dto.permission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); }; diff --git a/backend/src/services/membership-identity/membership-identity-dal.ts b/backend/src/services/membership-identity/membership-identity-dal.ts index 64e508fb8..682bfef3e 100644 --- a/backend/src/services/membership-identity/membership-identity-dal.ts +++ b/backend/src/services/membership-identity/membership-identity-dal.ts @@ -91,6 +91,7 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { .select( db.ref("name").withSchema(TableName.Identity).as("identityName"), db.ref("id").withSchema(TableName.Identity).as("identityId"), + db.ref("orgId").withSchema(TableName.Identity).as("identityOrgId"), db.ref("hasDeleteProtection").withSchema(TableName.Identity).as("identityHasDeleteProtection"), db.ref("slug").withSchema(TableName.Role).as("roleSlug"), @@ -132,6 +133,7 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { parentMapper: (el) => { const { identityId: actorIdentityId, + identityOrgId, identityHasDeleteProtection, identityName, uaId, @@ -153,6 +155,7 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { name: identityName, id: actorIdentityId, hasDeleteProtection: identityHasDeleteProtection, + identityOrgId, authMethods: buildAuthMethods({ uaId, awsId, @@ -353,5 +356,34 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { } }; - return { ...orm, findIdentities, getIdentityById }; + // this right now only support sub organization + const listAvailableIdentities = async (orgId: string, rootOrgId: string) => { + try { + const usersConnectedToOrg = db + .replicaNode()(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .select("actorIdentityId"); + + const docs = await db + .replicaNode()(TableName.Membership) + .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Membership}.actorIdentityId`) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .where(`${TableName.Membership}.scopeOrgId`, rootOrgId) + .whereNotIn(`${TableName.Membership}.actorIdentityId`, usersConnectedToOrg) + .select( + db.ref("id").withSchema(TableName.Identity), + db.ref("name").withSchema(TableName.Identity), + db.ref("hasDeleteProtection").withSchema(TableName.Identity) + ); + + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "ListAvailableIdentities" }); + } + }; + + return { ...orm, findIdentities, getIdentityById, listAvailableIdentities }; }; diff --git a/backend/src/services/membership-identity/membership-identity-service.ts b/backend/src/services/membership-identity/membership-identity-service.ts index 4dd3da064..16292ea82 100644 --- a/backend/src/services/membership-identity/membership-identity-service.ts +++ b/backend/src/services/membership-identity/membership-identity-service.ts @@ -6,6 +6,7 @@ import { ms } from "@app/lib/ms"; import { SearchResourceOperators } from "@app/lib/search-resource/search"; import { TAdditionalPrivilegeDALFactory } from "../additional-privilege/additional-privilege-dal"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; import { TOrgDALFactory } from "../org/org-dal"; import { TRoleDALFactory } from "../role/role-dal"; @@ -31,6 +32,7 @@ type TMembershipIdentityServiceFactoryDep = { >; orgDAL: Pick; additionalPrivilegeDAL: Pick; + identityDAL: Pick; }; export type TMembershipIdentityServiceFactory = ReturnType; @@ -41,12 +43,14 @@ export const membershipIdentityServiceFactory = ({ membershipRoleDAL, permissionService, orgDAL, - additionalPrivilegeDAL + additionalPrivilegeDAL, + identityDAL }: TMembershipIdentityServiceFactoryDep) => { const scopeFactory = { [AccessScope.Organization]: newOrgMembershipIdentityFactory({ orgDAL, - permissionService + permissionService, + identityDAL }), [AccessScope.Project]: newProjectMembershipIdentityFactory({ membershipIdentityDAL, @@ -305,7 +309,7 @@ export const membershipIdentityServiceFactory = ({ [SearchResourceOperators.$contains]: dto.data.identityName } : undefined, - role: dto.data.roles.length + role: dto.data?.roles?.length ? { [SearchResourceOperators.$in]: dto.data.roles } @@ -329,11 +333,29 @@ export const membershipIdentityServiceFactory = ({ return membership; }; + const listAvailableIdentities = async (dto: TListMembershipIdentityDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onListMembershipIdentityGuard(dto); + + const organizationDetails = await orgDAL.findById(dto.scopeData.orgId); + if (!organizationDetails.rootOrgId) return { identities: [] }; + + const identities = await membershipIdentityDAL.listAvailableIdentities( + organizationDetails.id, + organizationDetails.rootOrgId + ); + + return { identities }; + }; + return { createMembership, updateMembership, deleteMembership, listMemberships, - getMembershipByIdentityId + getMembershipByIdentityId, + listAvailableIdentities }; }; diff --git a/backend/src/services/membership-identity/membership-identity-types.ts b/backend/src/services/membership-identity/membership-identity-types.ts index adce10237..78923cb14 100644 --- a/backend/src/services/membership-identity/membership-identity-types.ts +++ b/backend/src/services/membership-identity/membership-identity-types.ts @@ -54,14 +54,11 @@ export type TUpdateMembershipIdentityDTO = { export type TListMembershipIdentityDTO = { permission: OrgServiceActor; scopeData: AccessScopeData; - selector: { - identityId: string; - }; data: { limit?: number; offset?: number; identityName?: string; - roles: string[]; + roles?: string[]; }; }; diff --git a/backend/src/services/membership-identity/org/org-membership-identity-factory.ts b/backend/src/services/membership-identity/org/org-membership-identity-factory.ts index 06789e274..8b3bdf6d5 100644 --- a/backend/src/services/membership-identity/org/org-membership-identity-factory.ts +++ b/backend/src/services/membership-identity/org/org-membership-identity-factory.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { AccessScope, OrgMembershipRole } from "@app/db/schemas"; +import { AccessScope, OrganizationActionScope, OrgMembershipRole } from "@app/db/schemas"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { constructPermissionErrorMessage, @@ -8,6 +8,7 @@ import { } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { BadRequestError, InternalServerError, PermissionBoundaryError } from "@app/lib/errors"; +import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { isCustomOrgRole } from "@app/services/org/org-role-fns"; @@ -16,11 +17,13 @@ import { TMembershipIdentityScopeFactory } from "../membership-identity-types"; type TOrgMembershipIdentityScopeFactoryDep = { permissionService: Pick; orgDAL: Pick; + identityDAL: Pick; }; export const newOrgMembershipIdentityFactory = ({ permissionService, - orgDAL + orgDAL, + identityDAL }: TOrgMembershipIdentityScopeFactoryDep): TMembershipIdentityScopeFactory => { const getScopeField: TMembershipIdentityScopeFactory["getScopeField"] = (dto) => { if (dto.scope === AccessScope.Organization) { @@ -38,23 +41,66 @@ export const newOrgMembershipIdentityFactory = ({ const isCustomRole: TMembershipIdentityScopeFactory["isCustomRole"] = (role: string) => isCustomOrgRole(role); - const onCreateMembershipIdentityGuard: TMembershipIdentityScopeFactory["onCreateMembershipIdentityGuard"] = - async () => { - throw new BadRequestError({ - message: "Organization membership cannot be created for organization scoped identity" - }); - }; + const onCreateMembershipIdentityGuard: TMembershipIdentityScopeFactory["onCreateMembershipIdentityGuard"] = async ( + dto + ) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.ChildOrganization + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + + const identityDetails = await identityDAL.findById(dto.data.identityId); + if (identityDetails.orgId !== dto.permission.rootOrgId) { + throw new BadRequestError({ message: "Only identities from parent organization can be invited" }); + } + + const permissionRoles = await permissionService.getOrgPermissionByRoles( + dto.data.roles.map((el) => el.role), + dto.permission.orgId + ); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); + for (const permissionRole of permissionRoles) { + if (permissionRole?.role?.name !== OrgMembershipRole.NoAccess) { + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GrantPrivileges, + OrgPermissionSubjects.Identity, + permission, + permissionRole.permission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update identity org membership", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GrantPrivileges, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + } + }; const onUpdateMembershipIdentityGuard: TMembershipIdentityScopeFactory["onUpdateMembershipIdentityGuard"] = async ( dto ) => { - const { permission } = await permissionService.getOrgPermission( - dto.permission.type, - dto.permission.id, - dto.permission.orgId, - dto.permission.authMethod, - dto.permission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const permissionRoles = await permissionService.getOrgPermissionByRoles( dto.data.roles.map((el) => el.role), @@ -85,35 +131,54 @@ export const newOrgMembershipIdentityFactory = ({ } }; - const onDeleteMembershipIdentityGuard: TMembershipIdentityScopeFactory["onDeleteMembershipIdentityGuard"] = - async () => { - throw new BadRequestError({ - message: "Organization membership cannot be deleted for organization scoped identity" - }); - }; + const onDeleteMembershipIdentityGuard: TMembershipIdentityScopeFactory["onDeleteMembershipIdentityGuard"] = async ( + dto + ) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.ChildOrganization + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); + + const identityDetails = await identityDAL.findById(dto.selector.identityId); + if (identityDetails.orgId !== dto.permission.rootOrgId) { + throw new BadRequestError({ message: "Only identities from parent organization can do this operation" }); + } + + if (identityDetails.orgId === dto.permission.orgId) { + throw new BadRequestError({ message: "Identity cannot exist as orphan" }); + } + }; const onListMembershipIdentityGuard: TMembershipIdentityScopeFactory["onListMembershipIdentityGuard"] = async ( dto ) => { - const { permission } = await permissionService.getOrgPermission( - dto.permission.type, - dto.permission.id, - dto.permission.orgId, - dto.permission.authMethod, - dto.permission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); }; const onGetMembershipIdentityByIdentityIdGuard: TMembershipIdentityScopeFactory["onGetMembershipIdentityByIdentityIdGuard"] = async (dto) => { - const { permission } = await permissionService.getOrgPermission( - dto.permission.type, - dto.permission.id, - dto.permission.orgId, - dto.permission.authMethod, - dto.permission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); }; diff --git a/backend/src/services/membership-user/membership-user-dal.ts b/backend/src/services/membership-user/membership-user-dal.ts index 7882b9639..17970f16f 100644 --- a/backend/src/services/membership-user/membership-user-dal.ts +++ b/backend/src/services/membership-user/membership-user-dal.ts @@ -291,5 +291,37 @@ export const membershipUserDALFactory = (db: TDbClient) => { } }; - return { ...orm, findUsers, getUserById }; + // this right now only support sub organization + const listAvailableUsers = async (orgId: string, rootOrgId: string) => { + try { + const usersConnectedToOrg = db + .replicaNode()(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .select("actorUserId"); + + const docs = await db + .replicaNode()(TableName.Membership) + .join(TableName.Users, `${TableName.Users}.id`, `${TableName.Membership}.actorUserId`) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .where(`${TableName.Users}.isGhost`, false) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .where(`${TableName.Membership}.scopeOrgId`, rootOrgId) + .whereNotIn(`${TableName.Membership}.actorUserId`, usersConnectedToOrg) + .select( + db.ref("id").withSchema(TableName.Users), + db.ref("email").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users) + ); + + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "ListAvailableUsers" }); + } + }; + + return { ...orm, findUsers, getUserById, listAvailableUsers }; }; diff --git a/backend/src/services/membership-user/membership-user-service.ts b/backend/src/services/membership-user/membership-user-service.ts index 82dca0159..4b14ee771 100644 --- a/backend/src/services/membership-user/membership-user-service.ts +++ b/backend/src/services/membership-user/membership-user-service.ts @@ -40,7 +40,7 @@ import { newProjectMembershipUserFactory } from "./project/project-membership-us type TMembershipUserServiceFactoryDep = { membershipUserDAL: TMembershipUserDALFactory; membershipRoleDAL: Pick; - orgDAL: Pick; + orgDAL: Pick; roleDAL: Pick; userDAL: TUserDALFactory; permissionService: Pick< @@ -83,7 +83,8 @@ export const membershipUserServiceFactory = ({ orgDAL, tokenService, userDAL, - userGroupMembershipDAL + userGroupMembershipDAL, + membershipUserDAL }), [AccessScope.Namespace]: newNamespaceMembershipUserFactory({}), [AccessScope.Project]: newProjectMembershipUserFactory({ @@ -404,7 +405,7 @@ export const membershipUserServiceFactory = ({ const membershipDoc = await membershipUserDAL.transaction(async (tx) => { if (dto.scopeData.scope === AccessScope.Organization) { const [doc] = await deleteOrgMembershipsFn({ - orgMembershipIds: [], + orgMembershipIds: [existingMembership.id], orgId: dto.permission.orgId, orgDAL, projectKeyDAL, @@ -471,11 +472,26 @@ export const membershipUserServiceFactory = ({ return membership; }; + // Should only be used for sub organization as of now + const listAvailableUsers = async (dto: TListMembershipUserDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onListMembershipUserGuard(dto); + + const organizationDetails = await orgDAL.findById(dto.scopeData.orgId); + if (!organizationDetails.rootOrgId) return { users: [] }; + + const users = await membershipUserDAL.listAvailableUsers(organizationDetails.id, organizationDetails.rootOrgId); + return { users }; + }; + return { createMembership, updateMembership, deleteMembership, listMemberships, - getMembershipByUserId + getMembershipByUserId, + listAvailableUsers }; }; diff --git a/backend/src/services/membership-user/membership-user-types.ts b/backend/src/services/membership-user/membership-user-types.ts index b8761671c..15982bb6a 100644 --- a/backend/src/services/membership-user/membership-user-types.ts +++ b/backend/src/services/membership-user/membership-user-types.ts @@ -93,3 +93,8 @@ export type TGetMembershipUserByUserIdDTO = { userId: string; }; }; + +export type TListAvailableUsersDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; +}; diff --git a/backend/src/services/membership-user/org/org-membership-user-factory.ts b/backend/src/services/membership-user/org/org-membership-user-factory.ts index 523e85bae..d21b27b69 100644 --- a/backend/src/services/membership-user/org/org-membership-user-factory.ts +++ b/backend/src/services/membership-user/org/org-membership-user-factory.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { AccessScope } from "@app/db/schemas"; +import { AccessScope, OrganizationActionScope } from "@app/db/schemas"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; @@ -15,6 +15,7 @@ import { isCustomOrgRole } from "@app/services/org/org-role-fns"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TMembershipUserDALFactory } from "../membership-user-dal"; import { TMembershipUserScopeFactory } from "../membership-user-types"; type TOrgMembershipUserScopeFactoryDep = { @@ -25,6 +26,7 @@ type TOrgMembershipUserScopeFactoryDep = { orgDAL: Pick; userGroupMembershipDAL: Pick; licenseService: Pick; + membershipUserDAL: Pick; }; export const newOrgMembershipUserFactory = ({ @@ -33,7 +35,8 @@ export const newOrgMembershipUserFactory = ({ userDAL, orgDAL, smtpService, - licenseService + licenseService, + membershipUserDAL }: TOrgMembershipUserScopeFactoryDep): TMembershipUserScopeFactory => { const getScopeField: TMembershipUserScopeFactory["getScopeField"] = (dto) => { if (dto.scope === AccessScope.Organization) { @@ -51,14 +54,18 @@ export const newOrgMembershipUserFactory = ({ const isCustomRole: TMembershipUserScopeFactory["isCustomRole"] = (role: string) => isCustomOrgRole(role); - const onCreateMembershipUserGuard: TMembershipUserScopeFactory["onCreateMembershipUserGuard"] = async (dto) => { - const { permission } = await permissionService.getOrgPermission( - dto.permission.type, - dto.permission.id, - dto.permission.orgId, - dto.permission.authMethod, - dto.permission.orgId - ); + const onCreateMembershipUserGuard: TMembershipUserScopeFactory["onCreateMembershipUserGuard"] = async ( + dto, + newMembers + ) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); const plan = await licenseService.getPlan(dto.permission.orgId); @@ -77,6 +84,25 @@ export const newOrgMembershipUserFactory = ({ message: "Failed to invite user due to org-level auth enforced for organization" }); } + + if (org.rootOrgId) { + const rootOrgMembership = await membershipUserDAL.find({ + scope: AccessScope.Organization, + $in: { + actorUserId: newMembers.map((el) => el.id) + }, + scopeOrgId: org.rootOrgId + }); + if (rootOrgMembership.length !== newMembers.length) { + const emails = newMembers + .filter((user) => !rootOrgMembership.find((i) => i.actorUserId === user.id)) + .map((el) => el.email) + .join(","); + throw new BadRequestError({ + message: `Users with email ${emails} doesn't have membership in root organization` + }); + } + } }; const onCreateMembershipComplete: TMembershipUserScopeFactory["onCreateMembershipComplete"] = async ( @@ -95,87 +121,103 @@ export const newOrgMembershipUserFactory = ({ const signUpTokens: { email: string; link: string }[] = []; const orgDetails = await orgDAL.findById(dto.permission.orgId); + if (orgDetails.rootOrgId) { + const emails = newUsers.map((el) => el.email).filter(Boolean); + await smtpService.sendMail({ + template: SmtpTemplates.SubOrgInvite, + subjectLine: "Infisical sub-organization invitation", + recipients: emails as string[], + substitutions: { + subOrganizationName: orgDetails.slug, + callback_url: `${appCfg.SITE_URL}/organization/projects?subOrganization=${orgDetails.slug}` + } + }); + } else { + await Promise.allSettled( + newUsers.map(async (el) => { + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_ORG_INVITATION, + userId: el.id, + orgId: dto.permission.orgId + }); - await Promise.allSettled( - newUsers.map(async (el) => { - const token = await tokenService.createTokenForUser({ - type: TokenType.TOKEN_EMAIL_ORG_INVITATION, - userId: el.id, - orgId: dto.permission.orgId - }); + if (el.email) { + if (!appCfg.isSmtpConfigured) { + signUpTokens.push({ + email: el.email, + link: `${appCfg.SITE_URL}/signupinvite?token=${token}&to=${el.email}&organization_id=${dto.permission.orgId}` + }); + } - if (el.email) { - if (!appCfg.isSmtpConfigured) { - signUpTokens.push({ - email: el.email, - link: `${appCfg.SITE_URL}/signupinvite?token=${token}&to=${el.email}&organization_id=${dto.permission.orgId}` + await smtpService.sendMail({ + template: SmtpTemplates.OrgInvite, + subjectLine: "Infisical organization invitation", + recipients: [el.email], + substitutions: { + inviterFirstName: actorDetails?.firstName, + inviterUsername: actorDetails?.email, + organizationName: orgDetails?.name, + email: el.email, + organizationId: orgDetails?.id.toString(), + token, + callback_url: `${appCfg.SITE_URL}/signupinvite` + } }); } - - await smtpService.sendMail({ - template: SmtpTemplates.OrgInvite, - subjectLine: "Infisical organization invitation", - recipients: [el.email], - substitutions: { - inviterFirstName: actorDetails?.firstName, - inviterUsername: actorDetails?.email, - organizationName: orgDetails?.name, - email: el.email, - organizationId: orgDetails?.id.toString(), - token, - callback_url: `${appCfg.SITE_URL}/signupinvite` - } - }); - } - }) - ); + }) + ); + } return { signUpTokens }; }; const onUpdateMembershipUserGuard: TMembershipUserScopeFactory["onUpdateMembershipUserGuard"] = async (dto) => { - const { permission } = await permissionService.getOrgPermission( - dto.permission.type, - dto.permission.id, - dto.permission.orgId, - dto.permission.authMethod, - dto.permission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Member); }; const onDeleteMembershipUserGuard: TMembershipUserScopeFactory["onDeleteMembershipUserGuard"] = async (dto) => { - const { permission } = await permissionService.getOrgPermission( - dto.permission.type, - dto.permission.id, - dto.permission.orgId, - dto.permission.authMethod, - dto.permission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); }; const onListMembershipUserGuard: TMembershipUserScopeFactory["onListMembershipUserGuard"] = async (dto) => { - const { permission } = await permissionService.getOrgPermission( - dto.permission.type, - dto.permission.id, - dto.permission.orgId, - dto.permission.authMethod, - dto.permission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); }; const onGetMembershipUserByUserIdGuard: TMembershipUserScopeFactory["onGetMembershipUserByUserIdGuard"] = async ( dto ) => { - const { permission } = await permissionService.getOrgPermission( - dto.permission.type, - dto.permission.id, - dto.permission.orgId, - dto.permission.authMethod, - dto.permission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); }; diff --git a/backend/src/services/microsoft-teams/microsoft-teams-service.ts b/backend/src/services/microsoft-teams/microsoft-teams-service.ts index 23ed61402..ff17daa75 100644 --- a/backend/src/services/microsoft-teams/microsoft-teams-service.ts +++ b/backend/src/services/microsoft-teams/microsoft-teams-service.ts @@ -9,6 +9,7 @@ import { import { CronJob } from "cron"; import { FastifyReply, FastifyRequest } from "fastify"; +import { OrganizationActionScope } from "@app/db/schemas"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; @@ -208,13 +209,14 @@ export const microsoftTeamsServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - microsoftTeamsIntegration.orgId, + orgId: microsoftTeamsIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); @@ -282,13 +284,14 @@ export const microsoftTeamsServiceFactory = ({ description, redirectUri }: TCreateMicrosoftTeamsIntegrationDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); @@ -393,13 +396,14 @@ export const microsoftTeamsServiceFactory = ({ }); }; const getClientId = async ({ actorId, actor, actorOrgId, actorAuthMethod }: TGetClientIdDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); @@ -427,13 +431,14 @@ export const microsoftTeamsServiceFactory = ({ actorOrgId, actorAuthMethod }: TGetMicrosoftTeamsIntegrationByOrgDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); @@ -463,13 +468,14 @@ export const microsoftTeamsServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - microsoftTeamsIntegration.orgId, + orgId: microsoftTeamsIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); @@ -495,13 +501,14 @@ export const microsoftTeamsServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - microsoftTeamsIntegration.orgId, + orgId: microsoftTeamsIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); @@ -549,13 +556,14 @@ export const microsoftTeamsServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - microsoftTeamsIntegration.orgId, + orgId: microsoftTeamsIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Settings); @@ -577,13 +585,14 @@ export const microsoftTeamsServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - microsoftTeamsIntegration.orgId, + orgId: microsoftTeamsIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); diff --git a/backend/src/services/org-admin/org-admin-service.ts b/backend/src/services/org-admin/org-admin-service.ts index 4c080717d..4f4a9b08c 100644 --- a/backend/src/services/org-admin/org-admin-service.ts +++ b/backend/src/services/org-admin/org-admin-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { AccessScope, ProjectMembershipRole, ProjectVersion } from "@app/db/schemas"; +import { AccessScope, OrganizationActionScope, ProjectMembershipRole, ProjectVersion } from "@app/db/schemas"; import { OrgPermissionAdminConsoleAction, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -44,13 +44,14 @@ export const orgAdminServiceFactory = ({ actorOrgId, actorAuthMethod }: TListOrgProjectsDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole @@ -76,13 +77,14 @@ export const orgAdminServiceFactory = ({ actorAuthMethod, projectId }: TAccessProjectDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole diff --git a/backend/src/services/org/org-bot-dal.ts b/backend/src/services/org/org-bot-dal.ts deleted file mode 100644 index b2ee54758..000000000 --- a/backend/src/services/org/org-bot-dal.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; - -export type TOrgBotDALFactory = ReturnType; - -export const orgBotDALFactory = (db: TDbClient) => { - const orgBotOrm = ormify(db, TableName.OrgBot); - return orgBotOrm; -}; diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index d987c890c..c2565f36b 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -26,6 +26,7 @@ import { } from "@app/lib/knex"; import { generateKnexQueryFromScim } from "@app/lib/knex/scim"; +import { ActorType } from "../auth/auth-type"; import { OrgAuthMethod } from "./org-types"; export type TOrgDALFactory = ReturnType; @@ -64,6 +65,7 @@ export const orgDALFactory = (db: TDbClient) => { const buildBaseQuery = (orgIdSubquery: Knex.QueryBuilder) => { return db .replicaNode()(TableName.Organization) + .whereNull(`${TableName.Organization}.rootOrgId`) .whereIn(`${TableName.Organization}.id`, orgIdSubquery) .leftJoin(TableName.Project, `${TableName.Organization}.id`, `${TableName.Project}.orgId`) .leftJoin(TableName.Membership, `${TableName.Organization}.id`, `${TableName.Membership}.scopeOrgId`) @@ -154,11 +156,49 @@ export const orgDALFactory = (db: TDbClient) => { } }; + const listSubOrganizations = async (dto: { + actorId: string; + actorType: ActorType; + orgId: string; + isAccessible?: boolean; + limit?: number; + offset?: number; + }) => { + try { + // TODO(sub-org:group): check this when implement group support + const query = db + .replicaNode()(TableName.Organization) + .where(`${TableName.Organization}.rootOrgId`, dto.orgId) + .select(selectAllTableCols(TableName.Organization)); + + if (dto.isAccessible) { + void query + .leftJoin(`${TableName.Membership}`, `${TableName.Membership}.scopeOrgId`, `${TableName.Organization}.id`) + .where((qb) => { + void qb.where(`${TableName.Membership}.scope`, AccessScope.Organization); + if (dto.actorType === ActorType.IDENTITY) { + void qb.andWhere(`${TableName.Membership}.actorIdentityId`, dto.actorId); + } else { + void qb.andWhere(`${TableName.Membership}.actorUserId`, dto.actorId); + } + }); + } + if (dto.limit) void query.limit(dto.limit); + if (dto.offset) void query.offset(dto.offset); + + const orgs = await query; + return orgs; + } catch (error) { + throw new DatabaseError({ error, name: "List sub organization" }); + } + }; + const findOrgById = async (orgId: string) => { try { const org = (await db .replicaNode()(TableName.Organization) .where({ [`${TableName.Organization}.id` as "id"]: orgId }) + .whereNull(`${TableName.Organization}.rootOrgId`) .leftJoin(TableName.SamlConfig, (qb) => { qb.on(`${TableName.SamlConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn( `${TableName.SamlConfig}.isActive`, @@ -195,6 +235,7 @@ export const orgDALFactory = (db: TDbClient) => { try { const org = (await db .replicaNode()(TableName.Organization) + .whereNull(`${TableName.Organization}.rootOrgId`) .where({ [`${TableName.Organization}.slug` as "slug"]: orgSlug }) .leftJoin(TableName.SamlConfig, (qb) => { qb.on(`${TableName.SamlConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn( @@ -240,6 +281,7 @@ export const orgDALFactory = (db: TDbClient) => { .whereNotNull(`${TableName.Membership}.actorUserId`) .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) .join(TableName.Organization, `${TableName.Membership}.scopeOrgId`, `${TableName.Organization}.id`) + .whereNull(`${TableName.Organization}.rootOrgId`) .leftJoin(TableName.SamlConfig, (qb) => { qb.on(`${TableName.SamlConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn( `${TableName.SamlConfig}.isActive`, @@ -337,6 +379,7 @@ export const orgDALFactory = (db: TDbClient) => { } }; + // TODO(sub-org): updated this logic later const countAllOrgMembers = async (orgId: string) => { try { interface CountResult { @@ -610,6 +653,7 @@ export const orgDALFactory = (db: TDbClient) => { }) .join(TableName.Users, `${TableName.Users}.id`, `${TableName.Membership}.actorUserId`) .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.Membership}.scopeOrgId`) + .whereNull(`${TableName.Organization}.rootOrgId`) .leftJoin(TableName.UserAliases, function joinUserAlias() { this.on(`${TableName.UserAliases}.userId`, "=", `${TableName.Membership}.actorUserId`) .andOn(`${TableName.UserAliases}.orgId`, "=", `${TableName.Membership}.scopeOrgId`) @@ -648,6 +692,7 @@ export const orgDALFactory = (db: TDbClient) => { .replicaNode()(TableName.Membership) .where({ actorIdentityId: identityId }) .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNull(`${TableName.Organization}.rootOrgId`) .whereNotNull(`${TableName.Membership}.actorIdentityId`) .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) .join(TableName.Organization, `${TableName.Membership}.scopeOrgId`, `${TableName.Organization}.id`) @@ -662,11 +707,30 @@ export const orgDALFactory = (db: TDbClient) => { } }; + const findRootOrgDetails = async (orgId: string, tx?: Knex): Promise => { + try { + const org = await (tx ?? db.replicaNode())(TableName.Organization) + .select(selectAllTableCols(TableName.Organization)) + .where( + "id", + db(TableName.Organization) + .select(db.raw(`CASE WHEN "rootOrgId" IS NULL THEN id ELSE "rootOrgId" END`)) + .where("id", orgId) + ) + .first(); + + return org; + } catch (error) { + throw new DatabaseError({ error, name: "FindRootOrgDetails" }); + } + }; + return withTransaction(db, { ...orgOrm, findOrgByProjectId, findAllOrgMembers, countAllOrgMembers, + listSubOrganizations, findOrgById, findOrgBySlug, findAllOrgsByUserId, @@ -684,6 +748,7 @@ export const orgDALFactory = (db: TDbClient) => { deleteMembershipById, deleteMembershipsById, updateMembership, - findIdentityOrganization + findIdentityOrganization, + findRootOrgDetails }); }; diff --git a/backend/src/services/org/org-fns.ts b/backend/src/services/org/org-fns.ts index 78d52e816..b887eeea1 100644 --- a/backend/src/services/org/org-fns.ts +++ b/backend/src/services/org/org-fns.ts @@ -13,7 +13,7 @@ import { TMembershipUserDALFactory } from "../membership-user/membership-user-da type TDeleteOrgMemberships = { orgMembershipIds: string[]; orgId: string; - orgDAL: Pick; + orgDAL: Pick; userGroupMembershipDAL: Pick; membershipUserDAL: Pick; membershipRoleDAL: Pick; @@ -34,19 +34,9 @@ export const deleteOrgMembershipsFn = async ({ userId, membershipUserDAL, userGroupMembershipDAL, - membershipRoleDAL, additionalPrivilegeDAL }: TDeleteOrgMemberships) => { const deletedMemberships = await orgDAL.transaction(async (tx) => { - await membershipRoleDAL.delete( - { - $in: { - membershipId: orgMembershipIds - } - }, - tx - ); - const orgMemberships = await membershipUserDAL.delete( { scopeOrgId: orgId, @@ -83,12 +73,13 @@ export const deleteOrgMembershipsFn = async ({ ); // Get all the project memberships of the users in the organization + const childOrgs = await orgDAL.find({ rootOrgId: orgId }, { tx }); // Delete all the project memberships of the users in the organization const otherMemberships = await membershipUserDAL.delete( { - scopeOrgId: orgId, $in: { + scopeOrgId: [orgId].concat(childOrgs.map((el) => el.id)), actorUserId: membershipUserIds } }, @@ -96,7 +87,9 @@ export const deleteOrgMembershipsFn = async ({ ); const orgGroups = await membershipUserDAL.find({ - scopeOrgId: orgId, + $in: { + scopeOrgId: [orgId].concat(childOrgs.map((el) => el.id)) + }, $notNull: ["actorGroupId"] }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 5b98b44b1..51d907e05 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -4,6 +4,7 @@ import { Knex } from "knex"; import { AccessScope, + OrganizationActionScope, OrgMembershipRole, OrgMembershipStatus, TableName, @@ -57,7 +58,6 @@ import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge- import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TIncidentContactsDALFactory } from "./incident-contacts-dal"; -import { TOrgBotDALFactory } from "./org-bot-dal"; import { TOrgDALFactory } from "./org-dal"; import { deleteOrgMembershipsFn } from "./org-fns"; import { @@ -81,7 +81,6 @@ type TOrgServiceFactoryDep = { secretV2BridgeDAL: Pick; folderDAL: Pick; orgDAL: TOrgDALFactory; - orgBotDAL: TOrgBotDALFactory; roleDAL: TRoleDALFactory; userDAL: TUserDALFactory; groupDAL: TGroupDALFactory; @@ -135,7 +134,6 @@ export const orgServiceFactory = ({ projectKeyDAL, orgMembershipDAL, tokenService, - orgBotDAL, licenseService, samlConfigDAL, oidcConfigDAL, @@ -156,16 +154,31 @@ export const orgServiceFactory = ({ userId: string, orgId: string, actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined + rootOrgId: string, + actorOrgId: string ) => { - await permissionService.getOrgPermission(ActorType.USER, userId, orgId, actorAuthMethod, actorOrgId); + await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: userId, + orgId, + actorAuthMethod, + actorOrgId: rootOrgId, + scope: OrganizationActionScope.Any + }); const appCfg = getConfig(); const org = await orgDAL.findOrgById(orgId); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); - if (!org.userTokenExpiration) { - return { ...org, userTokenExpiration: appCfg.JWT_REFRESH_LIFETIME }; + + const hasSubOrg = actorOrgId !== rootOrgId; + let subOrg; + if (hasSubOrg) { + subOrg = await orgDAL.findOne({ rootOrgId, id: actorOrgId }); } - return org; + + if (!org.userTokenExpiration) { + return { ...org, userTokenExpiration: appCfg.JWT_REFRESH_LIFETIME, subOrganization: subOrg }; + } + return { ...org, subOrganization: subOrg }; }; /* * Get all organization a user part of @@ -192,15 +205,16 @@ export const orgServiceFactory = ({ userId: string, orgId: string, actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined + actorOrgId: string ) => { - const { permission } = await permissionService.getOrgPermission( - ActorType.USER, - userId, + const { permission } = await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: userId, orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); const members = await orgDAL.findAllOrgMembers(orgId); @@ -208,7 +222,14 @@ export const orgServiceFactory = ({ }; const getOrgGroups = async ({ actor, actorId, orgId, actorAuthMethod, actorOrgId }: TGetOrgGroupsDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); const groups = await groupDAL.findByOrgId(orgId); return groups; @@ -222,7 +243,14 @@ export const orgServiceFactory = ({ orgId, emails }: TFindOrgMembersByEmailDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); const members = await orgDAL.findOrgMembersByUsername(orgId, emails); @@ -309,13 +337,14 @@ export const orgServiceFactory = ({ actorAuthMethod, orgId }: TUpgradePrivilegeSystemDTO) => { - const { hasRole } = await permissionService.getOrgPermission( - ActorType.USER, + const { hasRole } = await permissionService.getOrgPermission({ + actor: ActorType.USER, actorId, orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.ParentOrganization + }); if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ @@ -380,7 +409,14 @@ export const orgServiceFactory = ({ } }: TUpdateOrgDTO) => { const appCfg = getConfig(); - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); if (allowSecretSharingOutsideOrganization !== undefined) { @@ -477,6 +513,12 @@ export const orgServiceFactory = ({ } } + if (slug) { + const existingOrg = await orgDAL.findOne({ slug, rootOrgId: null }); + if (existingOrg && existingOrg?.id !== orgId) + throw new BadRequestError({ message: `Organization with slug ${slug} already exist` }); + } + if (googleSsoAuthEnforced) { if (googleSsoAuthEnforced && currentOrg.authEnforced) { throw new BadRequestError({ @@ -567,23 +609,6 @@ export const orgServiceFactory = ({ }, trx?: Knex ) => { - const { privateKey, publicKey } = await crypto.encryption().asymmetric().generateKeyPair(); - const key = crypto.randomBytes(32).toString("base64"); - const { - ciphertext: encryptedPrivateKey, - iv: privateKeyIV, - tag: privateKeyTag, - encoding: privateKeyKeyEncoding, - algorithm: privateKeyAlgorithm - } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(privateKey); - const { - ciphertext: encryptedSymmetricKey, - iv: symmetricKeyIV, - tag: symmetricKeyTag, - encoding: symmetricKeyKeyEncoding, - algorithm: symmetricKeyAlgorithm - } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(key); - const customerId = await licenseService.generateOrgCustomerId(orgName, userEmail); const createOrg = async (tx: Knex) => { @@ -611,30 +636,13 @@ export const orgServiceFactory = ({ tx ); } - await orgBotDAL.create( - { - name: org.name, - publicKey, - privateKeyIV, - encryptedPrivateKey, - symmetricKeyIV, - symmetricKeyTag, - encryptedSymmetricKey, - symmetricKeyAlgorithm, - orgId: org.id, - privateKeyTag, - privateKeyAlgorithm, - privateKeyKeyEncoding, - symmetricKeyKeyEncoding - }, - tx - ); + return org; }; const organization = await (trx ? createOrg(trx) : orgDAL.transaction(createOrg)); - await licenseService.updateSubscriptionOrgMemberCount(organization.id); + await licenseService.updateSubscriptionOrgMemberCount(organization.id, trx); return organization; }; @@ -656,15 +664,16 @@ export const orgServiceFactory = ({ ipAddress: string; orgId: string; actorAuthMethod: ActorAuthMethod; - actorOrgId: string | undefined; + actorOrgId: string; }) => { - const { hasRole } = await permissionService.getOrgPermission( - ActorType.USER, - userId, + const { hasRole } = await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: userId, orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ name: "DeleteOrganizationById", @@ -744,13 +753,14 @@ export const orgServiceFactory = ({ actorOrgId, metadata }: TUpdateOrgMembershipDTO) => { - const { permission } = await permissionService.getOrgPermission( - ActorType.USER, - userId, + const { permission } = await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: userId, orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Member); const foundMembership = await membershipUserDAL.findOne({ @@ -831,7 +841,14 @@ export const orgServiceFactory = ({ membershipId }: TResendOrgMemberInvitationDTO) => { const appCfg = getConfig(); - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); @@ -967,7 +984,14 @@ export const orgServiceFactory = ({ actorAuthMethod, actorOrgId }: TGetOrgMembershipDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); const membership = await orgMembershipDAL.findOrgMembershipById(membershipId); @@ -988,13 +1012,14 @@ export const orgServiceFactory = ({ actorAuthMethod, actorOrgId }: TDeleteOrgMembershipDTO) => { - const { permission } = await permissionService.getOrgPermission( - ActorType.USER, - userId, + const { permission } = await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: userId, orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); const [deletedMembership] = await deleteOrgMembershipsFn({ @@ -1021,13 +1046,14 @@ export const orgServiceFactory = ({ actorAuthMethod, actorOrgId }: TDeleteOrgMembershipsDTO) => { - const { permission } = await permissionService.getOrgPermission( - ActorType.USER, - userId, + const { permission } = await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: userId, orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); if (membershipIds.includes(userId)) { @@ -1059,7 +1085,14 @@ export const orgServiceFactory = ({ actorAuthMethod, actorOrgId }: TListProjectMembershipsByOrgMembershipIdDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); const membership = await orgMembershipDAL.findOrgMembershipById(orgMembershipId); @@ -1080,15 +1113,16 @@ export const orgServiceFactory = ({ userId: string, orgId: string, actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined + actorOrgId: string ) => { - const { permission } = await permissionService.getOrgPermission( - ActorType.USER, - userId, + const { permission } = await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: userId, orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); const incidentContacts = await incidentContactDAL.findByOrgId(orgId); return incidentContacts; @@ -1099,15 +1133,16 @@ export const orgServiceFactory = ({ orgId: string, email: string, actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined + actorOrgId: string ) => { - const { permission } = await permissionService.getOrgPermission( - ActorType.USER, - userId, + const { permission } = await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: userId, orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.IncidentAccount); const doesIncidentContactExist = await incidentContactDAL.findOne(orgId, { email }); if (doesIncidentContactExist) { @@ -1126,15 +1161,16 @@ export const orgServiceFactory = ({ orgId: string, id: string, actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined + actorOrgId: string ) => { - const { permission } = await permissionService.getOrgPermission( - ActorType.USER, - userId, + const { permission } = await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: userId, orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.IncidentAccount); const incidentContact = await incidentContactDAL.deleteById(id, orgId); diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 1a27d131f..48680456c 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -8,7 +8,7 @@ export type TUpdateOrgMembershipDTO = { membershipId: string; role?: string; isActive?: boolean; - actorOrgId: string | undefined; + actorOrgId: string; metadata?: { key: string; value: string }[]; actorAuthMethod: ActorAuthMethod; }; @@ -21,7 +21,7 @@ export type TDeleteOrgMembershipDTO = { userId: string; orgId: string; membershipId: string; - actorOrgId: string | undefined; + actorOrgId: string; actorAuthMethod: ActorAuthMethod; }; @@ -29,7 +29,7 @@ export type TDeleteOrgMembershipsDTO = { userId: string; orgId: string; membershipIds: string[]; - actorOrgId: string | undefined; + actorOrgId: string; actorAuthMethod: ActorAuthMethod; }; @@ -54,7 +54,7 @@ export type TVerifyUserToOrgDTO = { export type TFindOrgMembersByEmailDTO = { actor: ActorType; - actorOrgId: string | undefined; + actorOrgId: string; actorId: string; actorAuthMethod: ActorAuthMethod; orgId: string; @@ -64,7 +64,7 @@ export type TFindOrgMembersByEmailDTO = { export type TFindAllWorkspacesDTO = { actor: ActorType; actorId: string; - actorOrgId: string | undefined; + actorOrgId: string; actorAuthMethod: ActorAuthMethod; orgId: string; }; diff --git a/backend/src/services/pam-account-rotation/pam-account-rotation-queue.ts b/backend/src/services/pam-account-rotation/pam-account-rotation-queue.ts new file mode 100644 index 000000000..6ed78f665 --- /dev/null +++ b/backend/src/services/pam-account-rotation/pam-account-rotation-queue.ts @@ -0,0 +1,61 @@ +import { TPamAccountServiceFactory } from "@app/ee/services/pam-account/pam-account-service"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +type TPamAccountRotationServiceFactoryDep = { + queueService: TQueueServiceFactory; + pamAccountService: Pick; +}; + +export type TPamAccountRotationServiceFactory = ReturnType; + +export const pamAccountRotationServiceFactory = ({ + queueService, + pamAccountService +}: TPamAccountRotationServiceFactoryDep) => { + const appCfg = getConfig(); + + const init = async () => { + if (appCfg.isSecondaryInstance) { + return; + } + + await queueService.stopRepeatableJob( + QueueName.PamAccountRotation, + QueueJobs.PamAccountRotation, + { pattern: "0 * * * *", utc: true }, + QueueName.PamAccountRotation // job id + ); + + await queueService.startPg( + QueueJobs.PamAccountRotation, + async () => { + try { + logger.info(`${QueueName.PamAccountRotation}: pam account rotation task started`); + await pamAccountService.rotateAllDueAccounts(); + logger.info(`${QueueName.PamAccountRotation}: pam account rotation task completed`); + } catch (error) { + logger.error(error, `${QueueName.PamAccountRotation}: pam account rotation failed`); + throw error; + } + }, + { + batchSize: 1, + workerCount: 1, + pollingIntervalSeconds: 5 * 60 + } + ); + + await queueService.schedulePg( + QueueJobs.PamAccountRotation, + "0 * * * *", // Schedule to run every hour + undefined, + { tz: "UTC" } + ); + }; + + return { + init + }; +}; diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index 2abdebdbc..11d4239db 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -413,10 +413,12 @@ export const projectDALFactory = (db: TDbClient) => { const countOfOrgProjects = async (orgId: string | null, tx?: Knex) => { try { + const subOrgProjects = db.replicaNode()(TableName.Organization).where({ rootOrgId: orgId }).select("id"); + const doc = await (tx || db.replicaNode())(TableName.Project) .andWhere((bd) => { if (orgId) { - void bd.where({ orgId }); + void bd.where({ orgId }).orWhereIn("orgId", subOrgProjects); } }) .count(); diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 58b3c5395..e29f18404 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -5,6 +5,7 @@ import slugify from "@sindresorhus/slugify"; import { AccessScope, ActionProjectType, + OrganizationActionScope, ProjectMembershipRole, ProjectType, ProjectVersion, @@ -245,13 +246,14 @@ export const projectServiceFactory = ({ type = ProjectType.SecretManager }: TCreateProjectDTO) => { const organization = await orgDAL.findOne({ id: actorOrgId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - organization.id, + orgId: organization.id, actorAuthMethod, actorOrgId - ); + }); if ( permission.cannot(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace) && @@ -513,13 +515,14 @@ export const projectServiceFactory = ({ : await projectDAL.findUserProjects(actorId, actorOrgId, type); if (includeRoles) { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); // `includeRoles` is specifically used by organization admins when inviting new users to the organizations to avoid looping redundant api calls. ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); @@ -1822,13 +1825,14 @@ export const projectServiceFactory = ({ projectIds }: TSearchProjectsDTO) => { // check user belong to org - await permissionService.getOrgPermission( - permission.type, - permission.id, - permission.orgId, - permission.authMethod, - permission.orgId - ); + await permissionService.getOrgPermission({ + actor: permission.type, + actorId: permission.id, + orgId: permission.orgId, + actorAuthMethod: permission.authMethod, + scope: OrganizationActionScope.Any, + actorOrgId: permission.orgId + }); return projectDAL.searchProjects({ limit, @@ -1846,13 +1850,14 @@ export const projectServiceFactory = ({ const requestProjectAccess = async ({ permission, comment, projectId }: TProjectAccessRequestDTO) => { // check user belong to org - await permissionService.getOrgPermission( - permission.type, - permission.id, - permission.orgId, - permission.authMethod, - permission.orgId - ); + await permissionService.getOrgPermission({ + actor: permission.type, + actorId: permission.id, + orgId: permission.orgId, + actorAuthMethod: permission.authMethod, + actorOrgId: permission.orgId, + scope: OrganizationActionScope.Any + }); const projectMember = await permissionService .getProjectPermission({ diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 74c7e95f4..18ae74350 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -41,7 +41,7 @@ export type TCreateProjectDTO = { actor: ActorType; actorAuthMethod: ActorAuthMethod; actorId: string; - actorOrgId?: string; + actorOrgId: string; projectName: string; projectDescription?: string; slug?: string; diff --git a/backend/src/services/role/org/org-role-factory.ts b/backend/src/services/role/org/org-role-factory.ts index 50ffa5e43..f91dabccb 100644 --- a/backend/src/services/role/org/org-role-factory.ts +++ b/backend/src/services/role/org/org-role-factory.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { AccessScope } from "@app/db/schemas"; +import { AccessScope, OrganizationActionScope } from "@app/db/schemas"; import { orgAdminPermissions, orgMemberPermissions, @@ -34,35 +34,38 @@ export const newOrgRoleFactory = ({ const isCustomRole: TRoleScopeFactory["isCustomRole"] = (role: string) => isCustomOrgRole(role); const onCreateRoleGuard: TRoleScopeFactory["onCreateRoleGuard"] = async (dto) => { - const { permission } = await permissionService.getOrgPermission( - dto.permission.type, - dto.permission.id, - dto.permission.orgId, - dto.permission.authMethod, - dto.permission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Role); }; const onUpdateRoleGuard: TRoleScopeFactory["onUpdateRoleGuard"] = async (dto) => { - const { permission } = await permissionService.getOrgPermission( - dto.permission.type, - dto.permission.id, - dto.permission.orgId, - dto.permission.authMethod, - dto.permission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Role); }; const onDeleteRoleGuard: TRoleScopeFactory["onDeleteRoleGuard"] = async (dto) => { - const { permission } = await permissionService.getOrgPermission( - dto.permission.type, - dto.permission.id, - dto.permission.orgId, - dto.permission.authMethod, - dto.permission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Role); const externalGroupMapping = await externalGroupOrgRoleMappingDAL.findOne({ @@ -78,35 +81,38 @@ export const newOrgRoleFactory = ({ }; const onListRoleGuard: TRoleScopeFactory["onListRoleGuard"] = async (dto) => { - const { permission } = await permissionService.getOrgPermission( - dto.permission.type, - dto.permission.id, - dto.permission.orgId, - dto.permission.authMethod, - dto.permission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Role); }; const onGetRoleByIdGuard: TRoleScopeFactory["onGetRoleByIdGuard"] = async (dto) => { - const { permission } = await permissionService.getOrgPermission( - dto.permission.type, - dto.permission.id, - dto.permission.orgId, - dto.permission.authMethod, - dto.permission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Role); }; const onGetRoleBySlugGuard: TRoleScopeFactory["onGetRoleBySlugGuard"] = async (dto) => { - const { permission } = await permissionService.getOrgPermission( - dto.permission.type, - dto.permission.id, - dto.permission.orgId, - dto.permission.authMethod, - dto.permission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Role); }; diff --git a/backend/src/services/role/role-service.ts b/backend/src/services/role/role-service.ts index 41c825b2e..3387dc96b 100644 --- a/backend/src/services/role/role-service.ts +++ b/backend/src/services/role/role-service.ts @@ -1,7 +1,7 @@ import { packRules } from "@casl/ability/extra"; import { requestContext } from "@fastify/request-context"; -import { AccessScope, ActionProjectType, TableName } from "@app/db/schemas"; +import { AccessScope, ActionProjectType, OrganizationActionScope, TableName } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; @@ -214,13 +214,14 @@ export const roleServiceFactory = ({ const getUserPermission = async (dto: TGetUserPermissionDTO) => { if (dto.scopeData.scope === AccessScope.Organization) { - const { permission, memberships } = await permissionService.getOrgPermission( - dto.permission.type, - dto.permission.id, - dto.permission.orgId, - dto.permission.authMethod, - dto.permission.orgId - ); + const { permission, memberships } = await permissionService.getOrgPermission({ + actorId: dto.permission.id, + actor: dto.permission.type, + orgId: dto.permission.orgId, + actorOrgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + scope: OrganizationActionScope.Any + }); return { permissions: packRules(permission.rules), memberships, assumedPrivilegeDetails: undefined }; } diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 7bacd9468..c216ea3a8 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -118,24 +118,11 @@ export const secretFolderServiceFactory = ({ }); } - // check if the exact folder already exists - const existingFolder = await folderDAL.findOne( - { - envId: env.id, - parentId: parentFolder.id, - name, - isReserved: false - }, - tx - ); - - if (existingFolder) { - return existingFolder; - } - // exact folder case if (parentFolder.path === pathWithFolder) { - return parentFolder; + throw new BadRequestError({ + message: `Folder with name '${name}' already exists in path '${secretPath}'` + }); } let currentParentId = parentFolder.id; diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts index 4cbfcdc7f..87dd207f1 100644 --- a/backend/src/services/secret-sharing/secret-sharing-service.ts +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -1,4 +1,4 @@ -import { TSecretSharing } from "@app/db/schemas"; +import { OrganizationActionScope, TSecretSharing } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; @@ -81,12 +81,21 @@ export const secretSharingServiceFactory = ({ }: TCreateSharedSecretDTO) => { const appCfg = getConfig(); - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" }); $validateSharedSecretExpiry(expiresAt); - const org = await orgDAL.findOrgById(orgId); - if (!org.allowSecretSharingOutsideOrganization && accessType === SecretSharingAccessType.Anyone) { + const rootOrg = await orgDAL.findRootOrgDetails(orgId); + if (!rootOrg) throw new BadRequestError({ message: `Organization with id ${orgId} not found` }); + + if (!rootOrg.allowSecretSharingOutsideOrganization && accessType === SecretSharingAccessType.Anyone) { throw new BadRequestError({ message: "Organization does not allow sharing secrets to members outside of this organization" }); @@ -100,13 +109,16 @@ export const secretSharingServiceFactory = ({ const expiresAtTimestamp = new Date(expiresAt).getTime(); const lifetime = expiresAtTimestamp - new Date().getTime(); - // org.maxSharedSecretLifetime is in seconds - if (org.maxSharedSecretLifetime && lifetime / 1000 > org.maxSharedSecretLifetime) { + // rootOrg.maxSharedSecretLifetime is in seconds + if (rootOrg.maxSharedSecretLifetime && lifetime / 1000 > rootOrg.maxSharedSecretLifetime) { throw new BadRequestError({ message: "Secret lifetime exceeds organization limit" }); } // Check max view count is within org allowance - if (org.maxSharedSecretViewLimit && (!expiresAfterViews || expiresAfterViews > org.maxSharedSecretViewLimit)) { + if ( + rootOrg.maxSharedSecretViewLimit && + (!expiresAfterViews || expiresAfterViews > rootOrg.maxSharedSecretViewLimit) + ) { throw new BadRequestError({ message: "Secret max views parameter exceeds organization limit" }); } @@ -122,7 +134,10 @@ export const secretSharingServiceFactory = ({ if (allOrgMembers.some((v) => v.user.email === email)) { orgEmails.push(email); // If the email is not part of the org, but access type / org settings require it - } else if (!org.allowSecretSharingOutsideOrganization || accessType === SecretSharingAccessType.Organization) { + } else if ( + !rootOrg.allowSecretSharingOutsideOrganization || + accessType === SecretSharingAccessType.Organization + ) { throw new BadRequestError({ message: "Organization does not allow sharing secrets to members outside of this organization" }); @@ -196,7 +211,14 @@ export const secretSharingServiceFactory = ({ actorAuthMethod, actorOrgId }: TCreateSecretRequestDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" }); $validateSharedSecretExpiry(expiresAt); @@ -228,7 +250,14 @@ export const secretSharingServiceFactory = ({ throw new NotFoundError({ message: `Secret request with ID '${id}' not found` }); } - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" }); if (secretRequest.userId !== actorId || secretRequest.orgId !== orgId) { @@ -267,13 +296,14 @@ export const secretSharingServiceFactory = ({ throw new UnauthorizedError(); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - secretRequest.orgId, + orgId: secretRequest.orgId, actorAuthMethod, actorOrgId - ); + }); if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" }); } @@ -316,13 +346,14 @@ export const secretSharingServiceFactory = ({ throw new UnauthorizedError(); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - secretRequest.orgId, + orgId: secretRequest.orgId, actorAuthMethod, actorOrgId - ); + }); if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" }); const user = await userDAL.findById(actorId); @@ -415,13 +446,14 @@ export const secretSharingServiceFactory = ({ }: TGetSharedSecretsDTO) => { if (!actorOrgId) throw new ForbiddenRequestError(); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); if (!permission) throw new ForbiddenRequestError({ name: "User does not belong to the specified organization" }); const secrets = await secretSharingDAL.find( @@ -563,7 +595,14 @@ export const secretSharingServiceFactory = ({ const deleteSharedSecretById = async (deleteSharedSecretInput: TDeleteSharedSecretDTO) => { const { actor, actorId, orgId, actorAuthMethod, actorOrgId, sharedSecretId } = deleteSharedSecretInput; - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); if (!permission) throw new ForbiddenRequestError({ name: "User does not belong to the specified organization" }); const sharedSecret = isUuidV4(sharedSecretId) diff --git a/backend/src/services/service-token/service-token-service.ts b/backend/src/services/service-token/service-token-service.ts index 2aa495673..081b99208 100644 --- a/backend/src/services/service-token/service-token-service.ts +++ b/backend/src/services/service-token/service-token-service.ts @@ -14,6 +14,7 @@ import { logger } from "@app/lib/logger"; import { TAccessTokenQueueServiceFactory } from "../access-token-queue/access-token-queue"; import { ActorType } from "../auth/auth-type"; +import { TOrgDALFactory } from "../org/org-dal"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; @@ -29,6 +30,7 @@ import { type TServiceTokenServiceFactoryDep = { serviceTokenDAL: TServiceTokenDALFactory; userDAL: TUserDALFactory; + orgDAL: Pick; permissionService: Pick; projectEnvDAL: Pick; projectDAL: Pick; @@ -45,7 +47,8 @@ export const serviceTokenServiceFactory = ({ projectEnvDAL, projectDAL, accessTokenQueue, - smtpService + smtpService, + orgDAL }: TServiceTokenServiceFactoryDep) => { const createServiceToken = async ({ iv, @@ -184,7 +187,15 @@ export const serviceTokenServiceFactory = ({ if (!isMatch) throw new UnauthorizedError({ message: "Invalid service token" }); await accessTokenQueue.updateServiceTokenStatus(serviceToken.id); - return { ...serviceToken, lastUsed: new Date(), orgId: project.orgId }; + const serviceTokenOrgDetails = await orgDAL.findById(project.orgId); + + return { + ...serviceToken, + lastUsed: new Date(), + orgId: project.orgId, + parentOrgId: serviceTokenOrgDetails.parentOrgId || serviceTokenOrgDetails.id, + rootOrgId: serviceTokenOrgDetails.rootOrgId || serviceTokenOrgDetails.id + }; }; const notifyExpiringTokens = async () => { diff --git a/backend/src/services/slack/slack-service.ts b/backend/src/services/slack/slack-service.ts index c8aa8aaf6..e4110ac11 100644 --- a/backend/src/services/slack/slack-service.ts +++ b/backend/src/services/slack/slack-service.ts @@ -1,6 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import { InstallProvider } from "@slack/oauth"; +import { OrganizationActionScope } from "@app/db/schemas"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { getConfig } from "@app/lib/config/env"; @@ -230,13 +231,14 @@ export const slackServiceFactory = ({ }: TGetSlackInstallUrlDTO) => { const appCfg = getConfig(); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); @@ -264,13 +266,14 @@ export const slackServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - slackIntegration.orgId, + orgId: slackIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); @@ -293,13 +296,14 @@ export const slackServiceFactory = ({ actorOrgId, actorAuthMethod }: TGetSlackIntegrationByOrgDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); @@ -324,13 +328,14 @@ export const slackServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - slackIntegration.orgId, + orgId: slackIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); @@ -351,13 +356,14 @@ export const slackServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - slackIntegration.orgId, + orgId: slackIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); @@ -389,13 +395,14 @@ export const slackServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - slackIntegration.orgId, + orgId: slackIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); @@ -432,13 +439,14 @@ export const slackServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - slackIntegration.orgId, + orgId: slackIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Settings); diff --git a/backend/src/services/smtp/emails/SubOrganizationInvitationTemplate.tsx b/backend/src/services/smtp/emails/SubOrganizationInvitationTemplate.tsx new file mode 100644 index 000000000..da93fc045 --- /dev/null +++ b/backend/src/services/smtp/emails/SubOrganizationInvitationTemplate.tsx @@ -0,0 +1,50 @@ +import { Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseButton } from "./BaseButton"; +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface SubOrganizationInvitationTemplateProps extends Omit { + callback_url: string; + subOrganizationName: string; +} + +export const SubOrganizationInvitationTemplate = ({ + callback_url, + subOrganizationName, + siteUrl +}: SubOrganizationInvitationTemplateProps) => { + return ( + + + You've been invited to join a sub-organization on Infisical + +
+ + You've been invited to join the sub-organization {subOrganizationName}. + +
+
+ Join Sub-Organization +
+
+ + About Infisical: Infisical is an all-in-one platform to securely manage application secrets, + certificates, SSH keys, and configurations across your team and infrastructure. + +
+
+ ); +}; + +export default SubOrganizationInvitationTemplate; + +SubOrganizationInvitationTemplate.PreviewProps = { + subOrganizationName: "Example Project", + siteUrl: "https://infisical.com", + callback_url: "https://app.infisical.com" +} as SubOrganizationInvitationTemplateProps; diff --git a/backend/src/services/smtp/emails/index.ts b/backend/src/services/smtp/emails/index.ts index 06ac31ab6..692cacbaf 100644 --- a/backend/src/services/smtp/emails/index.ts +++ b/backend/src/services/smtp/emails/index.ts @@ -31,4 +31,5 @@ export * from "./SecretScanningSecretsDetectedTemplate"; export * from "./SecretSyncFailedTemplate"; export * from "./ServiceTokenExpiryNoticeTemplate"; export * from "./SignupEmailVerificationTemplate"; +export * from "./SubOrganizationInvitationTemplate"; export * from "./UnlockAccountTemplate"; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 652f56567..cef22009a 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -40,6 +40,7 @@ import { SecretSyncFailedTemplate, ServiceTokenExpiryNoticeTemplate, SignupEmailVerificationTemplate, + SubOrganizationInvitationTemplate, UnlockAccountTemplate } from "./emails"; @@ -65,6 +66,7 @@ export enum SmtpTemplates { // HistoricalSecretList = "historicalSecretLeakIncident", not used anymore? NewDeviceJoin = "newDevice", OrgInvite = "organizationInvitation", + SubOrgInvite = "subOrganizationInvitation", OrgAssignment = "organizationAssignment", OAuthPasswordReset = "oAuthPasswordReset", ResetPassword = "passwordReset", @@ -102,6 +104,7 @@ export enum SmtpHost { // eslint-disable-next-line @typescript-eslint/no-explicit-any const EmailTemplateMap: Record> = { [SmtpTemplates.OrgInvite]: OrganizationInvitationTemplate, + [SmtpTemplates.SubOrgInvite]: SubOrganizationInvitationTemplate, [SmtpTemplates.OrgAssignment]: OrganizationAssignmentTemplate, [SmtpTemplates.NewDeviceJoin]: NewDeviceLoginTemplate, [SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate, diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 84a53f407..63bab2666 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -592,7 +592,7 @@ export const superAdminServiceFactory = ({ }); const { identity, credentials } = await identityDAL.transaction(async (tx) => { - const newIdentity = await identityDAL.create({ name: "Instance Admin Identity" }, tx); + const newIdentity = await identityDAL.create({ name: "Instance Admin Identity", orgId: organization.id }, tx); const membership = await membershipIdentityDAL.create( { actorIdentityId: newIdentity.id, diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index b54eab8ef..56d7ee635 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -1,7 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import { Knex } from "knex"; -import { AccessScope } from "@app/db/schemas"; +import { AccessScope, OrganizationActionScope } from "@app/db/schemas"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { crypto } from "@app/lib/crypto"; @@ -458,13 +458,14 @@ export const userServiceFactory = ({ // This makes it so the user can always read information about themselves, but no one else if they don't have the Members Read permission. if (user.id !== actorId) { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); } diff --git a/backend/src/services/workflow-integration/workflow-integration-service.ts b/backend/src/services/workflow-integration/workflow-integration-service.ts index cb7f7a325..8fea0e240 100644 --- a/backend/src/services/workflow-integration/workflow-integration-service.ts +++ b/backend/src/services/workflow-integration/workflow-integration-service.ts @@ -1,5 +1,6 @@ import { ForbiddenError } from "@casl/ability"; +import { OrganizationActionScope } from "@app/db/schemas"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; @@ -23,13 +24,14 @@ export const workflowIntegrationServiceFactory = ({ actorOrgId, actorAuthMethod }: TGetWorkflowIntegrationsByOrg) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 00dc19a46..e60ef1ba5 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -77,6 +77,7 @@ services: - TELEMETRY_ENABLED=false volumes: - ./backend/src:/app/src + - softhsm_tokens:/etc/softhsm2/tokens # SoftHSM tokens are stored in a volume to persist across container restarts extra_hosts: - "host.docker.internal:host-gateway" @@ -198,3 +199,5 @@ volumes: ldap_data: ldap_config: grafana_storage: + softhsm_tokens: + driver: local \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/create.mdx b/docs/api-reference/endpoints/certificate-profiles/create.mdx new file mode 100644 index 000000000..e24e42207 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/pki/certificate-profiles" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/delete.mdx b/docs/api-reference/endpoints/certificate-profiles/delete.mdx new file mode 100644 index 000000000..a1762640a --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/pki/certificate-profiles/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/get-by-id.mdx b/docs/api-reference/endpoints/certificate-profiles/get-by-id.mdx new file mode 100644 index 000000000..38e0c20f8 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/pki/certificate-profiles/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/get-by-slug.mdx b/docs/api-reference/endpoints/certificate-profiles/get-by-slug.mdx new file mode 100644 index 000000000..9013020d6 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/get-by-slug.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Slug" +openapi: "GET /api/v1/pki/certificate-profiles/slug/{slug}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/list-certificates.mdx b/docs/api-reference/endpoints/certificate-profiles/list-certificates.mdx new file mode 100644 index 000000000..d0a690f76 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/list-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "List Certificates" +openapi: "GET /api/v1/pki/certificate-profiles/{id}/certificates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/list.mdx b/docs/api-reference/endpoints/certificate-profiles/list.mdx new file mode 100644 index 000000000..c0f461512 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/pki/certificate-profiles" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/update.mdx b/docs/api-reference/endpoints/certificate-profiles/update.mdx new file mode 100644 index 000000000..e483cf030 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/pki/certificate-profiles/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/create.mdx b/docs/api-reference/endpoints/certificate-templates-v2/create.mdx new file mode 100644 index 000000000..2fb4da177 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v2/certificate-templates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/delete.mdx b/docs/api-reference/endpoints/certificate-templates-v2/delete.mdx new file mode 100644 index 000000000..dc92ca55a --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/certificate-templates/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/get-by-id.mdx b/docs/api-reference/endpoints/certificate-templates-v2/get-by-id.mdx new file mode 100644 index 000000000..c97389a1d --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/certificate-templates/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/list.mdx b/docs/api-reference/endpoints/certificate-templates-v2/list.mdx new file mode 100644 index 000000000..ab752e851 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/certificate-templates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/update.mdx b/docs/api-reference/endpoints/certificate-templates-v2/update.mdx new file mode 100644 index 000000000..7bdeca14e --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/certificate-templates/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates/create.mdx b/docs/api-reference/endpoints/certificate-templates/create.mdx deleted file mode 100644 index 56fcf3791..000000000 --- a/docs/api-reference/endpoints/certificate-templates/create.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Create" -openapi: "POST /api/v1/pki/certificate-templates" ---- diff --git a/docs/api-reference/endpoints/certificate-templates/delete.mdx b/docs/api-reference/endpoints/certificate-templates/delete.mdx deleted file mode 100644 index c4f13d470..000000000 --- a/docs/api-reference/endpoints/certificate-templates/delete.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Delete" -openapi: "DELETE /api/v1/pki/certificate-templates/{certificateTemplateId}" ---- diff --git a/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx b/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx deleted file mode 100644 index 802dc5326..000000000 --- a/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Get by ID" -openapi: "GET /api/v1/pki/certificate-templates/{certificateTemplateId}" ---- diff --git a/docs/api-reference/endpoints/certificate-templates/update.mdx b/docs/api-reference/endpoints/certificate-templates/update.mdx deleted file mode 100644 index 53c5f6fdf..000000000 --- a/docs/api-reference/endpoints/certificate-templates/update.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Update" -openapi: "PATCH /api/v1/pki/certificate-templates/{certificateTemplateId}" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/create.mdx b/docs/api-reference/endpoints/pki/subscribers/create.mdx deleted file mode 100644 index 14a53b7fa..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/create.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Create" -openapi: "POST /api/v1/pki/subscribers" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/delete.mdx b/docs/api-reference/endpoints/pki/subscribers/delete.mdx deleted file mode 100644 index 5975b89e9..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/delete.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Delete" -openapi: "DELETE /api/v1/pki/subscribers/{subscriberName}" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle.mdx b/docs/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle.mdx deleted file mode 100644 index 894c8ed4e..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Retrieve latest certificate bundle" -openapi: "GET /api/v1/pki/subscribers/{subscriberName}/latest-certificate-bundle" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx b/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx deleted file mode 100644 index c9c71c80d..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Issue Certificate" -openapi: "POST /api/v1/pki/subscribers/{subscriberName}/issue-certificate" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/list-certs.mdx b/docs/api-reference/endpoints/pki/subscribers/list-certs.mdx deleted file mode 100644 index 3a4607303..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/list-certs.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "List Certificates" -openapi: "GET /api/v1/pki/subscribers/{subscriberName}/certificates" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/order-cert.mdx b/docs/api-reference/endpoints/pki/subscribers/order-cert.mdx deleted file mode 100644 index 93abf1433..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/order-cert.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Order Certificate" -openapi: "POST /api/v1/pki/subscribers/{subscriberName}/order-certificate" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/read.mdx b/docs/api-reference/endpoints/pki/subscribers/read.mdx deleted file mode 100644 index 0d223217d..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/read.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Retrieve" -openapi: "GET /api/v1/pki/subscribers/{subscriberName}" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/sign-cert.mdx b/docs/api-reference/endpoints/pki/subscribers/sign-cert.mdx deleted file mode 100644 index d31d30239..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/sign-cert.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Sign Certificate" -openapi: "POST /api/v1/pki/subscribers/{subscriberName}/sign-certificate" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/update.mdx b/docs/api-reference/endpoints/pki/subscribers/update.mdx deleted file mode 100644 index 5b62cbe7d..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/update.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Update" -openapi: "PATCH /api/v1/pki/subscribers/{subscriberName}" ---- diff --git a/docs/cli/commands/gateway.mdx b/docs/cli/commands/gateway.mdx index bddbf614c..59202e46e 100644 --- a/docs/cli/commands/gateway.mdx +++ b/docs/cli/commands/gateway.mdx @@ -31,6 +31,7 @@ If you are moving from Gateway v1 to Gateway v2, this is NOT a drop-in switch. G ## Subcommands & flags + Run the Infisical gateway component within your the network where your target resources are located. The gateway establishes an SSH reverse tunnel to the specified relay server and provides secure access to private resources within your network. @@ -245,6 +246,7 @@ The Relay supports multiple authentication methods. Below are the available auth ### Other Flags + The name of the relay that this gateway should connect to. The relay must be running and registered before starting the gateway. @@ -276,6 +278,7 @@ The Relay supports multiple authentication methods. Below are the available auth ``` + @@ -293,6 +296,7 @@ sudo infisical gateway systemd install --token= --domain= --name= ### Flags + The machine identity access token to authenticate with Infisical. @@ -334,6 +338,7 @@ sudo infisical gateway systemd install --token= --domain= --name= ``` + ### Service Details @@ -360,9 +365,11 @@ sudo systemctl disable infisical-gateway # Disable auto-start on boot ``` + ## Legacy Gateway Commands + **This command is deprecated and will be removed in a future release.** @@ -579,6 +586,7 @@ The Infisical CLI supports multiple authentication methods. Below are the availa ### Other Flags + Domain of your self-hosted Infisical instance. @@ -588,6 +596,7 @@ The Infisical CLI supports multiple authentication methods. Below are the availa ``` + @@ -614,6 +623,7 @@ sudo infisical gateway install --token= --domain= ### Flags + The machine identity access token to authenticate with Infisical. @@ -635,6 +645,7 @@ sudo infisical gateway install --token= --domain= ``` + ### Service Details @@ -659,3 +670,4 @@ sudo systemctl disable infisical-gateway # Disable auto-start on boot ``` + diff --git a/docs/cli/commands/login.mdx b/docs/cli/commands/login.mdx index f93e3b4b2..1f7a08350 100644 --- a/docs/cli/commands/login.mdx +++ b/docs/cli/commands/login.mdx @@ -9,22 +9,93 @@ infisical login ### Description -The CLI uses authentication to verify your identity. When you enter the correct email and password for your account, a token is generated and saved in your system Keyring to allow you to make future interactions with the CLI. +The CLI uses authentication to verify your identity. You can authenticate using: +- **Browser Login** (default): Opens a browser for authentication +- **Direct Login**: Provide email and password via flags or environment variables for non-interactive workflows +- **Interactive CLI Login**: Use the `--interactive` flag to enter credentials via CLI prompts + +When authenticated, a token is generated and saved in your system Keyring to allow you to make future interactions with the CLI. To change where the login credentials are stored, visit the [vaults command](./vault). If you have added multiple users, you can switch between the users by using the [user command](./user). - When you authenticate with **any other method than `user`**, an access token will be printed to the console upon successful login. This token can be used to authenticate with the Infisical API and the CLI by passing it in the `--token` flag when applicable. - - Use flag `--plain` along with `--silent` to print only the token in plain text when using a machine identity auth method. - + **JWT Token Output:** + - For **user authentication** with the `--plain --silent` flags: outputs only the JWT access token (useful for scripting) + - For **machine identity authentication**: an access token is always printed to the console + + Use the `--plain` flag to print only the token in plain text and the `--silent` flag to disable update alerts. + + Both flags are ideal for capturing the token in environment variables or CI/CD pipelines. ### Authentication Methods -The Infisical CLI supports multiple authentication methods. Below are the available authentication methods, with their respective flags. +The Infisical CLI supports two main categories of authentication: User Authentication and Machine Identity Authentication. + +#### User Authentication + +User authentication is designed for individual developers and supports multiple login flows. + + + + The User authentication method allows you to log in with your email and password. This method supports three different login flows: + + - **Browser Login** (default): Opens a browser for authentication + - **Direct Login**: Provide credentials via flags or environment variables for CI/CD + - **Interactive CLI Login**: Enter credentials via CLI prompts using `--interactive` + + + + + Your email address. Required for direct login along with `--password`. + + + Your password. Required for direct login along with `--email`. + + + Force interactive CLI login instead of browser-based authentication. + + + Output only the JWT token (useful for scripting and CI/CD). + + + + + + + ```bash + infisical login + ``` + + + ```bash + infisical login --email=user@example.com --password=your-password + + # Or using environment variables + export INFISICAL_EMAIL="user@example.com" + export INFISICAL_PASSWORD="your-password" + infisical login + ``` + + + ```bash + infisical login --interactive + ``` + + + ```bash + export INFISICAL_TOKEN=$(infisical login --email=user@example.com --password=your-password --plain --silent) + ``` + + + + + +#### Machine Identity Authentication + +Machine identity authentication methods are designed for automated systems, services, and CI/CD pipelines. @@ -237,7 +308,7 @@ The Infisical CLI supports multiple authentication methods. Below are the availa Run the `login` command with the following flags to obtain an access token: ```bash - infisical login --method=jwt-auth --jwt= --machine-identity-id= + infisical login --method=jwt-auth --jwt= --machine-identity-id= ``` @@ -262,7 +333,8 @@ The login command supports a number of flags that you can use for different auth - `gcp-id-token`: Login using a GCP ID token native auth. - `gcp-iam`: Login using a GCP IAM. - `aws-iam`: Login using an AWS IAM native auth. - - `oidc-auth`: Login using oidc auth. + - `oidc-auth`: Login using OIDC auth. + - `jwt-auth`: Login using a plain JWT token. @@ -330,22 +402,153 @@ The login command supports a number of flags that you can use for different auth - - - + ```bash - infisical login --oidc-jwt= + infisical login --email= --password= ``` #### Description - The JWT provided by an identity provider for OIDC authentication. + User email address. Required if you want to do a non-interactive login when the **--method** flag is set to **user**. Must be used together with the `--password` flag. - The `oidc-jwt` flag can be substituted with the `INFISICAL_OIDC_AUTH_JWT` environment variable. + You can omit the **--method=user** if you want as it's the default method. + + + + The `email` flag can be substituted with the `INFISICAL_EMAIL` environment variable. + + ```bash + infisical login --email= --password= + ``` + #### Description + User password. Required if you want to do a non-interactive login when the **--method** flag is set to **user**. Must be used together with the `--email` flag. + + + For security in CI/CD environments, prefer using the `INFISICAL_PASSWORD` environment variable instead of passing the password as a command-line flag. + + + + You can omit the **--method=user** if you want as it's the default method. + + + + The `password` flag can be substituted with the `INFISICAL_PASSWORD` environment variable. + + + + + ```bash + infisical login --interactive + ``` + + #### Description + Forces interactive CLI login where you'll be prompted to enter your email and password in the terminal, instead of opening a browser. + + + + ```bash + infisical login --email= --password= --plain + ``` + + #### Description + When used with direct user login or machine identity authentication, outputs only the JWT access token without any additional formatting. This is useful for scripting and CI/CD pipelines where you need to capture the token. + + ```bash + # Example: Capture token in a variable + export INFISICAL_TOKEN=$(infisical login --email= --password= --plain --silent) + ``` + + + Use it alongside the `silent` flag to disable all messages in the console except from the access token. + + + + + ```bash + infisical login --jwt= --machine-identity-id= + ``` + + #### Description + The JWT provided by an identity provider for OIDC or plain JWT authentication. This is required if the `--method` flag is set to `oidc-auth` or `jwt-auth`. + + + The `jwt` flag can be substituted with the `INFISICAL_JWT` environment variable. + + + + + +### User Authentication Examples + +The following examples demonstrate different ways to authenticate as a user with the Infisical CLI. + + + + By default, running `infisical login` without any flags opens your browser for authentication. + + ```bash + # Opens browser for authentication + infisical login + ``` + + The browser will open to the Infisical login page, and upon successful authentication, the CLI will be automatically authenticated. + + + + + Direct login is ideal for CI/CD pipelines and automation scripts where browser-based authentication is not possible. + + #### Using Command-Line Flags + + ```bash + # Basic direct login (defaults to US Cloud) + infisical login --email user@example.com --password "your-password" + + # EU Cloud (Custom domain) + infisical login --email user@example.com --password "your-password" --domain https://eu.infisical.com + + # Output only JWT token for scripting + export INFISICAL_TOKEN=$(infisical login --email user@example.com --password "your-password" --plain --silent) + ``` + + #### Using Environment Variables (Recommended for CI/CD) + + ```bash + # Set credentials as environment variables + export INFISICAL_EMAIL="user@example.com" + export INFISICAL_PASSWORD="your-password" + + # Login without additional flags + infisical login + + # Or with plain output for token capture + export INFISICAL_TOKEN=$(infisical login --plain --silent) + ``` + + + + Interactive login prompts you to enter credentials in the terminal instead of opening a browser. + + ```bash + # Force interactive CLI login + infisical login --interactive + ``` + + You'll be prompted to enter: + - Email address + - Password + + + + + + +If you have SSO enabled, we recommend using the default browser login. + ### Machine Identity Authentication Quick Start @@ -367,9 +570,9 @@ In this example we'll be using the `universal-auth` method to login to obtain an ``` - + ```bash - infisical secrets --projectId= --env=dev --recursive ``` This command will fetch all secrets from the `dev` environment in your project, including all secrets in subfolders. diff --git a/docs/cli/commands/relay.mdx b/docs/cli/commands/relay.mdx index b377b9ce2..92358ed8b 100644 --- a/docs/cli/commands/relay.mdx +++ b/docs/cli/commands/relay.mdx @@ -13,7 +13,7 @@ description: "Relay-related commands for Infisical" ```bash # Install systemd service sudo infisical relay systemd install --host= --name= --token= - + # Uninstall systemd service sudo infisical relay systemd uninstall ``` @@ -26,6 +26,7 @@ Relay-related commands for Infisical that provide identity-aware relay infrastru ## Subcommands & flags + Run the Infisical relay component. The relay handles network traffic routing between Infisical and your gateways. @@ -35,6 +36,7 @@ infisical relay start --host= --name= --auth-method= ### Flags + The host (IP address or hostname) of the instance where the relay is deployed. This must be a static public IP or resolvable hostname that gateways can reach. @@ -57,6 +59,7 @@ infisical relay start --host= --name= --auth-method= ``` + ### Authentication @@ -280,6 +283,7 @@ infisical relay systemd ### Subcommands + Install and enable systemd service for the relay. Must be run with sudo on Linux systems. @@ -289,6 +293,7 @@ sudo infisical relay systemd install --host= --name= --token= #### Flags + The host (IP address or hostname) of the instance where the relay is deployed. This must be a static public IP or resolvable hostname that gateways can reach. @@ -331,6 +336,7 @@ sudo infisical relay systemd install --domain=http://localhost:8080 --token= + #### Examples @@ -386,5 +392,7 @@ sudo infisical relay systemd uninstall - Cleans up the service configuration + + diff --git a/docs/contributing/getting-started/overview.mdx b/docs/contributing/getting-started/overview.mdx index 35912fc8c..1784b77e8 100644 --- a/docs/contributing/getting-started/overview.mdx +++ b/docs/contributing/getting-started/overview.mdx @@ -7,20 +7,20 @@ To set a strong foundation, this section outlines how we, the community and memb should approach the development and contribution process. ## Code-bases + Infisical has two major code-bases. One for the platform code, and one for SDKs. The contribution process has some key differences between the two, so we've split the documentation into two sections: - The [Infisical Platform](https://github.com/Infisical/infisical), the Infisical platform itself. -- The [Infisical SDK](https://infisical.com/docs/sdks/overview), the official Infisical client SDKs. - - - - - The Infisical platform is the core of the Infisical ecosystem. - - - The SDKs are the official Infisical client libraries, used by developers to easily interact with the Infisical platform. - - +- The Infisical SDKs, please refer to each individual SDK repositories for more information. + - [Node.js SDK](https://github.com/Infisical/node-sdk-v2) + - [Python SDK](https://github.com/Infisical/python-sdk-official) + - [Java SDK](https://github.com/Infisical/java-sdk) + - [.NET SDK](https://github.com/Infisical/infisical-dotnet-sdk) + - [Go SDK](https://github.com/Infisical/go-sdk) + - [C++ SDK](https://github.com/Infisical/infisical-cpp-sdk) + - [PHP SDK](https://github.com/Infisical/php-sdk) + - [Rust SDK](https://github.com/Infisical/rust-sdk) + - [Ruby SDK](https://github.com/infisical/sdk) ## Community @@ -45,15 +45,12 @@ If you're ever in doubt about whether or not a proposed feature aligns with Infi ## Writing and submitting code -Anyone can contribute code to Infisical. To get started, check out the local development guides for each language. - -- Local development guide for Platform is [here](/contributing/platform/developing). -- Local development guide for SDK is [here](/contributing/sdk/developing). +Anyone can contribute code to Infisical. To get started, check out the local development guide for the platform: +- Local development guide for Platform is [here](/contributing/platform/developing). ## Licensing Most of Infisical's code is under the MIT license, though some paid feature restrictions are covered by a proprietary license. Any third party components incorporated into our code are licensed under the original license provided by the applicable component owner. - diff --git a/docs/docs.json b/docs/docs.json index 22b2564ed..46f5aaf70 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -776,6 +776,15 @@ ] } ] + }, + { + "item": "Infisical PAM", + "groups": [ + { + "group": "Infisical PAM", + "pages": ["documentation/platform/pam/overview"] + } + ] } ] }, @@ -2452,20 +2461,6 @@ { "group": "Infisical PKI", "pages": [ - { - "group": "Subscribers", - "pages": [ - "api-reference/endpoints/pki/subscribers/list-certs", - "api-reference/endpoints/pki/subscribers/create", - "api-reference/endpoints/pki/subscribers/read", - "api-reference/endpoints/pki/subscribers/update", - "api-reference/endpoints/pki/subscribers/delete", - "api-reference/endpoints/pki/subscribers/issue-cert", - "api-reference/endpoints/pki/subscribers/sign-cert", - "api-reference/endpoints/pki/subscribers/order-cert", - "api-reference/endpoints/pki/subscribers/get-latest-cert-bundle" - ] - }, { "group": "Certificate Authorities", "pages": [ @@ -2522,10 +2517,11 @@ { "group": "Certificate Templates", "pages": [ - "api-reference/endpoints/certificate-templates/create", - "api-reference/endpoints/certificate-templates/update", - "api-reference/endpoints/certificate-templates/get-by-id", - "api-reference/endpoints/certificate-templates/delete" + "api-reference/endpoints/certificate-templates-v2/list", + "api-reference/endpoints/certificate-templates-v2/create", + "api-reference/endpoints/certificate-templates-v2/update", + "api-reference/endpoints/certificate-templates-v2/get-by-id", + "api-reference/endpoints/certificate-templates-v2/delete" ] }, { @@ -2549,6 +2545,15 @@ "api-reference/endpoints/pki-alerts/delete" ] }, + { + "group": "Certificate Profiles", + "pages": [ + "api-reference/endpoints/certificate-profiles/create", + "api-reference/endpoints/certificate-profiles/update", + "api-reference/endpoints/certificate-profiles/get-by-id", + "api-reference/endpoints/certificate-profiles/delete" + ] + }, { "group": "Certificate Syncs", "pages": [ diff --git a/docs/documentation/getting-started/introduction.mdx b/docs/documentation/getting-started/introduction.mdx index f773019ec..e10d594da 100644 --- a/docs/documentation/getting-started/introduction.mdx +++ b/docs/documentation/getting-started/introduction.mdx @@ -38,3 +38,4 @@ Infisical consists of several tightly integrated products, each designed to solv - [Infisical PKI](/documentation/platform/pki/overview): Issue and manage X.509 certificates using protocols like EST, with support for internal and external CAs. - [Infisical SSH](/documentation/platform/ssh/overview): Provide short-lived SSH access to servers using certificate-based authentication, replacing static keys with policy-driven, time-bound control. - [Infisical KMS](/documentation/platform/kms/overview): Encrypt and decrypt data using centrally managed keys with enforced access policies and full audit visibility. +- [Infisical PAM](/documentation/platform/pam/overview): Manage access to resources like databases, servers, and accounts with policy-based controls and approvals. diff --git a/docs/documentation/getting-started/overview.mdx b/docs/documentation/getting-started/overview.mdx index 769990987..f51136278 100644 --- a/docs/documentation/getting-started/overview.mdx +++ b/docs/documentation/getting-started/overview.mdx @@ -40,6 +40,12 @@ description: "The open source platform for managing secrets, certificates, and s > Replace static SSH keys with short-lived SSH certificates to simplify access and improve security. + + Manage access to resources like databases, servers, and accounts with policy-based controls and approvals. + diff --git a/docs/documentation/platform/gateways-deprecated/networking.mdx b/docs/documentation/platform/gateways-deprecated/networking.mdx index 6acdc1993..51a81ee42 100644 --- a/docs/documentation/platform/gateways-deprecated/networking.mdx +++ b/docs/documentation/platform/gateways-deprecated/networking.mdx @@ -3,7 +3,7 @@ title: "Networking" description: "Network configuration and firewall requirements for Infisical Gateway" --- -The Infisical Gateway requires outbound network connectivity to establish secure communication with Infisical's relay infrastructure. +The Infisical Gateway requires outbound network connectivity to establish secure communication with Infisical's relay infrastructure. This page outlines the required ports, protocols, and firewall configurations needed for optimal gateway usage. ## Network Architecture @@ -67,11 +67,11 @@ The gateway uses QUIC (Quick UDP Internet Connections) for primary communication ## Understanding Firewall Behavior with UDP Unlike TCP connections, UDP is a stateless protocol, and depending on your organization's firewall configuration, you may need to adjust network rules accordingly. -When the gateway sends UDP packets to a relay server, the return responses need to be allowed back through the firewall. -Modern firewalls handle this through "connection tracking" (also called "stateful inspection"), but the behavior can vary depending on your firewall configuration. +When the gateway sends UDP packets to a relay server, the return responses need to be allowed back through the firewall. +Modern firewalls handle this through "connection tracking" (also called "stateful inspection"), but the behavior can vary depending on your firewall configuration. -### Connection Tracking +### Connection Tracking Modern firewalls automatically track UDP connections and allow return responses. This is the preferred configuration as it: - Automatically handles return responses @@ -100,6 +100,7 @@ Configure security groups to allow: ## Frequently Asked Questions + The gateway is designed to handle network interruptions gracefully: @@ -139,7 +140,7 @@ This design maintains security by avoiding the need for inbound firewall rules t If your firewall has strict UDP restrictions: 1. **Work with your network team** to allow outbound UDP to the specific relay IP addresses -2. **Use explicit IP whitelisting** if connection tracking is disabled +2. **Use explicit IP whitelisting** if connection tracking is disabled 3. **Consider network policy exceptions** for the gateway host 4. **Monitor firewall logs** to identify which specific rules are blocking traffic @@ -161,8 +162,9 @@ No, relay servers cannot decrypt any traffic passing through them: - **End-to-end encryption**: All traffic between the gateway and Infisical Cloud is encrypted using mutual TLS with certificate pinning - **Relay acts as a tunnel**: The relay server only forwards encrypted packets - it has no access to encryption keys -- **No data storage**: Relay servers do not store any traffic or network-identifiable information +- **No data storage**: Relay servers do not store any traffic or network-identifiable information - **Certificate isolation**: Each organization has its own private PKI system, ensuring complete tenant isolation The relay infrastructure is designed as a secure forwarding mechanism, similar to a VPN tunnel, where the relay provider cannot see the contents of the traffic flowing through it. - \ No newline at end of file + + diff --git a/docs/documentation/platform/gateways/gateway-deployment.mdx b/docs/documentation/platform/gateways/gateway-deployment.mdx index cef258c41..9a5b7d816 100644 --- a/docs/documentation/platform/gateways/gateway-deployment.mdx +++ b/docs/documentation/platform/gateways/gateway-deployment.mdx @@ -3,7 +3,7 @@ title: "Gateway Deployment" description: "Complete guide to deploying Infisical Gateways including network configuration and firewall requirements" --- -Infisical Gateways enables secure communication between your private resources and the Infisical platform without exposing inbound ports in your network. +Infisical Gateways enables secure communication between your private resources and the Infisical platform without exposing inbound ports in your network. This guide covers everything you need to deploy and configure Infisical Gateways. ## Deployment Steps @@ -18,73 +18,73 @@ To successfully deploy an Infisical Gateway for use, follow these steps in order Simple and secure authentication using client ID and client secret. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=universal-auth` - `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID=` - `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET=` - + Direct authentication using a machine identity access token. - + **Environment Variables:** - `INFISICAL_TOKEN=` - + Authentication using Kubernetes service account tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=kubernetes` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using AWS IAM roles. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=aws-iam` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using GCP identity tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=gcp-id-token` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using GCP service account keys. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=gcp-iam` - `INFISICAL_MACHINE_IDENTITY_ID=` - `INFISICAL_GCP_SERVICE_ACCOUNT_KEY_FILE_PATH=` - + Authentication using Azure managed identity. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=azure` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using OIDC identity tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=oidc-auth` - `INFISICAL_MACHINE_IDENTITY_ID=` - `INFISICAL_JWT=` - + Authentication using JWT tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=jwt-auth` - `INFISICAL_MACHINE_IDENTITY_ID=` @@ -111,7 +111,7 @@ To successfully deploy an Infisical Gateway for use, follow these steps in order | TCP | Infisical instance host (US/EU, other) | 443 | API communication and certificate requests | For managed relays, allow outbound traffic to the provided relay server IP/hostname. For self-hosted relays, allow outbound traffic to your own relay server address. - + If you are in a corporate environment with strict egress filtering, ensure outbound TCP 2222 to relay servers and outbound HTTPS 443 to Infisical API endpoints are allowed. @@ -185,10 +185,9 @@ To successfully deploy an Infisical Gateway for use, follow these steps in order - - - ## Frequently Asked Questions + + No inbound ports need to be opened for gateways. The gateway only makes outbound connections: @@ -263,3 +262,4 @@ The gateway is designed to handle network interruptions gracefully: No manual intervention is typically required during network interruptions. + diff --git a/docs/documentation/platform/gateways/relay-deployment.mdx b/docs/documentation/platform/gateways/relay-deployment.mdx index adf5fdb9d..767cf3732 100644 --- a/docs/documentation/platform/gateways/relay-deployment.mdx +++ b/docs/documentation/platform/gateways/relay-deployment.mdx @@ -32,73 +32,73 @@ To successfully deploy an Infisical Relay for use, follow these steps in order. Simple and secure authentication using client ID and client secret. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=universal-auth` - `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID=` - `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET=` - + Direct authentication using a machine identity access token. - + **Environment Variables:** - `INFISICAL_TOKEN=` - + Authentication using Kubernetes service account tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=kubernetes` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using AWS IAM roles. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=aws-iam` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using GCP identity tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=gcp-id-token` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using GCP service account keys. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=gcp-iam` - `INFISICAL_MACHINE_IDENTITY_ID=` - `INFISICAL_GCP_SERVICE_ACCOUNT_KEY_FILE_PATH=` - + Authentication using Azure managed identity. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=azure` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using OIDC identity tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=oidc-auth` - `INFISICAL_MACHINE_IDENTITY_ID=` - `INFISICAL_JWT=` - + Authentication using JWT tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=jwt-auth` - `INFISICAL_MACHINE_IDENTITY_ID=` @@ -168,7 +168,6 @@ To successfully deploy an Infisical Relay for use, follow these steps in order. ```bash infisical relay start \ - --type= \ --host= \ --name= \ --auth-method= @@ -185,6 +184,7 @@ To successfully deploy an Infisical Relay for use, follow these steps in order. ## Frequently Asked Questions + No, relay servers cannot decrypt any traffic passing through them due to end-to-end encryption: @@ -241,3 +241,4 @@ Relay server outages affect gateway connectivity: For production environments, consider deploying multiple relay servers to avoid single points of failure. + diff --git a/docs/documentation/platform/identities/machine-identities.mdx b/docs/documentation/platform/identities/machine-identities.mdx index 7e40f85f9..d7b7663a9 100644 --- a/docs/documentation/platform/identities/machine-identities.mdx +++ b/docs/documentation/platform/identities/machine-identities.mdx @@ -13,7 +13,7 @@ Each identity must authenticate with the Infisical API using a supported authent Key Features: -- Role Assignment: Identities must be assigned [roles](/documentation/platform/role-based-access-controls). These roles determine the scope of access to resources, either at the organization level or project level. +- Role Assignment: Identities must be assigned [roles](/documentation/platform/access-controls/role-based-access-controls). These roles determine the scope of access to resources, either at the organization level or project level. - Auth/Token Configuration: Identities must be configured with corresponding authentication methods and access token properties to securely interact with the Infisical API. ## Workflow diff --git a/docs/documentation/platform/pam/overview.mdx b/docs/documentation/platform/pam/overview.mdx new file mode 100644 index 000000000..a6e0094f5 --- /dev/null +++ b/docs/documentation/platform/pam/overview.mdx @@ -0,0 +1,45 @@ +--- +title: "Infisical PAM" +sidebarTitle: "Overview" +description: "Learn how to manage access to resources like databases, servers, and accounts with policy-based controls and approvals." +--- + +Infisical Privileged Access Management (PAM) provides a centralized way to manage and secure access to your critical infrastructure. It allows you to enforce fine-grained, policy-based controls over resources like databases, servers, and more, ensuring that only authorized users can access sensitive systems, and only when they need to. + +### How it Works + +Infisical PAM employs a resource-based model to organize and manage access. This model is designed to be intuitive and scalable. + +#### 1. Create a Resource + +The first step is to define a resource you want to manage. A resource represents a target system, such as a PostgreSQL database. When creating a resource, you'll provide the necessary connection details, like the host and port. + +![Create Resource](/images/pam/overview/create-resource.png) + +#### 2. Add Accounts to the Resource + +Once a resource is created, you can add accounts to it. An account represents a specific set of credentials (e.g., a username and password) that can be used to access the resource. This allows you to manage multiple sets of credentials for a single database or server from one place. + +![Create Account](/images/pam/overview/create-account.png) + +### Infisical PAM Features + +#### Session Logging and Auditing + +- **Session Logging**: All user sessions are extensively logged, providing a detailed and searchable record of activities performed during a session. +- **Audit Logging**: Every significant event, such as a user starting a session or accessing an account's credentials, is recorded in audit logs. This gives you complete visibility over your project. + +![Session Page](/images/pam/overview/session-page.png) + +#### Automated Credential Rotation + +Infisical PAM can automatically rotate account credentials to enhance your security posture. + +Here’s how it works: +1. **Add a Rotation Account**: On the resource level, you configure a "rotation account." This is a master or privileged account that has the necessary permissions to change the passwords of other accounts on that same resource. +![Credential Rotation Account](/images/pam/overview/credential-rotation-account.png) + +2. **Configure Rotation on Accounts**: For each individual account you want to rotate, you can simply enable rotation and set a desired interval (e.g., every 30 days). +![Rotate Credentials Account](/images/pam/overview/rotate-credentials-account.png) + +Infisical will then use the rotation account on the resource to automatically update the credentials of the target account at the specified interval, eliminating credential staleness. diff --git a/docs/documentation/platform/project.mdx b/docs/documentation/platform/project.mdx index 7d0df2e22..f2570f290 100644 --- a/docs/documentation/platform/project.mdx +++ b/docs/documentation/platform/project.mdx @@ -22,6 +22,7 @@ The supported project types are: - [Infisical PKI](/documentation/platform/pki/overview): Issue and manage X.509 certificates using protocols like EST, with support for internal and external CAs. - [Infisical SSH](/documentation/platform/ssh/overview): Provide short-lived SSH access to servers using certificate-based authentication, replacing static keys with policy-driven, time-bound control. - [Infisical KMS](/documentation/platform/kms/overview): Encrypt and decrypt data using centrally managed keys with enforced access policies and full audit visibility. +- [Infisical PAM](/documentation/platform/pam/overview): Manage access to resources like databases, servers, and accounts with policy-based controls and approvals. ## Roles and Access Control diff --git a/docs/images/pam/overview/create-account.png b/docs/images/pam/overview/create-account.png new file mode 100644 index 000000000..34f1c7434 Binary files /dev/null and b/docs/images/pam/overview/create-account.png differ diff --git a/docs/images/pam/overview/create-resource.png b/docs/images/pam/overview/create-resource.png new file mode 100644 index 000000000..ac34b9dca Binary files /dev/null and b/docs/images/pam/overview/create-resource.png differ diff --git a/docs/images/pam/overview/credential-rotation-account.png b/docs/images/pam/overview/credential-rotation-account.png new file mode 100644 index 000000000..5e379eccc Binary files /dev/null and b/docs/images/pam/overview/credential-rotation-account.png differ diff --git a/docs/images/pam/overview/rotate-credentials-account.png b/docs/images/pam/overview/rotate-credentials-account.png new file mode 100644 index 000000000..3c908cd49 Binary files /dev/null and b/docs/images/pam/overview/rotate-credentials-account.png differ diff --git a/docs/images/pam/overview/session-page.png b/docs/images/pam/overview/session-page.png new file mode 100644 index 000000000..5c2fa41cf Binary files /dev/null and b/docs/images/pam/overview/session-page.png differ diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index 3a4dd11e6..0f4736d1a 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -291,6 +291,16 @@ Supports conditions and permission inversion | `create` | Issue new certificates | | `delete` | Revoke or remove certificates | +#### Subject: `certificate-profiles` + +| Action | Description | +| -------- | -------------------------------- | +| `read` | View certificate profiles | +| `create` | Create new certificate profiles | +| `edit` | Modify profile configurations | +| `delete` | Remove certificate profiles | +| `issue-cert` | Issue new certificates | + #### Subject: `certificate-templates` | Action | Description | diff --git a/frontend/src/components/features/WishForm.tsx b/frontend/src/components/features/WishForm.tsx index fc38d0731..118900bc9 100644 --- a/frontend/src/components/features/WishForm.tsx +++ b/frontend/src/components/features/WishForm.tsx @@ -63,14 +63,14 @@ export const WishForm = () => { open={isOpen} > -
+
Request a feature
( -
- - +const BreadcrumbContainer = ({ + breadcrumbs, + className +}: { + breadcrumbs: TBreadcrumbFormat[]; + className?: string; +}) => ( +
+ + {(breadcrumbs as TBreadcrumbFormat[]).map((el, index) => { const isNotLastCrumb = index + 1 !== breadcrumbs.length; const BreadcrumbSegment = isNotLastCrumb ? BreadcrumbLink : BreadcrumbPage; @@ -165,8 +171,8 @@ const BreadcrumbContainer = ({ breadcrumbs }: { breadcrumbs: TBreadcrumbFormat[] const Component = el.component; return ( - - + + diff --git a/frontend/src/components/v2/PageHeader/PageHeader.tsx b/frontend/src/components/v2/PageHeader/PageHeader.tsx index 01e710cd3..e3f72f61b 100644 --- a/frontend/src/components/v2/PageHeader/PageHeader.tsx +++ b/frontend/src/components/v2/PageHeader/PageHeader.tsx @@ -5,29 +5,48 @@ import { ReactNode } from "@tanstack/react-router"; import { twMerge } from "tailwind-merge"; import { Badge } from "@app/components/v2"; +import { BadgeProps } from "@app/components/v2/Badge/Badge"; +import { ProjectType } from "@app/hooks/api/projects/types"; type Props = { title: ReactNode; description?: ReactNode; children?: ReactNode; className?: string; - scope: "org" | "project" | "namespace" | "instance"; + scope: "org" | "namespace" | "instance" | ProjectType | null; }; const SCOPE_NAME: Record, { label: string; icon: IconDefinition }> = { org: { label: "Organization", icon: faGlobe }, - project: { label: "Project", icon: faCube }, - namespace: { label: "Namespace", icon: faCubes }, + [ProjectType.SecretManager]: { label: "Project", icon: faCube }, + [ProjectType.CertificateManager]: { label: "Project", icon: faCube }, + [ProjectType.SSH]: { label: "Project", icon: faCube }, + [ProjectType.KMS]: { label: "Project", icon: faCube }, + [ProjectType.PAM]: { label: "Project", icon: faCube }, + [ProjectType.SecretScanning]: { label: "Project", icon: faCube }, + namespace: { label: "Sub-Organization", icon: faCubes }, instance: { label: "Server", icon: faServer } }; +const SCOPE_VARIANT: Record, BadgeProps["variant"]> = { + org: "org", + [ProjectType.SecretManager]: "project", + [ProjectType.CertificateManager]: "project", + [ProjectType.SSH]: "project", + [ProjectType.KMS]: "project", + [ProjectType.PAM]: "project", + [ProjectType.SecretScanning]: "project", + namespace: "namespace", + instance: "instance" +}; + export const PageHeader = ({ title, description, children, className, scope }: Props) => ( -
+

{title}

{scope && ( - + {SCOPE_NAME[scope].label} diff --git a/frontend/src/components/v2/Tabs/Tabs.tsx b/frontend/src/components/v2/Tabs/Tabs.tsx index 4100fd144..ebe8eb2ed 100644 --- a/frontend/src/components/v2/Tabs/Tabs.tsx +++ b/frontend/src/components/v2/Tabs/Tabs.tsx @@ -1,10 +1,19 @@ +import { IconDefinition } from "@fortawesome/free-brands-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import * as TabsPrimitive from "@radix-ui/react-tabs"; import { twMerge } from "tailwind-merge"; export type TabsProps = TabsPrimitive.TabsProps; export const Tabs = ({ className, children, ...props }: TabsProps) => ( - + {children} ); @@ -13,7 +22,11 @@ export type TabListProps = TabsPrimitive.TabsListProps; export const TabList = ({ className, children, ...props }: TabListProps) => ( {children} @@ -26,18 +39,29 @@ export const Tab = ({ className, children, variant = "project", + icon, ...props -}: TabProps & { variant?: "project" | "namespace" | "org" }) => ( +}: TabProps & { + icon?: IconDefinition; + variant?: "project" | "namespace" | "org" | "instance"; +}) => ( + {icon && } {children} ); @@ -46,7 +70,10 @@ export type TabPanelProps = TabsPrimitive.TabsContentProps; export const TabPanel = ({ className, children, ...props }: TabPanelProps) => ( {children} diff --git a/frontend/src/config/request.ts b/frontend/src/config/request.ts index a37b38cb6..16b8b4f45 100644 --- a/frontend/src/config/request.ts +++ b/frontend/src/config/request.ts @@ -24,6 +24,8 @@ apiRequest.interceptors.request.use((config) => { const token = getAuthToken(); const providerAuthToken = SecurityClient.getProviderAuthToken(); + const params = new URLSearchParams(window.location.search); + if (config.headers) { if (signupTempToken) { // eslint-disable-next-line no-param-reassign @@ -38,6 +40,17 @@ apiRequest.interceptors.request.use((config) => { // eslint-disable-next-line no-param-reassign config.headers.Authorization = `Bearer ${providerAuthToken}`; } + + const rootOrgHeader = config.headers.get("x-root-org"); + + if (rootOrgHeader) { + config.headers.delete("x-root-org"); + } else { + const subOrganization = params.get("subOrganization"); + if (subOrganization) { + config.headers.set("x-infisical-org", subOrganization); + } + } } return config; diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts index 2e835a1db..c9a6b326d 100644 --- a/frontend/src/const/routes.ts +++ b/frontend/src/const/routes.ts @@ -33,10 +33,6 @@ export const ROUTE_PATHS = Object.freeze({ "/organization/secret-sharing", "/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/" ), - SecretSharingSettings: setRoute( - "/organization/secret-sharing/settings", - "/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/settings" - ), SettingsPage: setRoute( "/organization/settings", "/_authenticate/_inject-org-details/_org-layout/organization/settings/" @@ -66,7 +62,11 @@ export const ROUTE_PATHS = Object.freeze({ "/organization/app-connections/$appConnection/oauth/callback", "/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback" ) - } + }, + NetworkingPage: setRoute( + "/organization/networking", + "/_authenticate/_inject-org-details/_org-layout/organization/networking" + ) }, SecretManager: { ApprovalPage: setRoute( diff --git a/frontend/src/consts/pam.ts b/frontend/src/consts/pam.ts new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index bcab6169e..87dc40263 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -62,7 +62,8 @@ export enum OrgPermissionSubjects { SecretShare = "secret-share", GithubOrgSync = "github-org-sync", GithubOrgSyncManual = "github-org-sync-manual", - MachineIdentityAuthTemplate = "machine-identity-auth-template" + MachineIdentityAuthTemplate = "machine-identity-auth-template", + SubOrganization = "sub-organization" } export enum OrgPermissionAdminConsoleAction { @@ -112,6 +113,11 @@ export enum OrgPermissionGroupActions { RemoveMembers = "remove-members" } +export enum OrgPermissionSubOrgActions { + Create = "create", + DirectAccess = "direct-access" +} + export type AppConnectionSubjectFields = { connectionId: string; }; @@ -151,6 +157,7 @@ export type OrgPermissionSet = | OrgPermissionSubjects.AppConnections | (ForcedSubject & AppConnectionSubjectFields) ) - ]; + ] + | [OrgPermissionSubOrgActions, OrgPermissionSubjects.SubOrganization]; export type TOrgPermission = MongoAbility; diff --git a/frontend/src/context/OrganizationContext/OrganizationContext.tsx b/frontend/src/context/OrganizationContext/OrganizationContext.tsx index 26865b5bf..bfc17d143 100644 --- a/frontend/src/context/OrganizationContext/OrganizationContext.tsx +++ b/frontend/src/context/OrganizationContext/OrganizationContext.tsx @@ -1,5 +1,6 @@ +import { useMemo } from "react"; import { useSuspenseQuery } from "@tanstack/react-query"; -import { useRouteContext } from "@tanstack/react-router"; +import { useRouteContext, useSearch } from "@tanstack/react-router"; import { fetchOrganizationById, organizationKeys } from "@app/hooks/api/organization/queries"; @@ -9,11 +10,29 @@ export const useOrganization = () => { select: (el) => el.organizationId }); + const subOrganization = useSearch({ + strict: false, + select: (el) => el?.subOrganization + }); + const { data: currentOrg } = useSuspenseQuery({ - queryKey: organizationKeys.getOrgById(organizationId), + queryKey: organizationKeys.getOrgById(organizationId, subOrganization || "root"), queryFn: () => fetchOrganizationById(organizationId), staleTime: Infinity }); - return { currentOrg }; + const org = useMemo( + () => ({ + currentOrg: { + ...currentOrg, + id: currentOrg?.subOrganization?.id || currentOrg?.id, + parentOrgId: currentOrg.id + }, + isSubOrganization: Boolean(currentOrg.subOrganization), + isRootOrganization: !currentOrg.subOrganization + }), + [currentOrg, subOrganization] + ); + + return org; }; diff --git a/frontend/src/context/ProjectPermissionContext/index.tsx b/frontend/src/context/ProjectPermissionContext/index.tsx index a1669e18f..415a82641 100644 --- a/frontend/src/context/ProjectPermissionContext/index.tsx +++ b/frontend/src/context/ProjectPermissionContext/index.tsx @@ -4,6 +4,7 @@ export { ProjectPermissionActions, ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, + ProjectPermissionCertificateProfileActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, ProjectPermissionGroupActions, diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index d3019409f..0236113eb 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -124,6 +124,14 @@ export enum ProjectPermissionPkiTemplateActions { ListCerts = "list-certs" } +export enum ProjectPermissionCertificateProfileActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + IssueCert = "issue-cert" +} + export enum ProjectPermissionSecretRotationActions { Read = "read", ReadGeneratedCredentials = "read-generated-credentials", @@ -293,6 +301,7 @@ export enum ProjectPermissionSub { PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", PkiSubscribers = "pki-subscribers", + CertificateProfiles = "certificate-profiles", Kms = "kms", Cmek = "cmek", SecretSyncs = "secret-syncs", @@ -470,6 +479,7 @@ export type ProjectPermissionSet = | (ForcedSubject & PkiSubscriberSubjectFields) ) ] + | [ProjectPermissionCertificateProfileActions, ProjectPermissionSub.CertificateProfiles] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index fff370269..3e0f95807 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -15,6 +15,7 @@ export { ProjectPermissionActions, ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, + ProjectPermissionCertificateProfileActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, ProjectPermissionGroupActions, diff --git a/frontend/src/helpers/project.ts b/frontend/src/helpers/project.ts index 843ded3e3..11d561e02 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -79,7 +79,7 @@ export const getProjectHomePage = (type: ProjectType, environments: ProjectEnv[] case ProjectType.SecretManager: return "/projects/secret-management/$projectId/overview" as const; case ProjectType.CertificateManager: - return "/projects/cert-management/$projectId/subscribers" as const; + return "/projects/cert-management/$projectId/policies" as const; case ProjectType.SecretScanning: return `/projects/${type}/$projectId/data-sources` as const; case ProjectType.PAM: diff --git a/frontend/src/hoc/withPermission/withPermission.tsx b/frontend/src/hoc/withPermission/withPermission.tsx index 529a74930..762b067c2 100644 --- a/frontend/src/hoc/withPermission/withPermission.tsx +++ b/frontend/src/hoc/withPermission/withPermission.tsx @@ -24,7 +24,7 @@ export const withPermission = ( // akhilmhdh: Set as any due to casl/react ts type bug // REASON: casl due to its type checking can't seem to union even if union intersection is applied - if (permission.cannot(action as any, subject)) { + if (permission.cannot(action as any, subject as any)) { return (
{ }); }; +export const useCreateCertificateV3 = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + "/api/v3/certificates/issue-certificate", + body + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries({ + queryKey: projectKeys.forProjectCertificates(projectSlug) + }); + + queryClient.invalidateQueries({ + queryKey: ["certificate-profiles"] + }); + } + }); +}; + +export const useOrderCertificateWithProfile = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + "/api/v3/certificates/order-certificate", + body + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries({ + queryKey: projectKeys.forProjectCertificates(projectSlug) + }); + } + }); +}; + export const useRenewCa = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index 0443dd5e9..696e72494 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -155,7 +155,7 @@ export type TCreateCertificateDTO = { pkiCollectionId?: string; friendlyName?: string; commonName: string; - altNames: string; // sans + subjectAltNames: string; // sans ttl: string; // string compatible with ms notBefore?: string; notAfter?: string; @@ -171,6 +171,84 @@ export type TCreateCertificateResponse = { serialNumber: string; }; +export type TCreateCertificateV3DTO = { + projectSlug: string; + profileId: string; + pkiCollectionId?: string; + friendlyName?: string; + commonName?: string; + organization?: string; + organizationUnit?: string; + locality?: string; + state?: string; + country?: string; + email?: string; + streetAddress?: string; + postalCode?: string; + subjectAltNames: string; + ttl: string; + notBefore?: string; + notAfter?: string; + keyUsages: CertKeyUsage[]; + extendedKeyUsages: CertExtendedKeyUsage[]; + signatureAlgorithm?: string; + keyAlgorithm?: string; +}; + +export type TCreateCertificateV3Response = TCreateCertificateResponse & { + projectId: string; + profileName: string; + certificateId: string; +}; + +export type TOrderCertificateDTO = { + projectSlug: string; + profileId: string; + subjectAlternativeNames: Array<{ + type: "dns" | "ip"; + value: string; + }>; + ttl: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; + notBefore?: string; + notAfter?: string; + commonName?: string; + signatureAlgorithm?: string; + keyAlgorithm?: string; +}; + +export type TOrderCertificateResponse = { + orderId: string; + status: "pending" | "processing" | "valid" | "invalid"; + subjectAlternativeNames: Array<{ + type: "dns" | "ip"; + value: string; + status: "pending" | "processing" | "valid" | "invalid"; + }>; + authorizations: Array<{ + identifier: { + type: "dns" | "ip"; + value: string; + }; + status: "pending" | "processing" | "valid" | "invalid"; + expires?: string; + challenges: Array<{ + type: string; + status: "pending" | "processing" | "valid" | "invalid"; + url: string; + token: string; + validated?: string; + error?: string | Error; + }>; + }>; + certificate?: string; + privateKey?: string; + expires: string; + notBefore: string; + notAfter: string; +}; + export type TRenewCaDTO = { projectSlug: string; caId: string; diff --git a/frontend/src/hooks/api/certificateProfiles/index.ts b/frontend/src/hooks/api/certificateProfiles/index.ts new file mode 100644 index 000000000..dc5c17efa --- /dev/null +++ b/frontend/src/hooks/api/certificateProfiles/index.ts @@ -0,0 +1,14 @@ +export { + useCreateCertificateProfile, + useDeleteCertificateProfile, + useUpdateCertificateProfile +} from "./mutations"; +export { + certificateProfileKeys, + useGetCertificateProfileById, + useGetCertificateProfileBySlug, + useGetProfileCertificates, + useGetProfileMetrics, + useListCertificateProfiles +} from "./queries"; +export type * from "./types"; diff --git a/frontend/src/hooks/api/certificateProfiles/mutations.tsx b/frontend/src/hooks/api/certificateProfiles/mutations.tsx new file mode 100644 index 000000000..ca784ed0d --- /dev/null +++ b/frontend/src/hooks/api/certificateProfiles/mutations.tsx @@ -0,0 +1,71 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { certificateProfileKeys } from "./queries"; +import { + TCertificateProfile, + TCreateCertificateProfileDTO, + TDeleteCertificateProfileDTO, + TUpdateCertificateProfileDTO +} from "./types"; + +export const useCreateCertificateProfile = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data) => { + const { data: response } = await apiRequest.post<{ + certificateProfile: TCertificateProfile; + }>("/api/v1/pki/certificate-profiles", data); + return response.certificateProfile; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ + queryKey: certificateProfileKeys.list({ projectId }) + }); + } + }); +}; + +export const useUpdateCertificateProfile = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ profileId, ...data }) => { + const { data: response } = await apiRequest.patch<{ + certificateProfile: TCertificateProfile; + }>(`/api/v1/pki/certificate-profiles/${profileId}`, data); + return response.certificateProfile; + }, + onSuccess: (profile, { profileId }) => { + queryClient.invalidateQueries({ + queryKey: certificateProfileKeys.list({ projectId: profile.projectId }) + }); + queryClient.invalidateQueries({ + queryKey: certificateProfileKeys.getById(profileId) + }); + } + }); +}; + +export const useDeleteCertificateProfile = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ profileId }) => { + const { data: response } = await apiRequest.delete<{ + certificateProfile: TCertificateProfile; + }>(`/api/v1/pki/certificate-profiles/${profileId}`); + return response.certificateProfile; + }, + onSuccess: (profile, { profileId }) => { + queryClient.invalidateQueries({ + queryKey: certificateProfileKeys.list({ projectId: profile.projectId }) + }); + queryClient.removeQueries({ + queryKey: certificateProfileKeys.getById(profileId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/certificateProfiles/queries.tsx b/frontend/src/hooks/api/certificateProfiles/queries.tsx new file mode 100644 index 000000000..abdc93ddb --- /dev/null +++ b/frontend/src/hooks/api/certificateProfiles/queries.tsx @@ -0,0 +1,162 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { + TCertificateProfile, + TCertificateProfileMetrics, + TCertificateProfileWithDetails, + TGetCertificateProfileByIdDTO, + TGetCertificateProfileBySlugDTO, + TGetProfileCertificatesDTO, + TGetProfileMetricsDTO, + TListCertificateProfilesDTO, + TProfileCertificate +} from "./types"; + +export const certificateProfileKeys = { + list: (params: { + projectId: string; + limit?: number; + offset?: number; + search?: string; + includeMetrics?: boolean; + includeConfigs?: boolean; + enrollmentType?: string; + expiringDays?: number; + }) => ["certificate-profiles", "list", params], + getById: (profileId: string) => ["certificate-profiles", "get-by-id", profileId], + getBySlug: (projectId: string, slug: string) => [ + "certificate-profiles", + "get-by-slug", + projectId, + slug + ], + getCertificates: (profileId: string, params?: Omit) => [ + "certificate-profiles", + "certificates", + profileId, + params + ], + getMetrics: (profileId: string, params?: Omit) => [ + "certificate-profiles", + "metrics", + profileId, + params + ] +}; + +export const useListCertificateProfiles = ({ + projectId, + limit = 20, + offset = 0, + search, + includeMetrics = false, + includeConfigs = false, + enrollmentType, + expiringDays = 7 +}: TListCertificateProfilesDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.list({ + projectId, + limit, + offset, + search, + includeMetrics, + includeConfigs, + enrollmentType, + expiringDays + }), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateProfiles: TCertificateProfile[]; + totalCount: number; + }>("/api/v1/pki/certificate-profiles", { + params: { + projectId, + limit, + offset, + search, + includeMetrics, + includeConfigs, + enrollmentType, + expiringDays + } + }); + return data; + }, + enabled: Boolean(projectId) + }); +}; + +export const useGetCertificateProfileById = ({ profileId }: TGetCertificateProfileByIdDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.getById(profileId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateProfile: TCertificateProfileWithDetails; + }>(`/api/v1/pki/certificate-profiles/${profileId}`); + return data.certificateProfile; + }, + enabled: Boolean(profileId) + }); +}; + +export const useGetCertificateProfileBySlug = ({ + projectId, + slug +}: TGetCertificateProfileBySlugDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.getBySlug(projectId, slug), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateProfile: TCertificateProfile; + }>(`/api/v1/pki/certificate-profiles/slug/${slug}`, { + params: { projectId } + }); + return data.certificateProfile; + }, + enabled: Boolean(projectId && slug) + }); +}; + +export const useGetProfileCertificates = ({ + profileId, + offset = 0, + limit = 20, + status, + search +}: TGetProfileCertificatesDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.getCertificates(profileId, { offset, limit, status, search }), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificates: TProfileCertificate[]; + }>(`/api/v1/pki/certificate-profiles/${profileId}/certificates`, { + params: { + offset, + limit, + status, + search + } + }); + return data.certificates; + }, + enabled: Boolean(profileId) + }); +}; + +export const useGetProfileMetrics = ({ profileId, expiringDays = 7 }: TGetProfileMetricsDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.getMetrics(profileId, { expiringDays }), + queryFn: async () => { + const { data } = await apiRequest.get<{ + metrics: TCertificateProfileMetrics; + }>(`/api/v1/pki/certificate-profiles/${profileId}/metrics`, { + params: { expiringDays } + }); + return data.metrics; + }, + enabled: Boolean(profileId) + }); +}; diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts new file mode 100644 index 000000000..a9b6b7060 --- /dev/null +++ b/frontend/src/hooks/api/certificateProfiles/types.ts @@ -0,0 +1,130 @@ +export type TCertificateProfile = { + id: string; + projectId: string; + caId: string; + certificateTemplateId: string; + slug: string; + description?: string; + enrollmentType: "api" | "est"; + estConfigId?: string; + apiConfigId?: string; + createdAt: string; + updatedAt: string; + metrics?: TCertificateProfileMetrics; +}; + +export type TCertificateProfileWithDetails = TCertificateProfile & { + certificateAuthority?: { + id: string; + projectId: string; + status: string; + name: string; + }; + certificateTemplate?: { + id: string; + projectId: string; + name: string; + description?: string; + }; + estConfig?: { + id: string; + disableBootstrapCaValidation: boolean; + passphrase: string; + caChain: string; + }; + apiConfig?: { + id: string; + autoRenew: boolean; + autoRenewDays?: number; + }; +}; + +export type TCreateCertificateProfileDTO = { + projectId: string; + caId: string; + certificateTemplateId: string; + slug: string; + description?: string; + enrollmentType: "api" | "est"; + estConfig?: { + disableBootstrapCaValidation?: boolean; + passphrase: string; + caChain?: string; + }; + apiConfig?: { + autoRenew?: boolean; + autoRenewDays?: number; + }; +}; + +export type TUpdateCertificateProfileDTO = { + profileId: string; + slug?: string; + description?: string; + estConfig?: { + disableBootstrapCaValidation?: boolean; + passphrase?: string; + caChain?: string; + }; + apiConfig?: { + autoRenew?: boolean; + autoRenewDays?: number; + }; +}; + +export type TDeleteCertificateProfileDTO = { + profileId: string; +}; + +export type TListCertificateProfilesDTO = { + projectId: string; + limit?: number; + offset?: number; + search?: string; + includeMetrics?: boolean; + includeConfigs?: boolean; + enrollmentType?: "api" | "est"; + expiringDays?: number; +}; + +export type TGetCertificateProfileByIdDTO = { + profileId: string; +}; + +export type TGetCertificateProfileBySlugDTO = { + projectId: string; + slug: string; +}; + +export type TCertificateProfileMetrics = { + profileId: string; + totalCertificates: number; + activeCertificates: number; + expiredCertificates: number; + expiringCertificates: number; + revokedCertificates: number; +}; + +export type TProfileCertificate = { + id: string; + serialNumber: string; + cn: string; + status: string; + notBefore: string; + notAfter: string; + isRevoked: boolean; + createdAt: string; +}; + +export type TGetProfileCertificatesDTO = { + profileId: string; + offset?: number; + limit?: number; + status?: "active" | "expired" | "revoked"; + search?: string; +}; + +export type TGetProfileMetricsDTO = { + profileId: string; + expiringDays?: number; +}; diff --git a/frontend/src/hooks/api/certificateTemplates/mutations.tsx b/frontend/src/hooks/api/certificateTemplates/mutations.tsx index 24a7d0e5f..0355d9d87 100644 --- a/frontend/src/hooks/api/certificateTemplates/mutations.tsx +++ b/frontend/src/hooks/api/certificateTemplates/mutations.tsx @@ -7,13 +7,17 @@ import { projectKeys } from "../projects"; import { certTemplateKeys } from "./queries"; import { TCertificateTemplate, + TCertificateTemplateV2WithPolicies, TCreateCertificateTemplateDTO, TCreateCertificateTemplateV2DTO, + TCreateCertificateTemplateV2WithPoliciesDTO, TCreateEstConfigDTO, TDeleteCertificateTemplateDTO, TDeleteCertificateTemplateV2DTO, + TDeleteCertificateTemplateV2WithPoliciesDTO, TUpdateCertificateTemplateDTO, TUpdateCertificateTemplateV2DTO, + TUpdateCertificateTemplateV2WithPoliciesDTO, TUpdateEstConfigDTO } from "./types"; @@ -163,3 +167,72 @@ export const useUpdateEstConfig = () => { } }); }; + +export const useCreateCertificateTemplateV2WithPolicies = () => { + const queryClient = useQueryClient(); + return useMutation< + TCertificateTemplateV2WithPolicies, + object, + TCreateCertificateTemplateV2WithPoliciesDTO + >({ + mutationFn: async (data) => { + const { data: response } = await apiRequest.post<{ + certificateTemplate: TCertificateTemplateV2WithPolicies; + }>("/api/v2/certificate-templates", data); + return response.certificateTemplate; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ + queryKey: certTemplateKeys.listTemplatesV2({ projectId }) + }); + } + }); +}; + +export const useUpdateCertificateTemplateV2WithPolicies = () => { + const queryClient = useQueryClient(); + return useMutation< + TCertificateTemplateV2WithPolicies, + object, + TUpdateCertificateTemplateV2WithPoliciesDTO + >({ + mutationFn: async ({ templateId, ...data }) => { + const { data: response } = await apiRequest.patch<{ + certificateTemplate: TCertificateTemplateV2WithPolicies; + }>(`/api/v2/certificate-templates/${templateId}`, data); + return response.certificateTemplate; + }, + onSuccess: (template, { templateId }) => { + queryClient.invalidateQueries({ + queryKey: certTemplateKeys.listTemplatesV2({ projectId: template.projectId }) + }); + queryClient.invalidateQueries({ + queryKey: certTemplateKeys.getTemplateV2ById(templateId) + }); + } + }); +}; + +export const useDeleteCertificateTemplateV2WithPolicies = () => { + const queryClient = useQueryClient(); + return useMutation< + TCertificateTemplateV2WithPolicies, + object, + TDeleteCertificateTemplateV2WithPoliciesDTO + >({ + mutationFn: async ({ templateId }) => { + const { data: response } = await apiRequest.delete<{ + certificateTemplate: TCertificateTemplateV2WithPolicies; + }>(`/api/v2/certificate-templates/${templateId}`); + return response.certificateTemplate; + }, + onSuccess: (template, { templateId }) => { + queryClient.invalidateQueries({ + queryKey: certTemplateKeys.listTemplatesV2({ projectId: template.projectId }) + }); + queryClient.removeQueries({ + queryKey: certTemplateKeys.getTemplateV2ById(templateId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/certificateTemplates/queries.tsx b/frontend/src/hooks/api/certificateTemplates/queries.tsx index 435345ad9..383f4ed71 100644 --- a/frontend/src/hooks/api/certificateTemplates/queries.tsx +++ b/frontend/src/hooks/api/certificateTemplates/queries.tsx @@ -5,8 +5,11 @@ import { apiRequest } from "@app/config/request"; import { TCertificateTemplate, TCertificateTemplateV2, + TCertificateTemplateV2WithPolicies, TEstConfig, - TListCertificateTemplatesDTO + TGetCertificateTemplateV2ByIdDTO, + TListCertificateTemplatesDTO, + TListCertificateTemplatesV2DTO } from "./types"; export const certTemplateKeys = { @@ -16,7 +19,16 @@ export const certTemplateKeys = { projectId, el ], - getEstConfig: (id: string) => [{ id }, "cert-template-est-config"] + getEstConfig: (id: string) => [{ id }, "cert-template-est-config"], + listTemplatesV2: ({ + projectId, + ...el + }: { + limit?: number; + offset?: number; + projectId: string; + }) => ["list-templates-v2", projectId, el], + getTemplateV2ById: (id: string) => ["cert-template-v2", id] }; export const useGetCertTemplate = (id: string) => { @@ -68,3 +80,42 @@ export const useGetEstConfig = (certificateTemplateId: string) => { enabled: Boolean(certificateTemplateId) }); }; + +export const useListCertificateTemplatesV2 = ({ + projectId, + limit = 20, + offset = 0 +}: TListCertificateTemplatesV2DTO) => { + return useQuery({ + queryKey: certTemplateKeys.listTemplatesV2({ projectId, limit, offset }), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateTemplates: TCertificateTemplateV2WithPolicies[]; + totalCount: number; + }>("/api/v2/certificate-templates", { + params: { + projectId, + limit, + offset + } + }); + return data; + }, + enabled: Boolean(projectId) + }); +}; + +export const useGetCertificateTemplateV2ById = ({ + templateId +}: TGetCertificateTemplateV2ByIdDTO) => { + return useQuery({ + queryKey: certTemplateKeys.getTemplateV2ById(templateId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateTemplate: TCertificateTemplateV2WithPolicies; + }>(`/api/v2/certificate-templates/${templateId}`); + return data.certificateTemplate; + }, + enabled: Boolean(templateId) + }); +}; diff --git a/frontend/src/hooks/api/certificateTemplates/types.ts b/frontend/src/hooks/api/certificateTemplates/types.ts index 1c2a47178..373bbef2a 100644 --- a/frontend/src/hooks/api/certificateTemplates/types.ts +++ b/frontend/src/hooks/api/certificateTemplates/types.ts @@ -121,3 +121,90 @@ export type TListCertificateTemplatesDTO = { offset?: number; projectId: string; }; + +export type TCertificateTemplateV2Policy = { + subject?: Array<{ + type: "common_name" | "organization" | "country"; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + sans?: Array<{ + type: "dns_name" | "ip_address" | "email" | "uri"; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + keyUsages?: { + allowed?: string[]; + required?: string[]; + denied?: string[]; + }; + extendedKeyUsages?: { + allowed?: string[]; + required?: string[]; + denied?: string[]; + }; + algorithms?: { + signature?: Array< + "SHA256-RSA" | "SHA384-RSA" | "SHA512-RSA" | "SHA256-ECDSA" | "SHA384-ECDSA" | "SHA512-ECDSA" + >; + keyAlgorithm?: Array<"RSA-2048" | "RSA-3072" | "RSA-4096" | "ECDSA-P256" | "ECDSA-P384">; + }; + validity?: { + max?: string; + }; +}; + +export type TCertificateTemplateV2WithPolicies = { + id: string; + projectId: string; + name: string; + description?: string; + subject?: TCertificateTemplateV2Policy["subject"]; + sans?: TCertificateTemplateV2Policy["sans"]; + keyUsages?: TCertificateTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TCertificateTemplateV2Policy["algorithms"]; + validity?: TCertificateTemplateV2Policy["validity"]; + createdAt: string; + updatedAt: string; +}; + +export type TCreateCertificateTemplateV2WithPoliciesDTO = { + projectId: string; + name: string; + description?: string; + subject?: TCertificateTemplateV2Policy["subject"]; + sans?: TCertificateTemplateV2Policy["sans"]; + keyUsages?: TCertificateTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TCertificateTemplateV2Policy["algorithms"]; + validity?: TCertificateTemplateV2Policy["validity"]; +}; + +export type TUpdateCertificateTemplateV2WithPoliciesDTO = { + templateId: string; + name?: string; + description?: string; + subject?: TCertificateTemplateV2Policy["subject"]; + sans?: TCertificateTemplateV2Policy["sans"]; + keyUsages?: TCertificateTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TCertificateTemplateV2Policy["algorithms"]; + validity?: TCertificateTemplateV2Policy["validity"]; +}; + +export type TDeleteCertificateTemplateV2WithPoliciesDTO = { + templateId: string; +}; + +export type TListCertificateTemplatesV2DTO = { + projectId: string; + limit?: number; + offset?: number; +}; + +export type TGetCertificateTemplateV2ByIdDTO = { + templateId: string; +}; diff --git a/frontend/src/hooks/api/certificates/constants.tsx b/frontend/src/hooks/api/certificates/constants.tsx index 0384ea6cd..8647fafd5 100644 --- a/frontend/src/hooks/api/certificates/constants.tsx +++ b/frontend/src/hooks/api/certificates/constants.tsx @@ -24,6 +24,7 @@ export const getCertStatusBadgeVariant = (status: CertStatus) => { export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = { [CertKeyAlgorithm.RSA_2048]: "RSA 2048", + [CertKeyAlgorithm.RSA_3072]: "RSA 3072", [CertKeyAlgorithm.RSA_4096]: "RSA 4096", [CertKeyAlgorithm.ECDSA_P256]: "ECDSA P256", [CertKeyAlgorithm.ECDSA_P384]: "ECDSA P384" @@ -31,6 +32,7 @@ export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = { export const certKeyAlgorithms = [ { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_2048], value: CertKeyAlgorithm.RSA_2048 }, + { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_3072], value: CertKeyAlgorithm.RSA_3072 }, { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_4096], value: CertKeyAlgorithm.RSA_4096 }, { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.ECDSA_P256], @@ -96,3 +98,12 @@ export const EXTENDED_KEY_USAGES_OPTIONS = [ { value: CertExtendedKeyUsage.CODE_SIGNING, label: "Code Signing" }, { value: CertExtendedKeyUsage.TIMESTAMPING, label: "Timestamping" } ] as const; + +export const SIGNATURE_ALGORITHMS_OPTIONS = [ + { value: "RSA-SHA256", label: "RSA-SHA256" }, + { value: "RSA-SHA384", label: "RSA-SHA384" }, + { value: "RSA-SHA512", label: "RSA-SHA512" }, + { value: "ECDSA-SHA256", label: "ECDSA-SHA256" }, + { value: "ECDSA-SHA384", label: "ECDSA-SHA384" }, + { value: "ECDSA-SHA512", label: "ECDSA-SHA512" } +] as const; diff --git a/frontend/src/hooks/api/certificates/enums.tsx b/frontend/src/hooks/api/certificates/enums.tsx index 566da7506..6ee03c308 100644 --- a/frontend/src/hooks/api/certificates/enums.tsx +++ b/frontend/src/hooks/api/certificates/enums.tsx @@ -5,6 +5,7 @@ export enum CertStatus { export enum CertKeyAlgorithm { RSA_2048 = "RSA_2048", + RSA_3072 = "RSA_3072", RSA_4096 = "RSA_4096", ECDSA_P256 = "EC_prime256v1", ECDSA_P384 = "EC_secp384r1" @@ -24,22 +25,22 @@ export enum CrlReason { } export enum CertKeyUsage { - DIGITAL_SIGNATURE = "digitalSignature", - KEY_ENCIPHERMENT = "keyEncipherment", - NON_REPUDIATION = "nonRepudiation", - DATA_ENCIPHERMENT = "dataEncipherment", - KEY_AGREEMENT = "keyAgreement", - KEY_CERT_SIGN = "keyCertSign", - CRL_SIGN = "cRLSign", - ENCIPHER_ONLY = "encipherOnly", - DECIPHER_ONLY = "decipherOnly" + DIGITAL_SIGNATURE = "digital_signature", + KEY_ENCIPHERMENT = "key_encipherment", + NON_REPUDIATION = "non_repudiation", + DATA_ENCIPHERMENT = "data_encipherment", + KEY_AGREEMENT = "key_agreement", + KEY_CERT_SIGN = "key_cert_sign", + CRL_SIGN = "crl_sign", + ENCIPHER_ONLY = "encipher_only", + DECIPHER_ONLY = "decipher_only" } export enum CertExtendedKeyUsage { - CLIENT_AUTH = "clientAuth", - SERVER_AUTH = "serverAuth", - CODE_SIGNING = "codeSigning", - EMAIL_PROTECTION = "emailProtection", - TIMESTAMPING = "timeStamping", - OCSP_SIGNING = "ocspSigning" + CLIENT_AUTH = "client_auth", + SERVER_AUTH = "server_auth", + CODE_SIGNING = "code_signing", + EMAIL_PROTECTION = "email_protection", + TIMESTAMPING = "time_stamping", + OCSP_SIGNING = "ocsp_signing" } diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index 77a3dab72..388295b0a 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -52,6 +52,10 @@ export const useRevokeCert = () => { queryClient.invalidateQueries({ queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates() }); + + queryClient.invalidateQueries({ + queryKey: ["certificate-profiles", "list"] + }); } }); }; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index c1dd59eca..1ec3292a3 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -7,7 +7,7 @@ export type TCertificate = { status: CertStatus; friendlyName: string; commonName: string; - altNames: string; + subjectAltNames: string; serialNumber: string; notBefore: string; notAfter: string; diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 99e377143..a0eb828e8 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -20,6 +20,7 @@ export type Identity = { createdAt: string; updatedAt: string; isInstanceAdmin?: boolean; + orgId: string; }; export type IdentityAccessToken = { diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index d65c7e72c..e9e80feec 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -31,6 +31,7 @@ export * from "./pkiSubscriber"; export * from "./projects"; export * from "./projectUserAdditionalPrivilege"; export * from "./rateLimit"; +export * from "./relays"; export * from "./roles"; export * from "./scim"; export * from "./secretApproval"; @@ -48,6 +49,7 @@ export * from "./sshCertificateTemplates"; export * from "./sshHost"; export * from "./sshHostGroup"; export * from "./ssoConfig"; +export * from "./subOrganizations"; export * from "./subscriptions"; export * from "./tags"; export * from "./trustedIps"; diff --git a/frontend/src/hooks/api/orgIdentityMembership/index.tsx b/frontend/src/hooks/api/orgIdentityMembership/index.tsx new file mode 100644 index 000000000..a28824501 --- /dev/null +++ b/frontend/src/hooks/api/orgIdentityMembership/index.tsx @@ -0,0 +1,6 @@ +export { useCreateOrgIdentityMembership, useDeleteOrgIdentityMembership } from "./mutation"; +export type { + TCreateOrgIdentityMembershipDTO, + TDeleteOrgIdentityMembershipDTO, + TOrgIdentityMembership +} from "./types"; diff --git a/frontend/src/hooks/api/orgIdentityMembership/mutation.tsx b/frontend/src/hooks/api/orgIdentityMembership/mutation.tsx new file mode 100644 index 000000000..3ba41905a --- /dev/null +++ b/frontend/src/hooks/api/orgIdentityMembership/mutation.tsx @@ -0,0 +1,41 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { identitiesKeys } from "../identities"; +import { + TCreateOrgIdentityMembershipDTO, + TDeleteOrgIdentityMembershipDTO, + TOrgIdentityMembership +} from "./types"; + +export const useCreateOrgIdentityMembership = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ identityId, roles }: TCreateOrgIdentityMembershipDTO) => { + const { data } = await apiRequest.post<{ identityMembership: TOrgIdentityMembership }>( + `/api/v1/organization/identity-memberships/${identityId}`, + { roles } + ); + return data.identityMembership; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: identitiesKeys.searchIdentities({ search: {} }) }); + } + }); +}; + +export const useDeleteOrgIdentityMembership = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ identityId }: TDeleteOrgIdentityMembershipDTO) => { + const { data } = await apiRequest.delete<{ identityMembership: TOrgIdentityMembership }>( + `/api/v1/organization/identity-memberships/${identityId}` + ); + return data.identityMembership; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: identitiesKeys.searchIdentities({ search: {} }) }); + } + }); +}; diff --git a/frontend/src/hooks/api/orgIdentityMembership/types.ts b/frontend/src/hooks/api/orgIdentityMembership/types.ts new file mode 100644 index 000000000..95fa06b82 --- /dev/null +++ b/frontend/src/hooks/api/orgIdentityMembership/types.ts @@ -0,0 +1,32 @@ +export enum TemporaryPermissionMode { + Relative = "relative" +} + +export type TOrgIdentityMembership = { + id: string; + orgId: string; + identityId: string; + createdAt: string; + updatedAt: string; +}; + +export type TCreateOrgIdentityMembershipDTO = { + identityId: string; + roles: Array< + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: TemporaryPermissionMode; + temporaryRange: string; + temporaryAccessStartTime: string; + } + >; +}; + +export type TDeleteOrgIdentityMembershipDTO = { + identityId: string; +}; diff --git a/frontend/src/hooks/api/organization/index.ts b/frontend/src/hooks/api/organization/index.ts index f4627a614..7c283691e 100644 --- a/frontend/src/hooks/api/organization/index.ts +++ b/frontend/src/hooks/api/organization/index.ts @@ -6,6 +6,7 @@ export { useDeleteOrgById, useDeleteOrgPmtMethod, useDeleteOrgTaxId, + useGetAvailableOrgIdentities, useGetIdentityMembershipOrgs, useGetOrganizationGroups, useGetOrganizations, diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 15e1b861c..bbf73dd25 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -42,7 +42,9 @@ export const organizationKeys = { [...organizationKeys.getOrgIdentityMemberships(orgId), params] as const, getOrgGroups: (orgId: string) => [{ orgId }, "organization-groups"] as const, getOrgIntegrationAuths: (orgId: string) => [{ orgId }, "integration-auths"] as const, - getOrgById: (orgId: string) => ["organization", { orgId }] + getOrgById: (orgId: string, subOrg?: string) => ["organization", { orgId, subOrg }], + getAvailableIdentities: () => ["available-identities"], + getAvailableUsers: () => ["available-users"] }; export const fetchOrganizations = async () => { @@ -64,7 +66,9 @@ export const useGetOrganizations = () => { export const fetchOrganizationById = async (id: string) => { const { data: { organization } - } = await apiRequest.get<{ organization: Organization }>(`/api/v1/organization/${id}`); + } = await apiRequest.get<{ + organization: Organization & { subOrganization?: { id: string; name: string } }; + }>(`/api/v1/organization/${id}`); return organization; }; @@ -572,3 +576,29 @@ export const useGetOrgIntegrationAuths = ( select }); }; + +export const useGetAvailableOrgIdentities = (enabled = true) => + useQuery({ + queryKey: organizationKeys.getAvailableIdentities(), + queryFn: async () => { + const { data } = await apiRequest.get<{ identities: { name: string; id: string }[] }>( + "/api/v1/organization/identities/available" + ); + + return data.identities; + }, + enabled + }); + +export const useGetAvailableOrgUsers = (enabled = true) => + useQuery({ + queryKey: organizationKeys.getAvailableUsers(), + queryFn: async () => { + const { data } = await apiRequest.get<{ + users: { username: string; id: string; firstName: string; lastName: string }[]; + }>("/api/v1/organization/users/available"); + + return data.users; + }, + enabled + }); diff --git a/frontend/src/hooks/api/pam/constants.ts b/frontend/src/hooks/api/pam/constants.ts new file mode 100644 index 000000000..8cdbd3324 --- /dev/null +++ b/frontend/src/hooks/api/pam/constants.ts @@ -0,0 +1 @@ +export const UNCHANGED_PASSWORD_SENTINEL = "__INFISICAL_UNCHANGED__"; diff --git a/frontend/src/hooks/api/pam/queries.tsx b/frontend/src/hooks/api/pam/queries.tsx index 288d65ab9..6339b4761 100644 --- a/frontend/src/hooks/api/pam/queries.tsx +++ b/frontend/src/hooks/api/pam/queries.tsx @@ -3,6 +3,7 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { TPamResourceOption } from "./types/resource-options"; +import { PamResourceType } from "./enums"; import { TPamAccount, TPamFolder, TPamResource, TPamSession } from "./types"; export const pamKeys = { @@ -12,6 +13,12 @@ export const pamKeys = { session: () => [...pamKeys.all, "session"] as const, listResourceOptions: () => [...pamKeys.resource(), "options"] as const, listResources: (projectId: string) => [...pamKeys.resource(), "list", projectId], + getResource: (resourceType: string, resourceId: string) => [ + ...pamKeys.resource(), + "get", + resourceType, + resourceId + ], listAccounts: (projectId: string) => [...pamKeys.account(), "list", projectId], getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId], listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId] @@ -68,6 +75,28 @@ export const useListPamResources = ( }); }; +export const useGetPamResourceById = ( + resourceType?: PamResourceType, + resourceId?: string, + options?: Omit< + UseQueryOptions>, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: pamKeys.getResource(resourceType || "", resourceId || ""), + queryFn: async () => { + const { data } = await apiRequest.get<{ resource: TPamResource }>( + `/api/v1/pam/resources/${resourceType}/${resourceId}` + ); + + return data.resource; + }, + enabled: !!resourceId && !!resourceType && (options?.enabled ?? true), + ...options + }); +}; + // Accounts export const useListPamAccounts = ( projectId: string, diff --git a/frontend/src/hooks/api/pam/types/base-account.ts b/frontend/src/hooks/api/pam/types/base-account.ts index 9f45b1a4a..20cb7aa60 100644 --- a/frontend/src/hooks/api/pam/types/base-account.ts +++ b/frontend/src/hooks/api/pam/types/base-account.ts @@ -9,9 +9,13 @@ export interface TBasePamAccount { id: string; name: string; resourceType: PamResourceType; + rotationCredentialsConfigured: boolean; }; name: string; description?: string | null; + rotationEnabled: boolean; + rotationIntervalSeconds?: number | null; + lastRotatedAt?: string | null; createdAt: string; updatedAt: string; } diff --git a/frontend/src/hooks/api/pam/types/postgres-resource.ts b/frontend/src/hooks/api/pam/types/postgres-resource.ts index 513610be1..b1b5b7487 100644 --- a/frontend/src/hooks/api/pam/types/postgres-resource.ts +++ b/frontend/src/hooks/api/pam/types/postgres-resource.ts @@ -6,6 +6,7 @@ import { TBasePamResource } from "./base-resource"; // Resources export type TPostgresResource = TBasePamResource & { resourceType: PamResourceType.Postgres } & { connectionDetails: TBaseSqlConnectionDetails; + rotationAccountCredentials?: TBaseSqlCredentials | null; }; // Accounts diff --git a/frontend/src/hooks/api/secretFolders/index.tsx b/frontend/src/hooks/api/secretFolders/index.tsx index 0676f5d14..9d2e44621 100644 --- a/frontend/src/hooks/api/secretFolders/index.tsx +++ b/frontend/src/hooks/api/secretFolders/index.tsx @@ -2,6 +2,7 @@ export { useCreateFolder, useDeleteFolder, useGetFoldersByEnv, + useGetOrCreateFolder, useGetProjectFolders, useUpdateFolder } from "./queries"; diff --git a/frontend/src/hooks/api/secretFolders/queries.tsx b/frontend/src/hooks/api/secretFolders/queries.tsx index 694a33d13..56f7825c0 100644 --- a/frontend/src/hooks/api/secretFolders/queries.tsx +++ b/frontend/src/hooks/api/secretFolders/queries.tsx @@ -140,6 +140,59 @@ export const useGetFoldersByEnv = ({ return { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv }; }; +export const useGetOrCreateFolder = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto) => { + const { data: existingFolder } = await apiRequest.get<{ folders: TSecretFolder[] }>( + "/api/v2/folders", + { + params: { + projectId: dto.projectId, + environment: dto.environment, + path: dto.path || "/" + } + } + ); + + const folder = existingFolder.folders.find((f) => f.name === dto.name); + + if (folder) return folder; + + const { data } = await apiRequest.post("/api/v2/folders", { + ...dto, + projectId: dto.projectId + }); + + return data; + }, + onSuccess: (_, { projectId, environment, path }) => { + queryClient.invalidateQueries({ + queryKey: dashboardKeys.getDashboardSecrets({ + projectId, + secretPath: path ?? "/" + }) + }); + queryClient.invalidateQueries({ + queryKey: folderQueryKeys.getSecretFolders({ projectId, environment, path }) + }); + queryClient.invalidateQueries({ + queryKey: secretSnapshotKeys.list({ projectId, environment, directory: path }) + }); + queryClient.invalidateQueries({ + queryKey: secretSnapshotKeys.count({ projectId, environment, directory: path }) + }); + queryClient.invalidateQueries({ + queryKey: commitKeys.count({ projectId, environment, directory: path }) + }); + queryClient.invalidateQueries({ + queryKey: commitKeys.history({ projectId, environment, directory: path }) + }); + } + }); +}; + export const useCreateFolder = () => { const queryClient = useQueryClient(); @@ -170,6 +223,9 @@ export const useCreateFolder = () => { queryClient.invalidateQueries({ queryKey: commitKeys.count({ projectId, environment, directory: path }) }); + queryClient.invalidateQueries({ + queryKey: commitKeys.history({ projectId, environment, directory: path }) + }); } }); }; diff --git a/frontend/src/hooks/api/subOrganizations/index.tsx b/frontend/src/hooks/api/subOrganizations/index.tsx new file mode 100644 index 000000000..480377464 --- /dev/null +++ b/frontend/src/hooks/api/subOrganizations/index.tsx @@ -0,0 +1,8 @@ +export { useCreateSubOrganization, useUpdateSubOrganization } from "./mutations"; +export { subOrganizationsQuery } from "./queries"; +export type { + TCreateSubOrganizationDTO, + TListSubOrganizationsDTO, + TSubOrganization, + TUpdateSubOrganizationDTO +} from "./types"; diff --git a/frontend/src/hooks/api/subOrganizations/mutations.tsx b/frontend/src/hooks/api/subOrganizations/mutations.tsx new file mode 100644 index 000000000..81369a62e --- /dev/null +++ b/frontend/src/hooks/api/subOrganizations/mutations.tsx @@ -0,0 +1,41 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { subOrganizationsQuery } from "./queries"; +import { TCreateSubOrganizationDTO, TSubOrganization, TUpdateSubOrganizationDTO } from "./types"; + +export const useCreateSubOrganization = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (dto: TCreateSubOrganizationDTO) => { + const { data } = await apiRequest.post<{ organization: TSubOrganization }>( + "/api/v1/sub-organizations", + dto, + { + headers: { "x-root-org": "discard" } // akhi/scott: this just tells the request to use the root org ID header + } + ); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: subOrganizationsQuery.allKey() }); + } + }); +}; + +export const useUpdateSubOrganization = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ subOrgId, name }: TUpdateSubOrganizationDTO) => { + const { data } = await apiRequest.patch<{ organization: TSubOrganization }>( + `/api/v1/sub-organizations/${subOrgId}`, + { name } + ); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: subOrganizationsQuery.allKey() }); + } + }); +}; diff --git a/frontend/src/hooks/api/subOrganizations/queries.tsx b/frontend/src/hooks/api/subOrganizations/queries.tsx new file mode 100644 index 000000000..99ccb7770 --- /dev/null +++ b/frontend/src/hooks/api/subOrganizations/queries.tsx @@ -0,0 +1,28 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TListSubOrganizationsDTO, TSubOrganization } from "./types"; + +export const subOrganizationsQuery = { + allKey: () => ["sub-organizations"] as const, + listKey: (params?: TListSubOrganizationsDTO) => + [...subOrganizationsQuery.allKey(), "list", params] as const, + list: (params: TListSubOrganizationsDTO) => + queryOptions({ + queryKey: subOrganizationsQuery.listKey(params), + queryFn: async () => { + const { data } = await apiRequest.get<{ organizations: TSubOrganization[] }>( + "/api/v1/sub-organizations", + { + params: { + limit: params.limit, + offset: params.offset, + isAccessible: params.isAccessible + } + } + ); + return data.organizations; + } + }) +}; diff --git a/frontend/src/hooks/api/subOrganizations/types.ts b/frontend/src/hooks/api/subOrganizations/types.ts new file mode 100644 index 000000000..e9b3f2f01 --- /dev/null +++ b/frontend/src/hooks/api/subOrganizations/types.ts @@ -0,0 +1,23 @@ +export type TSubOrganization = { + id: string; + name: string; + slug: string; + createdAt: string; + updatedAt: string; + parentOrgId: string; +}; + +export type TCreateSubOrganizationDTO = { + name: string; +}; + +export type TListSubOrganizationsDTO = { + limit?: number; + offset?: number; + isAccessible?: boolean; +}; + +export type TUpdateSubOrganizationDTO = { + subOrgId: string; + name: string; +}; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index ede2f8cf1..98daf3ec1 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -13,6 +13,7 @@ export type SubscriptionPlan = { customRateLimits: boolean; pitRecovery: boolean; githubOrgSync: boolean; + subOrganization?: boolean; ipAllowlisting: boolean; rbac: boolean; secretVersioning: boolean; @@ -47,6 +48,7 @@ export type SubscriptionPlan = { gateway: boolean; externalKms: boolean; pkiEst: boolean; + pkiLegacyTemplates: boolean; enforceMfa: boolean; enforceGoogleSSO: boolean; projectTemplates: boolean; diff --git a/frontend/src/index.css b/frontend/src/index.css index 360894c2f..7c9b7db9b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -42,7 +42,7 @@ --font-inter: "Inter", sans-serif; --color-org-v1: #30B3FF; --color-namespace-v1: #96ff59; - + --max-width-8xl: 88rem; /* 1408px */ /* Primary */ --color-primary-50: #fffff5; --color-primary-100: #fcfce8; diff --git a/frontend/src/layouts/AdminLayout/AdminLayout.tsx b/frontend/src/layouts/AdminLayout/AdminLayout.tsx index 859ce9627..89e8a761e 100644 --- a/frontend/src/layouts/AdminLayout/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout/AdminLayout.tsx @@ -12,7 +12,7 @@ import { RedisBanner } from "@app/layouts/OrganizationLayout/components/RedisBan import { SmtpBanner } from "@app/layouts/OrganizationLayout/components/SmtpBanner"; import { InsecureConnectionBanner } from "../OrganizationLayout/components/InsecureConnectionBanner"; -import { AdminSidebar } from "./Sidebar"; +import { AdminNavBar } from "./AdminNavBar"; export const AdminLayout = () => { const { t } = useTranslation(); @@ -33,9 +33,9 @@ export const AdminLayout = () => { {!isLoading && !serverDetails?.emailConfigured && } {!isLoading && subscription.auditLogs && } {!window.isSecureContext && } -
- -
+
+ +
diff --git a/frontend/src/layouts/AdminLayout/AdminNavBar.tsx b/frontend/src/layouts/AdminLayout/AdminNavBar.tsx new file mode 100644 index 000000000..49cc1edf9 --- /dev/null +++ b/frontend/src/layouts/AdminLayout/AdminNavBar.tsx @@ -0,0 +1,99 @@ +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; +import { + faArrowLeft, + faBuilding, + faCog, + faDatabase, + faKey, + faLock, + faPlug, + faUserTie +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useMatchRoute } from "@tanstack/react-router"; +import { motion } from "framer-motion"; + +import { Tab, TabList, Tabs, Tooltip } from "@app/components/v2"; + +const generalTabs = [ + { + label: "General", + icon: faCog, + link: "/admin/" + }, + { + label: "Resource Overview", + icon: faBuilding, + link: "/admin/resources/overview" + }, + { + label: "Access Control", + icon: faUserTie, + link: "/admin/access-management" + }, + { + label: "Encryption", + icon: faLock, + link: "/admin/encryption" + }, + { + label: "Authentication", + icon: faCheckCircle, + link: "/admin/authentication" + }, + { + label: "Integrations", + icon: faPlug, + link: "/admin/integrations" + }, + { + label: "Caching", + icon: faDatabase, + link: "/admin/caching" + }, + { + label: "Environment Variables", + icon: faKey, + link: "/admin/environment" + } +]; + +export const AdminNavBar = () => { + const matchRoute = useMatchRoute(); + + return ( +
+ + + +
+ ); +}; diff --git a/frontend/src/layouts/AdminLayout/Sidebar.tsx b/frontend/src/layouts/AdminLayout/Sidebar.tsx deleted file mode 100644 index 185ea1dbf..000000000 --- a/frontend/src/layouts/AdminLayout/Sidebar.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; -import { - faBuilding, - faChevronLeft, - faCog, - faDatabase, - faKey, - faLock, - faPlug, - faUserTie -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, useMatchRoute } from "@tanstack/react-router"; - -import { Menu, MenuGroup, MenuItem } from "@app/components/v2"; - -const generalTabs = [ - { - label: "General", - icon: faCog, - link: "/admin/" - }, - { - label: "Encryption", - icon: faLock, - link: "/admin/encryption" - }, - { - label: "Authentication", - icon: faCheckCircle, - link: "/admin/authentication" - }, - { - label: "Integrations", - icon: faPlug, - link: "/admin/integrations" - }, - { - label: "Caching", - icon: faDatabase, - link: "/admin/caching" - }, - { - label: "Environment Variables", - icon: faKey, - link: "/admin/environment" - } -]; - -const othersTabs = [ - { - label: "Access Controls", - icon: faUserTie, - link: "/admin/access-management" - }, - { - label: "Resource Overview", - icon: faBuilding, - link: "/admin/resources/overview" - } -]; - -export const AdminSidebar = () => { - const matchRoute = useMatchRoute(); - - return ( - - ); -}; diff --git a/frontend/src/layouts/KmsLayout/KmsLayout.tsx b/frontend/src/layouts/KmsLayout/KmsLayout.tsx index 4aa1c1406..c4fdf32eb 100644 --- a/frontend/src/layouts/KmsLayout/KmsLayout.tsx +++ b/frontend/src/layouts/KmsLayout/KmsLayout.tsx @@ -1,9 +1,7 @@ -import { faBook, faCog, faCube, faHome, faLock, faUsers } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, Outlet } from "@tanstack/react-router"; +import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { motion } from "framer-motion"; -import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; +import { Tab, TabList, Tabs } from "@app/components/v2"; import { useProject, useProjectPermission } from "@app/context"; import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePrivilegeModeBanner"; @@ -12,138 +10,81 @@ export const KmsLayout = () => { const { currentProject } = useProject(); const { assumedPrivilegeDetails } = useProjectPermission(); + const location = useLocation(); + return (
-
+
-
+ + {({ isActive }) => KMIP} + + + {({ isActive }) => ( + + Access Control + + )} + + + {({ isActive }) => Audit Logs} + + + {({ isActive }) => Settings} + + + -
- {assumedPrivilegeDetails && } - -
+
+ {assumedPrivilegeDetails && } +
+
); diff --git a/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx b/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx index f2866259c..c1cc10e41 100644 --- a/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx +++ b/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx @@ -13,7 +13,7 @@ import { useFetchServerStatus } from "@app/hooks/api"; import { AuditLogBanner } from "./components/AuditLogBanner"; import { InsecureConnectionBanner } from "./components/InsecureConnectionBanner"; import { Navbar } from "./components/NavBar"; -import { OrgSidebar } from "./components/OrgSidebar"; +import { OrgNavBar } from "./components/OrgNavBar"; import { RedisBanner } from "./components/RedisBanner"; import { SmtpBanner } from "./components/SmtpBanner"; @@ -41,16 +41,16 @@ export const OrganizationLayout = () => { className={`dark hidden ${containerHeight} w-full flex-col overflow-x-hidden bg-bunker-800 transition-all md:flex`} > - {!isLoading && !serverDetails?.redisConfigured && } - {!isLoading && !serverDetails?.emailConfigured && } - {!isLoading && subscription.auditLogs && } - {!window.isSecureContext && } -
- +
+ + {!isLoading && !isInsideProject && !serverDetails?.redisConfigured && } + {!isLoading && !isInsideProject && !serverDetails?.emailConfigured && } + {!isLoading && !isInsideProject && subscription.auditLogs && } + {!window.isSecureContext && !isInsideProject && }
diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index 596202bf6..f9ef51133 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -6,11 +6,15 @@ import { faBook, faCaretDown, faCheck, + faChevronRight, + faCubes, faEnvelope, faExclamationTriangle, faGlobe, + faInfinity, faInfo, faInfoCircle, + faPlus, faServer, faSignOut, faToolbox, @@ -18,7 +22,7 @@ import { faUsers } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useLocation, useNavigate, useRouter, useRouterState } from "@tanstack/react-router"; import { twMerge } from "tailwind-merge"; @@ -33,6 +37,9 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + DropdownSubMenu, + DropdownSubMenuContent, + DropdownSubMenuTrigger, IconButton, Modal, ModalContent, @@ -43,15 +50,22 @@ import { envConfig } from "@app/config/env"; import { useOrganization, useSubscription, useUser } from "@app/context"; import { isInfisicalCloud } from "@app/helpers/platform"; import { useToggle } from "@app/hooks"; -import { projectKeys, useGetOrganizations, useLogoutUser } from "@app/hooks/api"; +import { + projectKeys, + subOrganizationsQuery, + useGetOrganizations, + useGetOrgTrialUrl, + useLogoutUser +} from "@app/hooks/api"; import { authKeys, selectOrganization } from "@app/hooks/api/auth/queries"; import { MfaMethod } from "@app/hooks/api/auth/types"; import { getAuthToken } from "@app/hooks/api/reactQuery"; -import { SubscriptionPlan } from "@app/hooks/api/types"; +import { Organization, SubscriptionPlan } from "@app/hooks/api/types"; import { AuthMethod } from "@app/hooks/api/users/types"; import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils"; import { ServerAdminsPanel } from "../ServerAdminsPanel/ServerAdminsPanel"; +import { NewSubOrganizationForm } from "./NewSubOrganizationForm"; import { NotificationDropdown } from "./NotificationDropdown"; const getPlan = (subscription: SubscriptionPlan) => { @@ -118,10 +132,18 @@ export const INFISICAL_SUPPORT_OPTIONS = [ export const Navbar = () => { const { user } = useUser(); const { subscription } = useSubscription(); - const { currentOrg } = useOrganization(); + const { currentOrg, isSubOrganization } = useOrganization(); + const [showAdminsModal, setShowAdminsModal] = useState(false); + const [showSubOrgForm, setShowSubOrgForm] = useState(false); const [showCardDeclinedModal, setShowCardDeclinedModal] = useState(false); + const subOrgQuery = subOrganizationsQuery.list({ limit: 500, isAccessible: true }); + const { data: subOrganizations = [] } = useQuery({ + ...subOrgQuery, + enabled: Boolean(subscription.subOrganization) + }); + useEffect(() => { if (subscription?.cardDeclined && !sessionStorage.getItem("paymentFailed")) { sessionStorage.setItem("paymentFailed", "true"); @@ -136,6 +158,7 @@ export const Navbar = () => { const [shouldShowMfa, toggleShowMfa] = useToggle(false); const router = useRouter(); const queryClient = useQueryClient(); + const [isOrgSelectOpen, setIsOrgSelectOpen] = useState(false); const location = useLocation(); const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context }); @@ -160,8 +183,11 @@ export const Navbar = () => { } await router.invalidate(); await navigateUserToOrg(navigate, orgId); + queryClient.removeQueries({ queryKey: subOrgQuery.queryKey }); }; + const { mutateAsync } = useGetOrgTrialUrl(); + const logout = useLogoutUser(); const logOutUser = async () => { try { @@ -201,45 +227,85 @@ export const Navbar = () => { const isServerAdminPanel = location.pathname.startsWith("/admin"); - const isOrgScope = breadcrumbs?.length === 1; // TODO: scott/akhil is this adequate? + const isOrgScope = location.pathname.startsWith("/organization"); // TODO: scott/akhil is this adequate? + + const handleOrgNav = async (org: Organization) => { + if (currentOrg?.id === org.id) return; + + if (org.authEnforced) { + // org has an org-level auth method enabled (e.g. SAML) + // -> logout + redirect to SAML SSO + + await logout.mutateAsync(); + if (org.orgAuthMethod === AuthMethod.OIDC) { + window.open(`/api/v1/sso/oidc/login?orgSlug=${org.slug}`); + } else { + window.open(`/api/v1/sso/redirect/saml2/organizations/${org.slug}`); + } + window.close(); + return; + } + + if (org.googleSsoAuthEnforced) { + await logout.mutateAsync(); + window.open(`/api/v1/sso/redirect/google?org_slug=${org.slug}`); + window.close(); + return; + } + + handleOrgChange(org?.id); + }; return ( -
-
- - infisical logo - -
-

/

- {isServerAdminPanel ? ( - <> - -
- -
-
Server Console
+
+
+
+ + infisical logo -

/

- {breadcrumbs ? ( - // scott: remove /admin as we show server console above - - ) : null} - - ) : ( - <> -
- - -
+
+

/

+ {isServerAdminPanel ? ( + <> + +
+ +
+
Server Console
+ +

/

+ {breadcrumbs ? ( + // scott: remove /admin as we show server console above + + ) : null} + + ) : ( + <> +
+ +
{ + navigate({ + to: "/organization/projects", + search: (search) => ({ ...search, subOrganization: undefined }) + }); + if (isSubOrganization) { + await router.invalidate({ sync: true }).catch(() => null); + } + }} variant="org" - className={twMerge("text-sm", !isOrgScope && "bg-transparent opacity-75")} + className={twMerge( + "max-w-full min-w-0 cursor-pointer text-sm", + (!isOrgScope || isSubOrganization) && + "bg-transparent text-mineshaft-200 hover:bg-transparent hover:underline" + )} > - {currentOrg?.name} +

{currentOrg?.name}

{getPlan(subscription)} @@ -258,87 +324,238 @@ export const Navbar = () => { )}
- - -
- +
+ + + +
+ + +
+ Organizations +
+ {orgs?.map((org) => { + if ( + subscription.subOrganization && + (org.id === currentOrg?.id || org.id === currentOrg?.parentOrgId) + ) { + return ( + + { + setIsOrgSelectOpen(false); + handleOrgNav(org); + }} + className="cursor-pointer font-normal" + > +
+ {currentOrg?.id === org.id && ( + + )} +

{org.name}

+ +
+
+ +
+ Sub-Organizations +
+ {subOrganizations.map((subOrg) => ( + { + navigate({ + to: "/organization/projects", + search: (prev) => ({ ...prev, subOrganization: subOrg.name }) + }); + await router.invalidate({ sync: true }).catch(() => null); + }} + className="cursor-pointer font-normal" + key={subOrg.id} + > +
+ {currentOrg?.id === subOrg.id && ( + + )} +

{subOrg.name}

+
+
+ ))} + {Boolean(subOrganizations.length) && ( +
+ )} + } + onClick={() => setShowSubOrgForm(true)} + > + New Sub-Organization + + + + ); + } + + return ( + handleOrgNav(org)} + className="cursor-pointer font-normal" + key={org.id} + > +
+ {currentOrg?.id === org.id && ( + + )} +

{org.name}

+
+
+ ); + })} +
+ } + onClick={logOutUser} > - - -
- - -
organizations
- {orgs?.map((org) => { - return ( - -
+ {currentOrg.subOrganization && ( + <> +

/

+ + + + +

{currentOrg.subOrganization.name}

+
+ + +
+ - ) - } + ariaLabel="switch-org" + className="px-2 py-1" > -
- {org.name} + + +
+ + +
+ Sub-Organizations +
+ {subOrganizations.map((subOrg) => ( + { + navigate({ + to: "/organization/projects", + search: (prev) => ({ ...prev, subOrganization: subOrg.name }) + }); + await router.invalidate({ sync: true }).catch(() => null); + }} + className="cursor-pointer font-normal" + key={subOrg.id} + > +
+ {currentOrg?.id === subOrg.id && ( + + )} +

{subOrg.name}

- +
+ ))} + {Boolean(subOrganizations.length) && ( +
+ )} + } + onClick={() => setShowSubOrgForm(true)} + > + New Sub-Organization - ); - })} -
- } onClick={logOutUser}> - Log Out - - - -
-

/

- {breadcrumbs ? ( - - ) : null} - + + + + )} + {!isOrgScope && ( + <> +

/

+ {breadcrumbs ? ( + + ) : null} + + )} + + )} +
+ {subscription && subscription.slug === "starter" && !subscription.has_used_trial && ( + + + + )} + {user.superAdmin && !location.pathname.startsWith("/admin") && ( + + + Server Console + )} -
@@ -466,6 +683,7 @@ export const Navbar = () => { + {
+ + +
+ { + setShowSubOrgForm(false); + }} + /> +
+
+
diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/NewSubOrganizationForm.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/NewSubOrganizationForm.tsx new file mode 100644 index 000000000..75041a69e --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/NewSubOrganizationForm.tsx @@ -0,0 +1,94 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useNavigate, useRouter } from "@tanstack/react-router"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { useCreateSubOrganization } from "@app/hooks/api"; +import { slugSchema } from "@app/lib/schemas"; + +type ContentProps = { + onClose: () => void; +}; + +const AddOrgSchema = z.object({ + name: slugSchema() +}); + +type FormData = z.infer; + +export const NewSubOrganizationForm = ({ onClose }: ContentProps) => { + const createSubOrg = useCreateSubOrganization(); + + const { + handleSubmit, + control, + formState: { isSubmitting } + } = useForm({ + defaultValues: { + name: "" + }, + resolver: zodResolver(AddOrgSchema) + }); + + const navigate = useNavigate(); + const router = useRouter(); + + const onSubmit = async ({ name }: FormData) => { + try { + const { organization } = await createSubOrg.mutateAsync({ + name + }); + + createNotification({ + type: "success", + text: "Successfully created sub organization" + }); + onClose(); + + navigate({ + to: "/organization/projects", + search: (prev) => ({ ...prev, subOrganization: organization.name }) + }); + await router.invalidate({ sync: true }).catch(() => null); + } catch { + createNotification({ + text: "Failed to create sub organization", + type: "error" + }); + } + }; + + return ( +
+ ( + + + + )} + control={control} + name="name" + /> +
+ + +
+ + ); +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx index 82f5d0fdd..ade478c69 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx @@ -16,15 +16,15 @@ export const Notification = ({ notification, onDelete }: Props) => { return (
-
+
{!notification.isRead && ( - + )} {notification.title}} @@ -45,7 +45,7 @@ export const Notification = ({ notification, onDelete }: Props) => { )}
-
+
{ + const { isRootOrganization } = useOrganization(); + const { popUp, handlePopUpToggle } = usePopUp(["createOrg"] as const); + + const { pathname } = useLocation(); + + const variant = isRootOrganization ? "org" : "namespace"; + + return ( + <> + {!isHidden && ( +
+ + + +
+ )} + handlePopUpToggle("createOrg", false)} + /> + + ); +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/OrgNavBar/index.tsx b/frontend/src/layouts/OrganizationLayout/components/OrgNavBar/index.tsx new file mode 100644 index 000000000..06509e919 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/OrgNavBar/index.tsx @@ -0,0 +1 @@ +export { OrgNavBar } from "./OrgNavBar"; diff --git a/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/OrgSidebar.tsx b/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/OrgSidebar.tsx deleted file mode 100644 index 51e14721e..000000000 --- a/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/OrgSidebar.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { - faBook, - faCog, - faInfinity, - faMoneyBill, - faNetworkWired, - faPlug, - faShare, - faTable, - faUsers, - faUserTie -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link } from "@tanstack/react-router"; -import { AnimatePresence, motion } from "framer-motion"; - -import { CreateOrgModal } from "@app/components/organization/CreateOrgModal"; -import { Menu, MenuGroup, MenuItem, Tooltip } from "@app/components/v2"; -import { useOrganization, useSubscription, useUser } from "@app/context"; -import { usePopUp } from "@app/hooks"; -import { useGetOrgTrialUrl } from "@app/hooks/api"; - -type Props = { - isHidden?: boolean; -}; - -export const OrgSidebar = ({ isHidden }: Props) => { - const { subscription } = useSubscription(); - - const { user } = useUser(); - const { mutateAsync } = useGetOrgTrialUrl(); - - const { currentOrg } = useOrganization(); - - const { popUp, handlePopUpToggle } = usePopUp(["createOrg"] as const); - - return ( - <> - - {!isHidden && ( - -
- } - > - Server Console - - - )} - - - - )} - - handlePopUpToggle("createOrg", false)} - /> - - ); -}; diff --git a/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/index.tsx b/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/index.tsx deleted file mode 100644 index 315d7ffab..000000000 --- a/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { OrgSidebar } from "./OrgSidebar"; diff --git a/frontend/src/layouts/PamLayout/PamLayout.tsx b/frontend/src/layouts/PamLayout/PamLayout.tsx index 48f6af116..f34c29d0d 100644 --- a/frontend/src/layouts/PamLayout/PamLayout.tsx +++ b/frontend/src/layouts/PamLayout/PamLayout.tsx @@ -1,19 +1,9 @@ import { useEffect } from "react"; -import { - faBook, - faBoxOpen, - faCog, - faDisplay, - faHome, - faUser, - faUsers -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, Outlet } from "@tanstack/react-router"; +import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { motion } from "framer-motion"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; -import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; +import { Tab, TabList, Tabs } from "@app/components/v2"; import { useProject, useProjectPermission, useSubscription } from "@app/context"; import { usePopUp } from "@app/hooks"; @@ -23,7 +13,7 @@ export const PamLayout = () => { const { currentProject } = useProject(); const { subscription } = useSubscription(); const { assumedPrivilegeDetails } = useProjectPermission(); - + const location = useLocation(); const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"]); useEffect(() => { @@ -35,152 +25,85 @@ export const PamLayout = () => { return ( <>
-
+
-
+ + {({ isActive }) => Resources} + + + {({ isActive }) => Sessions} + + + {({ isActive }) => ( + + Access Control + + )} + + + {({ isActive }) => Audit Logs} + + + {({ isActive }) => Settings} + + + -
- {assumedPrivilegeDetails && } - -
+
+ {assumedPrivilegeDetails && } +
+
{ return ( <> -
+
{!window.isSecureContext && }
- -
+
diff --git a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx index 93fa04ac9..2c088c293 100644 --- a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx +++ b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx @@ -1,249 +1,188 @@ import { useTranslation } from "react-i18next"; -import { - faBell, - faBook, - faCertificate, - faCog, - faFileLines, - faHome, - faMobile, - faPlug, - faPuzzlePiece, - faSitemap, - faStamp, - faUsers -} from "@fortawesome/free-solid-svg-icons"; +import { faMobile } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, Outlet } from "@tanstack/react-router"; +import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { motion } from "framer-motion"; -import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; -import { useProject, useProjectPermission } from "@app/context"; +import { Tab, TabList, Tabs } from "@app/components/v2"; +import { useProject, useProjectPermission, useSubscription } from "@app/context"; +import { + useListWorkspaceCertificateTemplates, + useListWorkspacePkiSubscribers +} from "@app/hooks/api"; import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePrivilegeModeBanner"; export const PkiManagerLayout = () => { const { currentProject } = useProject(); const { assumedPrivilegeDetails } = useProjectPermission(); + const { subscription } = useSubscription(); const { t } = useTranslation(); + const { data: subscribers = [] } = useListWorkspacePkiSubscribers(currentProject?.id || ""); + const { data: templatesData } = useListWorkspaceCertificateTemplates({ + projectId: currentProject?.id || "" + }); + const templates = templatesData?.certificateTemplates || []; + + const hasExistingSubscribers = subscribers.length > 0; + const hasExistingTemplates = templates.length > 0; + const showLegacySection = + subscription.pkiLegacyTemplates || hasExistingSubscribers || hasExistingTemplates; + + const location = useLocation(); return ( <>
-
+
-
+ + {({ isActive }) => ( + + Certificates + + )} + + + {({ isActive }) => ( + + Certificate Authorities + + )} + + + {({ isActive }) => Alerting} + + + {({ isActive }) => Integrations} + + + {({ isActive }) => ( + App Connections + )} + + {showLegacySection && ( + <> + {(subscription.pkiLegacyTemplates || hasExistingSubscribers) && ( + + {({ isActive }) => ( + Subscribers (Legacy) + )} + + )} + {(subscription.pkiLegacyTemplates || hasExistingTemplates) && ( + + {({ isActive }) => ( + + Certificate Templates (Legacy) + + )} + + )} + + )} + + {({ isActive }) => ( + + Access Control + + )} + + + {({ isActive }) => Audit Logs} + + + {({ isActive }) => Settings} + + + -
- {assumedPrivilegeDetails && } - -
+
+ {assumedPrivilegeDetails && } +
+
diff --git a/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx index 71af774ca..45978e3d7 100644 --- a/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx +++ b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx @@ -15,7 +15,7 @@ export const AssumePrivilegeModeBanner = () => { if (!assumedPrivilegeDetails) return null; return ( -
+
You are currently viewing the project with privileges of{" "} @@ -24,7 +24,7 @@ export const AssumePrivilegeModeBanner = () => { {assumedPrivilegeDetails?.actorName}
-
+
+ +
+ + )} + + + ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateModal.tsx index 918aa7303..22718222a 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateModal.tsx @@ -46,9 +46,8 @@ const schema = z.object({ certificateTemplateId: z.string().optional(), caId: z.string(), collectionId: z.string().optional(), - friendlyName: z.string(), commonName: z.string().trim().min(1), - altNames: z.string(), + subjectAltNames: z.string(), ttl: z.string().trim(), keyUsages: z.object({ [CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(), @@ -139,9 +138,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { if (cert) { reset({ caId: cert.caId, - friendlyName: cert.friendlyName, commonName: cert.commonName, - altNames: cert.altNames, + subjectAltNames: cert.subjectAltNames, certificateTemplateId: cert.certificateTemplateId ?? CERT_TEMPLATE_NONE_VALUE, ttl: "", keyUsages: Object.fromEntries((cert.keyUsages || []).map((name) => [name, true])), @@ -152,9 +150,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { } else { reset({ caId: "", - friendlyName: "", commonName: "", - altNames: "", + subjectAltNames: "", ttl: "", certificateTemplateId: CERT_TEMPLATE_NONE_VALUE, keyUsages: { @@ -182,10 +179,9 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { const onFormSubmit = async ({ caId, - friendlyName, collectionId, commonName, - altNames, + subjectAltNames, ttl, keyUsages, extendedKeyUsages @@ -198,9 +194,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { certificateTemplateId: selectedCertTemplate ? selectedCertTemplateId : undefined, projectSlug: currentProject.slug, pkiCollectionId: collectionId, - friendlyName, commonName, - altNames, + subjectAltNames, ttl, keyUsages: Object.entries(keyUsages) .filter(([, value]) => value) @@ -359,20 +354,6 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { /> )} - ( - - - - )} - /> { ( { const { currentProject } = useProject(); + const { subscription } = useSubscription(); const { mutateAsync: deleteCert } = useDeleteCert(); + const isLegacyTemplatesEnabled = subscription.pkiLegacyTemplates; + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "certificateIssuance", "certificate", "certificateImport", "certificateCert", @@ -73,7 +79,9 @@ export const CertificatesSection = () => { colorSchema="primary" type="submit" leftIcon={} - onClick={() => handlePopUpOpen("certificate")} + onClick={() => + handlePopUpOpen(isLegacyTemplatesEnabled ? "certificate" : "certificateIssuance") + } isDisabled={!isAllowed} > Issue @@ -83,7 +91,11 @@ export const CertificatesSection = () => {
- + {isLegacyTemplatesEnabled ? ( + + ) : ( + + )} diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/KeyUsageSection.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/KeyUsageSection.tsx new file mode 100644 index 000000000..747c6c09f --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/KeyUsageSection.tsx @@ -0,0 +1,78 @@ +import { Control, Controller } from "react-hook-form"; + +import { + AccordionContent, + AccordionItem, + AccordionTrigger, + Checkbox, + FormLabel +} from "@app/components/v2"; + +type KeyUsageOption = { + label: string; + value: string; +}; + +type KeyUsageSectionProps = { + control: Control; + title: string; + accordionValue: string; + namePrefix: "keyUsages" | "extendedKeyUsages"; + options: KeyUsageOption[]; + requiredUsages: string[]; +}; + +export const KeyUsageSection = ({ + control, + title, + accordionValue, + namePrefix, + options, + requiredUsages +}: KeyUsageSectionProps) => { + if (options.length === 0) return null; + + return ( + + {title} + +
+ {options.map(({ label, value }) => { + const isRequired = requiredUsages.includes(value); + return ( + ( +
+ { + if (!isRequired) { + field.onChange(checked); + } + }} + isDisabled={isRequired} + /> +
+ + {isRequired && (Required)} +
+
+ )} + /> + ); + })} +
+
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/SubjectAltNamesField.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/SubjectAltNamesField.tsx new file mode 100644 index 000000000..4614539e0 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/SubjectAltNamesField.tsx @@ -0,0 +1,99 @@ +import { Control, Controller } from "react-hook-form"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2"; +import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; + +import { getSanPlaceholder, getSanTypeLabels, SubjectAltName } from "./certificateUtils"; + +type SubjectAltNamesFieldProps = { + control: Control; + allowedSanTypes: CertSubjectAlternativeNameType[]; + error?: string; +}; + +export const SubjectAltNamesField = ({ + control, + allowedSanTypes, + error +}: SubjectAltNamesFieldProps) => { + const sanTypeLabels = getSanTypeLabels(); + + return ( + ( + +
+ {value.map((san: SubjectAltName, index: number) => ( + // eslint-disable-next-line react/no-array-index-key +
+ + { + const newValue = [...value]; + newValue[index] = { ...san, value: e.target.value }; + onChange(newValue); + }} + placeholder={getSanPlaceholder(san.type)} + className="flex-1" + /> + { + const newValue = value.filter((_: any, i: number) => i !== index); + onChange(newValue); + }} + > + + +
+ ))} + +
+
+ )} + /> + ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/certificateUtils.ts b/frontend/src/pages/cert-manager/CertificatesPage/components/certificateUtils.ts new file mode 100644 index 000000000..7f16b87e0 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/certificateUtils.ts @@ -0,0 +1,51 @@ +import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; + +export const getSanPlaceholder = (sanType: CertSubjectAlternativeNameType): string => { + switch (sanType) { + case CertSubjectAlternativeNameType.DNS_NAME: + return "example.com or *.example.com"; + case CertSubjectAlternativeNameType.IP_ADDRESS: + return "192.168.1.1"; + case CertSubjectAlternativeNameType.EMAIL: + return "admin@example.com"; + case CertSubjectAlternativeNameType.URI: + return "https://example.com"; + default: + return "Enter value"; + } +}; + +export const getSanTypeLabels = () => ({ + [CertSubjectAlternativeNameType.DNS_NAME]: "DNS", + [CertSubjectAlternativeNameType.IP_ADDRESS]: "IP", + [CertSubjectAlternativeNameType.EMAIL]: "Email", + [CertSubjectAlternativeNameType.URI]: "URI" +}); + +export type SubjectAltName = { + type: CertSubjectAlternativeNameType; + value: string; +}; + +export const formatSubjectAltNames = (subjectAltNames: SubjectAltName[]) => { + return subjectAltNames + .filter((san) => san.value.trim()) + .map((san) => ({ + type: san.type, + value: san.value.trim() + })); +}; + +export const filterUsages = >(usages: T): string[] => { + return Object.entries(usages) + .filter(([, value]) => value) + .map(([key]) => key); +}; + +export const getAttributeValue = ( + subjectAttributes: Array<{ type: string; value: string }> | undefined, + type: string +): string => { + const foundAttr = subjectAttributes?.find((attr) => attr.type === type); + return foundAttr?.value || ""; +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts b/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts new file mode 100644 index 000000000..871ad00a4 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useState } from "react"; +import { UseFormSetValue, UseFormWatch } from "react-hook-form"; + +import { + EXTENDED_KEY_USAGES_OPTIONS, + KEY_USAGES_OPTIONS +} from "@app/hooks/api/certificates/constants"; +import { + CertSubjectAlternativeNameType, + mapTemplateKeyAlgorithmToApi, + mapTemplateSignatureAlgorithmToApi +} from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; + +export type TemplateConstraints = { + allowedKeyUsages: string[]; + allowedExtendedKeyUsages: string[]; + requiredKeyUsages: string[]; + requiredExtendedKeyUsages: string[]; + allowedSignatureAlgorithms: string[]; + allowedKeyAlgorithms: string[]; + allowedSanTypes: CertSubjectAlternativeNameType[]; + shouldShowSanSection: boolean; + shouldShowSubjectSection: boolean; +}; + +export const useCertificateTemplate = ( + templateData: any, + selectedProfile: any, + isModalOpen: boolean, + setValue: UseFormSetValue, + watch: UseFormWatch +) => { + const [constraints, setConstraints] = useState({ + allowedKeyUsages: [], + allowedExtendedKeyUsages: [], + requiredKeyUsages: [], + requiredExtendedKeyUsages: [], + allowedSignatureAlgorithms: [], + allowedKeyAlgorithms: [], + allowedSanTypes: [ + CertSubjectAlternativeNameType.DNS_NAME, + CertSubjectAlternativeNameType.IP_ADDRESS, + CertSubjectAlternativeNameType.EMAIL, + CertSubjectAlternativeNameType.URI + ], + shouldShowSanSection: true, + shouldShowSubjectSection: true + }); + + const filteredKeyUsages = useMemo(() => { + return KEY_USAGES_OPTIONS.filter(({ value }) => constraints.allowedKeyUsages.includes(value)); + }, [constraints.allowedKeyUsages]); + + const filteredExtendedKeyUsages = useMemo(() => { + return EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) => + constraints.allowedExtendedKeyUsages.includes(value) + ); + }, [constraints.allowedExtendedKeyUsages]); + + const availableSignatureAlgorithms = useMemo(() => { + return constraints.allowedSignatureAlgorithms.map((templateAlgorithm) => { + const apiAlgorithm = mapTemplateSignatureAlgorithmToApi(templateAlgorithm); + return { + value: apiAlgorithm, + label: apiAlgorithm + }; + }); + }, [constraints.allowedSignatureAlgorithms]); + + const availableKeyAlgorithms = useMemo(() => { + return constraints.allowedKeyAlgorithms.map((templateAlgorithm) => { + const apiAlgorithm = mapTemplateKeyAlgorithmToApi(templateAlgorithm); + return { + value: apiAlgorithm, + label: apiAlgorithm + }; + }); + }, [constraints.allowedKeyAlgorithms]); + + const resetConstraints = () => { + setConstraints({ + allowedKeyUsages: [], + allowedExtendedKeyUsages: [], + requiredKeyUsages: [], + requiredExtendedKeyUsages: [], + allowedSignatureAlgorithms: [], + allowedKeyAlgorithms: [], + allowedSanTypes: [ + CertSubjectAlternativeNameType.DNS_NAME, + CertSubjectAlternativeNameType.IP_ADDRESS, + CertSubjectAlternativeNameType.EMAIL, + CertSubjectAlternativeNameType.URI + ], + shouldShowSanSection: true, + shouldShowSubjectSection: true + }); + }; + + useEffect(() => { + if (templateData && selectedProfile && isModalOpen) { + const newConstraints: TemplateConstraints = { + allowedSignatureAlgorithms: templateData.algorithms?.signature || [], + allowedKeyAlgorithms: templateData.algorithms?.keyAlgorithm || [], + allowedKeyUsages: [ + ...(templateData.keyUsages?.required || []), + ...(templateData.keyUsages?.allowed || []) + ], + allowedExtendedKeyUsages: [ + ...(templateData.extendedKeyUsages?.required || []), + ...(templateData.extendedKeyUsages?.allowed || []) + ], + requiredKeyUsages: templateData.keyUsages?.required || [], + requiredExtendedKeyUsages: templateData.extendedKeyUsages?.required || [], + allowedSanTypes: [], + shouldShowSanSection: true, + shouldShowSubjectSection: true + }; + + // Set TTL if available + if (templateData.validity?.max) { + setValue("ttl", templateData.validity.max); + } + + // Handle SAN types + if (templateData.sans && templateData.sans.length > 0) { + const sanTypes: CertSubjectAlternativeNameType[] = []; + templateData.sans.forEach((sanPolicy: any) => { + if (!sanTypes.includes(sanPolicy.type)) { + sanTypes.push(sanPolicy.type); + } + }); + newConstraints.allowedSanTypes = sanTypes; + newConstraints.shouldShowSanSection = true; + } else { + newConstraints.allowedSanTypes = []; + newConstraints.shouldShowSanSection = false; + setValue("subjectAltNames", []); + } + + // Handle subject section + if (templateData.subject && templateData.subject.length > 0) { + newConstraints.shouldShowSubjectSection = true; + const currentSubjectAttrs = watch("subjectAttributes"); + if (!currentSubjectAttrs || currentSubjectAttrs.length === 0) { + setValue("subjectAttributes", [{ type: "common_name", value: "" }]); + } + } else { + newConstraints.shouldShowSubjectSection = false; + setValue("subjectAttributes", undefined); + } + + setConstraints(newConstraints); + + // Set initial required usages + const initialKeyUsages: Record = {}; + const initialExtendedKeyUsages: Record = {}; + + (templateData.keyUsages?.required || []).forEach((usage: string) => { + initialKeyUsages[usage] = true; + }); + + (templateData.extendedKeyUsages?.required || []).forEach((usage: string) => { + initialExtendedKeyUsages[usage] = true; + }); + + setValue("keyUsages", initialKeyUsages); + setValue("extendedKeyUsages", initialExtendedKeyUsages); + } + }, [templateData, selectedProfile, setValue, watch, isModalOpen]); + + return { + constraints, + filteredKeyUsages, + filteredExtendedKeyUsages, + availableSignatureAlgorithms, + availableKeyAlgorithms, + resetConstraints + }; +}; diff --git a/frontend/src/pages/cert-manager/IntegrationsListPage/IntegrationsListPage.tsx b/frontend/src/pages/cert-manager/IntegrationsListPage/IntegrationsListPage.tsx index 6545636f0..8358c28a3 100644 --- a/frontend/src/pages/cert-manager/IntegrationsListPage/IntegrationsListPage.tsx +++ b/frontend/src/pages/cert-manager/IntegrationsListPage/IntegrationsListPage.tsx @@ -7,6 +7,7 @@ import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { ROUTE_PATHS } from "@app/const/routes"; import { ProjectPermissionSub, useProject } from "@app/context"; import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types"; +import { ProjectType } from "@app/hooks/api/projects/types"; import { IntegrationsListPageTabs } from "@app/types/integrations"; import { PkiSyncsTab } from "./components"; @@ -42,16 +43,18 @@ export const IntegrationsListPage = () => { -
+
- + - Certificate Syncs + + Certificate Syncs + { }; return ( -
+
{data && ( -
- +
+ + + Certificates + +
diff --git a/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/PkiSubscriberDetailsByIDPage.tsx b/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/PkiSubscriberDetailsByIDPage.tsx index 2a7fdf56d..1f723b395 100644 --- a/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/PkiSubscriberDetailsByIDPage.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/PkiSubscriberDetailsByIDPage.tsx @@ -1,6 +1,8 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useNavigate, useParams } from "@tanstack/react-router"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; @@ -22,6 +24,7 @@ import { useProject } from "@app/context"; import { useDeletePkiSubscriber, useGetPkiSubscriber } from "@app/hooks/api"; +import { ProjectType } from "@app/hooks/api/projects/types"; import { usePopUp } from "@app/hooks/usePopUp"; import { PkiSubscriberModal } from "../PkiSubscribersPage/components/PkiSubscriberModal"; @@ -75,10 +78,24 @@ const Page = () => { }; return ( -
+
{data && ( -
- +
+ + + Subscribers + +
diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/PkiSubscribersPage.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/PkiSubscribersPage.tsx index bdb525b59..f38839be9 100644 --- a/frontend/src/pages/cert-manager/PkiSubscribersPage/PkiSubscribersPage.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/PkiSubscribersPage.tsx @@ -2,6 +2,7 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; import { PageHeader } from "@app/components/v2"; +import { ProjectType } from "@app/hooks/api/projects/types"; import { PkiSubscriberSection } from "./components"; @@ -13,10 +14,10 @@ export const PkiSubscribersPage = () => { {t("common.head-title", { title: "PKI Subscribers" })}
-
-
+
+
diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx index 984ef45ae..cb4b9ef39 100644 --- a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx @@ -7,7 +7,8 @@ import { Button, DeleteActionModal } from "@app/components/v2"; import { ProjectPermissionPkiSubscriberActions, ProjectPermissionSub, - useProject + useProject, + useSubscription } from "@app/context"; import { useDeletePkiSubscriber, useUpdatePkiSubscriber } from "@app/hooks/api"; import { PkiSubscriberStatus } from "@app/hooks/api/pkiSubscriber/types"; @@ -18,7 +19,10 @@ import { PkiSubscribersTable } from "./PkiSubscribersTable"; export const PkiSubscriberSection = () => { const { currentProject } = useProject(); + const { subscription } = useSubscription(); const projectId = currentProject.id; + + const canCreateLegacySubscribers = subscription.pkiLegacyTemplates; const { mutateAsync: deletePkiSubscriber } = useDeletePkiSubscriber(); const { mutateAsync: updatePkiSubscriber } = useUpdatePkiSubscriber(); @@ -100,23 +104,25 @@ export const PkiSubscriberSection = () => { /> - - {(isAllowed) => ( - - )} - + {canCreateLegacySubscribers && ( + + {(isAllowed) => ( + + )} + + )}
diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/PkiSyncDetailsByIDPage.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/PkiSyncDetailsByIDPage.tsx index 26d2b43f9..76cc0ec7b 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/PkiSyncDetailsByIDPage.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/PkiSyncDetailsByIDPage.tsx @@ -68,8 +68,8 @@ const PageContent = () => { return ( <> -
-
+
+
- )} - +
+ {subscription?.pkiLegacyTemplates && ( +
+

Templates

+
+ + {(isAllowed) => ( + + )} + +
-
+ )} @@ -267,7 +271,7 @@ export const PkiTemplateListPage = () => { onDeleteApproved={() => onRemovePkiSubscriberSubmit()} /> -
+
{ + const { t } = useTranslation(); + const { currentProject } = useProject(); + const [activeTab, setActiveTab] = useState(TabSections.CertificateProfiles); + + if (!currentProject) { + return ; + } + + return ( + + {(isAllowed) => { + if (!isAllowed) { + return ( +
+
+

You don't have permission to access certificate policies.

+
+
+ ); + } + + return ( +
+ + {t("common.head-title", { title: "Certificate Policies" })} + +
+ + + setActiveTab(value as TabSections)} + > + + + Certificate Profiles + + + Certificate Templates + + + + + + + + + + + +
+
+ ); + }} +
+ ); +}; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx new file mode 100644 index 000000000..d034aeda1 --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx @@ -0,0 +1,127 @@ +import { useState } from "react"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { useProjectPermission } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext/types"; +import { + TCertificateProfileWithDetails, + useDeleteCertificateProfile +} from "@app/hooks/api/certificateProfiles"; + +import { CreateProfileModal } from "./CreateProfileModal"; +import { ProfileList } from "./ProfileList"; + +export const CertificateProfilesTab = () => { + const { permission } = useProjectPermission(); + + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedProfile, setSelectedProfile] = useState( + null + ); + + const deleteProfile = useDeleteCertificateProfile(); + + const canCreateProfile = permission.can( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + const handleCreateProfile = () => { + setIsCreateModalOpen(true); + }; + + const handleEditProfile = (profile: TCertificateProfileWithDetails) => { + setSelectedProfile(profile); + setIsEditModalOpen(true); + }; + + const handleDeleteProfile = (profile: TCertificateProfileWithDetails) => { + setSelectedProfile(profile); + setIsDeleteModalOpen(true); + }; + + const handleDeleteConfirm = async () => { + if (!selectedProfile) return; + + try { + await deleteProfile.mutateAsync({ + profileId: selectedProfile.id + }); + setIsDeleteModalOpen(false); + setSelectedProfile(null); + createNotification({ + text: `Certificate profile "${selectedProfile.slug}" deleted successfully`, + type: "success" + }); + } catch (error) { + console.error( + `Failed to delete profile "${selectedProfile.slug}" (ID: ${selectedProfile.id}):`, + error + ); + } + }; + + return ( +
+
+
+

Certificate Profiles

+

+ Unified certificate issuance configurations combining CA, template, and enrollment + method +

+
+ + {canCreateProfile && ( + + )} +
+ + + + setIsCreateModalOpen(false)} /> + + {selectedProfile && ( + <> + { + setIsEditModalOpen(false); + setSelectedProfile(null); + }} + profile={selectedProfile} + mode="edit" + /> + + { + setIsDeleteModalOpen(isOpen); + if (!isOpen) { + setSelectedProfile(null); + } + }} + deleteKey={selectedProfile.slug} + onDeleteApproved={handleDeleteConfirm} + /> + + )} +
+ ); +}; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx new file mode 100644 index 000000000..3f7553f83 --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -0,0 +1,609 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Checkbox, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem, + TextArea +} from "@app/components/v2"; +import { useProject } from "@app/context"; +import { useListCasByProjectId } from "@app/hooks/api/ca/queries"; +import { + TCertificateProfileWithDetails, + TCreateCertificateProfileDTO, + TUpdateCertificateProfileDTO, + useCreateCertificateProfile, + useUpdateCertificateProfile +} from "@app/hooks/api/certificateProfiles"; +import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries"; + +const createSchema = z + .object({ + slug: z + .string() + .trim() + .min(1, "Profile slug is required") + .max(255, "Profile slug must be less than 255 characters") + .regex( + /^[a-zA-Z0-9-_]+$/, + "Profile slug must contain only letters, numbers, hyphens, and underscores" + ), + description: z + .string() + .trim() + .max(1000, "Description must be less than 1000 characters") + .optional(), + enrollmentType: z.enum(["api", "est"]), + certificateAuthorityId: z.string().min(1, "Certificate Authority is required"), + certificateTemplateId: z.string().min(1, "Certificate Template is required"), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().optional(), + passphrase: z.string().min(1, "EST passphrase is required"), + caChain: z.string().min(1, "EST CA chain is required").optional() + }) + .refine( + (data) => { + if (!data.disableBootstrapCaValidation && !data.caChain) { + return false; + } + return true; + }, + { + message: "EST CA chain is required when bootstrap CA validation is enabled", + path: ["caChain"] + } + ) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().optional(), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === "est" && !data.estConfig) { + return false; + } + if (data.enrollmentType === "api" && !data.apiConfig) { + return false; + } + return true; + }, + { + message: "Configuration is required for selected enrollment type" + } + ); + +const editSchema = z + .object({ + slug: z + .string() + .trim() + .min(1, "Profile slug is required") + .max(255, "Profile slug must be less than 255 characters") + .regex( + /^[a-zA-Z0-9-_]+$/, + "Profile slug must contain only letters, numbers, hyphens, and underscores" + ), + description: z + .string() + .trim() + .max(1000, "Description must be less than 1000 characters") + .optional(), + enrollmentType: z.enum(["api", "est"]), + certificateAuthorityId: z.string().optional(), + certificateTemplateId: z.string().optional(), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().optional(), + passphrase: z.string().optional(), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().optional(), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === "est" && !data.estConfig) { + return false; + } + if (data.enrollmentType === "api" && !data.apiConfig) { + return false; + } + return true; + }, + { + message: "Configuration is required for selected enrollment type" + } + ); + +export type FormData = z.infer; + +interface Props { + isOpen: boolean; + onClose: () => void; + profile?: TCertificateProfileWithDetails; + mode?: "create" | "edit"; +} + +export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }: Props) => { + const { currentProject } = useProject(); + + const { data: caData } = useListCasByProjectId(currentProject?.id || ""); + const { data: templateData } = useListCertificateTemplatesV2({ + projectId: currentProject?.id || "", + limit: 100, + offset: 0 + }); + + const createProfile = useCreateCertificateProfile(); + const updateProfile = useUpdateCertificateProfile(); + + const isEdit = mode === "edit" && profile; + + const certificateAuthorities = caData || []; + const certificateTemplates = templateData?.certificateTemplates || []; + + const { control, handleSubmit, reset, watch, setValue, formState } = useForm({ + resolver: zodResolver(isEdit ? editSchema : createSchema), + defaultValues: isEdit + ? { + slug: profile.slug, + description: profile.description || "", + enrollmentType: profile.enrollmentType, + certificateAuthorityId: profile.caId, + certificateTemplateId: profile.certificateTemplateId, + estConfig: + profile.enrollmentType === "est" + ? { + disableBootstrapCaValidation: + profile.estConfig?.disableBootstrapCaValidation || false, + passphrase: profile.estConfig?.passphrase || "", + caChain: profile.estConfig?.caChain || "" + } + : undefined, + apiConfig: + profile.enrollmentType === "api" + ? { + autoRenew: profile.apiConfig?.autoRenew || false, + autoRenewDays: profile.apiConfig?.autoRenewDays || 30 + } + : undefined + } + : { + slug: "", + description: "", + enrollmentType: "api", + certificateAuthorityId: "", + certificateTemplateId: "", + apiConfig: { + autoRenew: false, + autoRenewDays: 30 + } + } + }); + + const watchedEnrollmentType = watch("enrollmentType"); + const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation"); + const watchedAutoRenew = watch("apiConfig.autoRenew"); + + useEffect(() => { + if (isEdit && profile) { + reset({ + slug: profile.slug, + description: profile.description || "", + enrollmentType: profile.enrollmentType, + certificateAuthorityId: profile.caId, + certificateTemplateId: profile.certificateTemplateId, + estConfig: + profile.enrollmentType === "est" + ? { + disableBootstrapCaValidation: + profile.estConfig?.disableBootstrapCaValidation || false, + passphrase: profile.estConfig?.passphrase || "", + caChain: profile.estConfig?.caChain || "" + } + : undefined, + apiConfig: + profile.enrollmentType === "api" + ? { + autoRenew: profile.apiConfig?.autoRenew || false, + autoRenewDays: profile.apiConfig?.autoRenewDays || 30 + } + : undefined + }); + } + }, [isEdit, profile, reset]); + + const onFormSubmit = async (data: FormData) => { + try { + if (!currentProject?.id && !isEdit) return; + + if (isEdit) { + const updateData: TUpdateCertificateProfileDTO = { + profileId: profile.id, + slug: data.slug, + description: data.description + }; + + if (data.enrollmentType === "est" && data.estConfig) { + updateData.estConfig = data.estConfig; + } else if (data.enrollmentType === "api" && data.apiConfig) { + updateData.apiConfig = data.apiConfig; + } + + await updateProfile.mutateAsync(updateData); + } else { + if (!currentProject?.id) { + throw new Error("Project ID is required for creating a profile"); + } + + const createData: TCreateCertificateProfileDTO = { + projectId: currentProject.id, + slug: data.slug, + description: data.description, + enrollmentType: data.enrollmentType, + caId: data.certificateAuthorityId, + certificateTemplateId: data.certificateTemplateId + }; + + if (data.enrollmentType === "est" && data.estConfig) { + createData.estConfig = { + passphrase: data.estConfig.passphrase, + caChain: data.estConfig.caChain || undefined, + disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation + }; + } else if (data.enrollmentType === "api" && data.apiConfig) { + createData.apiConfig = data.apiConfig; + } + + await createProfile.mutateAsync(createData); + } + + createNotification({ + text: `Certificate profile ${isEdit ? "updated" : "created"} successfully`, + type: "success" + }); + + reset(); + onClose(); + } catch (error) { + console.error(`Error ${isEdit ? "updating" : "creating"} profile:`, error); + createNotification({ + text: `Failed to ${isEdit ? "update" : "create"} certificate profile`, + type: "error" + }); + } + }; + + return ( + { + if (!open) { + reset(); + } + onClose(); + }} + > + +
+ ( + + + + )} + /> + + ( + +