diff --git a/.github/workflows/check-api-for-breaking-changes.yml b/.github/workflows/check-api-for-breaking-changes.yml index 914f23fc2..b6d698af4 100644 --- a/.github/workflows/check-api-for-breaking-changes.yml +++ b/.github/workflows/check-api-for-breaking-changes.yml @@ -35,7 +35,7 @@ jobs: echo "SECRET_SCANNING_GIT_APP_ID=793712" >> .env echo "SECRET_SCANNING_PRIVATE_KEY=some-random" >> .env echo "SECRET_SCANNING_WEBHOOK_SECRET=some-random" >> .env - docker run --name infisical-api -d -p 4000:4000 -e DB_CONNECTION_URI=$DB_CONNECTION_URI -e REDIS_URL=$REDIS_URL -e JWT_AUTH_SECRET=$JWT_AUTH_SECRET --env-file .env --entrypoint '/bin/sh' infisical-api -c "npm run migration:latest && ls && node dist/main.mjs" + docker run --name infisical-api -d -p 4000:4000 -e DB_CONNECTION_URI=$DB_CONNECTION_URI -e REDIS_URL=$REDIS_URL -e JWT_AUTH_SECRET=$JWT_AUTH_SECRET -e ENCRYPTION_KEY=$ENCRYPTION_KEY --env-file .env --entrypoint '/bin/sh' infisical-api -c "npm run migration:latest && ls && node dist/main.mjs" env: REDIS_URL: redis://172.17.0.1:6379 DB_CONNECTION_URI: postgres://infisical:infisical@172.17.0.1:5432/infisical?sslmode=disable diff --git a/backend/package-lock.json b/backend/package-lock.json index 4e22ed5a1..97c335139 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,6 +25,8 @@ "@node-saml/passport-saml": "^4.0.4", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/x509": "^1.10.0", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "^2.2.1", "@ucast/mongo2js": "^1.3.4", @@ -2461,9 +2463,9 @@ } }, "node_modules/@fastify/session": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/@fastify/session/-/session-10.7.0.tgz", - "integrity": "sha512-ECA75gnyaxcyIukgyO2NGT3XdbLReNl/pTKrrkRfDc6pVqNtdptwwfx9KXrIMOfsO4B3m84eF3wZ9GgnebiZ4w==", + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/@fastify/session/-/session-10.9.0.tgz", + "integrity": "sha512-u/c42RuAaxCeEuRCAwK2+/SfGqKOd0NSyRzEvDwFBWySQoKUZQyb9OmmJSWJBbOP1OfaU2OsDrjbPbghE1l/YQ==", "dependencies": { "fastify-plugin": "^4.0.0", "safe-stable-stringify": "^2.3.1" @@ -3301,6 +3303,149 @@ "resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-7.1.0.tgz", "integrity": "sha512-y92CpG4kFFtBBjni8LHoV12IegJ+KFxLgKRengrVjKmGE5XMeCuGvlfRe75lTRrgXaG6XIWJlFpIDTlkoJsU8w==" }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.3.8.tgz", + "integrity": "sha512-Wtk9R7yQxGaIaawHorWKP2OOOm/RZzamOmSWwaqGphIuU6TcKYih0slL6asZlSSZtVoYTrBfrddSOD/jTu9vuQ==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@peculiar/asn1-x509-attr": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.3.8.tgz", + "integrity": "sha512-ZmAaP2hfzgIGdMLcot8gHTykzoI+X/S53x1xoGbTmratETIaAbSWMiPGvZmXRA0SNEIydpMkzYtq4fQBxN1u1w==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.8.tgz", + "integrity": "sha512-Ah/Q15y3A/CtxbPibiLM/LKcMbnLTdUdLHUgdpB5f60sSvGkXzxJCu5ezGTFHogZXWNX3KSmYqilCrfdmBc6pQ==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.3.8.tgz", + "integrity": "sha512-XhdnCVznMmSmgy68B9pVxiZ1XkKoE1BjO4Hv+eUGiY1pM14msLsFZ3N7K46SoITIVZLq92kKkXpGiTfRjlNLyg==", + "dependencies": { + "@peculiar/asn1-cms": "^2.3.8", + "@peculiar/asn1-pkcs8": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.3.8.tgz", + "integrity": "sha512-rL8k2x59v8lZiwLRqdMMmOJ30GHt6yuHISFIuuWivWjAJjnxzZBVzMTQ72sknX5MeTSSvGwPmEFk2/N8+UztFQ==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.3.8.tgz", + "integrity": "sha512-+nONq5tcK7vm3qdY7ZKoSQGQjhJYMJbwJGbXLFOhmqsFIxEWyQPHyV99+wshOjpOjg0wUSSkEEzX2hx5P6EKeQ==", + "dependencies": { + "@peculiar/asn1-cms": "^2.3.8", + "@peculiar/asn1-pfx": "^2.3.8", + "@peculiar/asn1-pkcs8": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@peculiar/asn1-x509-attr": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.8.tgz", + "integrity": "sha512-ES/RVEHu8VMYXgrg3gjb1m/XG0KJWnV4qyZZ7mAg7rrF3VTmRbLxO8mk+uy0Hme7geSMebp+Wvi2U6RLLEs12Q==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.8.tgz", + "integrity": "sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA==", + "dependencies": { + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.8.tgz", + "integrity": "sha512-voKxGfDU1c6r9mKiN5ZUsZWh3Dy1BABvTM3cimf0tztNwyMJPhiXY94eRTgsMQe6ViLfT6EoXxkWVzcm3mFAFw==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "asn1js": "^3.0.5", + "ipaddr.js": "^2.1.0", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.3.8.tgz", + "integrity": "sha512-4Z8mSN95MOuX04Aku9BUyMdsMKtVQUqWnr627IheiWnwFoheUhX3R4Y2zh23M7m80r4/WG8MOAckRKc77IRv6g==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-x509/node_modules/ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.10.0.tgz", + "integrity": "sha512-gdH6H8gWjAYoM4Yr6wPnRbzU77nU7xq/jipqYyyv5/AHTrulN2Z5DlnOSq9jjKrB+Ya0D6YJ2cGGtwkWDK75jA==", + "dependencies": { + "@peculiar/asn1-cms": "^2.3.8", + "@peculiar/asn1-csr": "^2.3.8", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-pkcs9": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "pvtsutils": "^1.3.5", + "reflect-metadata": "^0.2.2", + "tslib": "^2.6.2", + "tsyringe": "^4.8.0" + } + }, "node_modules/@phc/format": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", @@ -5956,6 +6101,19 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/asn1js": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", + "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", + "dependencies": { + "pvtsutils": "^1.3.2", + "pvutils": "^1.1.3", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -6303,12 +6461,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -8019,9 +8177,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -11826,6 +11984,22 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz", + "integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==", + "dependencies": { + "tslib": "^2.6.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -12016,6 +12190,11 @@ "node": ">=4" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", @@ -13799,6 +13978,22 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsyringe": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.8.0.tgz", + "integrity": "sha512-YB1FG+axdxADa3ncEtRnQCFq/M0lALGLxSZeVNbTU8NqhOVc51nnv2CISTcvc1kyv6EGPtXVr0v6lWeDxiijOA==", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", diff --git a/backend/package.json b/backend/package.json index dbd55705e..ab397daa5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -86,6 +86,8 @@ "@node-saml/passport-saml": "^4.0.4", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/x509": "^1.10.0", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "^2.2.1", "@ucast/mongo2js": "^1.3.4", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 80080f6c5..c7e58fb09 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -6,6 +6,7 @@ import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-ap import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; +import { TCertificateAuthorityCrlServiceFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-service"; import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service"; import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service"; import { TGroupServiceFactory } from "@app/ee/services/group/group-service"; @@ -15,6 +16,7 @@ import { TLicenseServiceFactory } from "@app/ee/services/license/license-service import { TOidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TProjectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; +import { TRateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-service"; import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; import { TScimServiceFactory } from "@app/ee/services/scim/scim-service"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; @@ -30,6 +32,8 @@ import { TAuthPasswordFactory } from "@app/services/auth/auth-password-service"; import { TAuthSignupFactory } from "@app/services/auth/auth-signup-service"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; +import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; import { TGroupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; @@ -49,7 +53,6 @@ import { TProjectEnvServiceFactory } from "@app/services/project-env/project-env import { TProjectKeyServiceFactory } from "@app/services/project-key/project-key-service"; import { TProjectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service"; import { TProjectRoleServiceFactory } from "@app/services/project-role/project-role-service"; -import { TRateLimitServiceFactory } from "@app/services/rate-limit/rate-limit-service"; import { TSecretServiceFactory } from "@app/services/secret/secret-service"; import { TSecretBlindIndexServiceFactory } from "@app/services/secret-blind-index/secret-blind-index-service"; import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service"; @@ -140,6 +143,9 @@ declare module "fastify" { ldap: TLdapConfigServiceFactory; auditLog: TAuditLogServiceFactory; auditLogStream: TAuditLogStreamServiceFactory; + certificate: TCertificateServiceFactory; + certificateAuthority: TCertificateAuthorityServiceFactory; + certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; secretScanning: TSecretScanningServiceFactory; license: TLicenseServiceFactory; trustedIp: TTrustedIpServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index bdd1de711..4fdfda70c 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -32,6 +32,27 @@ import { TBackupPrivateKey, TBackupPrivateKeyInsert, TBackupPrivateKeyUpdate, + TCertificateAuthorities, + TCertificateAuthoritiesInsert, + TCertificateAuthoritiesUpdate, + TCertificateAuthorityCerts, + TCertificateAuthorityCertsInsert, + TCertificateAuthorityCertsUpdate, + TCertificateAuthorityCrl, + TCertificateAuthorityCrlInsert, + TCertificateAuthorityCrlUpdate, + TCertificateAuthoritySecret, + TCertificateAuthoritySecretInsert, + TCertificateAuthoritySecretUpdate, + TCertificateBodies, + TCertificateBodiesInsert, + TCertificateBodiesUpdate, + TCertificates, + TCertificateSecrets, + TCertificateSecretsInsert, + TCertificateSecretsUpdate, + TCertificatesInsert, + TCertificatesUpdate, TDynamicSecretLeases, TDynamicSecretLeasesInsert, TDynamicSecretLeasesUpdate, @@ -263,6 +284,37 @@ declare module "knex/types/tables" { interface Tables { [TableName.Users]: Knex.CompositeTableType; [TableName.Groups]: Knex.CompositeTableType; + [TableName.CertificateAuthority]: Knex.CompositeTableType< + TCertificateAuthorities, + TCertificateAuthoritiesInsert, + TCertificateAuthoritiesUpdate + >; + [TableName.CertificateAuthorityCert]: Knex.CompositeTableType< + TCertificateAuthorityCerts, + TCertificateAuthorityCertsInsert, + TCertificateAuthorityCertsUpdate + >; + [TableName.CertificateAuthoritySecret]: Knex.CompositeTableType< + TCertificateAuthoritySecret, + TCertificateAuthoritySecretInsert, + TCertificateAuthoritySecretUpdate + >; + [TableName.CertificateAuthorityCrl]: Knex.CompositeTableType< + TCertificateAuthorityCrl, + TCertificateAuthorityCrlInsert, + TCertificateAuthorityCrlUpdate + >; + [TableName.Certificate]: Knex.CompositeTableType; + [TableName.CertificateBody]: Knex.CompositeTableType< + TCertificateBodies, + TCertificateBodiesInsert, + TCertificateBodiesUpdate + >; + [TableName.CertificateSecret]: Knex.CompositeTableType< + TCertificateSecrets, + TCertificateSecretsInsert, + TCertificateSecretsUpdate + >; [TableName.UserGroupMembership]: Knex.CompositeTableType< TUserGroupMembership, TUserGroupMembershipInsert, diff --git a/backend/src/db/migrations/20240614115952_tag-machine-identity.ts b/backend/src/db/migrations/20240614115952_tag-machine-identity.ts new file mode 100644 index 000000000..fd11928b6 --- /dev/null +++ b/backend/src/db/migrations/20240614115952_tag-machine-identity.ts @@ -0,0 +1,25 @@ +import { Knex } from "knex"; + +import { ActorType } from "@app/services/auth/auth-type"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasCreatedByActorType = await knex.schema.hasColumn(TableName.SecretTag, "createdByActorType"); + await knex.schema.alterTable(TableName.SecretTag, (tb) => { + if (!hasCreatedByActorType) { + tb.string("createdByActorType").notNullable().defaultTo(ActorType.USER); + tb.dropForeign("createdBy"); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasCreatedByActorType = await knex.schema.hasColumn(TableName.SecretTag, "createdByActorType"); + await knex.schema.alterTable(TableName.SecretTag, (tb) => { + if (hasCreatedByActorType) { + tb.dropColumn("createdByActorType"); + tb.foreign("createdBy").references("id").inTable(TableName.Users).onDelete("SET NULL"); + } + }); +} diff --git a/backend/src/db/migrations/20240614154212_certificate-mgmt.ts b/backend/src/db/migrations/20240614154212_certificate-mgmt.ts new file mode 100644 index 000000000..a738a6b64 --- /dev/null +++ b/backend/src/db/migrations/20240614154212_certificate-mgmt.ts @@ -0,0 +1,137 @@ +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.Project)) { + const doesProjectCertificateKeyIdExist = await knex.schema.hasColumn(TableName.Project, "kmsCertificateKeyId"); + await knex.schema.alterTable(TableName.Project, (t) => { + if (!doesProjectCertificateKeyIdExist) { + t.uuid("kmsCertificateKeyId").nullable(); + t.foreign("kmsCertificateKeyId").references("id").inTable(TableName.KmsKey); + } + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateAuthority))) { + await knex.schema.createTable(TableName.CertificateAuthority, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("parentCaId").nullable(); + t.foreign("parentCaId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.string("type").notNullable(); // root / intermediate + t.string("status").notNullable(); // active / pending-certificate + t.string("friendlyName").notNullable(); + t.string("organization").notNullable(); + t.string("ou").notNullable(); + t.string("country").notNullable(); + t.string("province").notNullable(); + t.string("locality").notNullable(); + t.string("commonName").notNullable(); + t.string("dn").notNullable(); + t.string("serialNumber").nullable().unique(); + t.integer("maxPathLength").nullable(); + t.string("keyAlgorithm").notNullable(); + t.datetime("notBefore").nullable(); + t.datetime("notAfter").nullable(); + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateAuthorityCert))) { + // table to keep track of certificates belonging to CA + await knex.schema.createTable(TableName.CertificateAuthorityCert, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("caId").notNullable().unique(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.binary("encryptedCertificate").notNullable(); + t.binary("encryptedCertificateChain").notNullable(); + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateAuthoritySecret))) { + await knex.schema.createTable(TableName.CertificateAuthoritySecret, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("caId").notNullable().unique(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.binary("encryptedPrivateKey").notNullable(); + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateAuthorityCrl))) { + await knex.schema.createTable(TableName.CertificateAuthorityCrl, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("caId").notNullable().unique(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.binary("encryptedCrl").notNullable(); + }); + } + + if (!(await knex.schema.hasTable(TableName.Certificate))) { + await knex.schema.createTable(TableName.Certificate, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("caId").notNullable(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.string("status").notNullable(); // active / pending-certificate + t.string("serialNumber").notNullable().unique(); + t.string("friendlyName").notNullable(); + t.string("commonName").notNullable(); + t.datetime("notBefore").notNullable(); + t.datetime("notAfter").notNullable(); + t.datetime("revokedAt").nullable(); + t.integer("revocationReason").nullable(); // integer based on crl reason in RFC 5280 + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateBody))) { + await knex.schema.createTable(TableName.CertificateBody, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("certId").notNullable().unique(); + t.foreign("certId").references("id").inTable(TableName.Certificate).onDelete("CASCADE"); + t.binary("encryptedCertificate").notNullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.CertificateAuthority); + await createOnUpdateTrigger(knex, TableName.CertificateAuthorityCert); + await createOnUpdateTrigger(knex, TableName.CertificateAuthoritySecret); + await createOnUpdateTrigger(knex, TableName.Certificate); + await createOnUpdateTrigger(knex, TableName.CertificateBody); +} + +export async function down(knex: Knex): Promise { + // project + if (await knex.schema.hasTable(TableName.Project)) { + const doesProjectCertificateKeyIdExist = await knex.schema.hasColumn(TableName.Project, "kmsCertificateKeyId"); + await knex.schema.alterTable(TableName.Project, (t) => { + if (doesProjectCertificateKeyIdExist) t.dropColumn("kmsCertificateKeyId"); + }); + } + + // certificates + await knex.schema.dropTableIfExists(TableName.CertificateBody); + await dropOnUpdateTrigger(knex, TableName.CertificateBody); + + await knex.schema.dropTableIfExists(TableName.Certificate); + await dropOnUpdateTrigger(knex, TableName.Certificate); + + // certificate authorities + await knex.schema.dropTableIfExists(TableName.CertificateAuthoritySecret); + await dropOnUpdateTrigger(knex, TableName.CertificateAuthoritySecret); + + await knex.schema.dropTableIfExists(TableName.CertificateAuthorityCrl); + await dropOnUpdateTrigger(knex, TableName.CertificateAuthorityCrl); + + await knex.schema.dropTableIfExists(TableName.CertificateAuthorityCert); + await dropOnUpdateTrigger(knex, TableName.CertificateAuthorityCert); + + await knex.schema.dropTableIfExists(TableName.CertificateAuthority); + await dropOnUpdateTrigger(knex, TableName.CertificateAuthority); +} diff --git a/backend/src/db/migrations/20240614184133_make-secret-sharing-public.ts b/backend/src/db/migrations/20240614184133_make-secret-sharing-public.ts new file mode 100644 index 000000000..dc2756b74 --- /dev/null +++ b/backend/src/db/migrations/20240614184133_make-secret-sharing-public.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasOrgIdColumn = await knex.schema.hasColumn(TableName.SecretSharing, "orgId"); + const hasUserIdColumn = await knex.schema.hasColumn(TableName.SecretSharing, "userId"); + + if (await knex.schema.hasTable(TableName.SecretSharing)) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + if (hasOrgIdColumn) t.uuid("orgId").nullable().alter(); + if (hasUserIdColumn) t.uuid("userId").nullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasOrgIdColumn = await knex.schema.hasColumn(TableName.SecretSharing, "orgId"); + const hasUserIdColumn = await knex.schema.hasColumn(TableName.SecretSharing, "userId"); + + if (await knex.schema.hasTable(TableName.SecretSharing)) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + if (hasOrgIdColumn) t.uuid("orgId").notNullable().alter(); + if (hasUserIdColumn) t.uuid("userId").notNullable().alter(); + }); + } +} diff --git a/backend/src/db/schemas/certificate-authorities.ts b/backend/src/db/schemas/certificate-authorities.ts new file mode 100644 index 000000000..16f303b5c --- /dev/null +++ b/backend/src/db/schemas/certificate-authorities.ts @@ -0,0 +1,37 @@ +// 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 CertificateAuthoritiesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + parentCaId: z.string().uuid().nullable().optional(), + projectId: z.string(), + type: z.string(), + status: z.string(), + friendlyName: z.string(), + organization: z.string(), + ou: z.string(), + country: z.string(), + province: z.string(), + locality: z.string(), + commonName: z.string(), + dn: z.string(), + serialNumber: z.string().nullable().optional(), + maxPathLength: z.number().nullable().optional(), + keyAlgorithm: z.string(), + notBefore: z.date().nullable().optional(), + notAfter: z.date().nullable().optional() +}); + +export type TCertificateAuthorities = z.infer; +export type TCertificateAuthoritiesInsert = Omit, TImmutableDBKeys>; +export type TCertificateAuthoritiesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/certificate-authority-certs.ts b/backend/src/db/schemas/certificate-authority-certs.ts new file mode 100644 index 000000000..96ad54f00 --- /dev/null +++ b/backend/src/db/schemas/certificate-authority-certs.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 CertificateAuthorityCertsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + caId: z.string().uuid(), + encryptedCertificate: zodBuffer, + encryptedCertificateChain: zodBuffer +}); + +export type TCertificateAuthorityCerts = z.infer; +export type TCertificateAuthorityCertsInsert = Omit, TImmutableDBKeys>; +export type TCertificateAuthorityCertsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/certificate-authority-crl.ts b/backend/src/db/schemas/certificate-authority-crl.ts new file mode 100644 index 000000000..204a0c60c --- /dev/null +++ b/backend/src/db/schemas/certificate-authority-crl.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateAuthorityCrlSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + caId: z.string().uuid(), + encryptedCrl: zodBuffer +}); + +export type TCertificateAuthorityCrl = z.infer; +export type TCertificateAuthorityCrlInsert = Omit, TImmutableDBKeys>; +export type TCertificateAuthorityCrlUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/certificate-authority-secret.ts b/backend/src/db/schemas/certificate-authority-secret.ts new file mode 100644 index 000000000..36ab1c506 --- /dev/null +++ b/backend/src/db/schemas/certificate-authority-secret.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateAuthoritySecretSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + caId: z.string().uuid(), + encryptedPrivateKey: zodBuffer +}); + +export type TCertificateAuthoritySecret = z.infer; +export type TCertificateAuthoritySecretInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TCertificateAuthoritySecretUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/certificate-bodies.ts b/backend/src/db/schemas/certificate-bodies.ts new file mode 100644 index 000000000..75afbddbd --- /dev/null +++ b/backend/src/db/schemas/certificate-bodies.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 { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateBodiesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + certId: z.string().uuid(), + encryptedCertificate: zodBuffer +}); + +export type TCertificateBodies = z.infer; +export type TCertificateBodiesInsert = Omit, TImmutableDBKeys>; +export type TCertificateBodiesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/certificate-secrets.ts b/backend/src/db/schemas/certificate-secrets.ts new file mode 100644 index 000000000..f8cad74f1 --- /dev/null +++ b/backend/src/db/schemas/certificate-secrets.ts @@ -0,0 +1,21 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateSecretsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + certId: z.string().uuid(), + pk: z.string(), + sk: z.string() +}); + +export type TCertificateSecrets = z.infer; +export type TCertificateSecretsInsert = Omit, TImmutableDBKeys>; +export type TCertificateSecretsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts new file mode 100644 index 000000000..b635420d5 --- /dev/null +++ b/backend/src/db/schemas/certificates.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificatesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + caId: z.string().uuid(), + status: z.string(), + serialNumber: z.string(), + friendlyName: z.string(), + commonName: z.string(), + notBefore: z.date(), + notAfter: z.date(), + revokedAt: z.date().nullable().optional(), + revocationReason: z.number().nullable().optional() +}); + +export type TCertificates = z.infer; +export type TCertificatesInsert = Omit, TImmutableDBKeys>; +export type TCertificatesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index cc7cea33c..af8c2070a 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -8,6 +8,13 @@ export * from "./audit-logs"; export * from "./auth-token-sessions"; export * from "./auth-tokens"; export * from "./backup-private-key"; +export * from "./certificate-authorities"; +export * from "./certificate-authority-certs"; +export * from "./certificate-authority-crl"; +export * from "./certificate-authority-secret"; +export * from "./certificate-bodies"; +export * from "./certificate-secrets"; +export * from "./certificates"; export * from "./dynamic-secret-leases"; export * from "./dynamic-secrets"; export * from "./git-app-install-sessions"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index df15456fb..bdc574bcb 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -2,6 +2,13 @@ import { z } from "zod"; export enum TableName { Users = "users", + CertificateAuthority = "certificate_authorities", + CertificateAuthorityCert = "certificate_authority_certs", + CertificateAuthoritySecret = "certificate_authority_secret", + CertificateAuthorityCrl = "certificate_authority_crl", + Certificate = "certificates", + CertificateBody = "certificate_bodies", + CertificateSecret = "certificate_secrets", Groups = "groups", GroupProjectMembership = "group_project_memberships", GroupProjectMembershipRole = "group_project_membership_roles", diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index dd7e99990..91035ab8e 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -17,6 +17,7 @@ export const ProjectsSchema = z.object({ updatedAt: z.date(), version: z.number().default(1), upgradeStatus: z.string().nullable().optional(), + kmsCertificateKeyId: z.string().uuid().nullable().optional(), pitVersionLimit: z.number().default(10) }); diff --git a/backend/src/db/schemas/secret-sharing.ts b/backend/src/db/schemas/secret-sharing.ts index 6fa104ebe..c8d938861 100644 --- a/backend/src/db/schemas/secret-sharing.ts +++ b/backend/src/db/schemas/secret-sharing.ts @@ -14,8 +14,8 @@ export const SecretSharingSchema = z.object({ tag: z.string(), hashedHex: z.string(), expiresAt: z.date(), - userId: z.string().uuid(), - orgId: z.string().uuid(), + userId: z.string().uuid().nullable().optional(), + orgId: z.string().uuid().nullable().optional(), createdAt: z.date(), updatedAt: z.date(), expiresAfterViews: z.number().nullable().optional() diff --git a/backend/src/db/schemas/secret-tags.ts b/backend/src/db/schemas/secret-tags.ts index f94e1e262..04bb7b752 100644 --- a/backend/src/db/schemas/secret-tags.ts +++ b/backend/src/db/schemas/secret-tags.ts @@ -15,7 +15,8 @@ export const SecretTagsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), createdBy: z.string().uuid().nullable().optional(), - projectId: z.string() + projectId: z.string(), + createdByActorType: z.string().default("user") }); export type TSecretTags = z.infer; diff --git a/backend/src/ee/routes/v1/certificate-authority-crl-router.ts b/backend/src/ee/routes/v1/certificate-authority-crl-router.ts new file mode 100644 index 000000000..10792f508 --- /dev/null +++ b/backend/src/ee/routes/v1/certificate-authority-crl-router.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerCaCrlRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:caId/crl", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get CRL of the CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CRL.caId) + }), + response: { + 200: z.object({ + crl: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CRL.crl) + }) + } + }, + handler: async (req) => { + const { crl, ca } = await server.services.certificateAuthorityCrl.getCaCrl({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CA_CRL, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + crl + }; + } + }); + + // server.route({ + // method: "GET", + // url: "/:caId/crl/rotate", + // config: { + // rateLimit: writeLimit + // }, + // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + // schema: { + // description: "Rotate CRL of the CA", + // params: z.object({ + // caId: z.string().trim() + // }), + // response: { + // 200: z.object({ + // message: z.string() + // }) + // } + // }, + // handler: async (req) => { + // await server.services.certificateAuthority.rotateCaCrl({ + // caId: req.params.caId, + // actor: req.permission.type, + // actorId: req.permission.id, + // actorAuthMethod: req.permission.authMethod, + // actorOrgId: req.permission.orgId + // }); + // return { + // message: "Successfully rotated CA CRL" + // }; + // } + // }); +}; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 7431ec609..6bd9176d8 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,6 +1,7 @@ import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; +import { registerCaCrlRouter } from "./certificate-authority-crl-router"; import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; import { registerDynamicSecretRouter } from "./dynamic-secret-router"; import { registerGroupRouter } from "./group-router"; @@ -11,6 +12,7 @@ import { registerOidcRouter } from "./oidc-router"; import { registerOrgRoleRouter } from "./org-role-router"; import { registerProjectRoleRouter } from "./project-role-router"; import { registerProjectRouter } from "./project-router"; +import { registerRateLimitRouter } from "./rate-limit-router"; import { registerSamlRouter } from "./saml-router"; import { registerScimRouter } from "./scim-router"; import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router"; @@ -46,6 +48,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register(registerAccessApprovalPolicyRouter, { prefix: "/access-approvals/policies" }); await server.register(registerAccessApprovalRequestRouter, { prefix: "/access-approvals/requests" }); + await server.register(registerRateLimitRouter, { prefix: "/rate-limit" }); await server.register( async (dynamicSecretRouter) => { @@ -55,6 +58,13 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { { prefix: "/dynamic-secrets" } ); + await server.register( + async (pkiRouter) => { + await pkiRouter.register(registerCaCrlRouter, { prefix: "/ca" }); + }, + { prefix: "/pki" } + ); + await server.register( async (ssoRouter) => { await ssoRouter.register(registerSamlRouter); @@ -62,6 +72,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { }, { prefix: "/sso" } ); + await server.register(registerScimRouter, { prefix: "/scim" }); await server.register(registerLdapRouter, { prefix: "/ldap" }); await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" }); diff --git a/backend/src/ee/routes/v1/project-router.ts b/backend/src/ee/routes/v1/project-router.ts index 9795aaf86..8639ef1a1 100644 --- a/backend/src/ee/routes/v1/project-router.ts +++ b/backend/src/ee/routes/v1/project-router.ts @@ -143,7 +143,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorAuthMethod: req.permission.authMethod, projectId: req.params.workspaceId, ...req.query, - startDate: req.query.endDate || getLastMidnightDateISO(), + endDate: req.query.endDate, + startDate: req.query.startDate || getLastMidnightDateISO(), auditLogActor: req.query.actor, actor: req.permission.type }); diff --git a/backend/src/server/routes/v1/rate-limit-router.ts b/backend/src/ee/routes/v1/rate-limit-router.ts similarity index 100% rename from backend/src/server/routes/v1/rate-limit-router.ts rename to backend/src/ee/routes/v1/rate-limit-router.ts 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 415814998..8a0d2aef9 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -1,5 +1,6 @@ import { TProjectPermission } from "@app/lib/types"; import { ActorType } from "@app/services/auth/auth-type"; +import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types"; import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; export type TListProjectAuditLogDTO = { @@ -104,7 +105,21 @@ export enum EventType { SECRET_APPROVAL_MERGED = "secret-approval-merged", SECRET_APPROVAL_REQUEST = "secret-approval-request", SECRET_APPROVAL_CLOSED = "secret-approval-closed", - SECRET_APPROVAL_REOPENED = "secret-approval-reopened" + SECRET_APPROVAL_REOPENED = "secret-approval-reopened", + CREATE_CA = "create-certificate-authority", + GET_CA = "get-certificate-authority", + UPDATE_CA = "update-certificate-authority", + DELETE_CA = "delete-certificate-authority", + GET_CA_CSR = "get-certificate-authority-csr", + GET_CA_CERT = "get-certificate-authority-cert", + SIGN_INTERMEDIATE = "sign-intermediate", + IMPORT_CA_CERT = "import-certificate-authority-cert", + GET_CA_CRL = "get-certificate-authority-crl", + ISSUE_CERT = "issue-cert", + GET_CERT = "get-cert", + DELETE_CERT = "delete-cert", + REVOKE_CERT = "revoke-cert", + GET_CERT_BODY = "get-cert-body" } interface UserActorMetadata { @@ -843,6 +858,125 @@ interface SecretApprovalRequest { }; } +interface CreateCa { + type: EventType.CREATE_CA; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCa { + type: EventType.GET_CA; + metadata: { + caId: string; + dn: string; + }; +} + +interface UpdateCa { + type: EventType.UPDATE_CA; + metadata: { + caId: string; + dn: string; + status: CaStatus; + }; +} + +interface DeleteCa { + type: EventType.DELETE_CA; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCaCsr { + type: EventType.GET_CA_CSR; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCaCert { + type: EventType.GET_CA_CERT; + metadata: { + caId: string; + dn: string; + }; +} + +interface SignIntermediate { + type: EventType.SIGN_INTERMEDIATE; + metadata: { + caId: string; + dn: string; + serialNumber: string; + }; +} + +interface ImportCaCert { + type: EventType.IMPORT_CA_CERT; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCaCrl { + type: EventType.GET_CA_CRL; + metadata: { + caId: string; + dn: string; + }; +} + +interface IssueCert { + type: EventType.ISSUE_CERT; + metadata: { + caId: string; + dn: string; + serialNumber: string; + }; +} + +interface GetCert { + type: EventType.GET_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface DeleteCert { + type: EventType.DELETE_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface RevokeCert { + type: EventType.REVOKE_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface GetCertBody { + type: EventType.GET_CERT_BODY; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -910,4 +1044,18 @@ export type Event = | SecretApprovalMerge | SecretApprovalClosed | SecretApprovalRequest - | SecretApprovalReopened; + | SecretApprovalReopened + | CreateCa + | GetCa + | UpdateCa + | DeleteCa + | GetCaCsr + | GetCaCert + | SignIntermediate + | ImportCaCert + | GetCaCrl + | IssueCert + | GetCert + | DeleteCert + | RevokeCert + | GetCertBody; diff --git a/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-dal.ts b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-dal.ts new file mode 100644 index 000000000..d367e1616 --- /dev/null +++ b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateAuthorityCrlDALFactory = ReturnType; + +export const certificateAuthorityCrlDALFactory = (db: TDbClient) => { + const caCrlOrm = ormify(db, TableName.CertificateAuthorityCrl); + return caCrlOrm; +}; diff --git a/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts new file mode 100644 index 000000000..c8b56561e --- /dev/null +++ b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts @@ -0,0 +1,172 @@ +import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; + +import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError } from "@app/lib/errors"; +import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-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 { TGetCrl } from "./certificate-authority-crl-types"; + +type TCertificateAuthorityCrlServiceFactoryDep = { + certificateAuthorityDAL: Pick; + certificateAuthorityCrlDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TCertificateAuthorityCrlServiceFactory = ReturnType; + +export const certificateAuthorityCrlServiceFactory = ({ + certificateAuthorityDAL, + certificateAuthorityCrlDAL, + projectDAL, + kmsService, + permissionService, + licenseService +}: TCertificateAuthorityCrlServiceFactoryDep) => { + /** + * Return the Certificate Revocation List (CRL) for CA with id [caId] + */ + const getCaCrl = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCrl) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.caCrl) + throw new BadRequestError({ + message: + "Failed to get CA certificate revocation list (CRL) due to plan restriction. Upgrade plan to get the CA CRL." + }); + + const caCrl = await certificateAuthorityCrlDAL.findOne({ caId: ca.id }); + if (!caCrl) throw new BadRequestError({ message: "CRL not found" }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const decryptedCrl = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caCrl.encryptedCrl + }); + + const crl = new x509.X509Crl(decryptedCrl); + + const base64crl = crl.toString("base64"); + const crlPem = `-----BEGIN X509 CRL-----\n${base64crl.match(/.{1,64}/g)?.join("\n")}\n-----END X509 CRL-----`; + + return { + crl: crlPem, + ca + }; + }; + + // const rotateCaCrl = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TRotateCrlDTO) => { + // const ca = await certificateAuthorityDAL.findById(caId); + // if (!ca) throw new BadRequestError({ message: "CA not found" }); + + // const { permission } = await permissionService.getProjectPermission( + // actor, + // actorId, + // ca.projectId, + // actorAuthMethod, + // actorOrgId + // ); + + // ForbiddenError.from(permission).throwUnlessCan( + // ProjectPermissionActions.Read, + // ProjectPermissionSub.CertificateAuthorities + // ); + + // const caSecret = await certificateAuthoritySecretDAL.findOne({ caId: ca.id }); + + // const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + // const keyId = await getProjectKmsCertificateKeyId({ + // projectId: ca.projectId, + // projectDAL, + // kmsService + // }); + + // const privateKey = await kmsService.decrypt({ + // kmsId: keyId, + // cipherTextBlob: caSecret.encryptedPrivateKey + // }); + + // const skObj = crypto.createPrivateKey({ key: privateKey, format: "der", type: "pkcs8" }); + // const sk = await crypto.subtle.importKey("pkcs8", skObj.export({ format: "der", type: "pkcs8" }), alg, true, [ + // "sign" + // ]); + + // const revokedCerts = await certificateDAL.find({ + // caId: ca.id, + // status: CertStatus.REVOKED + // }); + + // const crl = await x509.X509CrlGenerator.create({ + // issuer: ca.dn, + // thisUpdate: new Date(), + // nextUpdate: new Date("2025/12/12"), + // entries: revokedCerts.map((revokedCert) => { + // return { + // serialNumber: revokedCert.serialNumber, + // revocationDate: new Date(revokedCert.revokedAt as Date), + // reason: revokedCert.revocationReason as number, + // invalidity: new Date("2022/01/01"), + // issuer: ca.dn + // }; + // }), + // signingAlgorithm: alg, + // signingKey: sk + // }); + + // const { cipherTextBlob: encryptedCrl } = await kmsService.encrypt({ + // kmsId: keyId, + // plainText: Buffer.from(new Uint8Array(crl.rawData)) + // }); + + // await certificateAuthorityCrlDAL.update( + // { + // caId: ca.id + // }, + // { + // encryptedCrl + // } + // ); + + // const base64crl = crl.toString("base64"); + // const crlPem = `-----BEGIN X509 CRL-----\n${base64crl.match(/.{1,64}/g)?.join("\n")}\n-----END X509 CRL-----`; + + // return { + // crl: crlPem + // }; + // }; + + return { + getCaCrl + // rotateCaCrl + }; +}; diff --git a/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-types.ts b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-types.ts new file mode 100644 index 000000000..fc31e9eef --- /dev/null +++ b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-types.ts @@ -0,0 +1,5 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TGetCrl = { + caId: string; +} & Omit; diff --git a/backend/src/ee/services/license/__mocks__/licence-fns.ts b/backend/src/ee/services/license/__mocks__/licence-fns.ts index b5cbf103e..ddbffba45 100644 --- a/backend/src/ee/services/license/__mocks__/licence-fns.ts +++ b/backend/src/ee/services/license/__mocks__/licence-fns.ts @@ -25,6 +25,7 @@ export const getDefaultOnPremFeatures = () => { trial_end: null, has_used_trial: true, secretApproval: false, - secretRotation: true + secretRotation: true, + caCrl: false }; }; diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index c9a002dd5..46cc656d5 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -35,7 +35,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ trial_end: null, has_used_trial: true, secretApproval: false, - secretRotation: true + secretRotation: true, + caCrl: false }); export const setupLicenceRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 47b46d010..46931468f 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -575,6 +575,9 @@ export const licenseServiceFactory = ({ getInstanceType() { return instanceType; }, + get onPremFeatures() { + return onPremFeatures; + }, getPlan, updateSubscriptionOrgMemberCount, refreshPlan, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 6d8ac6a64..9e1bdd9b3 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -53,6 +53,7 @@ export type TFeatureSet = { has_used_trial: true; secretApproval: false; secretRotation: true; + caCrl: false; }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index b24024bd4..4853faf61 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -26,7 +26,9 @@ export enum ProjectPermissionSub { SecretRollback = "secret-rollback", SecretApproval = "secret-approval", SecretRotation = "secret-rotation", - Identity = "identity" + Identity = "identity", + CertificateAuthorities = "certificate-authorities", + Certificates = "certificates" } type SubjectFields = { @@ -53,6 +55,8 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] | [ProjectPermissionActions, ProjectPermissionSub.Identity] + | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] + | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Project] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] @@ -139,6 +143,17 @@ const buildAdminPermissionRules = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.IpAllowList); can(ProjectPermissionActions.Delete, ProjectPermissionSub.IpAllowList); + // double check if all CRUD are needed for CA and Certificates + can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); + can(ProjectPermissionActions.Create, ProjectPermissionSub.CertificateAuthorities); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.CertificateAuthorities); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.CertificateAuthorities); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Project); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); @@ -205,6 +220,14 @@ const buildMemberPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); + // double check if all CRUD are needed for CA and Certificates + can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + return rules; }; @@ -229,6 +252,8 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); + can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); return rules; }; diff --git a/backend/src/services/rate-limit/rate-limit-dal.ts b/backend/src/ee/services/rate-limit/rate-limit-dal.ts similarity index 100% rename from backend/src/services/rate-limit/rate-limit-dal.ts rename to backend/src/ee/services/rate-limit/rate-limit-dal.ts diff --git a/backend/src/services/rate-limit/rate-limit-service.ts b/backend/src/ee/services/rate-limit/rate-limit-service.ts similarity index 82% rename from backend/src/services/rate-limit/rate-limit-service.ts rename to backend/src/ee/services/rate-limit/rate-limit-service.ts index 742628061..df90ca03f 100644 --- a/backend/src/services/rate-limit/rate-limit-service.ts +++ b/backend/src/ee/services/rate-limit/rate-limit-service.ts @@ -2,6 +2,7 @@ import { CronJob } from "cron"; import { logger } from "@app/lib/logger"; +import { TLicenseServiceFactory } from "../license/license-service"; import { TRateLimitDALFactory } from "./rate-limit-dal"; import { TRateLimit, TRateLimitUpdateDTO } from "./rate-limit-types"; @@ -24,11 +25,12 @@ export const getRateLimiterConfig = () => { type TRateLimitServiceFactoryDep = { rateLimitDAL: TRateLimitDALFactory; + licenseService: Pick; }; export type TRateLimitServiceFactory = ReturnType; -export const rateLimitServiceFactory = ({ rateLimitDAL }: TRateLimitServiceFactoryDep) => { +export const rateLimitServiceFactory = ({ rateLimitDAL, licenseService }: TRateLimitServiceFactoryDep) => { const DEFAULT_RATE_LIMIT_CONFIG_ID = "00000000-0000-0000-0000-000000000000"; const getRateLimits = async (): Promise => { @@ -78,7 +80,16 @@ export const rateLimitServiceFactory = ({ rateLimitDAL }: TRateLimitServiceFacto } }; - const initializeBackgroundSync = () => { + const initializeBackgroundSync = async () => { + if (!licenseService.onPremFeatures.customRateLimits) { + logger.info("Current license does not support custom rate limit configuration"); + return; + } + + logger.info("Setting up background sync process for rate limits"); + // initial sync upon startup + await syncRateLimitConfiguration(); + // sync rate limits configuration every 10 minutes const job = new CronJob("*/10 * * * *", syncRateLimitConfiguration); job.start(); diff --git a/backend/src/services/rate-limit/rate-limit-types.ts b/backend/src/ee/services/rate-limit/rate-limit-types.ts similarity index 100% rename from backend/src/services/rate-limit/rate-limit-types.ts rename to backend/src/ee/services/rate-limit/rate-limit-types.ts diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 1637b266a..d768066fa 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -343,7 +343,8 @@ export const RAW_SECRETS = { secretValue: "The value of the secret to create.", skipMultilineEncoding: "Skip multiline encoding for the secret value.", type: "The type of the secret to create.", - workspaceId: "The ID of the project to create the secret in." + workspaceId: "The ID of the project to create the secret in.", + tagIds: "The ID of the tags to be attached to the created secret." }, GET: { secretName: "The name of the secret to get.", @@ -364,7 +365,8 @@ export const RAW_SECRETS = { skipMultilineEncoding: "Skip multiline encoding for the secret value.", type: "The type of the secret to update.", projectSlug: "The slug of the project to update the secret in.", - workspaceId: "The ID of the project to update the secret in." + workspaceId: "The ID of the project to update the secret in.", + tagIds: "The ID of the tags to be attached to the updated secret." }, DELETE: { secretName: "The name of the secret to delete.", @@ -506,12 +508,27 @@ export const SECRET_TAGS = { LIST: { projectId: "The ID of the project to list tags from." }, + GET_TAG_BY_ID: { + projectId: "The ID of the project to get tags from.", + tagId: "The ID of the tag to get details" + }, + GET_TAG_BY_SLUG: { + projectId: "The ID of the project to get tags from.", + tagSlug: "The slug of the tag to get details" + }, CREATE: { projectId: "The ID of the project to create the tag in.", name: "The name of the tag to create.", slug: "The slug of the tag to create.", color: "The color of the tag to create." }, + UPDATE: { + projectId: "The ID of the project to update the tag in.", + tagId: "The ID of the tag to get details", + name: "The name of the tag to update.", + slug: "The slug of the tag to update.", + color: "The color of the tag to update." + }, DELETE: { tagId: "The ID of the tag to delete.", projectId: "The ID of the project to delete the tag from." @@ -728,6 +745,102 @@ export const AUDIT_LOG_STREAMS = { } }; +export const CERTIFICATE_AUTHORITIES = { + CREATE: { + projectSlug: "Slug of the project to create the CA in.", + type: "The type of CA to create", + friendlyName: "A friendly name for the CA", + organization: "The organization (O) for the CA", + ou: "The organization unit (OU) for the CA", + country: "The country name (C) for the CA", + province: "The state of province name for the CA", + locality: "The locality name for the CA", + commonName: "The common name (CN) for the CA", + notBefore: "The date and time when the CA becomes valid in YYYY-MM-DDTHH:mm:ss.sssZ format", + notAfter: "The date and time when the CA expires in YYYY-MM-DDTHH:mm:ss.sssZ format", + maxPathLength: + "The maximum number of intermediate CAs that may follow this CA in the certificate / CA chain. A maxPathLength of -1 implies no path limit on the chain.", + keyAlgorithm: + "The type of public key algorithm and size, in bits, of the key pair for the CA; when you create an intermediate CA, you must use a key algorithm supported by the parent CA." + }, + GET: { + caId: "The ID of the CA to get" + }, + UPDATE: { + caId: "The ID of the CA to update", + status: "The status of the CA to update to. This can be one of active or disabled" + }, + DELETE: { + caId: "The ID of the CA to delete" + }, + GET_CSR: { + caId: "The ID of the CA to generate CSR from", + csr: "The generated CSR from the CA" + }, + GET_CERT: { + caId: "The ID of the CA to get the certificate body and certificate chain from", + certificate: "The certificate body of the CA", + certificateChain: "The certificate chain of the CA", + serialNumber: "The serial number of the CA certificate" + }, + SIGN_INTERMEDIATE: { + caId: "The ID of the CA to sign the intermediate certificate with", + csr: "The CSR to sign with the CA", + notBefore: "The date and time when the intermediate CA becomes valid in YYYY-MM-DDTHH:mm:ss.sssZ format", + notAfter: "The date and time when the intermediate CA expires in YYYY-MM-DDTHH:mm:ss.sssZ format", + maxPathLength: + "The maximum number of intermediate CAs that may follow this CA in the certificate / CA chain. A maxPathLength of -1 implies no path limit on the chain.", + certificate: "The signed intermediate certificate", + certificateChain: "The certificate chain of the intermediate certificate", + issuingCaCertificate: "The certificate of the issuing CA", + serialNumber: "The serial number of the intermediate certificate" + }, + IMPORT_CERT: { + caId: "The ID of the CA to import the certificate for", + certificate: "The certificate body to import", + certificateChain: "The certificate chain to import" + }, + ISSUE_CERT: { + caId: "The ID of the CA to issue the certificate from", + friendlyName: "A friendly name for the certificate", + commonName: "The common name (CN) for the certificate", + ttl: "The time to live for the certificate such as 1m, 1h, 1d, 1y, ...", + notBefore: "The date and time when the certificate becomes valid in YYYY-MM-DDTHH:mm:ss.sssZ format", + notAfter: "The date and time when the certificate expires in YYYY-MM-DDTHH:mm:ss.sssZ format", + certificate: "The issued certificate", + issuingCaCertificate: "The certificate of the issuing CA", + certificateChain: "The certificate chain of the issued certificate", + privateKey: "The private key of the issued certificate", + serialNumber: "The serial number of the issued certificate" + }, + GET_CRL: { + caId: "The ID of the CA to get the certificate revocation list (CRL) for", + crl: "The certificate revocation list (CRL) of the CA" + } +}; + +export const CERTIFICATES = { + GET: { + serialNumber: "The serial number of the certificate to get" + }, + REVOKE: { + serialNumber: + "The serial number of the certificate to revoke. The revoked certificate will be added to the certificate revocation list (CRL) of the CA.", + revocationReason: "The reason for revoking the certificate.", + revokedAt: "The date and time when the certificate was revoked", + serialNumberRes: "The serial number of the revoked certificate." + }, + DELETE: { + serialNumber: "The serial number of the certificate to delete" + }, + GET_CERT: { + serialNumber: "The serial number of the certificate to get the certificate body and certificate chain for", + certificate: "The certificate body of the certificate", + certificateChain: "The certificate chain of the certificate", + serialNumberRes: "The serial number of the certificate" + } +}; + export const PROJECT_ROLE = { CREATE: { projectSlug: "Slug of the project to create the role for.", diff --git a/backend/src/lib/errors/index.ts b/backend/src/lib/errors/index.ts index 18b40acfd..0a7cb8014 100644 --- a/backend/src/lib/errors/index.ts +++ b/backend/src/lib/errors/index.ts @@ -59,6 +59,18 @@ export class BadRequestError extends Error { } } +export class NotFoundError extends Error { + name: string; + + error: unknown; + + constructor({ name, error, message }: { message?: string; name?: string; error?: unknown }) { + super(message ?? "The requested entity is not found"); + this.name = name || "NotFound"; + this.error = error; + } +} + export class DisableRotationErrors extends Error { name: string; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 7046058b7..d51a8e683 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -23,6 +23,7 @@ export enum QueueName { SecretPushEventScan = "secret-push-event-scan", UpgradeProjectToGhost = "upgrade-project-to-ghost", DynamicSecretRevocation = "dynamic-secret-revocation", + CaCrlRotation = "ca-crl-rotation", SecretReplication = "secret-replication", SecretSync = "secret-sync" // parent queue to push integration sync, webhook, and secret replication } @@ -41,6 +42,7 @@ export enum QueueJobs { UpgradeProjectToGhost = "upgrade-project-to-ghost-job", DynamicSecretRevocation = "dynamic-secret-revocation", DynamicSecretPruning = "dynamic-secret-pruning", + CaCrlRotation = "ca-crl-rotation-job", SecretReplication = "secret-replication", SecretSync = "secret-sync" // parent queue to push integration sync, webhook, and secret replication } @@ -55,7 +57,6 @@ export type TQueueJobTypes = { }; name: QueueJobs.SecretReminder; }; - [QueueName.SecretRotation]: { payload: { rotationId: string }; name: QueueJobs.SecretRotation; @@ -121,6 +122,12 @@ export type TQueueJobTypes = { dynamicSecretCfgId: string; }; }; + [QueueName.CaCrlRotation]: { + name: QueueJobs.CaCrlRotation; + payload: { + caId: string; + }; + }; [QueueName.SecretReplication]: { name: QueueJobs.SecretReplication; payload: TSyncSecretsDTO; diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index 19f5986fb..863162c2b 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -17,8 +17,6 @@ import { Logger } from "pino"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { TQueueServiceFactory } from "@app/queue"; -import { rateLimitDALFactory } from "@app/services/rate-limit/rate-limit-dal"; -import { rateLimitServiceFactory } from "@app/services/rate-limit/rate-limit-service"; import { TSmtpService } from "@app/services/smtp/smtp-service"; import { globalRateLimiterCfg } from "./config/rateLimiter"; @@ -71,9 +69,6 @@ export const main = async ({ db, smtp, logger, queue, keyStore }: TMain) => { // Rate limiters and security headers if (appCfg.isProductionMode) { - const rateLimitDAL = rateLimitDALFactory(db); - const rateLimitService = rateLimitServiceFactory({ rateLimitDAL }); - await rateLimitService.syncRateLimitConfiguration(); await server.register(ratelimiter, globalRateLimiterCfg()); } diff --git a/backend/src/server/config/rateLimiter.ts b/backend/src/server/config/rateLimiter.ts index 819aa617f..ad54a151a 100644 --- a/backend/src/server/config/rateLimiter.ts +++ b/backend/src/server/config/rateLimiter.ts @@ -1,8 +1,8 @@ import type { RateLimitOptions, RateLimitPluginOptions } from "@fastify/rate-limit"; import { Redis } from "ioredis"; +import { getRateLimiterConfig } from "@app/ee/services/rate-limit/rate-limit-service"; import { getConfig } from "@app/lib/config/env"; -import { getRateLimiterConfig } from "@app/services/rate-limit/rate-limit-service"; export const globalRateLimiterCfg = (): RateLimitPluginOptions => { const appCfg = getConfig(); @@ -70,8 +70,15 @@ export const creationLimit: RateLimitOptions = { // Public endpoints to avoid brute force attacks export const publicEndpointLimit: RateLimitOptions = { - // Shared Secrets + // Read Shared Secrets timeWindow: 60 * 1000, max: () => getRateLimiterConfig().publicEndpointLimit, keyGenerator: (req) => req.realIp }; + +export const publicSecretShareCreationLimit: RateLimitOptions = { + // Create Shared Secrets + timeWindow: 60 * 1000, + max: 5, + keyGenerator: (req) => req.realIp +}; diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index c8da4077a..3320c7d87 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -6,6 +6,7 @@ import { BadRequestError, DatabaseError, InternalServerError, + NotFoundError, ScimRequestError, UnauthorizedError } from "@app/lib/errors"; @@ -15,6 +16,8 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider req.log.error(error); if (error instanceof BadRequestError) { void res.status(400).send({ statusCode: 400, message: error.message, error: error.name }); + } else if (error instanceof NotFoundError) { + void res.status(404).send({ statusCode: 404, message: error.message, error: error.name }); } else if (error instanceof UnauthorizedError) { void res.status(403).send({ statusCode: 403, message: error.message, error: error.name }); } else if (error instanceof DatabaseError || error instanceof InternalServerError) { diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 925ae5f39..265fc18fd 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -14,6 +14,8 @@ import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-lo import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { auditLogStreamDALFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-dal"; import { auditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; +import { certificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { certificateAuthorityCrlServiceFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-service"; import { dynamicSecretDALFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-dal"; import { dynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service"; import { buildDynamicSecretProviders } from "@app/ee/services/dynamic-secret/providers"; @@ -36,6 +38,8 @@ import { permissionDALFactory } from "@app/ee/services/permission/permission-dal import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { projectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; import { projectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; +import { rateLimitDALFactory } from "@app/ee/services/rate-limit/rate-limit-dal"; +import { rateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-service"; import { samlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal"; import { samlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; import { scimDALFactory } from "@app/ee/services/scim/scim-dal"; @@ -74,6 +78,14 @@ import { authPaswordServiceFactory } from "@app/services/auth/auth-password-serv import { authSignupServiceFactory } from "@app/services/auth/auth-signup-service"; import { tokenDALFactory } from "@app/services/auth-token/auth-token-dal"; import { tokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { certificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; +import { certificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { certificateServiceFactory } from "@app/services/certificate/certificate-service"; +import { certificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; +import { certificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { certificateAuthorityQueueFactory } from "@app/services/certificate-authority/certificate-authority-queue"; +import { certificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; +import { certificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; import { groupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { groupProjectMembershipRoleDALFactory } from "@app/services/group-project/group-project-membership-role-dal"; import { groupProjectServiceFactory } from "@app/services/group-project/group-project-service"; @@ -124,8 +136,6 @@ import { projectMembershipServiceFactory } from "@app/services/project-membershi import { projectUserMembershipRoleDALFactory } from "@app/services/project-membership/project-user-membership-role-dal"; import { projectRoleDALFactory } from "@app/services/project-role/project-role-dal"; import { projectRoleServiceFactory } from "@app/services/project-role/project-role-service"; -import { rateLimitDALFactory } from "@app/services/rate-limit/rate-limit-dal"; -import { rateLimitServiceFactory } from "@app/services/rate-limit/rate-limit-service"; import { dailyResourceCleanUpQueueServiceFactory } from "@app/services/resource-cleanup/resource-cleanup-queue"; import { secretDALFactory } from "@app/services/secret/secret-dal"; import { secretQueueFactory } from "@app/services/secret/secret-queue"; @@ -452,7 +462,8 @@ export const registerRoutes = async ( keyStore }); const rateLimitService = rateLimitServiceFactory({ - rateLimitDAL + rateLimitDAL, + licenseService }); const apiKeyService = apiKeyServiceFactory({ apiKeyDAL, userDAL }); @@ -516,6 +527,58 @@ export const registerRoutes = async ( projectUserMembershipRoleDAL }); + const certificateAuthorityDAL = certificateAuthorityDALFactory(db); + const certificateAuthorityCertDAL = certificateAuthorityCertDALFactory(db); + const certificateAuthoritySecretDAL = certificateAuthoritySecretDALFactory(db); + const certificateAuthorityCrlDAL = certificateAuthorityCrlDALFactory(db); + + const certificateDAL = certificateDALFactory(db); + const certificateBodyDAL = certificateBodyDALFactory(db); + + const certificateService = certificateServiceFactory({ + certificateDAL, + certificateBodyDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthorityCrlDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService, + permissionService + }); + + const certificateAuthorityQueue = certificateAuthorityQueueFactory({ + certificateAuthorityCrlDAL, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + certificateDAL, + projectDAL, + kmsService, + queueService + }); + + const certificateAuthorityService = certificateAuthorityServiceFactory({ + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateAuthorityQueue, + certificateDAL, + certificateBodyDAL, + projectDAL, + kmsService, + permissionService + }); + + const certificateAuthorityCrlService = certificateAuthorityCrlServiceFactory({ + certificateAuthorityDAL, + certificateAuthorityCrlDAL, + projectDAL, + kmsService, + permissionService, + licenseService + }); + const projectService = projectServiceFactory({ permissionService, projectDAL, @@ -532,6 +595,8 @@ export const registerRoutes = async ( projectMembershipDAL, folderDAL, licenseService, + certificateAuthorityDAL, + certificateDAL, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, keyStore @@ -914,6 +979,9 @@ export const registerRoutes = async ( ldap: ldapService, auditLog: auditLogService, auditLogStream: auditLogStreamService, + certificate: certificateService, + certificateAuthority: certificateAuthorityService, + certificateAuthorityCrl: certificateAuthorityCrlService, secretScanning: secretScanningService, license: licenseService, trustedIp: trustedIpService, @@ -927,7 +995,10 @@ export const registerRoutes = async ( const cronJobs: CronJob[] = []; if (appCfg.isProductionMode) { - cronJobs.push(rateLimitService.initializeBackgroundSync()); + const rateLimitSyncJob = await rateLimitService.initializeBackgroundSync(); + if (rateLimitSyncJob) { + cronJobs.push(rateLimitSyncJob); + } } server.decorate("store", { diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts new file mode 100644 index 000000000..7573c0bd2 --- /dev/null +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -0,0 +1,515 @@ +import ms from "ms"; +import { z } from "zod"; + +import { CertificateAuthoritiesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { CERTIFICATE_AUTHORITIES } 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 { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; +import { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-types"; +import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; + +export const registerCaRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Create CA", + body: z + .object({ + projectSlug: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.projectSlug), + type: z.nativeEnum(CaType).describe(CERTIFICATE_AUTHORITIES.CREATE.type), + friendlyName: z.string().optional().describe(CERTIFICATE_AUTHORITIES.CREATE.friendlyName), + commonName: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.commonName), + organization: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.organization), + ou: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.ou), + country: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.country), + province: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.province), + locality: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.locality), + // format: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format + notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.CREATE.notBefore), + notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.CREATE.notAfter), + maxPathLength: z.number().min(-1).default(-1).describe(CERTIFICATE_AUTHORITIES.CREATE.maxPathLength), + keyAlgorithm: z + .nativeEnum(CertKeyAlgorithm) + .default(CertKeyAlgorithm.RSA_2048) + .describe(CERTIFICATE_AUTHORITIES.CREATE.keyAlgorithm) + }) + .refine( + (data) => { + // Check that at least one of the specified fields is non-empty + return [data.commonName, data.organization, data.ou, data.country, data.province, data.locality].some( + (field) => field !== "" + ); + }, + { + message: + "At least one of the fields commonName, organization, ou, country, province, or locality must be non-empty", + path: [] + } + ), + response: { + 200: z.object({ + ca: CertificateAuthoritiesSchema + }) + } + }, + handler: async (req) => { + const ca = await server.services.certificateAuthority.createCa({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.CREATE_CA, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "GET", + url: "/:caId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET.caId) + }), + response: { + 200: z.object({ + ca: CertificateAuthoritiesSchema + }) + } + }, + handler: async (req) => { + const ca = await server.services.certificateAuthority.getCaById({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CA, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "PATCH", + url: "/:caId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.UPDATE.caId) + }), + body: z.object({ + status: z.enum([CaStatus.ACTIVE, CaStatus.DISABLED]).optional().describe(CERTIFICATE_AUTHORITIES.UPDATE.status) + }), + response: { + 200: z.object({ + ca: CertificateAuthoritiesSchema + }) + } + }, + handler: async (req) => { + const ca = await server.services.certificateAuthority.updateCaById({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.UPDATE_CA, + metadata: { + caId: ca.id, + dn: ca.dn, + status: ca.status as CaStatus + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "DELETE", + url: "/:caId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.DELETE.caId) + }), + response: { + 200: z.object({ + ca: CertificateAuthoritiesSchema + }) + } + }, + handler: async (req) => { + const ca = await server.services.certificateAuthority.deleteCaById({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.DELETE_CA, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "GET", + url: "/:caId/csr", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get CA CSR", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CSR.caId) + }), + response: { + 200: z.object({ + csr: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CSR.csr) + }) + } + }, + handler: async (req) => { + const { ca, csr } = await server.services.certificateAuthority.getCaCsr({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CA_CSR, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + csr + }; + } + }); + + server.route({ + method: "GET", + url: "/:caId/certificate", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get cert and cert chain of a CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CERT.caId) + }), + response: { + 200: z.object({ + certificate: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CERT.certificate), + certificateChain: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CERT.certificateChain), + serialNumber: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CERT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, serialNumber, ca } = await server.services.certificateAuthority.getCaCert({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CA_CERT, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + certificate, + certificateChain, + serialNumber + }; + } + }); + + server.route({ + method: "POST", + url: "/:caId/sign-intermediate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Create intermediate CA certificate from parent CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.caId) + }), + body: z.object({ + csr: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.csr), + notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.notBefore), + notAfter: validateCaDateField.describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.notAfter), + maxPathLength: z.number().min(-1).default(-1).describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.maxPathLength) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.certificate), + certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.certificateChain), + issuingCaCertificate: z + .string() + .trim() + .describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.issuingCaCertificate), + serialNumber: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca } = + await server.services.certificateAuthority.signIntermediate({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.SIGN_INTERMEDIATE, + metadata: { + caId: ca.id, + dn: ca.dn, + serialNumber + } + } + }); + + return { + certificate, + certificateChain, + issuingCaCertificate, + serialNumber + }; + } + }); + + server.route({ + method: "POST", + url: "/:caId/import-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Import certificate and chain to CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.IMPORT_CERT.caId) + }), + body: z.object({ + certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.IMPORT_CERT.certificate), + certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.IMPORT_CERT.certificateChain) + }), + response: { + 200: z.object({ + message: z.string().trim(), + caId: z.string().trim() + }) + } + }, + handler: async (req) => { + const { ca } = await server.services.certificateAuthority.importCertToCa({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.IMPORT_CA_CERT, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + message: "Successfully imported certificate to CA", + caId: req.params.caId + }; + } + }); + + server.route({ + method: "POST", + url: "/:caId/issue-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Issue certificate from CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.caId) + }), + body: z + .object({ + friendlyName: z.string().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.friendlyName), + commonName: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.commonName), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.ttl), + notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notBefore), + notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notAfter) + }) + .refine( + (data) => { + const { ttl, notAfter } = data; + return (ttl !== undefined && notAfter === undefined) || (ttl === undefined && notAfter !== undefined); + }, + { + message: "Either ttl or notAfter must be present, but not both", + path: ["ttl", "notAfter"] + } + ), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificate), + issuingCaCertificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.issuingCaCertificate), + certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateChain), + privateKey: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.privateKey), + serialNumber: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber, ca } = + await server.services.certificateAuthority.issueCertFromCa({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.ISSUE_CERT, + metadata: { + caId: ca.id, + dn: ca.dn, + serialNumber + } + } + }); + + return { + certificate, + certificateChain, + issuingCaCertificate, + privateKey, + serialNumber + }; + } + }); +}; diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts new file mode 100644 index 000000000..938fbf7fe --- /dev/null +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -0,0 +1,207 @@ +import { z } from "zod"; + +import { CertificatesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { CERTIFICATES } 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 { CrlReason } from "@app/services/certificate/certificate-types"; + +export const registerCertRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:serialNumber", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get certificate", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.GET.serialNumber) + }), + response: { + 200: z.object({ + certificate: CertificatesSchema + }) + } + }, + handler: async (req) => { + const { cert, ca } = await server.services.certificate.getCert({ + serialNumber: req.params.serialNumber, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CERT, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber: cert.serialNumber + } + } + }); + + return { + certificate: cert + }; + } + }); + + server.route({ + method: "POST", + url: "/:serialNumber/revoke", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Revoke", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.REVOKE.serialNumber) + }), + body: z.object({ + revocationReason: z.nativeEnum(CrlReason).describe(CERTIFICATES.REVOKE.revocationReason) + }), + response: { + 200: z.object({ + message: z.string().trim(), + serialNumber: z.string().trim().describe(CERTIFICATES.REVOKE.serialNumberRes), + revokedAt: z.date().describe(CERTIFICATES.REVOKE.revokedAt) + }) + } + }, + handler: async (req) => { + const { revokedAt, cert, ca } = await server.services.certificate.revokeCert({ + serialNumber: req.params.serialNumber, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.REVOKE_CERT, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber: cert.serialNumber + } + } + }); + + return { + message: "Successfully revoked certificate", + serialNumber: req.params.serialNumber, + revokedAt + }; + } + }); + + server.route({ + method: "DELETE", + url: "/:serialNumber", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete certificate", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.DELETE.serialNumber) + }), + response: { + 200: z.object({ + certificate: CertificatesSchema + }) + } + }, + handler: async (req) => { + const { deletedCert, ca } = await server.services.certificate.deleteCert({ + serialNumber: req.params.serialNumber, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.DELETE_CERT, + metadata: { + certId: deletedCert.id, + cn: deletedCert.commonName, + serialNumber: deletedCert.serialNumber + } + } + }); + + return { + certificate: deletedCert + }; + } + }); + + server.route({ + method: "GET", + url: "/:serialNumber/certificate", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get certificate body of certificate", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumber) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate), + certificateChain: z.string().trim().describe(CERTIFICATES.GET_CERT.certificateChain), + serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, serialNumber, cert, ca } = await server.services.certificate.getCertBody({ + serialNumber: req.params.serialNumber, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.DELETE_CERT, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber: cert.serialNumber + } + } + }); + + return { + certificate, + certificateChain, + serialNumber + }; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index fd8255e63..eee7dac65 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -1,6 +1,8 @@ import { registerAdminRouter } from "./admin-router"; import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; +import { registerCaRouter } from "./certificate-authority-router"; +import { registerCertRouter } from "./certificate-router"; import { registerIdentityAccessTokenRouter } from "./identity-access-token-router"; import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router"; import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router"; @@ -17,7 +19,6 @@ import { registerProjectEnvRouter } from "./project-env-router"; import { registerProjectKeyRouter } from "./project-key-router"; import { registerProjectMembershipRouter } from "./project-membership-router"; import { registerProjectRouter } from "./project-router"; -import { registerRateLimitRouter } from "./rate-limit-router"; import { registerSecretFolderRouter } from "./secret-folder-router"; import { registerSecretImportRouter } from "./secret-import-router"; import { registerSecretSharingRouter } from "./secret-sharing-router"; @@ -44,7 +45,6 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerPasswordRouter, { prefix: "/password" }); await server.register(registerOrgRouter, { prefix: "/organization" }); await server.register(registerAdminRouter, { prefix: "/admin" }); - await server.register(registerRateLimitRouter, { prefix: "/rate-limit" }); await server.register(registerUserRouter, { prefix: "/user" }); await server.register(registerInviteOrgRouter, { prefix: "/invite-org" }); await server.register(registerUserActionRouter, { prefix: "/user-action" }); @@ -63,6 +63,14 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { { prefix: "/workspace" } ); + await server.register( + async (pkiRouter) => { + await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); + await pkiRouter.register(registerCertRouter, { prefix: "/certificates" }); + }, + { prefix: "/pki" } + ); + await server.register(registerProjectBotRouter, { prefix: "/bot" }); await server.register(registerIntegrationRouter, { prefix: "/integration" }); await server.register(registerIntegrationAuthRouter, { prefix: "/integration-auth" }); diff --git a/backend/src/server/routes/v1/project-membership-router.ts b/backend/src/server/routes/v1/project-membership-router.ts index 6bbb8d7ef..4f92783c5 100644 --- a/backend/src/server/routes/v1/project-membership-router.ts +++ b/backend/src/server/routes/v1/project-membership-router.ts @@ -309,4 +309,32 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider return { membership }; } }); + + server.route({ + method: "DELETE", + url: "/:workspaceId/leave", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + response: { + 200: z.object({ + membership: ProjectMembershipsSchema + }) + } + }, + + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const membership = await server.services.projectMembership.leaveProject({ + actorId: req.permission.id, + actor: req.permission.type, + projectId: req.params.workspaceId + }); + return { membership }; + } + }); }; diff --git a/backend/src/server/routes/v1/secret-sharing-router.ts b/backend/src/server/routes/v1/secret-sharing-router.ts index 6cb551698..4ec2737fb 100644 --- a/backend/src/server/routes/v1/secret-sharing-router.ts +++ b/backend/src/server/routes/v1/secret-sharing-router.ts @@ -1,7 +1,12 @@ import { z } from "zod"; import { SecretSharingSchema } from "@app/db/schemas"; -import { publicEndpointLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { + publicEndpointLimit, + publicSecretShareCreationLimit, + readLimit, + writeLimit +} from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -72,7 +77,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => server.route({ method: "POST", - url: "/", + url: "/public", config: { rateLimit: writeLimit }, @@ -82,9 +87,42 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => iv: z.string(), tag: z.string(), hashedHex: z.string(), - expiresAt: z - .string() - .refine((date) => date === undefined || new Date(date) > new Date(), "Expires at should be a future date"), + expiresAt: z.string(), + expiresAfterViews: z.number() + }), + response: { + 200: z.object({ + id: z.string().uuid() + }) + } + }, + handler: async (req) => { + const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = req.body; + const sharedSecret = await req.server.services.secretSharing.createPublicSharedSecret({ + encryptedValue, + iv, + tag, + hashedHex, + expiresAt: new Date(expiresAt), + expiresAfterViews + }); + return { id: sharedSecret.id }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: publicSecretShareCreationLimit + }, + schema: { + body: z.object({ + encryptedValue: z.string(), + iv: z.string(), + tag: z.string(), + hashedHex: z.string(), + expiresAt: z.string(), expiresAfterViews: z.number() }), response: { diff --git a/backend/src/server/routes/v1/secret-tag-router.ts b/backend/src/server/routes/v1/secret-tag-router.ts index 1715aa3c3..ce92409f6 100644 --- a/backend/src/server/routes/v1/secret-tag-router.ts +++ b/backend/src/server/routes/v1/secret-tag-router.ts @@ -23,7 +23,7 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const workspaceTags = await server.services.secretTag.getProjectTags({ actor: req.permission.type, @@ -36,6 +36,67 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:projectId/tags/:tagId", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(SECRET_TAGS.GET_TAG_BY_ID.projectId), + tagId: z.string().trim().describe(SECRET_TAGS.GET_TAG_BY_ID.tagId) + }), + response: { + 200: z.object({ + workspaceTag: SecretTagsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workspaceTag = await server.services.secretTag.getTagById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.tagId + }); + return { workspaceTag }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/tags/slug/:tagSlug", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(SECRET_TAGS.GET_TAG_BY_SLUG.projectId), + tagSlug: z.string().trim().describe(SECRET_TAGS.GET_TAG_BY_SLUG.tagSlug) + }), + response: { + 200: z.object({ + workspaceTag: SecretTagsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workspaceTag = await server.services.secretTag.getTagBySlug({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + slug: req.params.tagSlug, + projectId: req.params.projectId + }); + return { workspaceTag }; + } + }); + server.route({ method: "POST", url: "/:projectId/tags", @@ -57,7 +118,7 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const workspaceTag = await server.services.secretTag.createTag({ actor: req.permission.type, @@ -71,6 +132,42 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "PATCH", + url: "/:projectId/tags/:tagId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(SECRET_TAGS.UPDATE.projectId), + tagId: z.string().trim().describe(SECRET_TAGS.UPDATE.tagId) + }), + body: z.object({ + name: z.string().trim().describe(SECRET_TAGS.UPDATE.name), + slug: z.string().trim().describe(SECRET_TAGS.UPDATE.slug), + color: z.string().trim().describe(SECRET_TAGS.UPDATE.color) + }), + response: { + 200: z.object({ + workspaceTag: SecretTagsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workspaceTag = await server.services.secretTag.updateTag({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + id: req.params.tagId + }); + return { workspaceTag }; + } + }); + server.route({ method: "DELETE", url: "/:projectId/tags/:tagId", @@ -88,7 +185,7 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const workspaceTag = await server.services.secretTag.deleteTag({ actor: req.permission.type, diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index a199cf0d4..e1c7c2e69 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -1,13 +1,14 @@ import slugify from "@sindresorhus/slugify"; import { z } from "zod"; -import { ProjectKeysSchema, ProjectsSchema } from "@app/db/schemas"; +import { CertificateAuthoritiesSchema, CertificatesSchema, ProjectKeysSchema, ProjectsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { PROJECTS } from "@app/lib/api-docs"; import { creationLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types"; import { ProjectFilterType } from "@app/services/project/project-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; @@ -307,4 +308,80 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return project; } }); + + server.route({ + method: "GET", + url: "/:slug/cas", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + slug: slugSchema.describe("The slug of the project to list CAs.") + }), + querystring: z.object({ + status: z.enum([CaStatus.ACTIVE, CaStatus.PENDING_CERTIFICATE]).optional() + }), + response: { + 200: z.object({ + cas: z.array(CertificateAuthoritiesSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const cas = await server.services.project.listProjectCas({ + filter: { + slug: req.params.slug, + orgId: req.permission.orgId, + type: ProjectFilterType.SLUG + }, + status: req.query.status, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type + }); + return { cas }; + } + }); + + server.route({ + method: "GET", + url: "/:slug/certificates", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + slug: slugSchema.describe("The slug of the project to list certificates.") + }), + querystring: z.object({ + offset: z.coerce.number().min(0).max(100).default(0), + limit: z.coerce.number().min(1).max(100).default(25) + }), + response: { + 200: z.object({ + certificates: z.array(CertificatesSchema), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificates, totalCount } = await server.services.project.listProjectCertificates({ + filter: { + slug: req.params.slug, + orgId: req.permission.orgId, + type: ProjectFilterType.SLUG + }, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + ...req.query + }); + return { certificates, totalCount }; + } + }); }; diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index 05db617b9..e3f1528ad 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -8,7 +8,7 @@ import { SecretType, ServiceTokenScopes } from "@app/db/schemas"; -import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; import { RAW_SECRETS, SECRETS } from "@app/lib/api-docs"; import { BadRequestError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; @@ -259,18 +259,20 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); - await server.services.telemetry.sendPostHogEvents({ - event: PostHogEventTypes.SecretPulled, - distinctId: getTelemetryDistinctId(req), - properties: { - numberOfSecrets: secrets.length, - workspaceId, - environment, - secretPath: req.query.secretPath, - channel: getUserAgentType(req.headers["user-agent"]), - ...req.auditLogInfo - } - }); + if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretPulled, + distinctId: getTelemetryDistinctId(req), + properties: { + numberOfSecrets: secrets.length, + workspaceId, + environment, + secretPath: req.query.secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + } return { secrets, imports }; } }); @@ -306,7 +308,16 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - secret: secretRawSchema + secret: secretRawSchema.extend({ + tags: SecretTagsSchema.pick({ + id: true, + slug: true, + name: true, + color: true + }) + .array() + .optional() + }) }) } }, @@ -358,18 +369,20 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); - await server.services.telemetry.sendPostHogEvents({ - event: PostHogEventTypes.SecretPulled, - distinctId: getTelemetryDistinctId(req), - properties: { - numberOfSecrets: 1, - workspaceId: secret.workspace, - environment, - secretPath: req.query.secretPath, - channel: getUserAgentType(req.headers["user-agent"]), - ...req.auditLogInfo - } - }); + if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretPulled, + distinctId: getTelemetryDistinctId(req), + properties: { + numberOfSecrets: 1, + workspaceId: secret.workspace, + environment, + secretPath: req.query.secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + } return { secret }; } }); @@ -404,6 +417,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())) .describe(RAW_SECRETS.CREATE.secretValue), secretComment: z.string().trim().optional().default("").describe(RAW_SECRETS.CREATE.secretComment), + tagIds: z.string().array().optional().describe(RAW_SECRETS.CREATE.tagIds), skipMultilineEncoding: z.boolean().optional().describe(RAW_SECRETS.CREATE.skipMultilineEncoding), type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(RAW_SECRETS.CREATE.type) }), @@ -427,7 +441,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { type: req.body.type, secretValue: req.body.secretValue, skipMultilineEncoding: req.body.skipMultilineEncoding, - secretComment: req.body.secretComment + secretComment: req.body.secretComment, + tagIds: req.body.tagIds }); await server.services.auditLog.createAuditLog({ @@ -492,7 +507,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { .transform(removeTrailingSlash) .describe(RAW_SECRETS.UPDATE.secretPath), skipMultilineEncoding: z.boolean().optional().describe(RAW_SECRETS.UPDATE.skipMultilineEncoding), - type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(RAW_SECRETS.UPDATE.type) + type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(RAW_SECRETS.UPDATE.type), + tagIds: z.string().array().optional().describe(RAW_SECRETS.UPDATE.tagIds) }), response: { 200: z.object({ @@ -513,7 +529,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretName: req.params.secretName, type: req.body.type, secretValue: req.body.secretValue, - skipMultilineEncoding: req.body.skipMultilineEncoding + skipMultilineEncoding: req.body.skipMultilineEncoding, + tagIds: req.body.tagIds }); await server.services.auditLog.createAuditLog({ @@ -710,24 +727,22 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }); // TODO: Move to telemetry plugin - let shouldRecordK8Event = false; - if (req.headers["user-agent"] === "k8-operatoer") { - const randomNumber = Math.random(); - if (randomNumber > 0.95) { - shouldRecordK8Event = true; - } - } + // let shouldRecordK8Event = false; + // if (req.headers["user-agent"] === "k8-operatoer") { + // const randomNumber = Math.random(); + // if (randomNumber > 0.95) { + // shouldRecordK8Event = true; + // } + // } const shouldCapture = - req.query.workspaceId !== "650e71fbae3e6c8572f436d4" && - (req.headers["user-agent"] !== "k8-operator" || shouldRecordK8Event); - const approximateNumberTotalSecrets = secrets.length * 20; + req.query.workspaceId !== "650e71fbae3e6c8572f436d4" && req.headers["user-agent"] !== "k8-operator"; if (shouldCapture) { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretPulled, distinctId: getTelemetryDistinctId(req), properties: { - numberOfSecrets: shouldRecordK8Event ? approximateNumberTotalSecrets : secrets.length, + numberOfSecrets: secrets.length, workspaceId: req.query.workspaceId, environment: req.query.environment, secretPath: req.query.secretPath, @@ -804,18 +819,20 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); - await server.services.telemetry.sendPostHogEvents({ - event: PostHogEventTypes.SecretPulled, - distinctId: getTelemetryDistinctId(req), - properties: { - numberOfSecrets: 1, - workspaceId: req.query.workspaceId, - environment: req.query.environment, - secretPath: req.query.secretPath, - channel: getUserAgentType(req.headers["user-agent"]), - ...req.auditLogInfo - } - }); + if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretPulled, + distinctId: getTelemetryDistinctId(req), + properties: { + numberOfSecrets: 1, + workspaceId: req.query.workspaceId, + environment: req.query.environment, + secretPath: req.query.secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + } return { secret }; } }); diff --git a/backend/src/services/certificate-authority/certificate-authority-cert-dal.ts b/backend/src/services/certificate-authority/certificate-authority-cert-dal.ts new file mode 100644 index 000000000..763240986 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-cert-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateAuthorityCertDALFactory = ReturnType; + +export const certificateAuthorityCertDALFactory = (db: TDbClient) => { + const caCertOrm = ormify(db, TableName.CertificateAuthorityCert); + return caCertOrm; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-dal.ts b/backend/src/services/certificate-authority/certificate-authority-dal.ts new file mode 100644 index 000000000..1b4b30e73 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-dal.ts @@ -0,0 +1,48 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateAuthorityDALFactory = ReturnType; + +export const certificateAuthorityDALFactory = (db: TDbClient) => { + const caOrm = ormify(db, TableName.CertificateAuthority); + + // note: not used + const buildCertificateChain = async (caId: string) => { + try { + const result: { + caId: string; + parentCaId?: string; + encryptedCertificate: Buffer; + }[] = await db + .withRecursive("cte", (cte) => { + void cte + .select("ca.id as caId", "ca.parentCaId", "cert.encryptedCertificate") + .from({ ca: TableName.CertificateAuthority }) + .leftJoin({ cert: TableName.CertificateAuthorityCert }, "ca.id", "cert.caId") + .where("ca.id", caId) + .unionAll((builder) => { + void builder + .select("ca.id as caId", "ca.parentCaId", "cert.encryptedCertificate") + .from({ ca: TableName.CertificateAuthority }) + .leftJoin({ cert: TableName.CertificateAuthorityCert }, "ca.id", "cert.caId") + .innerJoin("cte", "cte.parentCaId", "ca.id"); + }); + }) + .select("*") + .from("cte"); + + // Extract certificates and reverse the order to have the root CA at the end + const certChain: Buffer[] = result.map((row) => row.encryptedCertificate); + return certChain; + } catch (error) { + throw new DatabaseError({ error, name: "BuildCertificateChain" }); + } + }; + + return { + ...caOrm, + buildCertificateChain + }; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts new file mode 100644 index 000000000..cf42a058e --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -0,0 +1,216 @@ +import * as x509 from "@peculiar/x509"; +import crypto from "crypto"; + +import { BadRequestError } from "@app/lib/errors"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { CertKeyAlgorithm, CertStatus } from "../certificate/certificate-types"; +import { TDNParts, TGetCaCertChainDTO, TGetCaCredentialsDTO, TRebuildCaCrlDTO } from "./certificate-authority-types"; + +export const createDistinguishedName = (parts: TDNParts) => { + const dnParts = []; + if (parts.country) dnParts.push(`C=${parts.country}`); + if (parts.organization) dnParts.push(`O=${parts.organization}`); + if (parts.ou) dnParts.push(`OU=${parts.ou}`); + if (parts.province) dnParts.push(`ST=${parts.province}`); + if (parts.commonName) dnParts.push(`CN=${parts.commonName}`); + if (parts.locality) dnParts.push(`L=${parts.locality}`); + return dnParts.join(", "); +}; + +export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => { + switch (keyAlgorithm) { + case CertKeyAlgorithm.RSA_4096: + return { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 4096 + }; + case CertKeyAlgorithm.ECDSA_P256: + return { + name: "ECDSA", + namedCurve: "P-256", + hash: "SHA-256" + }; + case CertKeyAlgorithm.ECDSA_P384: + return { + name: "ECDSA", + namedCurve: "P-384", + hash: "SHA-384" + }; + default: { + // RSA_2048 + return { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 2048 + }; + } + } +}; + +/** + * Return the public and private key of CA with id [caId] + * Note: credentials are returned as crypto.webcrypto.CryptoKey + * suitable for use with @peculiar/x509 module + */ +export const getCaCredentials = async ({ + caId, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService +}: TGetCaCredentialsDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const caSecret = await certificateAuthoritySecretDAL.findOne({ caId }); + if (!caSecret) throw new BadRequestError({ message: "CA secret not found" }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const decryptedPrivateKey = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caSecret.encryptedPrivateKey + }); + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + const skObj = crypto.createPrivateKey({ key: decryptedPrivateKey, format: "der", type: "pkcs8" }); + const caPrivateKey = await crypto.subtle.importKey( + "pkcs8", + skObj.export({ format: "der", type: "pkcs8" }), + alg, + true, + ["sign"] + ); + + const pkObj = crypto.createPublicKey(skObj); + const caPublicKey = await crypto.subtle.importKey("spki", pkObj.export({ format: "der", type: "spki" }), alg, true, [ + "verify" + ]); + + return { + caPrivateKey, + caPublicKey + }; +}; + +/** + * Return the decrypted pem-encoded certificate and certificate chain + * for CA with id [caId]. + */ +export const getCaCertChain = async ({ + caId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService +}: TGetCaCertChainDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const decryptedCaCert = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + + const decryptedChain = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caCert.encryptedCertificateChain + }); + + return { + caCert: caCertObj.toString("pem"), + caCertChain: decryptedChain.toString("utf-8"), + serialNumber: caCertObj.serialNumber + }; +}; + +/** + * Rebuilds the certificate revocation list (CRL) + * for CA with id [caId] + */ +export const rebuildCaCrl = async ({ + caId, + certificateAuthorityDAL, + certificateAuthorityCrlDAL, + certificateAuthoritySecretDAL, + projectDAL, + certificateDAL, + kmsService +}: TRebuildCaCrlDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const caSecret = await certificateAuthoritySecretDAL.findOne({ caId: ca.id }); + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const privateKey = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caSecret.encryptedPrivateKey + }); + + const skObj = crypto.createPrivateKey({ key: privateKey, format: "der", type: "pkcs8" }); + const sk = await crypto.subtle.importKey("pkcs8", skObj.export({ format: "der", type: "pkcs8" }), alg, true, [ + "sign" + ]); + + const revokedCerts = await certificateDAL.find({ + caId: ca.id, + status: CertStatus.REVOKED + }); + + const crl = await x509.X509CrlGenerator.create({ + issuer: ca.dn, + thisUpdate: new Date(), + nextUpdate: new Date("2025/12/12"), + entries: revokedCerts.map((revokedCert) => { + return { + serialNumber: revokedCert.serialNumber, + revocationDate: new Date(revokedCert.revokedAt as Date), + reason: revokedCert.revocationReason as number, + invalidity: new Date("2022/01/01"), + issuer: ca.dn + }; + }), + signingAlgorithm: alg, + signingKey: sk + }); + + const { cipherTextBlob: encryptedCrl } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(new Uint8Array(crl.rawData)) + }); + + await certificateAuthorityCrlDAL.update( + { + caId: ca.id + }, + { + encryptedCrl + } + ); +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-queue.ts b/backend/src/services/certificate-authority/certificate-authority-queue.ts new file mode 100644 index 000000000..384f45c09 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-queue.ts @@ -0,0 +1,145 @@ +import * as x509 from "@peculiar/x509"; +import crypto from "crypto"; + +import { getConfig } from "@app/lib/config/env"; +import { daysToMillisecond, secondsToMillis } from "@app/lib/dates"; +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { CertKeyAlgorithm, CertStatus } from "@app/services/certificate/certificate-types"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; +import { keyAlgorithmToAlgCfg } from "./certificate-authority-fns"; +import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal"; +import { TRotateCaCrlTriggerDTO } from "./certificate-authority-types"; + +type TCertificateAuthorityQueueFactoryDep = { + // TODO: Pick + certificateAuthorityDAL: TCertificateAuthorityDALFactory; + certificateAuthorityCrlDAL: TCertificateAuthorityCrlDALFactory; + certificateAuthoritySecretDAL: TCertificateAuthoritySecretDALFactory; + certificateDAL: TCertificateDALFactory; + projectDAL: Pick; + kmsService: Pick; + queueService: TQueueServiceFactory; +}; +export type TCertificateAuthorityQueueFactory = ReturnType; + +export const certificateAuthorityQueueFactory = ({ + certificateAuthorityCrlDAL, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + certificateDAL, + projectDAL, + kmsService, + queueService +}: TCertificateAuthorityQueueFactoryDep) => { + // TODO 1: auto-periodic rotation + // TODO 2: manual rotation + + const setCaCrlRotationInterval = async ({ caId, rotationIntervalDays }: TRotateCaCrlTriggerDTO) => { + const appCfg = getConfig(); + + // query for config + // const caCrl = await certificateAuthorityCrlDAL.findOne({ + // caId + // }); + + await queueService.queue( + // TODO: clarify queue + job naming + QueueName.CaCrlRotation, + QueueJobs.CaCrlRotation, + { + caId + }, + { + jobId: `ca-crl-rotation-${caId}`, + repeat: { + // on prod it this will be in days, in development this will be second + every: + appCfg.NODE_ENV === "development" + ? secondsToMillis(rotationIntervalDays) + : daysToMillisecond(rotationIntervalDays), + immediately: true + } + } + ); + }; + + queueService.start(QueueName.CaCrlRotation, async (job) => { + const { caId } = job.data; + logger.info(`secretReminderQueue.process: [secretDocument=${caId}]`); + + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const caSecret = await certificateAuthoritySecretDAL.findOne({ caId: ca.id }); + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const privateKey = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caSecret.encryptedPrivateKey + }); + + const skObj = crypto.createPrivateKey({ key: privateKey, format: "der", type: "pkcs8" }); + const sk = await crypto.subtle.importKey("pkcs8", skObj.export({ format: "der", type: "pkcs8" }), alg, true, [ + "sign" + ]); + + const revokedCerts = await certificateDAL.find({ + caId: ca.id, + status: CertStatus.REVOKED + }); + + const crl = await x509.X509CrlGenerator.create({ + issuer: ca.dn, + thisUpdate: new Date(), + nextUpdate: new Date("2025/12/12"), // TODO: depends on configured rebuild interval + entries: revokedCerts.map((revokedCert) => { + return { + serialNumber: revokedCert.serialNumber, + revocationDate: new Date(revokedCert.revokedAt as Date), + reason: revokedCert.revocationReason as number, + invalidity: new Date("2022/01/01"), + issuer: ca.dn + }; + }), + signingAlgorithm: alg, + signingKey: sk + }); + + const { cipherTextBlob: encryptedCrl } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(new Uint8Array(crl.rawData)) + }); + + await certificateAuthorityCrlDAL.update( + { + caId: ca.id + }, + { + encryptedCrl + } + ); + }); + + queueService.listen(QueueName.CaCrlRotation, "failed", (job, err) => { + logger.error(err, "Failed to rotate CA CRL %s", job?.id); + }); + + return { + setCaCrlRotationInterval + }; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-secret-dal.ts b/backend/src/services/certificate-authority/certificate-authority-secret-dal.ts new file mode 100644 index 000000000..2ade72e7e --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-secret-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateAuthoritySecretDALFactory = ReturnType; + +export const certificateAuthoritySecretDALFactory = (db: TDbClient) => { + const caSecretOrm = ormify(db, TableName.CertificateAuthoritySecret); + return caSecretOrm; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts new file mode 100644 index 000000000..2345180a3 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -0,0 +1,821 @@ +/* eslint-disable no-bitwise */ +import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; +import crypto, { KeyObject } from "crypto"; +import ms from "ms"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError } from "@app/lib/errors"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { CertKeyAlgorithm, CertStatus } from "../certificate/certificate-types"; +import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; +import { + createDistinguishedName, + getCaCertChain, + getCaCredentials, + keyAlgorithmToAlgCfg +} from "./certificate-authority-fns"; +import { TCertificateAuthorityQueueFactory } from "./certificate-authority-queue"; +import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal"; +import { + CaStatus, + CaType, + TCreateCaDTO, + TDeleteCaDTO, + TGetCaCertDTO, + TGetCaCsrDTO, + TGetCaDTO, + TImportCertToCaDTO, + TIssueCertFromCaDTO, + TSignIntermediateDTO, + TUpdateCaDTO +} from "./certificate-authority-types"; + +type TCertificateAuthorityServiceFactoryDep = { + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + "transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" + >; + certificateAuthorityCertDAL: Pick; + certificateAuthoritySecretDAL: Pick; + certificateAuthorityCrlDAL: Pick; + certificateAuthorityQueue: TCertificateAuthorityQueueFactory; // TODO: Pick + certificateDAL: Pick; + certificateBodyDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + permissionService: Pick; +}; + +export type TCertificateAuthorityServiceFactory = ReturnType; + +export const certificateAuthorityServiceFactory = ({ + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateDAL, + certificateBodyDAL, + projectDAL, + kmsService, + permissionService +}: TCertificateAuthorityServiceFactoryDep) => { + /** + * Generates new root or intermediate CA + */ + const createCa = async ({ + projectSlug, + type, + friendlyName, + commonName, + organization, + ou, + country, + province, + locality, + notBefore, + notAfter, + maxPathLength, + keyAlgorithm, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateCaDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + const dn = createDistinguishedName({ + commonName, + organization, + ou, + country, + province, + locality + }); + + const alg = keyAlgorithmToAlgCfg(keyAlgorithm); + const keys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); + + const newCa = await certificateAuthorityDAL.transaction(async (tx) => { + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + + // if undefined, set [notAfterDate] to 10 years from now + const notAfterDate = notAfter + ? new Date(notAfter) + : new Date(new Date().setFullYear(new Date().getFullYear() + 10)); + + const serialNumber = crypto.randomBytes(32).toString("hex"); + + const ca = await certificateAuthorityDAL.create( + { + projectId: project.id, + type, + organization, + ou, + country, + province, + locality, + friendlyName: friendlyName || dn, + commonName, + status: type === CaType.ROOT ? CaStatus.ACTIVE : CaStatus.PENDING_CERTIFICATE, + dn, + keyAlgorithm, + ...(type === CaType.ROOT && { + maxPathLength, + notBefore: notBeforeDate, + notAfter: notAfterDate, + serialNumber + }) + }, + tx + ); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: project.id, + projectDAL, + kmsService + }); + + if (type === CaType.ROOT) { + // note: create self-signed cert only applicable for root CA + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + name: dn, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingAlgorithm: alg, + keys, + extensions: [ + new x509.BasicConstraintsExtension(true, maxPathLength === -1 ? undefined : maxPathLength, true), + new x509.ExtendedKeyUsageExtension(["1.2.3.4.5.6.7", "2.3.4.5.6.7.8"], true), + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), + await x509.SubjectKeyIdentifierExtension.create(keys.publicKey) + ] + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(new Uint8Array(cert.rawData)) + }); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.alloc(0) + }); + + await certificateAuthorityCertDAL.create( + { + caId: ca.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + } + + // create empty CRL + const crl = await x509.X509CrlGenerator.create({ + issuer: ca.dn, + thisUpdate: new Date(), + nextUpdate: new Date("2025/12/12"), // TODO: change + entries: [], + signingAlgorithm: alg, + signingKey: keys.privateKey + }); + + const { cipherTextBlob: encryptedCrl } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(new Uint8Array(crl.rawData)) + }); + + await certificateAuthorityCrlDAL.create( + { + caId: ca.id, + encryptedCrl + }, + tx + ); + + // https://nodejs.org/api/crypto.html#static-method-keyobjectfromkey + const skObj = KeyObject.from(keys.privateKey); + + const { cipherTextBlob: encryptedPrivateKey } = await kmsService.encrypt({ + kmsId: keyId, + plainText: skObj.export({ + type: "pkcs8", + format: "der" + }) + }); + + await certificateAuthoritySecretDAL.create( + { + caId: ca.id, + encryptedPrivateKey + }, + tx + ); + + return ca; + }); + + return newCa; + }; + + /** + * Return CA with id [caId] + */ + const getCaById = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + return ca; + }; + + /** + * Update CA with id [caId]. + * Note: Used to enable/disable CA + */ + const updateCaById = async ({ caId, status, actorId, actorAuthMethod, actor, actorOrgId }: TUpdateCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.CertificateAuthorities + ); + + const updatedCa = await certificateAuthorityDAL.updateById(caId, { status }); + + return updatedCa; + }; + + /** + * Delete CA with id [caId] + */ + const deleteCaById = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.CertificateAuthorities + ); + + const deletedCa = await certificateAuthorityDAL.deleteById(caId); + + return deletedCa; + }; + + /** + * Return certificate signing request (CSR) made with CA with id [caId] + */ + const getCaCsr = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCsrDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + if (ca.type === CaType.ROOT) throw new BadRequestError({ message: "Root CA cannot generate CSR" }); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + if (caCert) throw new BadRequestError({ message: "CA already has a certificate installed" }); + + const { caPrivateKey, caPublicKey } = await getCaCredentials({ + caId, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: ca.dn, + keys: { + privateKey: caPrivateKey, + publicKey: caPublicKey + }, + signingAlgorithm: alg, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment + ) + ], + attributes: [new x509.ChallengePasswordAttribute("password")] + }); + + return { + csr: csrObj.toString("pem"), + ca + }; + }; + + /** + * Return certificate and certificate chain for CA + */ + const getCaCert = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCertDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + const { caCert, caCertChain, serialNumber } = await getCaCertChain({ + caId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + certificate: caCert, + certificateChain: caCertChain, + serialNumber, + ca + }; + }; + + /** + * Issue certificate to be imported back in for intermediate CA + */ + const signIntermediate = async ({ + caId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + csr, + notBefore, + notAfter, + maxPathLength + }: TSignIntermediateDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + const decryptedCaCert = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + const csrObj = new x509.Pkcs10CertificateRequest(csr); + + // check path length constraint + const caPathLength = caCertObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; + if (caPathLength !== undefined) { + if (caPathLength === 0) + throw new BadRequestError({ + message: "Failed to issue intermediate certificate due to CA path length constraint" + }); + if (maxPathLength >= caPathLength || (maxPathLength === -1 && caPathLength !== -1)) + throw new BadRequestError({ + message: "The requested path length constraint exceeds the CA's allowed path length" + }); + } + + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + const notAfterDate = new Date(notAfter); + + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" }); + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const { caPrivateKey } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const serialNumber = crypto.randomBytes(32).toString("hex"); + const intermediateCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension(true, maxPathLength === -1 ? undefined : maxPathLength, true), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) + ] + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + certificate: intermediateCert.toString("pem"), + issuingCaCertificate, + certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), + serialNumber: intermediateCert.serialNumber, + ca + }; + }; + + /** + * Import certificate for (un-installed) CA with id [caId]. + * Note: Can be used to import an external certificate and certificate chain + * to be installed into the CA. + */ + const importCertToCa = async ({ + caId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + certificate, + certificateChain + }: TImportCertToCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + if (caCert) throw new BadRequestError({ message: "CA has already imported a certificate" }); + + const certObj = new x509.X509Certificate(certificate); + const maxPathLength = certObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; + + // validate imported certificate and certificate chain + const certificates = certificateChain + .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) + ?.map((cert) => new x509.X509Certificate(cert)); + + if (!certificates) throw new BadRequestError({ message: "Failed to parse certificate chain" }); + + const chain = new x509.X509ChainBuilder({ + certificates + }); + + const chainItems = await chain.build(certObj); + + // chain.build() implicitly verifies the chain + if (chainItems.length !== certificates.length + 1) + throw new BadRequestError({ message: "Invalid certificate chain" }); + + const parentCertObj = chainItems[1]; + const parentCertSubject = parentCertObj.subject; + + const parentCa = await certificateAuthorityDAL.findOne({ + projectId: ca.projectId, + dn: parentCertSubject + }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(new Uint8Array(certObj.rawData)) + }); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(certificateChain) + }); + + await certificateAuthorityCertDAL.transaction(async (tx) => { + await certificateAuthorityCertDAL.create( + { + caId: ca.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateAuthorityDAL.updateById( + ca.id, + { + status: CaStatus.ACTIVE, + maxPathLength: maxPathLength === undefined ? -1 : maxPathLength, + notBefore: new Date(certObj.notBefore), + notAfter: new Date(certObj.notAfter), + serialNumber: certObj.serialNumber, + parentCaId: parentCa?.id + }, + tx + ); + }); + + return { ca }; + }; + + /** + * Return new leaf certificate issued by CA with id [caId] + */ + const issueCertFromCa = async ({ + caId, + friendlyName, + commonName, + ttl, + notBefore, + notAfter, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TIssueCertFromCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); + + if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + if (!caCert) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const decryptedCaCert = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + + let notAfterDate = new Date(new Date().setFullYear(new Date().getFullYear() + 1)); + if (notAfter) { + notAfterDate = new Date(notAfter); + } else if (ttl) { + notAfterDate = new Date(new Date().getTime() + ms(ttl)); + } + + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" }); + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: `CN=${commonName}`, + keys: leafKeys, + signingAlgorithm: alg, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment) + ], + attributes: [new x509.ChallengePasswordAttribute("password")] + }); + + const { caPrivateKey } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const serialNumber = crypto.randomBytes(32).toString("hex"); + const leafCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment, true), + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) + ] + }); + + const skLeafObj = KeyObject.from(leafKeys.privateKey); + const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + + const { cipherTextBlob: encryptedCertificate } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(new Uint8Array(leafCert.rawData)) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + status: CertStatus.ACTIVE, + friendlyName: friendlyName || commonName, + commonName, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate + }, + tx + ); + + return cert; + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + certificate: leafCert.toString("pem"), + certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), + issuingCaCertificate, + privateKey: skLeaf, + serialNumber, + ca + }; + }; + + return { + createCa, + getCaById, + updateCaById, + deleteCaById, + getCaCsr, + getCaCert, + signIntermediate, + importCertToCa, + issueCertFromCa + }; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts new file mode 100644 index 000000000..3ba7624c0 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -0,0 +1,121 @@ +import { TProjectPermission } from "@app/lib/types"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; + +import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { CertKeyAlgorithm } from "../certificate/certificate-types"; +import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; +import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal"; + +export enum CaType { + ROOT = "root", + INTERMEDIATE = "intermediate" +} + +export enum CaStatus { + ACTIVE = "active", + DISABLED = "disabled", + PENDING_CERTIFICATE = "pending-certificate" +} + +export type TCreateCaDTO = { + projectSlug: string; + type: CaType; + friendlyName?: string; + commonName: string; + organization: string; + ou: string; + country: string; + province: string; + locality: string; + notBefore?: string; + notAfter?: string; + maxPathLength: number; + keyAlgorithm: CertKeyAlgorithm; +} & Omit; + +export type TGetCaDTO = { + caId: string; +} & Omit; + +export type TUpdateCaDTO = { + caId: string; + status?: CaStatus; +} & Omit; + +export type TDeleteCaDTO = { + caId: string; +} & Omit; + +export type TGetCaCsrDTO = { + caId: string; +} & Omit; + +export type TGetCaCertDTO = { + caId: string; +} & Omit; + +export type TSignIntermediateDTO = { + caId: string; + csr: string; + notBefore?: string; + notAfter: string; + maxPathLength: number; +} & Omit; + +export type TImportCertToCaDTO = { + caId: string; + certificate: string; + certificateChain: string; +} & Omit; + +export type TIssueCertFromCaDTO = { + caId: string; + friendlyName?: string; + commonName: string; + ttl: string; + notBefore?: string; + notAfter?: string; +} & Omit; + +export type TDNParts = { + commonName?: string; + organization?: string; + ou?: string; + country?: string; + province?: string; + locality?: string; +}; + +export type TGetCaCredentialsDTO = { + caId: string; + certificateAuthorityDAL: Pick; + certificateAuthoritySecretDAL: Pick; + projectDAL: Pick; + kmsService: Pick; +}; + +export type TGetCaCertChainDTO = { + caId: string; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; + projectDAL: Pick; + kmsService: Pick; +}; + +export type TRebuildCaCrlDTO = { + caId: string; + certificateAuthorityDAL: Pick; + certificateAuthorityCrlDAL: Pick; + certificateAuthoritySecretDAL: Pick; + projectDAL: Pick; + certificateDAL: Pick; + kmsService: Pick; +}; + +export type TRotateCaCrlTriggerDTO = { + caId: string; + rotationIntervalDays: number; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-validators.ts b/backend/src/services/certificate-authority/certificate-authority-validators.ts new file mode 100644 index 000000000..77bf9ad2f --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-validators.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +const isValidDate = (dateString: string) => { + const date = new Date(dateString); + return !Number.isNaN(date.getTime()); +}; + +export const validateCaDateField = z.string().trim().refine(isValidDate, { message: "Invalid date format" }); diff --git a/backend/src/services/certificate/certificate-body-dal.ts b/backend/src/services/certificate/certificate-body-dal.ts new file mode 100644 index 000000000..9ddc98966 --- /dev/null +++ b/backend/src/services/certificate/certificate-body-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateBodyDALFactory = ReturnType; + +export const certificateBodyDALFactory = (db: TDbClient) => { + const certificateBodyOrm = ormify(db, TableName.CertificateBody); + return certificateBodyOrm; +}; diff --git a/backend/src/services/certificate/certificate-dal.ts b/backend/src/services/certificate/certificate-dal.ts new file mode 100644 index 000000000..415bfabf9 --- /dev/null +++ b/backend/src/services/certificate/certificate-dal.ts @@ -0,0 +1,34 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateDALFactory = ReturnType; + +export const certificateDALFactory = (db: TDbClient) => { + const certificateOrm = ormify(db, TableName.Certificate); + + const countCertificatesInProject = async (projectId: string) => { + try { + interface CountResult { + count: string; + } + + const count = await db(TableName.Certificate) + .join(TableName.CertificateAuthority, `${TableName.Certificate}.caId`, `${TableName.CertificateAuthority}.id`) + .join(TableName.Project, `${TableName.CertificateAuthority}.projectId`, `${TableName.Project}.id`) + .where(`${TableName.Project}.id`, projectId) + .count("*") + .first(); + + return parseInt((count as unknown as CountResult).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count all project certificates" }); + } + }; + + return { + ...certificateOrm, + countCertificatesInProject + }; +}; diff --git a/backend/src/services/certificate/certificate-fns.ts b/backend/src/services/certificate/certificate-fns.ts new file mode 100644 index 000000000..dfbd50551 --- /dev/null +++ b/backend/src/services/certificate/certificate-fns.ts @@ -0,0 +1,26 @@ +import * as x509 from "@peculiar/x509"; + +import { CrlReason } from "./certificate-types"; + +export const revocationReasonToCrlCode = (crlReason: CrlReason) => { + switch (crlReason) { + case CrlReason.KEY_COMPROMISE: + return x509.X509CrlReason.keyCompromise; + case CrlReason.CA_COMPROMISE: + return x509.X509CrlReason.cACompromise; + case CrlReason.AFFILIATION_CHANGED: + return x509.X509CrlReason.affiliationChanged; + case CrlReason.SUPERSEDED: + return x509.X509CrlReason.superseded; + case CrlReason.CESSATION_OF_OPERATION: + return x509.X509CrlReason.cessationOfOperation; + case CrlReason.CERTIFICATE_HOLD: + return x509.X509CrlReason.certificateHold; + case CrlReason.PRIVILEGE_WITHDRAWN: + return x509.X509CrlReason.privilegeWithdrawn; + case CrlReason.A_A_COMPROMISE: + return x509.X509CrlReason.aACompromise; + default: + return x509.X509CrlReason.unspecified; + } +}; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts new file mode 100644 index 000000000..ba865caa1 --- /dev/null +++ b/backend/src/services/certificate/certificate-service.ts @@ -0,0 +1,203 @@ +import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; + +import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { getCaCertChain, rebuildCaCrl } from "../certificate-authority/certificate-authority-fns"; +import { revocationReasonToCrlCode } from "./certificate-fns"; +import { CertStatus, TDeleteCertDTO, TGetCertBodyDTO, TGetCertDTO, TRevokeCertDTO } from "./certificate-types"; + +type TCertificateServiceFactoryDep = { + certificateDAL: Pick; + certificateBodyDAL: Pick; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; + certificateAuthorityCrlDAL: Pick; + certificateAuthoritySecretDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + permissionService: Pick; +}; + +export type TCertificateServiceFactory = ReturnType; + +export const certificateServiceFactory = ({ + certificateDAL, + certificateBodyDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthorityCrlDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService, + permissionService +}: TCertificateServiceFactoryDep) => { + /** + * Return details for certificate with serial number [serialNumber] + */ + const getCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertDTO) => { + const cert = await certificateDAL.findOne({ serialNumber }); + const ca = await certificateAuthorityDAL.findById(cert.caId); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + + return { + cert, + ca + }; + }; + + /** + * Delete certificate with serial number [serialNumber] + */ + const deleteCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteCertDTO) => { + const cert = await certificateDAL.findOne({ serialNumber }); + const ca = await certificateAuthorityDAL.findById(cert.caId); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + + const deletedCert = await certificateDAL.deleteById(cert.id); + + return { + deletedCert, + ca + }; + }; + + /** + * Revoke certificate with serial number [serialNumber]. + * Note: Revoking a certificate adds it to the certificate revocation list (CRL) + * of its issuing CA + */ + const revokeCert = async ({ + serialNumber, + revocationReason, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TRevokeCertDTO) => { + const cert = await certificateDAL.findOne({ serialNumber }); + const ca = await certificateAuthorityDAL.findById(cert.caId); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + + if (cert.status === CertStatus.REVOKED) throw new Error("Certificate already revoked"); + + const revokedAt = new Date(); + await certificateDAL.update( + { + id: cert.id + }, + { + status: CertStatus.REVOKED, + revokedAt, + revocationReason: revocationReasonToCrlCode(revocationReason) + } + ); + + // rebuild CRL (TODO: move to interval-based cron job) + await rebuildCaCrl({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthorityCrlDAL, + certificateAuthoritySecretDAL, + projectDAL, + certificateDAL, + kmsService + }); + + return { revokedAt, cert, ca }; + }; + + /** + * Return certificate body and certificate chain for certificate with + * serial number [serialNumber] + */ + const getCertBody = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertBodyDTO) => { + const cert = await certificateDAL.findOne({ serialNumber }); + const ca = await certificateAuthorityDAL.findById(cert.caId); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + + const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const decryptedCert = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: certBody.encryptedCertificate + }); + + const certObj = new x509.X509Certificate(decryptedCert); + + const { caCert, caCertChain } = await getCaCertChain({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + certificate: certObj.toString("pem"), + certificateChain: `${caCert}\n${caCertChain}`.trim(), + serialNumber: certObj.serialNumber, + cert, + ca + }; + }; + + return { + getCert, + deleteCert, + revokeCert, + getCertBody + }; +}; diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts new file mode 100644 index 000000000..93f72afe3 --- /dev/null +++ b/backend/src/services/certificate/certificate-types.ts @@ -0,0 +1,43 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum CertStatus { + ACTIVE = "active", + REVOKED = "revoked" +} + +export enum CertKeyAlgorithm { + RSA_2048 = "RSA_2048", + RSA_4096 = "RSA_4096", + ECDSA_P256 = "EC_prime256v1", + ECDSA_P384 = "EC_secp384r1" +} + +export enum CrlReason { + UNSPECIFIED = "UNSPECIFIED", + KEY_COMPROMISE = "KEY_COMPROMISE", + CA_COMPROMISE = "CA_COMPROMISE", + AFFILIATION_CHANGED = "AFFILIATION_CHANGED", + SUPERSEDED = "SUPERSEDED", + CESSATION_OF_OPERATION = "CESSATION_OF_OPERATION", + CERTIFICATE_HOLD = "CERTIFICATE_HOLD", + // REMOVE_FROM_CRL = "REMOVE_FROM_CRL", + PRIVILEGE_WITHDRAWN = "PRIVILEGE_WITHDRAWN", + A_A_COMPROMISE = "A_A_COMPROMISE" +} + +export type TGetCertDTO = { + serialNumber: string; +} & Omit; + +export type TDeleteCertDTO = { + serialNumber: string; +} & Omit; + +export type TRevokeCertDTO = { + serialNumber: string; + revocationReason: CrlReason; +} & Omit; + +export type TGetCertBodyDTO = { + serialNumber: string; +} & Omit; diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 6351b4d82..70e3435a2 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -18,7 +18,7 @@ import { UpdateSecretCommand } from "@aws-sdk/client-secrets-manager"; import { Octokit } from "@octokit/rest"; -import AWS from "aws-sdk"; +import AWS, { AWSError } from "aws-sdk"; import { AxiosError } from "axios"; import sodium from "libsodium-wrappers"; import isEqual from "lodash.isequal"; @@ -452,7 +452,11 @@ const syncSecretsAWSParameterStore = async ({ accessId: string | null; accessToken: string; }) => { - if (!accessId) return; + let response: { isSynced: boolean; syncMessage: string } | null = null; + + if (!accessId) { + throw new Error("AWS access ID is required"); + } const config = new AWS.Config({ region: integration.region as string, @@ -557,6 +561,11 @@ const syncSecretsAWSParameterStore = async ({ `AWS Parameter Store Error [integration=${integration.id}]: double check AWS account permissions (refer to the Infisical docs)` ); } + + response = { + isSynced: false, + syncMessage: (err as AWSError)?.message || "Error syncing with AWS Parameter Store" + }; } } } @@ -585,6 +594,8 @@ const syncSecretsAWSParameterStore = async ({ } } } + + return response; }; /** @@ -603,7 +614,9 @@ const syncSecretsAWSSecretManager = async ({ }) => { const metadata = z.record(z.any()).parse(integration.metadata || {}); - if (!accessId) return; + if (!accessId) { + throw new Error("AWS access ID is required"); + } const secretsManager = new SecretsManagerClient({ region: integration.region as string, @@ -722,7 +735,7 @@ const syncSecretsAWSSecretManager = async ({ } } } catch (err) { - // case when AWS manager can't find the specified secret + // case 1: when AWS manager can't find the specified secret if (err instanceof ResourceNotFoundException && secretsManager) { await secretsManager.send( new CreateSecretCommand({ @@ -734,6 +747,9 @@ const syncSecretsAWSSecretManager = async ({ : [] }) ); + // case 2: something unexpected went wrong, so we'll throw the error to reflect the error in the integration sync status + } else { + throw err; } } }; @@ -753,14 +769,12 @@ const syncSecretsAWSSecretManager = async ({ const syncSecretsHeroku = async ({ createManySecretsRawFn, updateManySecretsRawFn, - integrationDAL, integration, secrets, accessToken }: { createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise>; - integrationDAL: Pick; integration: TIntegrations & { projectId: string; environment: { @@ -862,10 +876,6 @@ const syncSecretsHeroku = async ({ } } ); - - await integrationDAL.updateById(integration.id, { - lastUsed: new Date() - }); }; /** @@ -2656,7 +2666,9 @@ const syncSecretsHashiCorpVault = async ({ accessId: string | null; accessToken: string; }) => { - if (!accessId) return; + if (!accessId) { + throw new Error("Access ID is required"); + } interface LoginAppRoleRes { auth: { @@ -3486,6 +3498,8 @@ export const syncIntegrationSecrets = async ({ accessToken: string; appendices?: { prefix: string; suffix: string }; }) => { + let response: { isSynced: boolean; syncMessage: string } | null = null; + switch (integration.integration) { case Integrations.GCP_SECRET_MANAGER: await syncSecretsGCPSecretManager({ @@ -3502,7 +3516,7 @@ export const syncIntegrationSecrets = async ({ }); break; case Integrations.AWS_PARAMETER_STORE: - await syncSecretsAWSParameterStore({ + response = await syncSecretsAWSParameterStore({ integration, secrets, accessId, @@ -3521,7 +3535,6 @@ export const syncIntegrationSecrets = async ({ await syncSecretsHeroku({ createManySecretsRawFn, updateManySecretsRawFn, - integrationDAL, integration, secrets, accessToken @@ -3727,4 +3740,6 @@ export const syncIntegrationSecrets = async ({ default: throw new BadRequestError({ message: "Invalid integration" }); } + + return response; }; diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 97d2b29d6..63aba8939 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -29,19 +29,22 @@ export const kmsServiceFactory = ({ kmsDAL, kmsRootConfigDAL, keyStore }: TKmsSe let ROOT_ENCRYPTION_KEY = Buffer.alloc(0); // this is used symmetric encryption - const generateKmsKey = async ({ scopeId, scopeType, isReserved = true }: TGenerateKMSDTO) => { + const generateKmsKey = async ({ scopeId, scopeType, isReserved = true, tx }: TGenerateKMSDTO) => { const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); const kmsKeyMaterial = randomSecureBytes(32); const encryptedKeyMaterial = cipher.encrypt(kmsKeyMaterial, ROOT_ENCRYPTION_KEY); - const { encryptedKey, ...doc } = await kmsDAL.create({ - version: 1, - encryptedKey: encryptedKeyMaterial, - encryptionAlgorithm: SymmetricEncryption.AES_GCM_256, - isReserved, - orgId: scopeType === "org" ? scopeId : undefined, - projectId: scopeType === "project" ? scopeId : undefined - }); + const { encryptedKey, ...doc } = await kmsDAL.create( + { + version: 1, + encryptedKey: encryptedKeyMaterial, + encryptionAlgorithm: SymmetricEncryption.AES_GCM_256, + isReserved, + orgId: scopeType === "org" ? scopeId : undefined, + projectId: scopeType === "project" ? scopeId : undefined + }, + tx + ); return doc; }; diff --git a/backend/src/services/kms/kms-types.ts b/backend/src/services/kms/kms-types.ts index 96ad25f6e..63fdaf484 100644 --- a/backend/src/services/kms/kms-types.ts +++ b/backend/src/services/kms/kms-types.ts @@ -1,7 +1,10 @@ +import { Knex } from "knex"; + export type TGenerateKMSDTO = { scopeType: "project" | "org"; scopeId: string; isReserved?: boolean; + tx?: Knex; }; export type TEncryptWithKmsDTO = { diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index a6682465f..45e3f9ab5 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -36,6 +36,7 @@ import { TDeleteProjectMembershipsDTO, TGetProjectMembershipByUsernameDTO, TGetProjectMembershipDTO, + TLeaveProjectDTO, TUpdateProjectMembershipDTO } from "./project-membership-types"; import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal"; @@ -531,6 +532,53 @@ export const projectMembershipServiceFactory = ({ return memberships; }; + const leaveProject = async ({ projectId, actorId, actor }: TLeaveProjectDTO) => { + if (actor !== ActorType.USER) { + throw new BadRequestError({ message: "Only users can leave projects" }); + } + + const project = await projectDAL.findById(projectId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + if (project.version !== ProjectVersion.V2) { + throw new BadRequestError({ + message: "Please ask your project administrator to upgrade the project before leaving." + }); + } + + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + + if (!projectMembers?.length) { + throw new BadRequestError({ message: "Failed to find project members" }); + } + + if (projectMembers.length < 2) { + throw new BadRequestError({ message: "You cannot leave the project as you are the only member" }); + } + + const adminMembers = projectMembers.filter( + (member) => member.roles.map((r) => r.role).includes("admin") && member.userId !== actorId + ); + if (!adminMembers.length) { + throw new BadRequestError({ + message: "You cannot leave the project as you are the only admin. Promote another user to admin before leaving." + }); + } + + const deletedMembership = ( + await projectMembershipDAL.delete({ + projectId: project.id, + userId: actorId + }) + )?.[0]; + + if (!deletedMembership) { + throw new BadRequestError({ message: "Failed to leave project" }); + } + + return deletedMembership; + }; + return { getProjectMemberships, getProjectMembershipByUsername, @@ -538,6 +586,7 @@ export const projectMembershipServiceFactory = ({ addUsersToProjectNonE2EE, deleteProjectMemberships, deleteProjectMembership, // TODO: Remove this - addUsersToProject + addUsersToProject, + leaveProject }; }; diff --git a/backend/src/services/project-membership/project-membership-types.ts b/backend/src/services/project-membership/project-membership-types.ts index 1eab75265..dc3a62016 100644 --- a/backend/src/services/project-membership/project-membership-types.ts +++ b/backend/src/services/project-membership/project-membership-types.ts @@ -1,6 +1,7 @@ import { TProjectPermission } from "@app/lib/types"; export type TGetProjectMembershipDTO = TProjectPermission; +export type TLeaveProjectDTO = Omit; export enum ProjectUserMembershipTemporaryMode { Relative = "relative" } diff --git a/backend/src/services/project/project-fns.ts b/backend/src/services/project/project-fns.ts index 3ac75248d..78c7b442f 100644 --- a/backend/src/services/project/project-fns.ts +++ b/backend/src/services/project/project-fns.ts @@ -1,6 +1,9 @@ import crypto from "crypto"; import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; +import { BadRequestError } from "@app/lib/errors"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; import { AddUserToWsDTO } from "./project-types"; @@ -49,3 +52,44 @@ export const createProjectKey = ({ publicKey, privateKey, plainProjectKey }: TCr return { key: encryptedProjectKey, iv: encryptedProjectKeyIv }; }; + +export const getProjectKmsCertificateKeyId = async ({ + projectId, + projectDAL, + kmsService +}: { + projectId: string; + projectDAL: Pick; + kmsService: Pick; +}) => { + const keyId = await projectDAL.transaction(async (tx) => { + const project = await projectDAL.findOne({ id: projectId }, tx); + if (!project) { + throw new BadRequestError({ message: "Project not found" }); + } + + if (!project.kmsCertificateKeyId) { + // create default kms key for certificate service + const key = await kmsService.generateKmsKey({ + scopeId: projectId, + scopeType: "project", + isReserved: true, + tx + }); + + await projectDAL.updateById( + projectId, + { + kmsCertificateKeyId: key.id + }, + tx + ); + + return key.id; + } + + return project.kmsCertificateKeyId; + }); + + return keyId; +}; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index ac6cfbc00..1a8e65a41 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -16,6 +16,8 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TProjectPermission } from "@app/lib/types"; import { ActorType } from "../auth/auth-type"; +import { TCertificateDALFactory } from "../certificate/certificate-dal"; +import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal"; import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal"; @@ -36,6 +38,8 @@ import { TCreateProjectDTO, TDeleteProjectDTO, TGetProjectDTO, + TListProjectCasDTO, + TListProjectCertsDTO, TToggleProjectAutoCapitalizationDTO, TUpdateProjectDTO, TUpdateProjectNameDTO, @@ -50,6 +54,7 @@ export const DEFAULT_PROJECT_ENVS = [ ]; type TProjectServiceFactoryDep = { + // TODO: Pick projectDAL: TProjectDALFactory; projectQueue: TProjectQueueFactory; userDAL: TUserDALFactory; @@ -63,6 +68,8 @@ type TProjectServiceFactoryDep = { projectMembershipDAL: Pick; projectUserMembershipRoleDAL: Pick; secretBlindIndexDAL: Pick; + certificateAuthorityDAL: Pick; + certificateDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; licenseService: Pick; @@ -90,6 +97,8 @@ export const projectServiceFactory = ({ licenseService, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, + certificateAuthorityDAL, + certificateDAL, keyStore }: TProjectServiceFactoryDep) => { /* @@ -523,6 +532,83 @@ export const projectServiceFactory = ({ return project.upgradeStatus || null; }; + /** + * Return list of CAs for project + */ + const listProjectCas = async ({ + status, + actorId, + actorOrgId, + actorAuthMethod, + filter, + actor + }: TListProjectCasDTO) => { + const project = await projectDAL.findProjectByFilter(filter); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + const cas = await certificateAuthorityDAL.find({ + projectId: project.id, + ...(status && { status }) + }); + + return cas; + }; + + /** + * Return list of certificates for project + */ + const listProjectCertificates = async ({ + offset, + limit, + actorId, + actorOrgId, + actorAuthMethod, + filter, + actor + }: TListProjectCertsDTO) => { + const project = await projectDAL.findProjectByFilter(filter); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + + const cas = await certificateAuthorityDAL.find({ projectId: project.id }); + + const certificates = await certificateDAL.find( + { + $in: { + caId: cas.map((ca) => ca.id) + } + }, + { offset, limit, sort: [["updatedAt", "desc"]] } + ); + + const count = await certificateDAL.countCertificatesInProject(project.id); + + return { + certificates, + totalCount: count + }; + }; + return { createProject, deleteProject, @@ -533,6 +619,8 @@ export const projectServiceFactory = ({ toggleAutoCapitalization, updateName, upgradeProject, + listProjectCas, + listProjectCertificates, updateVersionLimit }; }; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index fbcfd2d9a..e2d145d3d 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -2,6 +2,7 @@ import { ProjectMembershipRole, TProjectKeys } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { CaStatus } from "../certificate-authority/certificate-authority-types"; export enum ProjectFilterType { ID = "id", @@ -80,3 +81,14 @@ export type AddUserToWsDTO = { userPublicKey: string; }[]; }; + +export type TListProjectCasDTO = { + status?: CaStatus; + filter: Filter; +} & Omit; + +export type TListProjectCertsDTO = { + filter: Filter; + offset: number; + limit: number; +} & Omit; diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts index ccbce0a52..012b0f130 100644 --- a/backend/src/services/secret-sharing/secret-sharing-service.ts +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -1,8 +1,13 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { TSecretSharingDALFactory } from "./secret-sharing-dal"; -import { TCreateSharedSecretDTO, TDeleteSharedSecretDTO, TSharedSecretPermission } from "./secret-sharing-types"; +import { + TCreatePublicSharedSecretDTO, + TCreateSharedSecretDTO, + TDeleteSharedSecretDTO, + TSharedSecretPermission +} from "./secret-sharing-types"; type TSecretSharingServiceFactoryDep = { permissionService: Pick; @@ -31,6 +36,24 @@ export const secretSharingServiceFactory = ({ } = createSharedSecretInput; const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); if (!permission) throw new UnauthorizedError({ name: "User not in org" }); + + if (new Date(expiresAt) < new Date()) { + throw new BadRequestError({ message: "Expiration date cannot be in the past" }); + } + + // Limit Expiry Time to 1 month + const expiryTime = new Date(expiresAt).getTime(); + const currentTime = new Date().getTime(); + const thirtyDays = 30 * 24 * 60 * 60 * 1000; + if (expiryTime - currentTime > thirtyDays) { + throw new BadRequestError({ message: "Expiration date cannot be more than 30 days" }); + } + + // Limit Input ciphertext length to 13000 (equivalent to 10,000 characters of Plaintext) + if (encryptedValue.length > 13000) { + throw new BadRequestError({ message: "Shared secret value too long" }); + } + const newSharedSecret = await secretSharingDAL.create({ encryptedValue, iv, @@ -44,6 +67,36 @@ export const secretSharingServiceFactory = ({ return { id: newSharedSecret.id }; }; + const createPublicSharedSecret = async (createSharedSecretInput: TCreatePublicSharedSecretDTO) => { + const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = createSharedSecretInput; + if (new Date(expiresAt) < new Date()) { + throw new BadRequestError({ message: "Expiration date cannot be in the past" }); + } + + // Limit Expiry Time to 1 month + const expiryTime = new Date(expiresAt).getTime(); + const currentTime = new Date().getTime(); + const thirtyDays = 30 * 24 * 60 * 60 * 1000; + if (expiryTime - currentTime > thirtyDays) { + throw new BadRequestError({ message: "Expiration date cannot exceed more than 30 days" }); + } + + // Limit Input ciphertext length to 13000 (equivalent to 10,000 characters of Plaintext) + if (encryptedValue.length > 13000) { + throw new BadRequestError({ message: "Shared secret value too long" }); + } + + const newSharedSecret = await secretSharingDAL.create({ + encryptedValue, + iv, + tag, + hashedHex, + expiresAt, + expiresAfterViews + }); + return { id: newSharedSecret.id }; + }; + const getSharedSecrets = async (getSharedSecretsInput: TSharedSecretPermission) => { const { actor, actorId, orgId, actorAuthMethod, actorOrgId } = getSharedSecretsInput; const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); @@ -54,6 +107,7 @@ export const secretSharingServiceFactory = ({ const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string) => { const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex }); + if (!sharedSecret) return; if (sharedSecret.expiresAt && sharedSecret.expiresAt < new Date()) { return; } @@ -77,6 +131,7 @@ export const secretSharingServiceFactory = ({ return { createSharedSecret, + createPublicSharedSecret, getSharedSecrets, deleteSharedSecretById, getActiveSharedSecretByIdAndHashedHex diff --git a/backend/src/services/secret-sharing/secret-sharing-types.ts b/backend/src/services/secret-sharing/secret-sharing-types.ts index 5f35b2848..769bb4479 100644 --- a/backend/src/services/secret-sharing/secret-sharing-types.ts +++ b/backend/src/services/secret-sharing/secret-sharing-types.ts @@ -8,14 +8,16 @@ export type TSharedSecretPermission = { orgId: string; }; -export type TCreateSharedSecretDTO = { +export type TCreatePublicSharedSecretDTO = { encryptedValue: string; iv: string; tag: string; hashedHex: string; expiresAt: Date; expiresAfterViews: number; -} & TSharedSecretPermission; +}; + +export type TCreateSharedSecretDTO = TSharedSecretPermission & TCreatePublicSharedSecretDTO; export type TDeleteSharedSecretDTO = { sharedSecretId: string; diff --git a/backend/src/services/secret-tag/secret-tag-service.ts b/backend/src/services/secret-tag/secret-tag-service.ts index ed8f5fec7..76b57dc90 100644 --- a/backend/src/services/secret-tag/secret-tag-service.ts +++ b/backend/src/services/secret-tag/secret-tag-service.ts @@ -2,10 +2,17 @@ import { ForbiddenError } from "@casl/ability"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { BadRequestError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TSecretTagDALFactory } from "./secret-tag-dal"; -import { TCreateTagDTO, TDeleteTagDTO, TListProjectTagsDTO } from "./secret-tag-types"; +import { + TCreateTagDTO, + TDeleteTagDTO, + TGetTagByIdDTO, + TGetTagBySlugDTO, + TListProjectTagsDTO, + TUpdateTagDTO +} from "./secret-tag-types"; type TSecretTagServiceFactoryDep = { secretTagDAL: TSecretTagDALFactory; @@ -42,11 +49,34 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe name, slug, color, - createdBy: actorId + createdBy: actorId, + createdByActorType: actor }); return newTag; }; + const updateTag = async ({ actorId, actor, actorOrgId, actorAuthMethod, id, name, color, slug }: TUpdateTagDTO) => { + const tag = await secretTagDAL.findById(id); + if (!tag) throw new BadRequestError({ message: "Tag doesn't exist" }); + + if (slug) { + const existingTag = await secretTagDAL.findOne({ slug, projectId: tag.projectId }); + if (existingTag && existingTag.id !== tag.id) throw new BadRequestError({ message: "Tag already exist" }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + tag.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Tags); + + const updatedTag = await secretTagDAL.updateById(tag.id, { name, color, slug }); + return updatedTag; + }; + const deleteTag = async ({ actorId, actor, actorOrgId, actorAuthMethod, id }: TDeleteTagDTO) => { const tag = await secretTagDAL.findById(id); if (!tag) throw new BadRequestError({ message: "Tag doesn't exist" }); @@ -64,6 +94,38 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe return deletedTag; }; + const getTagById = async ({ actorId, actor, actorOrgId, actorAuthMethod, id }: TGetTagByIdDTO) => { + const tag = await secretTagDAL.findById(id); + if (!tag) throw new NotFoundError({ message: "Tag doesn't exist" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + tag.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); + + return tag; + }; + + const getTagBySlug = async ({ actorId, actor, actorOrgId, actorAuthMethod, slug, projectId }: TGetTagBySlugDTO) => { + const tag = await secretTagDAL.findOne({ projectId, slug }); + if (!tag) throw new NotFoundError({ message: "Tag doesn't exist" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + tag.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); + + return tag; + }; + const getProjectTags = async ({ actor, actorId, actorOrgId, actorAuthMethod, projectId }: TListProjectTagsDTO) => { const { permission } = await permissionService.getProjectPermission( actor, @@ -78,5 +140,5 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe return tags; }; - return { createTag, deleteTag, getProjectTags }; + return { createTag, deleteTag, getProjectTags, getTagById, getTagBySlug, updateTag }; }; diff --git a/backend/src/services/secret-tag/secret-tag-types.ts b/backend/src/services/secret-tag/secret-tag-types.ts index d2f027153..f2ace0901 100644 --- a/backend/src/services/secret-tag/secret-tag-types.ts +++ b/backend/src/services/secret-tag/secret-tag-types.ts @@ -6,6 +6,21 @@ export type TCreateTagDTO = { slug: string; } & TProjectPermission; +export type TUpdateTagDTO = { + id: string; + name?: string; + slug?: string; + color?: string; +} & Omit; + +export type TGetTagByIdDTO = { + id: string; +} & Omit; + +export type TGetTagBySlugDTO = { + slug: string; +} & TProjectPermission; + export type TDeleteTagDTO = { id: string; } & Omit; diff --git a/backend/src/services/secret/secret-dal.ts b/backend/src/services/secret/secret-dal.ts index 1a2e414dd..790b403dd 100644 --- a/backend/src/services/secret/secret-dal.ts +++ b/backend/src/services/secret/secret-dal.ts @@ -311,6 +311,40 @@ export const secretDALFactory = (db: TDbClient) => { } }; + const findOneWithTags = async (filter: Partial, tx?: Knex) => { + try { + const rawDocs = await (tx || db)(TableName.Secret) + .where(filter) + .leftJoin(TableName.JnSecretTag, `${TableName.Secret}.id`, `${TableName.JnSecretTag}.${TableName.Secret}Id`) + .leftJoin(TableName.SecretTag, `${TableName.JnSecretTag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id`) + .select(selectAllTableCols(TableName.Secret)) + .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) + .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) + .select(db.ref("name").withSchema(TableName.SecretTag).as("tagName")); + const docs = sqlNestRelationships({ + data: rawDocs, + key: "id", + parentMapper: (el) => ({ _id: el.id, ...SecretsSchema.parse(el) }), + childrenMapper: [ + { + key: "tagId", + label: "tags" as const, + mapper: ({ tagId: id, tagColor: color, tagSlug: slug, tagName: name }) => ({ + id, + color, + slug, + name + }) + } + ] + }); + return docs?.[0]; + } catch (error) { + throw new DatabaseError({ error, name: "FindOneWIthTags" }); + } + }; + return { ...secretOrm, update, @@ -318,6 +352,7 @@ export const secretDALFactory = (db: TDbClient) => { deleteMany, bulkUpdateNoVersionIncrement, getSecretTags, + findOneWithTags, findByFolderId, findByFolderIds, findByBlindIndexes, diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index 6758f4815..aa112e6b0 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -356,7 +356,17 @@ export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderD }; export const decryptSecretRaw = ( - secret: TSecrets & { workspace: string; environment: string; secretPath: string }, + secret: TSecrets & { + workspace: string; + environment: string; + secretPath: string; + tags?: { + id: string; + slug: string; + color?: string | null; + name: string; + }[]; + }, key: string ) => { const secretKey = decryptSymmetric128BitHexKeyUTF8({ @@ -396,6 +406,7 @@ export const decryptSecretRaw = ( _id: secret.id, id: secret.id, user: secret.userId, + tags: secret.tags, skipMultilineEncoding: secret.skipMultilineEncoding }; }; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 42e13b445..ac27d912f 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -421,94 +421,88 @@ export const secretQueueFactory = ({ const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) { - logger.error(new Error("Secret path not found")); - return; + throw new Error("Secret path not found"); } - // start syncing all linked imports also - if (depth < MAX_SYNC_SECRET_DEPTH) { - // find all imports made with the given environment and secret path - const linkSourceDto = { - projectId, - importEnv: folder.environment.id, - importPath: secretPath, - isReplication: false - }; - const imports = await secretImportDAL.find(linkSourceDto); + // find all imports made with the given environment and secret path + const linkSourceDto = { + projectId, + importEnv: folder.environment.id, + importPath: secretPath, + isReplication: false + }; + const imports = await secretImportDAL.find(linkSourceDto); - if (imports.length) { - // keep calling sync secret for all the imports made - const importedFolderIds = unique(imports, (i) => i.folderId).map(({ folderId }) => folderId); - const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds); - const foldersGroupedById = groupBy(importedFolders.filter(Boolean), (i) => i?.id as string); - logger.info( - `getIntegrationSecrets: Syncing secret due to link change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` - ); - await Promise.all( - imports - .filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0]?.path as string)) - // filter out already synced ones - .filter( - ({ folderId }) => - !deDupeQueue[ - uniqueSecretQueueKey( - foldersGroupedById[folderId][0]?.environmentSlug as string, - foldersGroupedById[folderId][0]?.path as string - ) - ] - ) - .map(({ folderId }) => - syncSecrets({ - projectId, - secretPath: foldersGroupedById[folderId][0]?.path as string, - environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string, - _deDupeQueue: deDupeQueue, - _depth: depth + 1, - excludeReplication: true - }) - ) - ); - } + if (imports.length) { + // keep calling sync secret for all the imports made + const importedFolderIds = unique(imports, (i) => i.folderId).map(({ folderId }) => folderId); + const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds); + const foldersGroupedById = groupBy(importedFolders.filter(Boolean), (i) => i?.id as string); + logger.info( + `getIntegrationSecrets: Syncing secret due to link change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` + ); + await Promise.all( + imports + .filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0]?.path as string)) + // filter out already synced ones + .filter( + ({ folderId }) => + !deDupeQueue[ + uniqueSecretQueueKey( + foldersGroupedById[folderId][0]?.environmentSlug as string, + foldersGroupedById[folderId][0]?.path as string + ) + ] + ) + .map(({ folderId }) => + syncSecrets({ + projectId, + secretPath: foldersGroupedById[folderId][0]?.path as string, + environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string, + _deDupeQueue: deDupeQueue, + _depth: depth + 1, + excludeReplication: true + }) + ) + ); + } - const secretReferences = await secretDAL.findReferencedSecretReferences( - projectId, - folder.environment.slug, - secretPath + const secretReferences = await secretDAL.findReferencedSecretReferences( + projectId, + folder.environment.slug, + secretPath + ); + if (secretReferences.length) { + const referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId); + const referencedFolders = await folderDAL.findSecretPathByFolderIds(projectId, referencedFolderIds); + const referencedFoldersGroupedById = groupBy(referencedFolders.filter(Boolean), (i) => i?.id as string); + logger.info( + `getIntegrationSecrets: Syncing secret due to reference change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` + ); + await Promise.all( + secretReferences + .filter(({ folderId }) => Boolean(referencedFoldersGroupedById[folderId][0]?.path)) + // filter out already synced ones + .filter( + ({ folderId }) => + !deDupeQueue[ + uniqueSecretQueueKey( + referencedFoldersGroupedById[folderId][0]?.environmentSlug as string, + referencedFoldersGroupedById[folderId][0]?.path as string + ) + ] + ) + .map(({ folderId }) => + syncSecrets({ + projectId, + secretPath: referencedFoldersGroupedById[folderId][0]?.path as string, + environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string, + _deDupeQueue: deDupeQueue, + _depth: depth + 1, + excludeReplication: true + }) + ) ); - if (secretReferences.length) { - const referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId); - const referencedFolders = await folderDAL.findSecretPathByFolderIds(projectId, referencedFolderIds); - const referencedFoldersGroupedById = groupBy(referencedFolders.filter(Boolean), (i) => i?.id as string); - logger.info( - `getIntegrationSecrets: Syncing secret due to reference change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` - ); - await Promise.all( - secretReferences - .filter(({ folderId }) => Boolean(referencedFoldersGroupedById[folderId][0]?.path)) - // filter out already synced ones - .filter( - ({ folderId }) => - !deDupeQueue[ - uniqueSecretQueueKey( - referencedFoldersGroupedById[folderId][0]?.environmentSlug as string, - referencedFoldersGroupedById[folderId][0]?.path as string - ) - ] - ) - .map(({ folderId }) => - syncSecrets({ - projectId, - secretPath: referencedFoldersGroupedById[folderId][0]?.path as string, - environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string, - _deDupeQueue: deDupeQueue, - _depth: depth + 1, - excludeReplication: true - }) - ) - ); - } - } else { - logger.info(`getIntegrationSecrets: Secret depth exceeded for [projectId=${projectId}] [folderId=${folder.id}]`); } const integrations = await integrationDAL.findByProjectIdV2(projectId, environment); // note: returns array of integrations + integration auths in this environment @@ -550,7 +544,7 @@ export const secretQueueFactory = ({ } try { - await syncIntegrationSecrets({ + const response = await syncIntegrationSecrets({ createManySecretsRawFn, updateManySecretsRawFn, integrationDAL, @@ -568,13 +562,15 @@ export const secretQueueFactory = ({ await integrationDAL.updateById(integration.id, { lastSyncJobId: job.id, lastUsed: new Date(), - syncMessage: "", - isSynced: true + syncMessage: response?.syncMessage ?? "", + isSynced: response?.isSynced ?? true }); - } catch (err: unknown) { + } catch (err) { logger.info("Secret integration sync error: %o", err); + const message = - err instanceof AxiosError ? JSON.stringify((err as AxiosError)?.response?.data) : (err as Error)?.message; + (err instanceof AxiosError ? JSON.stringify(err?.response?.data) : (err as Error)?.message) || + "Unknown error occurred."; await integrationDAL.updateById(integration.id, { lastSyncJobId: job.id, diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index d6682a253..a5a469a8f 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -608,7 +608,7 @@ export const secretServiceFactory = ({ } const secret = await (version === undefined - ? secretDAL.findOne({ + ? secretDAL.findOneWithTags({ folderId, type: secretType, userId: secretType === SecretType.Personal ? actorId : null, @@ -1120,7 +1120,8 @@ export const secretServiceFactory = ({ secretPath, secretValue, secretComment, - skipMultilineEncoding + skipMultilineEncoding, + tagIds }: TCreateSecretRawDTO) => { const botKey = await projectBotService.getBotKey(projectId); if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); @@ -1148,7 +1149,8 @@ export const secretServiceFactory = ({ secretCommentCiphertext: secretCommentEncrypted.ciphertext, secretCommentIV: secretCommentEncrypted.iv, secretCommentTag: secretCommentEncrypted.tag, - skipMultilineEncoding + skipMultilineEncoding, + tags: tagIds }); return decryptSecretRaw(secret, botKey); @@ -1165,7 +1167,8 @@ export const secretServiceFactory = ({ type, secretPath, secretValue, - skipMultilineEncoding + skipMultilineEncoding, + tagIds }: TUpdateSecretRawDTO) => { const botKey = await projectBotService.getBotKey(projectId); if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); @@ -1185,7 +1188,8 @@ export const secretServiceFactory = ({ secretValueCiphertext: secretValueEncrypted.ciphertext, secretValueIV: secretValueEncrypted.iv, secretValueTag: secretValueEncrypted.tag, - skipMultilineEncoding + skipMultilineEncoding, + tags: tagIds }); await snapshotService.performSnapshot(secret.folderId); diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 18a0077fe..1aac324c5 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -164,6 +164,7 @@ export type TCreateSecretRawDTO = TProjectPermission & { secretName: string; secretValue: string; type: SecretType; + tagIds?: string[]; secretComment?: string; skipMultilineEncoding?: boolean; }; @@ -174,6 +175,7 @@ export type TUpdateSecretRawDTO = TProjectPermission & { secretName: string; secretValue?: string; type: SecretType; + tagIds?: string[]; skipMultilineEncoding?: boolean; secretReminderRepeatDays?: number | null; secretReminderNote?: string | null; diff --git a/docs/api-reference/endpoints/certificate-authorities/cert.mdx b/docs/api-reference/endpoints/certificate-authorities/cert.mdx new file mode 100644 index 000000000..3706e0b11 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve certificate / chain" +openapi: "GET /api/v1/pki/ca/{caId}/certificate" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/create.mdx b/docs/api-reference/endpoints/certificate-authorities/create.mdx new file mode 100644 index 000000000..35e758e4b --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/pki/ca" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/crl.mdx b/docs/api-reference/endpoints/certificate-authorities/crl.mdx new file mode 100644 index 000000000..a7b7755de --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/crl.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve CRL" +openapi: "GET /api/v1/pki/ca/{caId}/crl" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/csr.mdx b/docs/api-reference/endpoints/certificate-authorities/csr.mdx new file mode 100644 index 000000000..2477a629e --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/csr.mdx @@ -0,0 +1,4 @@ +--- +title: "Get CSR" +openapi: "GET /api/v1/pki/ca/{caId}/csr" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/delete.mdx b/docs/api-reference/endpoints/certificate-authorities/delete.mdx new file mode 100644 index 000000000..f79b8f458 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/pki/ca/{caId}" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/import-cert.mdx b/docs/api-reference/endpoints/certificate-authorities/import-cert.mdx new file mode 100644 index 000000000..7f0e40f95 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/import-cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Import certificate" +openapi: "POST /api/v1/pki/ca/{caId}/import-certificate" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/issue-cert.mdx b/docs/api-reference/endpoints/certificate-authorities/issue-cert.mdx new file mode 100644 index 000000000..045cada58 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/issue-cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Issue certificate" +openapi: "POST /api/v1/pki/ca/{caId}/issue-certificate" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/read.mdx b/docs/api-reference/endpoints/certificate-authorities/read.mdx new file mode 100644 index 000000000..54dc26392 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/pki/ca/{caId}" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/sign-intermediate.mdx b/docs/api-reference/endpoints/certificate-authorities/sign-intermediate.mdx new file mode 100644 index 000000000..310bbea26 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/sign-intermediate.mdx @@ -0,0 +1,4 @@ +--- +title: "Sign intermediate certificate" +openapi: "POST /api/v1/pki/ca/{caId}/sign-intermediate" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/update.mdx b/docs/api-reference/endpoints/certificate-authorities/update.mdx new file mode 100644 index 000000000..d18a728bf --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/pki/ca/{caId}" +--- diff --git a/docs/api-reference/endpoints/certificates/cert-body.mdx b/docs/api-reference/endpoints/certificates/cert-body.mdx new file mode 100644 index 000000000..e4c3b0123 --- /dev/null +++ b/docs/api-reference/endpoints/certificates/cert-body.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Certificate Body / Chain" +openapi: "GET /api/v1/pki/certificates/{serialNumber}/certificate" +--- diff --git a/docs/api-reference/endpoints/certificates/delete.mdx b/docs/api-reference/endpoints/certificates/delete.mdx new file mode 100644 index 000000000..27042af42 --- /dev/null +++ b/docs/api-reference/endpoints/certificates/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/pki/certificates/{serialNumber}" +--- diff --git a/docs/api-reference/endpoints/certificates/read.mdx b/docs/api-reference/endpoints/certificates/read.mdx new file mode 100644 index 000000000..ce6463dde --- /dev/null +++ b/docs/api-reference/endpoints/certificates/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/pki/certificates/{serialNumber}" +--- diff --git a/docs/api-reference/endpoints/certificates/revoke.mdx b/docs/api-reference/endpoints/certificates/revoke.mdx new file mode 100644 index 000000000..e4da73a19 --- /dev/null +++ b/docs/api-reference/endpoints/certificates/revoke.mdx @@ -0,0 +1,4 @@ +--- +title: "Revoke" +openapi: "POST /api/v1/pki/certificates/{serialNumber}/revoke" +--- diff --git a/docs/api-reference/endpoints/secret-tags/get-by-id.mdx b/docs/api-reference/endpoints/secret-tags/get-by-id.mdx new file mode 100644 index 000000000..de02fe133 --- /dev/null +++ b/docs/api-reference/endpoints/secret-tags/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get By ID" +openapi: "GET /api/v1/workspace/{projectId}/tags/{tagId}" +--- diff --git a/docs/api-reference/endpoints/secret-tags/get-by-slug.mdx b/docs/api-reference/endpoints/secret-tags/get-by-slug.mdx new file mode 100644 index 000000000..91eab730f --- /dev/null +++ b/docs/api-reference/endpoints/secret-tags/get-by-slug.mdx @@ -0,0 +1,4 @@ +--- +title: "Get By Slug" +openapi: "GET /api/v1/workspace/{projectId}/tags/slug/{tagSlug}" +--- diff --git a/docs/api-reference/endpoints/secret-tags/update.mdx b/docs/api-reference/endpoints/secret-tags/update.mdx new file mode 100644 index 000000000..b9c290db8 --- /dev/null +++ b/docs/api-reference/endpoints/secret-tags/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/workspace/{projectId}/tags/{tagId}" +--- diff --git a/docs/changelog/overview.mdx b/docs/changelog/overview.mdx index d73c0bb14..6c2bf6a72 100644 --- a/docs/changelog/overview.mdx +++ b/docs/changelog/overview.mdx @@ -4,6 +4,25 @@ title: "Changelog" The changelog below reflects new product developments and updates on a monthly basis. +## May 2024 +- Released [AWS](https://infisical.com/docs/documentation/platform/identities/aws-auth), [GCP](https://infisical.com/docs/documentation/platform/identities/gcp-auth), [Azure](https://infisical.com/docs/documentation/platform/identities/azure-auth), and [Kubernetes](https://infisical.com/docs/documentation/platform/identities/kubernetes-auth) Native Auth Methods. +- Added [Secret Sharing](https://infisical.com/docs/documentation/platform/secret-sharing) functionality for sharing sensitive data through encrypted links – within and outside of an organization. +- Updated [Secret Referencing](https://infisical.com/docs/documentation/platform/secret-reference) to be supported in all Infisical clients. Infisical UI is now able to provide automatic reference suggestions when typing. +- Released new [Infisical Jenkins Plugin](https://infisical.com/docs/integrations/cicd/jenkins). +- Added statuses and manual sync option to integrations in the Dashboard UI. +- Released universal [Audit Log Streaming](https://infisical.com/docs/documentation/platform/audit-log-streams). +- Added [Dynamic Secret template for AWS IAM](https://infisical.com/docs/documentation/platform/dynamic-secrets/aws-iam). +- Added support for syncing tags and custom KMS keys to [AWS Secrets Manager](https://infisical.com/docs/integrations/cloud/aws-secret-manager) and [Parameter Store](https://infisical.com/docs/integrations/cloud/aws-parameter-store) Integrations. +- Officially released Infisical on [AWS Marketplace](https://infisical.com/blog/infisical-launches-on-aws-marketplace). + +## April 2024 +- Added [Access Requests](https://infisical.com/docs/documentation/platform/access-controls/access-requests) as part of self-serve secrets management workflows. +- Added [Temporary Access Provisioning](https://infisical.com/docs/documentation/platform/access-controls/temporary-access) for roles and additional privileges. + +## May 2024 +- Released support for [Dynamic Secrets](https://infisical.com/docs/documentation/platform/dynamic-secrets/overview). +- Released the concept of [Additional Privileges](https://infisical.com/docs/documentation/platform/access-controls/additional-privileges) on top of user/machine roles. + ## Feb 2024 - Added org-scoped authentication enforcement for SAML - Added support for [SCIM](https://infisical.com/docs/documentation/platform/scim/overview) along with instructions for setting it up with [Okta](https://infisical.com/docs/documentation/platform/scim/okta), [Azure](https://infisical.com/docs/documentation/platform/scim/azure), and [JumpCloud](https://infisical.com/docs/documentation/platform/scim/jumpcloud). diff --git a/docs/documentation/guides/local-development.mdx b/docs/documentation/guides/local-development.mdx index c2651cb58..9ffc0fca0 100644 --- a/docs/documentation/guides/local-development.mdx +++ b/docs/documentation/guides/local-development.mdx @@ -13,11 +13,11 @@ There is a number of issues that arise with secret management in local developme ## Solution -One of the main benefits of Infisical is the facilitation of secret management workflows in local development use cases. In particular, Infisical heavily follows the "Security Shift Left" principle to enable developers to effotlessly follow secure practices when coding. +One of the main benefits of Infisical is the facilitation of secret management workflows in local development use cases. In particular, Infisical heavily follows the "Security Shift Left" principle to enable developers to effortlessly follow secure practices when coding. ### CLI -[Infisical CLI](/cli/overview) is the most frequently used Infisical tool for secret management in local development environments. It makes it easy to inject secrets right into the local application environments based on the permissions given to corresponsing developers. +[Infisical CLI](/cli/overview) is the most frequently used Infisical tool for secret management in local development environments. It makes it easy to inject secrets right into the local application environments based on the permissions given to corresponding developers. ### Dashboard @@ -31,4 +31,4 @@ By default, all the secrets in the Infisical environments are shared among proje ### Secret Scanning -In addition, Infisical also provides a set of tools to automatically prevent secret leaks to git history. This functionlality can be set up on the level of [Infisical CLI using pre-commit hooks](/cli/scanning-overview#automatically-scan-changes-before-you-commit) or through a direct integration with platforms like GitHub. \ No newline at end of file +In addition, Infisical also provides a set of tools to automatically prevent secret leaks to git history. This functionality can be set up on the level of [Infisical CLI using pre-commit hooks](/cli/scanning-overview#automatically-scan-changes-before-you-commit) or through a direct integration with platforms like GitHub. \ No newline at end of file diff --git a/docs/documentation/platform/pki/certificates.mdx b/docs/documentation/platform/pki/certificates.mdx new file mode 100644 index 000000000..fe5546681 --- /dev/null +++ b/docs/documentation/platform/pki/certificates.mdx @@ -0,0 +1,211 @@ +--- +title: "Certificates" +sidebarTitle: "Certificates" +description: "Learn how to issue X.509 certificates with Infisical." +--- + +## Concept + +Assuming that you've created a Private CA hierarchy with a root CA and an intermediate CA, you can now issue/revoke X.509 certificates using the intermediate CA. + +
+ +```mermaid +graph TD + A[Root CA] + A --> B[Intermediate CA] + A --> C[Intermediate CA] + B --> D[Leaf Certificate] + C --> E[Leaf Certificate] +``` + +
+ +## Workflow + +The typical workflow for managing certificates consists of the following steps: + +1. Issuing a certificate under an intermediate CA with details like name and validity period. +2. Managing certificate lifecycle events such as certificate renewal and revocation. As part of the certificate revocation flow, + you can also query for a Certificate Revocation List [CRL](https://en.wikipedia.org/wiki/Certificate_revocation_list), a time-stamped, signed + data structure issued by a CA containing a list of revoked certificates to check if a certificate has been revoked. + + + Note that this workflow can be executed via the Infisical UI or manually such + as via API. + + +## Guide to Issuing Certificates + +In the following steps, we explore how to issue a X.509 certificate under a CA. + + + + + + + To create a certificate, head to your Project > Internal PKI > Certificates and press **Create Certificate**. + + ![pki issue certificate](/images/platform/pki/cert-issue.png) + + Here, set the **CA** to the CA you want to issue the certificate under and fill out details for the certificate. + + ![pki issue certificate modal](/images/platform/pki/cert-issue-modal.png) + + Here's some guidance on each field: + + - Issuing CA: The CA under which to issue the certificate. + - Friendly Name: A friendly name for the certificate; this is only for display and defaults to the common name of the certificate if left empty. + - Common Name (CN): The (common) name of the certificate. + - TTL: The lifetime of the certificate in seconds. + - Valid Until: The date until which the certificate is valid in the date time string format specified [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format). For example, the following formats would be valid: `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-MM-DDTHH:mm:ss.sssZ`. + + + + Once you have created the certificate from step 1, you'll be presented with the certificate details including the **Certificate Body**, **Certificate Chain**, and **Private Key**. + + ![pki certificate body](/images/platform/pki/cert-body.png) + + + Make sure to download and store the **Private Key** in a secure location as it will only be displayed once at the time of certificate issuance. + The **Certificate Body** and **Certificate Chain** will remain accessible and can be copied at any time. + + + + + + To create a certificate, make an API request to the [Create Certificate](/api-reference/endpoints/certificate-authorities/sign-intermediate) API endpoint, + specifying the issuing CA. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca//issue-certificate' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "commonName": "My Certificate", + }' + ``` + + ### Sample response + + ```bash Response + { + certificate: "...", + certificateChain: "...", + issuingCaCertificate: "...", + privateKey: "...", + serialNumber: "..." + } + ``` + + + Make sure to store the `privateKey` as it is only returned once here at the time of certificate issuance. The `certificate` and `certificateChain` will remain accessible and can be retrieved at any time. + + + + + +## Guide to Revoking Certificates + +In the following steps, we explore how to revoke a X.509 certificate under a CA and obtain a Certificate Revocation List (CRL) for a CA. + + + + + + Assuming that you've issued a certificate under a CA, you can revoke it by + selecting the **Revoke Certificate** option for it and specifying the reason + for revocation. + + ![pki revoke certificate](/images/platform/pki/cert-revoke.png) + + ![pki revoke certificate modal](/images/platform/pki/cert-revoke-modal.png) + + + + In order to check the revocation status of a certificate, you can check it + against the CRL of a CA by selecting the **View CRL** option under the + issuing CA and downloading the CRL file. + + ![pki view crl](/images/platform/pki/ca-crl.png) + + ![pki download crl](/images/platform/pki/ca-crl-modal.png) + + To verify a certificate against the + downloaded CRL with OpenSSL, you can use the following command: + +```bash +openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem +``` + + + + + + + + Assuming that you've issued a certificate under a CA, you can revoke it by making an API request to the [Revoke Certificate](/api-reference/endpoints/certificate-authorities/revoke) API endpoint, + specifying the serial number of the certificate and the reason for revocation. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/certificates//revoke' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "revocationReason": "UNSPECIFIED" + }' + ``` + + ### Sample response + + ```bash Response + { + message: "Successfully revoked certificate", + serialNumber: "...", + revokedAt: "..." + } + ``` + + + In order to check the revocation status of a certificate, you can check it against the CRL of the issuing CA. + To obtain the CRL of the CA, make an API request to the [Get CRL](/api-reference/endpoints/certificate-authorities/crl) API endpoint. + + ### Sample request + + ```bash Request + curl --location --request GET 'https://app.infisical.com/api/v1/pki/ca//crl' \ + --header 'Authorization: Bearer ' + ``` + + ### Sample response + + ```bash Response + { + crl: "..." + } + ``` + + To verify a certificate against the CRL with OpenSSL, you can use the following command: + + ```bash + openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem + ``` + + + + + + +## FAQ + + + + To renew a certificate, you have to issue a new certificate from the same CA + with the same common name as the old certificate. The original certificate + will continue to be valid through its original TTL unless explicitly + revoked. + + diff --git a/docs/documentation/platform/pki/overview.mdx b/docs/documentation/platform/pki/overview.mdx new file mode 100644 index 000000000..259f15a5d --- /dev/null +++ b/docs/documentation/platform/pki/overview.mdx @@ -0,0 +1,12 @@ +--- +title: "Internal PKI" +sidebarTitle: "Overview" +description: "Learn how to create a Private CA hierarchy and issue X.509 certificates." +--- + +Infisical can be used to create a Private Certificate Authority (CA) hierarchy and issue X.509 certificates for internal use. This allows you to manage your own PKI infrastructure and issue digital certificates for services, applications, and devices. + +Infisical's internal PKI offering is split into two modules: + +- [Private CA](/documentation/platform/pki/private-ca): Infisical lets you create private CAs, including root and intermediary CAs. +- [Certificates](/documentation/platform/pki/certificates): Infisical allows you to issue X.509 certificates using the private CAs you create. diff --git a/docs/documentation/platform/pki/private-ca.mdx b/docs/documentation/platform/pki/private-ca.mdx new file mode 100644 index 000000000..0ebb31e2c --- /dev/null +++ b/docs/documentation/platform/pki/private-ca.mdx @@ -0,0 +1,250 @@ +--- +title: "Private CA" +sidebarTitle: "Private CA" +description: "Learn how to create a Private CA hierarchy with Infisical." +--- + +## Concept + +The first step to creating your Internal PKI is to create a Private Certificate Authority (CA) hierarchy that is a structure of entities +used to issue digital certificates for services, applications, and devices. + +
+ +```mermaid +graph TD + A[Root CA] + A --> B[Intermediate CA] + A --> C[Intermediate CA] +``` + +
+ +## Workflow + +A typical workflow for setting up a Private CA hierarchy consists of the following steps: + +1. Configuring a root CA with details like name, validity period, and path length. +2. Configuring and chaining intermediate CA(s) with details like name, validity period, path length, and imported certificate. +3. Managing the CA lifecycle events such as CA succession. + + + Note that this workflow can be executed via the Infisical UI or manually such + as via API. If manually executing the workflow, you may have to create a + Certificate Signing Request (CSR) for the intermediate CA, create an + intermediate certificate using the root CA private key and CSR, and import the + intermediate certificate back to the intermediate CA as part of Step 2. + + +## Guide + +In the following steps, we explore how to create a simple Private CA hierarchy +consisting of a root CA and an intermediate CA. + + + + + + To create a root CA, head to your Project > Internal PKI > Certificate Authorities and press **Create CA**. + + ![pki create ca](/images/platform/pki/ca-create.png) + + Here, set the **CA Type** to **Root** and fill out details for the root CA. + + ![pki create root ca](/images/platform/pki/ca-create-root.png) + + Here's some guidance on each field: + + - Valid Until: The date until which the CA is valid in the date time string format specified [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format). For example, the following formats would be valid: `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-MM-DDTHH:mm:ss.sssZ`. + - Path Length: The maximum number of intermediate CAs that can be chained to this CA. A path of `-1` implies no limit; a path of `0` implies no intermediate CAs can be chained. + - Key Algorithm: The type of public key algorithm and size, in bits, of the key pair that the CA creates when it issues a certificate. Supported key algorithms are `RSA 2048`, `RSA 4096`, `ECDSA P-256`, and `ECDSA P-384` with the default being `RSA 2048`. + - Friendly Name: A friendly name for the CA; this is only for display and defaults to the subject of the CA if left empty. + - Organization (O): The organization name. + - Country (C): The country code. + - State or Province Name: The state or province. + - Locality Name: The city or locality. + - Common Name: The name of the CA. + + + The Organization, Country, State or Province Name, Locality Name, and Common Name make up the **Distinguished Name (DN)** or **subject** of the CA. + At least one of these fields must be filled out. + + + + 1.1. To create an intermediate CA, press **Create CA** again but this time specifying the **CA Type** to be **Intermediate**. Fill out the details for the intermediate CA. + + ![pki create intermediate ca](/images/platform/pki/ca-create-intermediate.png) + + 1.2. Next, press the **Install Certificate** option on the intermediate CA from step 1.1. + + ![pki install cert opt](/images/platform/pki/ca-install-intermediate-opt.png) + + Here, set the **Parent CA** to the root CA created in step 1 and configure the intended **Valid Until** and **Path Length** fields on the intermediate CA; feel free to use the prefilled values. + + ![pki install cert](/images/platform/pki/ca-install-intermediate.png) + + Here's some guidance on each field: + + - Parent CA: The parent CA to which this intermediate CA will be chained. In this case, it should be the root CA created in step 1. + - Valid Until: The date until which the CA is valid in the date time string format specified [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format). The date must be within the validity period of the parent CA. + - Path Length: The maximum number of intermediate CAs that can be chained to this CA. The path length must be less than the path length of the parent CA. + + Finally, press **Install** to chain the intermediate CA to the root CA; this creates a Certificate Signing Request (CSR) for the intermediate CA, creates an intermediate certificate using the root CA private key and CSR, and imports the signed certificate back to the intermediate CA. + + ![pki cas](/images/platform/pki/cas.png) + + Great! You've successfully created a Private CA hierarchy with a root CA and an intermediate CA. + Now check out the [Certificates](/documentation/platform/pki/certificates) page to learn more about how to issue X.509 certificates using the intermediate CA. + + + + + + + + To create a root CA, make an API request to the [Create CA](/api-reference/endpoints/certificate-authorities/create) API endpoint, specifying the `type` as `root`. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "projectSlug": "", + "type": "root", + "commonName": "My Root CA" + }' + ``` + + ### Sample response + + ```bash Response + { + ca: { + id: "", + type: "root", + commonName: "My Root CA", + ... + } + } + ``` + + By default, Infisical creates a root CA with the `RSA_2048` key algorithm, validity period of 10 years, with no restrictions on path length; + you may override these defaults by specifying your own options when making the API request. + + + + 2.1. To create an intermediate CA, make an API request to the [Create CA](/api-reference/endpoints/certificate-authorities/create) API endpoint, specifying the `type` as `intermediate`. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "projectSlug": "", + "type": "intermediate", + "commonName": "My Intermediate CA" + }' + ``` + + ### Sample response + + ```bash Response + { + ca: { + id: "", + type: "intermediate", + commonName: "My Intermediate CA", + ... + } + } + ``` + + 2.2. Next, get a certificate signing request from the intermediate CA by making an API request to the [Get CSR](/api-reference/endpoints/certificate-authorities/csr) API endpoint. + + ### Sample request + + ```bash Request + curl --location --request GET 'https://app.infisical.com/api/v1/pki/ca//csr' \ + --header 'Authorization: Bearer ' \ + --data-raw '' + ``` + + ### Sample response + + ```bash Response + { + csr: "..." + } + ``` + + 2.3. Next, create an intermediate certificate by making an API request to the [Sign Intermediate](/api-reference/endpoints/certificate-authorities/sign-intermediate) API endpoint + containing the CSR from step 2.2, referencing the root CA created in step 1. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca//sign-intermediate' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "csr": "", + "notAfter": "2029-06-12" + }' + ``` + + ### Sample response + + ```bash Response + { + certificate: "...", + certificateChain: "...", + issuingCaCertificate: "...", + serialNumber: "...", + } + ``` + + + The `notAfter` value must be within the validity period of the root CA that is if the root CA is valid until `2029-06-12`, the intermediate CA must be valid until a date before `2029-06-12`. + + + 2.4. Finally, import the intermediate certificate and certificate chain from step 2.3 back to the intermediate CA by making an API request to the [Import Certificate](/api-reference/endpoints/certificate-authorities/import-cert) API endpoint. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca//import-certificate' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "certificate": "", + "certificateChain": "" + }' + ``` + + ### Sample response + + ```bash Response + { + message: "Successfully imported certificate to CA", + caId: "..." + } + ``` + + Great! You’ve successfully created a Private CA hierarchy with a root CA and an intermediate CA. Now check out the Certificates page to learn more about how to issue X.509 certificates using the intermediate CA. + + + + + + +## FAQ + + + + Infisical supports `RSA 2048`, `RSA 4096`, `ECDSA P-256`, `ECDSA P-384` key + algorithms specified at the time of creating a CA. + + diff --git a/docs/images/platform/pki/ca-create-intermediate.png b/docs/images/platform/pki/ca-create-intermediate.png new file mode 100644 index 000000000..e52e5735c Binary files /dev/null and b/docs/images/platform/pki/ca-create-intermediate.png differ diff --git a/docs/images/platform/pki/ca-create-root.png b/docs/images/platform/pki/ca-create-root.png new file mode 100644 index 000000000..3c954b833 Binary files /dev/null and b/docs/images/platform/pki/ca-create-root.png differ diff --git a/docs/images/platform/pki/ca-create.png b/docs/images/platform/pki/ca-create.png new file mode 100644 index 000000000..35096c721 Binary files /dev/null and b/docs/images/platform/pki/ca-create.png differ diff --git a/docs/images/platform/pki/ca-crl-modal.png b/docs/images/platform/pki/ca-crl-modal.png new file mode 100644 index 000000000..af26b1aca Binary files /dev/null and b/docs/images/platform/pki/ca-crl-modal.png differ diff --git a/docs/images/platform/pki/ca-crl.png b/docs/images/platform/pki/ca-crl.png new file mode 100644 index 000000000..4794034a1 Binary files /dev/null and b/docs/images/platform/pki/ca-crl.png differ diff --git a/docs/images/platform/pki/ca-install-intermediate-opt.png b/docs/images/platform/pki/ca-install-intermediate-opt.png new file mode 100644 index 000000000..2bdcbf306 Binary files /dev/null and b/docs/images/platform/pki/ca-install-intermediate-opt.png differ diff --git a/docs/images/platform/pki/ca-install-intermediate.png b/docs/images/platform/pki/ca-install-intermediate.png new file mode 100644 index 000000000..ca30ad6ff Binary files /dev/null and b/docs/images/platform/pki/ca-install-intermediate.png differ diff --git a/docs/images/platform/pki/cas.png b/docs/images/platform/pki/cas.png new file mode 100644 index 000000000..b532768e2 Binary files /dev/null and b/docs/images/platform/pki/cas.png differ diff --git a/docs/images/platform/pki/cert-body.png b/docs/images/platform/pki/cert-body.png new file mode 100644 index 000000000..8ed67a7ec Binary files /dev/null and b/docs/images/platform/pki/cert-body.png differ diff --git a/docs/images/platform/pki/cert-issue-modal.png b/docs/images/platform/pki/cert-issue-modal.png new file mode 100644 index 000000000..1516ab1cb Binary files /dev/null and b/docs/images/platform/pki/cert-issue-modal.png differ diff --git a/docs/images/platform/pki/cert-issue.png b/docs/images/platform/pki/cert-issue.png new file mode 100644 index 000000000..6b3e5887b Binary files /dev/null and b/docs/images/platform/pki/cert-issue.png differ diff --git a/docs/images/platform/pki/cert-revoke-modal.png b/docs/images/platform/pki/cert-revoke-modal.png new file mode 100644 index 000000000..07bc7fce8 Binary files /dev/null and b/docs/images/platform/pki/cert-revoke-modal.png differ diff --git a/docs/images/platform/pki/cert-revoke.png b/docs/images/platform/pki/cert-revoke.png new file mode 100644 index 000000000..ff7fcc597 Binary files /dev/null and b/docs/images/platform/pki/cert-revoke.png differ diff --git a/docs/images/platform/pki/certs.png b/docs/images/platform/pki/certs.png new file mode 100644 index 000000000..4e1b49959 Binary files /dev/null and b/docs/images/platform/pki/certs.png differ diff --git a/docs/images/platform/secret-sharing/public-view.png b/docs/images/platform/secret-sharing/public-view.png index 8b4077c65..9673fcd37 100644 Binary files a/docs/images/platform/secret-sharing/public-view.png and b/docs/images/platform/secret-sharing/public-view.png differ diff --git a/docs/integrations/platforms/ansible.mdx b/docs/integrations/platforms/ansible.mdx index ad95d0d5d..321dbec6e 100644 --- a/docs/integrations/platforms/ansible.mdx +++ b/docs/integrations/platforms/ansible.mdx @@ -3,7 +3,48 @@ title: "Ansible" description: "Learn how to use Infisical for secret management in Ansible." --- -The documentation for using Infisical to manage secrets in Ansible is currently available [here](https://galaxy.ansible.com/ui/repo/published/infisical/vault/). +You can find the Infisical Ansible collection on [Ansible Galaxy](https://galaxy.ansible.com/ui/repo/published/infisical/vault/). + + +This Ansible Infisical collection includes a variety of Ansible content to help automate the management of Infisical services. This collection is maintained by the Infisical team. + + +## Ansible version compatibility +Tested with the Ansible Core >= 2.12.0 versions, and the current development version of Ansible. Ansible Core versions prior to 2.12.0 have not been tested. + +## Python version compatibility +This collection depends on the Infisical SDK for Python. + +Requires Python 3.7 or greater. + +## Installing this collection +You can install the Infisical collection with the Ansible Galaxy CLI: + +```bash +$ ansible-galaxy collection install infisical.vault +``` + +The python module dependencies are not installed by ansible-galaxy. They can be manually installed using pip: + +```bash +$ pip install infisical-python +``` + +## Using this collection + +You can either call modules by their Fully Qualified Collection Name (FQCN), such as `infisical.vault.read_secrets`, or you can call modules by their short name if you list the `infisical.vault` collection in the playbook's collections keyword: + + +```bash +--- +vars: + read_all_secrets_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', url='https://spotify.infisical.com') }}" + # [{ "key": "HOST", "value": "google.com" }, { "key": "SMTP", "value": "gmail.smtp.edu" }] + + read_secret_by_name_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', secret_name='HOST', url='https://spotify.infisical.com') }}" + # [{ "key": "HOST", "value": "google.com" }] +``` + ## Troubleshoot diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index e9cb548e9..9f3a63f8c 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -24,14 +24,13 @@ The operator can be install via [Helm](https://helm.sh) or [kubectl](https://git **Install the Helm chart** - For production deployments, it is highly recommended to set the chart version and the application version during installs and upgrades. - This will prevent the operator from being accidentally updated to the latest version and introduce unintended breaking changes. - - View application versions [here](https://hub.docker.com/r/infisical/kubernetes-operator/tags) and chart versions [here](https://cloudsmith.io/~infisical/repos/helm-charts/packages/detail/helm/secrets-operator/#versions) + To select a specific version, view the application versions [here](https://hub.docker.com/r/infisical/kubernetes-operator/tags) and chart versions [here](https://cloudsmith.io/~infisical/repos/helm-charts/packages/detail/helm/secrets-operator/#versions) ```bash - helm install --generate-name infisical-helm-charts/secrets-operator --version= --set controllerManager.manager.image.tag= + helm install --generate-name infisical-helm-charts/secrets-operator + ``` + ```bash # Example installing app version v0.2.0 and chart version 0.1.4 helm install --generate-name infisical-helm-charts/secrets-operator --version=0.1.4 --set controllerManager.manager.image.tag=v0.2.0 ``` @@ -253,16 +252,122 @@ spec: The Kubernetes machine identity authentication method is used to authenticate with Infisical. The identity ID is stored in a field in the InfisicalSecret resource. This authentication method can only be used within a Kubernetes environment. - - You need to create a machine identity, and give it access to the project(s) you want to interact with. You can [read more about Kubernetes machine identities here](/documentation/platform/identities/kubernetes-auth). + + 1.1. Start by creating a service account in your Kubernetes cluster that will be used by Infisical to authenticate with the Kubernetes API Server. + + ```yaml infisical-service-account.yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: infisical-auth + namespace: default + + ``` + + ``` + kubectl apply -f infisical-service-account.yaml + ``` + + 1.2. Bind the service account to the `system:auth-delegator` cluster role. As described [here](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#other-component-roles), this role allows delegated authentication and authorization checks, specifically for Infisical to access the [TokenReview API](https://kubernetes.io/docs/reference/kubernetes-api/authentication-resources/token-review-v1/). You can apply the following configuration file: + + ```yaml cluster-role-binding.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: role-tokenreview-binding + namespace: default + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator + subjects: + - kind: ServiceAccount + name: infisical-auth + namespace: default + ``` + + ``` + kubectl apply -f cluster-role-binding.yaml + ``` + + 1.3. Next, create a long-lived service account JWT token (i.e. the token reviewer JWT token) for the service account using this configuration file for a new `Secret` resource: + + ```yaml service-account-token.yaml + apiVersion: v1 + kind: Secret + type: kubernetes.io/service-account-token + metadata: + name: infisical-auth-token + annotations: + kubernetes.io/service-account.name: "infisical-auth" + ``` + + + ``` + kubectl apply -f service-account-token.yaml + ``` + + 1.4. Link the secret in step 1.3 to the service account in step 1.1: + + ```bash + kubectl patch serviceaccount infisical-auth -p '{"secrets": [{"name": "infisical-auth-token"}]}' -n default + ``` + + 1.5. Finally, retrieve the token reviewer JWT token from the secret. + + ```bash + kubectl get secret infisical-auth-token -n default -o=jsonpath='{.data.token}' | base64 --decode + ``` + + Keep this JWT token handy as you will need it for the **Token Reviewer JWT** field when configuring the Kubernetes Auth authentication method for the identity in step 2. + - - Once you have created your machine identity and added it to your project(s), you will need to add the identity ID to your InfisicalSecret resource. In the `authentication.kubernetesAuth.identityId` field, add the identity ID of the machine identity you created. See the example below for more details. + + + To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be prompted to configure the authentication method for it. Here, select **Kubernetes Auth**. + + + To learn more about each field of the Kubernetes native authentication method, see step 2 of [guide](/documentation/platform/identities/kubernetes-auth#guide). + + + ![identities organization create auth method](/images/platform/identities/identities-org-create-kubernetes-auth-method.png) + + + + + To allow the operator to use the given identity to access secrets, you will need to add the identity to project(s) that you would like to grant it access to. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + + Once you have created your machine identity and added it to your project(s), you will need to add the identity ID to your InfisicalSecret resource. + In the `authentication.kubernetesAuth.identityId` field, add the identity ID of the machine identity you created. + See the example below for more details. - When you configured your Kubernetes machine identity, you would have created a service account token if you followed the [Kubernetes machine identity guide](/documentation/platform/identities/kubernetes-auth). If you did not create a service account token, please follow the guide to do so. - - You will need to enter the name of the service account and the namespace where the service account lives. The example below shows how to add the service account token to the InfisicalSecret resource. + Add the service account details from the previous steps under `authentication.kubernetesAuth.serviceAccountRef`. + Here you will need to enter the name and namespace of the service account. + The example below shows a complete InfisicalSecret resource with all required fields defined. diff --git a/docs/mint.json b/docs/mint.json index 3103bb59a..698172e67 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -102,6 +102,14 @@ "documentation/platform/webhooks" ] }, + { + "group": "Internal PKI", + "pages": [ + "documentation/platform/pki/overview ", + "documentation/platform/pki/private-ca", + "documentation/platform/pki/certificates" + ] + }, { "group": "Identities", "pages": [ @@ -497,7 +505,10 @@ "group": "Secret Tags", "pages": [ "api-reference/endpoints/secret-tags/list", + "api-reference/endpoints/secret-tags/get-by-id", + "api-reference/endpoints/secret-tags/get-by-slug", "api-reference/endpoints/secret-tags/create", + "api-reference/endpoints/secret-tags/update", "api-reference/endpoints/secret-tags/delete" ] }, @@ -557,6 +568,30 @@ { "group": "Audit Logs", "pages": ["api-reference/endpoints/audit-logs/export-audit-log"] + }, + { + "group": "Certificate Authorities", + "pages": [ + "api-reference/endpoints/certificate-authorities/create", + "api-reference/endpoints/certificate-authorities/read", + "api-reference/endpoints/certificate-authorities/update", + "api-reference/endpoints/certificate-authorities/delete", + "api-reference/endpoints/certificate-authorities/csr", + "api-reference/endpoints/certificate-authorities/cert", + "api-reference/endpoints/certificate-authorities/sign-intermediate", + "api-reference/endpoints/certificate-authorities/import-cert", + "api-reference/endpoints/certificate-authorities/issue-cert", + "api-reference/endpoints/certificate-authorities/crl" + ] + }, + { + "group": "Certificates", + "pages": [ + "api-reference/endpoints/certificates/read", + "api-reference/endpoints/certificates/revoke", + "api-reference/endpoints/certificates/delete", + "api-reference/endpoints/certificates/cert-body" + ] } ] }, diff --git a/docs/sdks/languages/go.mdx b/docs/sdks/languages/go.mdx index a8affa154..2d5164b36 100644 --- a/docs/sdks/languages/go.mdx +++ b/docs/sdks/languages/go.mdx @@ -25,15 +25,10 @@ import ( func main() { - client, err := infisical.NewInfisicalClient(infisical.Config{ + client := infisical.NewInfisicalClient(infisical.Config{ SiteUrl: "https://app.infisical.com", // Optional, default is https://app.infisical.com }) - if err != nil { - fmt.Printf("Error: %v", err) - os.Exit(1) - } - _, err = client.Auth().UniversalAuthLogin("YOUR_CLIENT_ID", "YOUR_CLIENT_SECRET") if err != nil { @@ -74,14 +69,9 @@ $ go get github.com/infisical/go-sdk Import the SDK and create a client instance. ```go -client, err := infisical.NewInfisicalClient(infisical.Config{ +client := infisical.NewInfisicalClient(infisical.Config{ SiteUrl: "https://app.infisical.com", // Optional, default is https://api.infisical.com }) - -if err != nil { - fmt.Printf("Error: %v", err) - os.Exit(1) -} ``` ### ClientSettings methods @@ -435,4 +425,146 @@ Delete a secret in Infisical. The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". + + +## Working with folders + + +### client.Folders().List(options) + +```go +folders, err := client.Folders().List(infisical.ListFoldersOptions{ + ProjectID: "PROJECT_ID", + Environment: "dev", + Path: "/", +}) +``` + +Retrieve all within the Infisical project and environment that client is connected to. + +#### Parameters + + + + + The slug name (dev, prod, etc) of the environment from where folders should be fetched from. + + + + The project ID where the folder lives in. + + + + The path from where folders should be fetched from. + + + + + +### client.Folders().Create(options) + +```go +folder, err := client.Folders().Create(infisical.CreateFolderOptions{ + ProjectID: "PROJECT_ID", + Name: "new=folder-name", + Environment: "dev", + Path: "/", +}) +``` + +Create a new folder in Infisical. + +#### Parameters + + + + + The ID of the project where the folder will be created. + + + The slug name (dev, prod, etc) of the environment where the folder will be created. + + + The path to create the folder in. The root path is `/`. + + + The name of the folder to create. + + + + + + +### client.Folders().Update(options) + +```go +folder, err := client.Folders().Update(infisical.UpdateFolderOptions{ + ProjectID: "PROJECT_ID", + Environment: "dev", + Path: "/", + FolderID: "FOLDER_ID_TO_UPDATE", + NewName: "new-folder-name", +}) +``` + +Update an existing folder in Infisical. + +#### Parameters + + + + + The ID of the project where the folder will be updated. + + + The slug name (dev, prod, etc) of the environment from where the folder lives in. + + + The path from where the folder should be updated. + + + The ID of the folder to update. + + + The new name of the folder. + + + + +### client.Folders().Delete(options) + +```go +deletedFolder, err := client.Folders().Delete(infisical.DeleteFolderOptions{ + // Either folder ID or folder name is required. + FolderName: "name-of-folder-to-delete", + FolderID: "folder-id-to-delete", + ProjectID: "PROJECT_ID", + Environment: "dev", + Path: "/", +}) +``` + +Delete a folder in Infisical. + +#### Parameters + + + + + The name of the folder to delete. Note that either `FolderName` or `FolderID` is required. + + + The ID of the folder to delete. Note that either `FolderName` or `FolderID` is required. + + + + The ID of the project where the folder lives in. + + + The slug name (dev, prod, etc) of the environment from where the folder lives in. + + + The path from where the folder should be deleted. + + \ No newline at end of file diff --git a/frontend/src/components/v2/LeaveProjectModal/LeaveProjectModal.tsx b/frontend/src/components/v2/LeaveProjectModal/LeaveProjectModal.tsx new file mode 100644 index 000000000..563c33bef --- /dev/null +++ b/frontend/src/components/v2/LeaveProjectModal/LeaveProjectModal.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from "react"; + +import { useToggle } from "@app/hooks"; + +import { Button } from "../Button"; +import { FormControl } from "../FormControl"; +import { Input } from "../Input"; +import { Modal, ModalClose, ModalContent } from "../Modal"; + +type Props = { + deleteKey: string; + title: string; + onLeaveApproved: () => Promise; + onClose?: () => void; + onChange?: (isOpen: boolean) => void; + isOpen?: boolean; + subTitle?: string; + buttonText?: string; +}; + +export const LeaveProjectModal = ({ + isOpen, + onClose, + onChange, + deleteKey, + onLeaveApproved, + title, + subTitle, + buttonText = "Leave Project" +}: Props): JSX.Element => { + const [inputData, setInputData] = useState(""); + const [isLoading, setIsLoading] = useToggle(); + + useEffect(() => { + setInputData(""); + }, [isOpen]); + + const onDelete = async () => { + setIsLoading.on(); + try { + await onLeaveApproved(); + } catch { + setIsLoading.off(); + } finally { + setIsLoading.off(); + } + }; + + return ( + { + setInputData(""); + if (onChange) onChange(isOpenState); + }} + > + + + + + {" "} + + } + onClose={onClose} + > +
{ + evt.preventDefault(); + if (deleteKey === inputData) onDelete(); + }} + > + + Type {deleteKey} to leave the project + + } + className="mb-0" + > + setInputData(e.target.value)} + placeholder="Type to confirm..." + /> + +
+
+
+ ); +}; diff --git a/frontend/src/components/v2/LeaveProjectModal/index.tsx b/frontend/src/components/v2/LeaveProjectModal/index.tsx new file mode 100644 index 000000000..5d33aa108 --- /dev/null +++ b/frontend/src/components/v2/LeaveProjectModal/index.tsx @@ -0,0 +1 @@ +export { LeaveProjectModal } from "./LeaveProjectModal"; diff --git a/frontend/src/const.ts b/frontend/src/const.ts index 4d13b4602..880d2f021 100644 --- a/frontend/src/const.ts +++ b/frontend/src/const.ts @@ -24,7 +24,8 @@ export const publicPaths = [ "/login/provider/error", // TODO: change "/login/sso", "/admin/signup", - "/shared/secret/[id]" + "/shared/secret/[id]", + "/share-secret" ]; export const languageMap = { diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 79c8f2d30..113aaff19 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -24,7 +24,9 @@ export enum ProjectPermissionSub { SecretRollback = "secret-rollback", SecretApproval = "secret-approval", SecretRotation = "secret-rotation", - Identity = "identity" + Identity = "identity", + CertificateAuthorities = "certificate-authorities", + Certificates = "certificates" } type SubjectFields = { @@ -51,6 +53,8 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] + | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] + | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 348700374..082bff02c 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -26,7 +26,6 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Create universal auth client secret", [EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Revoke universal auth client secret", [EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS]: "Get universal auth client secrets", - [EventType.GET_IDENTITY_UNIVERSAL_AUTH]: "Get universal auth", [EventType.CREATE_ENVIRONMENT]: "Create environment", [EventType.UPDATE_ENVIRONMENT]: "Update environment", [EventType.DELETE_ENVIRONMENT]: "Delete environment", @@ -43,7 +42,21 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.UPDATE_SECRET_IMPORT]: "Update secret import", [EventType.DELETE_SECRET_IMPORT]: "Delete secret import", [EventType.UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS]: "Update denied permissions", - [EventType.UPDATE_USER_WORKSPACE_ROLE]: "Update user role" + [EventType.UPDATE_USER_WORKSPACE_ROLE]: "Update user role", + [EventType.CREATE_CA]: "Create CA", + [EventType.GET_CA]: "Get CA", + [EventType.UPDATE_CA]: "Update CA", + [EventType.DELETE_CA]: "Delete CA", + [EventType.GET_CA_CSR]: "Get CA CSR", + [EventType.GET_CA_CERT]: "Get CA certificate", + [EventType.SIGN_INTERMEDIATE]: "Sign intermediate", + [EventType.IMPORT_CA_CERT]: "Import CA certificate", + [EventType.GET_CA_CRL]: "Get CA CRL", + [EventType.ISSUE_CERT]: "Issue certificate", + [EventType.GET_CERT]: "Get certificate", + [EventType.DELETE_CERT]: "Delete certificate", + [EventType.REVOKE_CERT]: "Revoke certificate", + [EventType.GET_CERT_BODY]: "Get certificate body" }; export const userAgentTTypeoNameMap: { [K in UserAgentType]: string } = { diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index f6ccd8f81..ad49998c5 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -56,5 +56,19 @@ export enum EventType { UPDATE_SECRET_IMPORT = "update-secret-import", DELETE_SECRET_IMPORT = "delete-secret-import", UPDATE_USER_WORKSPACE_ROLE = "update-user-workspace-role", - UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions" + UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions", + CREATE_CA = "create-certificate-authority", + GET_CA = "get-certificate-authority", + UPDATE_CA = "update-certificate-authority", + DELETE_CA = "delete-certificate-authority", + GET_CA_CSR = "get-certificate-authority-csr", + GET_CA_CERT = "get-certificate-authority-cert", + SIGN_INTERMEDIATE = "sign-intermediate", + IMPORT_CA_CERT = "import-certificate-authority-cert", + GET_CA_CRL = "get-certificate-authority-crl", + ISSUE_CERT = "issue-cert", + GET_CERT = "get-cert", + DELETE_CERT = "delete-cert", + REVOKE_CERT = "revoke-cert", + GET_CERT_BODY = "get-cert-body" } diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 92611c814..3b607e596 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -1,3 +1,4 @@ +import { CaStatus } from "../ca"; import { IdentityTrustedIp } from "../identities/types"; import { ActorType, EventType, UserAgentType } from "./enums"; @@ -462,6 +463,125 @@ interface UpdateUserDeniedPermissions { }; } +interface CreateCa { + type: EventType.CREATE_CA; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCa { + type: EventType.GET_CA; + metadata: { + caId: string; + dn: string; + }; +} + +interface UpdateCa { + type: EventType.UPDATE_CA; + metadata: { + caId: string; + dn: string; + status: CaStatus; + }; +} + +interface DeleteCa { + type: EventType.DELETE_CA; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCaCsr { + type: EventType.GET_CA_CSR; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCaCert { + type: EventType.GET_CA_CERT; + metadata: { + caId: string; + dn: string; + }; +} + +interface SignIntermediate { + type: EventType.SIGN_INTERMEDIATE; + metadata: { + caId: string; + dn: string; + serialNumber: string; + }; +} + +interface ImportCaCert { + type: EventType.IMPORT_CA_CERT; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCaCrl { + type: EventType.GET_CA_CRL; + metadata: { + caId: string; + dn: string; + }; +} + +interface IssueCert { + type: EventType.ISSUE_CERT; + metadata: { + caId: string; + dn: string; + serialNumber: string; + }; +} + +interface GetCert { + type: EventType.GET_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface DeleteCert { + type: EventType.DELETE_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface RevokeCert { + type: EventType.REVOKE_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface GetCertBody { + type: EventType.GET_CERT_BODY; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -504,7 +624,21 @@ export type Event = | UpdateSecretImportEvent | DeleteSecretImportEvent | UpdateUserRole - | UpdateUserDeniedPermissions; + | UpdateUserDeniedPermissions + | CreateCa + | GetCa + | UpdateCa + | DeleteCa + | GetCaCsr + | GetCaCert + | SignIntermediate + | ImportCaCert + | GetCaCrl + | IssueCert + | GetCert + | DeleteCert + | RevokeCert + | GetCertBody; export type AuditLog = { id: string; diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx new file mode 100644 index 000000000..dbef15ffd --- /dev/null +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -0,0 +1,12 @@ +import { CaStatus,CaType } from "./enums"; + +export const caTypeToNameMap: { [K in CaType]: string } = { + [CaType.ROOT]: "Root", + [CaType.INTERMEDIATE]: "Intermediate" +}; + +export const caStatusToNameMap: { [K in CaStatus]: string } = { + [CaStatus.ACTIVE]: "Active", + [CaStatus.DISABLED]: "Disabled", + [CaStatus.PENDING_CERTIFICATE]: "Pending Certificate" +}; diff --git a/frontend/src/hooks/api/ca/enums.tsx b/frontend/src/hooks/api/ca/enums.tsx new file mode 100644 index 000000000..bdd498a12 --- /dev/null +++ b/frontend/src/hooks/api/ca/enums.tsx @@ -0,0 +1,10 @@ +export enum CaType { + ROOT = "root", + INTERMEDIATE = "intermediate" +} + +export enum CaStatus { + ACTIVE = "active", + DISABLED = "disabled", + PENDING_CERTIFICATE = "pending-certificate" +} diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx new file mode 100644 index 000000000..60b53478d --- /dev/null +++ b/frontend/src/hooks/api/ca/index.tsx @@ -0,0 +1,10 @@ +export { CaStatus, CaType } from "./enums"; +export { + useCreateCa, + useCreateCertificate, + useDeleteCa, + useImportCaCertificate, + useSignIntermediate, + useUpdateCa +} from "./mutations"; +export { useGetCaById, useGetCaCert, useGetCaCrl,useGetCaCsr } from "./queries"; diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx new file mode 100644 index 000000000..bb018f71a --- /dev/null +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -0,0 +1,108 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/queries"; +import { + TCertificateAuthority, + TCreateCaDTO, + TCreateCertificateDTO, + TCreateCertificateResponse, + TDeleteCaDTO, + TImportCaCertificateDTO, + TImportCaCertificateResponse, + TSignIntermediateDTO, + TSignIntermediateResponse, + TUpdateCaDTO +} from "./types"; + +export const useCreateCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { + data: { ca } + } = await apiRequest.post<{ ca: TCertificateAuthority }>("/api/v1/pki/ca/", body); + return ca; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug })); + } + }); +}; + +export const useUpdateCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId, projectSlug, ...body }) => { + const { + data: { ca } + } = await apiRequest.patch<{ ca: TCertificateAuthority }>(`/api/v1/pki/ca/${caId}`, body); + return ca; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug })); + } + }); +}; + +export const useDeleteCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId }) => { + const { + data: { ca } + } = await apiRequest.delete<{ ca: TCertificateAuthority }>(`/api/v1/pki/ca/${caId}`); + return ca; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug })); + } + }); +}; + +export const useSignIntermediate = () => { + // TODO: consider renaming + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + `/api/v1/pki/ca/${body.caId}/sign-intermediate`, + body + ); + return data; + } + }); +}; + +export const useImportCaCertificate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId, ...body }) => { + const { data } = await apiRequest.post( + `/api/v1/pki/ca/${caId}/import-certificate`, + body + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug })); + } + }); +}; + +// consider rename to issue certificate +export const useCreateCertificate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId, ...body }) => { + const { data } = await apiRequest.post( + `/api/v1/pki/ca/${caId}/issue-certificate`, + body + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.forWorkspaceCertificates(projectSlug)); + } + }); +}; diff --git a/frontend/src/hooks/api/ca/queries.tsx b/frontend/src/hooks/api/ca/queries.tsx new file mode 100644 index 000000000..e78274391 --- /dev/null +++ b/frontend/src/hooks/api/ca/queries.tsx @@ -0,0 +1,70 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TCertificateAuthority } from "./types"; + +export const caKeys = { + getCaById: (caId: string) => [{ caId }, "ca"], + getCaCert: (caId: string) => [{ caId }, "ca-cert"], + getCaCsr: (caId: string) => [{ caId }, "ca-csr"], + getCaCrl: (caId: string) => [{ caId }, "ca-crl"] +}; + +export const useGetCaById = (caId: string) => { + return useQuery({ + queryKey: caKeys.getCaById(caId), + queryFn: async () => { + const { + data: { ca } + } = await apiRequest.get<{ ca: TCertificateAuthority }>(`/api/v1/pki/ca/${caId}`); + return ca; + }, + enabled: Boolean(caId) + }); +}; + +export const useGetCaCert = (caId: string) => { + return useQuery({ + queryKey: caKeys.getCaCert(caId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificate: string; + certificateChain: string; + serialNumber: string; + }>(`/api/v1/pki/ca/${caId}/certificate`); + return data; + }, + enabled: Boolean(caId) + }); +}; + +export const useGetCaCsr = (caId: string) => { + return useQuery({ + queryKey: caKeys.getCaCsr(caId), + queryFn: async () => { + const { + data: { csr } + } = await apiRequest.get<{ + csr: string; + }>(`/api/v1/pki/ca/${caId}/csr`); + return csr; + }, + enabled: Boolean(caId) + }); +}; + +export const useGetCaCrl = (caId: string) => { + return useQuery({ + queryKey: caKeys.getCaCrl(caId), + queryFn: async () => { + const { + data: { crl } + } = await apiRequest.get<{ + crl: string; + }>(`/api/v1/pki/ca/${caId}/crl`); + return crl; + }, + enabled: Boolean(caId) + }); +}; diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts new file mode 100644 index 000000000..64511253b --- /dev/null +++ b/frontend/src/hooks/api/ca/types.ts @@ -0,0 +1,95 @@ +import { CertKeyAlgorithm } from "../certificates/enums"; +import { CaStatus, CaType } from "./enums"; + +export type TCertificateAuthority = { + id: string; + parentCaId?: string; + projectId: string; + type: CaType; + status: CaStatus; + friendlyName: string; + organization: string; + ou: string; + country: string; + province: string; + locality: string; + commonName: string; + dn: string; + maxPathLength?: number; + notAfter?: string; + notBefore?: string; + keyAlgorithm: CertKeyAlgorithm; + createdAt: string; + updatedAt: string; +}; + +export type TCreateCaDTO = { + projectSlug: string; + type: string; + friendlyName?: string; + organization: string; + ou: string; + country: string; + province: string; + locality: string; + commonName: string; + notAfter?: string; + maxPathLength: number; + keyAlgorithm: CertKeyAlgorithm; +}; + +export type TUpdateCaDTO = { + projectSlug: string; + caId: string; + status?: CaStatus; +}; + +export type TDeleteCaDTO = { + projectSlug: string; + caId: string; +}; + +export type TSignIntermediateDTO = { + caId: string; + csr: string; + maxPathLength: number; + notBefore?: string; + notAfter?: string; +}; + +export type TSignIntermediateResponse = { + certificate: string; + certificateChain: string; + issuingCaCertificate: string; + serialNumber: string; +}; + +export type TImportCaCertificateDTO = { + caId: string; + projectSlug: string; + certificate: string; + certificateChain: string; +}; + +export type TImportCaCertificateResponse = { + message: string; + caId: string; +}; + +export type TCreateCertificateDTO = { + projectSlug: string; + caId: string; + friendlyName?: string; + commonName: string; + ttl: string; // string compatible with ms + notBefore?: string; + notAfter?: string; +}; + +export type TCreateCertificateResponse = { + certificate: string; + issuingCertificate: string; + certificateChain: string; + privateKey: string; + serialNumber: string; +}; diff --git a/frontend/src/hooks/api/certificates/constants.tsx b/frontend/src/hooks/api/certificates/constants.tsx new file mode 100644 index 000000000..e5a9b7a43 --- /dev/null +++ b/frontend/src/hooks/api/certificates/constants.tsx @@ -0,0 +1,60 @@ +import { CertKeyAlgorithm, CertStatus,CrlReason } from "./enums"; + +export const certStatusToNameMap: { [K in CertStatus]: string } = { + [CertStatus.ACTIVE]: "Active", + [CertStatus.REVOKED]: "Revoked" +}; + +export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = { + [CertKeyAlgorithm.RSA_2048]: "RSA 2048", + [CertKeyAlgorithm.RSA_4096]: "RSA 4096", + [CertKeyAlgorithm.ECDSA_P256]: "ECDSA P256", + [CertKeyAlgorithm.ECDSA_P384]: "ECDSA P384" +}; + +export const certKeyAlgorithms = [ + { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_2048], value: CertKeyAlgorithm.RSA_2048 }, + { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_4096], value: CertKeyAlgorithm.RSA_4096 }, + { + label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.ECDSA_P256], + value: CertKeyAlgorithm.ECDSA_P256 + }, + { + label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.ECDSA_P384], + value: CertKeyAlgorithm.ECDSA_P384 + } +]; + +export const crlReasonToNameMap: { [K in CrlReason]: string } = { + [CrlReason.UNSPECIFIED]: "Unspecified", + [CrlReason.KEY_COMPROMISE]: "Key Compromise", + [CrlReason.CA_COMPROMISE]: "CA Compromise", + [CrlReason.AFFILIATION_CHANGED]: "Affiliation Changed", + [CrlReason.SUPERSEDED]: "Superseded", + [CrlReason.CESSATION_OF_OPERATION]: "Cessation of Operation", + [CrlReason.CERTIFICATE_HOLD]: "Certificate Hold", + // [CrlReason.REMOVE_FROM_CRL]: "Remove from CRL", + [CrlReason.PRIVILEGE_WITHDRAWN]: "Privilege Withdrawn", + [CrlReason.A_A_COMPROMISE]: "A/A Compromise" +}; + +export const crlReasons = [ + { label: crlReasonToNameMap[CrlReason.UNSPECIFIED], value: CrlReason.UNSPECIFIED }, + { label: crlReasonToNameMap[CrlReason.KEY_COMPROMISE], value: CrlReason.KEY_COMPROMISE }, + { label: crlReasonToNameMap[CrlReason.CA_COMPROMISE], value: CrlReason.CA_COMPROMISE }, + { + label: crlReasonToNameMap[CrlReason.AFFILIATION_CHANGED], + value: CrlReason.AFFILIATION_CHANGED + }, + { label: crlReasonToNameMap[CrlReason.SUPERSEDED], value: CrlReason.SUPERSEDED }, + { + label: crlReasonToNameMap[CrlReason.CESSATION_OF_OPERATION], + value: CrlReason.CESSATION_OF_OPERATION + }, + { label: crlReasonToNameMap[CrlReason.CERTIFICATE_HOLD], value: CrlReason.CERTIFICATE_HOLD }, + { + label: crlReasonToNameMap[CrlReason.PRIVILEGE_WITHDRAWN], + value: CrlReason.PRIVILEGE_WITHDRAWN + }, + { label: crlReasonToNameMap[CrlReason.A_A_COMPROMISE], value: CrlReason.A_A_COMPROMISE } +]; diff --git a/frontend/src/hooks/api/certificates/enums.tsx b/frontend/src/hooks/api/certificates/enums.tsx new file mode 100644 index 000000000..d0da0273a --- /dev/null +++ b/frontend/src/hooks/api/certificates/enums.tsx @@ -0,0 +1,24 @@ +export enum CertStatus { + ACTIVE = "active", + REVOKED = "revoked" +} + +export enum CertKeyAlgorithm { + RSA_2048 = "RSA_2048", + RSA_4096 = "RSA_4096", + ECDSA_P256 = "EC_prime256v1", + ECDSA_P384 = "EC_secp384r1" +} + +export enum CrlReason { + UNSPECIFIED = "UNSPECIFIED", + KEY_COMPROMISE = "KEY_COMPROMISE", + CA_COMPROMISE = "CA_COMPROMISE", + AFFILIATION_CHANGED = "AFFILIATION_CHANGED", + SUPERSEDED = "SUPERSEDED", + CESSATION_OF_OPERATION = "CESSATION_OF_OPERATION", + CERTIFICATE_HOLD = "CERTIFICATE_HOLD", + // REMOVE_FROM_CRL = "REMOVE_FROM_CRL", + PRIVILEGE_WITHDRAWN = "PRIVILEGE_WITHDRAWN", + A_A_COMPROMISE = "A_A_COMPROMISE" +} diff --git a/frontend/src/hooks/api/certificates/index.tsx b/frontend/src/hooks/api/certificates/index.tsx new file mode 100644 index 000000000..dd922fd6a --- /dev/null +++ b/frontend/src/hooks/api/certificates/index.tsx @@ -0,0 +1,2 @@ +export { useDeleteCert, useRevokeCert } from "./mutations"; +export { useGetCert, useGetCertBody } from "./queries"; diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx new file mode 100644 index 000000000..4c0f92339 --- /dev/null +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -0,0 +1,43 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/queries"; +import { TCertificate, TDeleteCertDTO, TRevokeCertDTO } from "./types"; + +export const useDeleteCert = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ serialNumber }) => { + const { + data: { certificate } + } = await apiRequest.delete<{ certificate: TCertificate }>( + `/api/v1/pki/certificates/${serialNumber}` + ); + return certificate; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.forWorkspaceCertificates(projectSlug)); + } + }); +}; + +export const useRevokeCert = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ serialNumber, revocationReason }) => { + const { + data: { certificate } + } = await apiRequest.post<{ certificate: TCertificate }>( + `/api/v1/pki/certificates/${serialNumber}/revoke`, + { + revocationReason + } + ); + return certificate; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.forWorkspaceCertificates(projectSlug)); + } + }); +}; diff --git a/frontend/src/hooks/api/certificates/queries.tsx b/frontend/src/hooks/api/certificates/queries.tsx new file mode 100644 index 000000000..50c751c06 --- /dev/null +++ b/frontend/src/hooks/api/certificates/queries.tsx @@ -0,0 +1,40 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TCertificate } from "./types"; + +export const certKeys = { + getCertById: (serialNumber: string) => [{ serialNumber }, "cert"], + getCertBody: (serialNumber: string) => [{ serialNumber }, "certBody"] +}; + +export const useGetCert = (serialNumber: string) => { + return useQuery({ + queryKey: certKeys.getCertById(serialNumber), + queryFn: async () => { + const { + data: { certificate } + } = await apiRequest.get<{ certificate: TCertificate }>( + `/api/v1/pki/certificates/${serialNumber}` + ); + return certificate; + }, + enabled: Boolean(serialNumber) + }); +}; + +export const useGetCertBody = (serialNumber: string) => { + return useQuery({ + queryKey: certKeys.getCertBody(serialNumber), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificate: string; + certificateChain: string; + serialNumber: string; + }>(`/api/v1/pki/certificates/${serialNumber}/certificate`); + return data; + }, + enabled: Boolean(serialNumber) + }); +}; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts new file mode 100644 index 000000000..2dd600187 --- /dev/null +++ b/frontend/src/hooks/api/certificates/types.ts @@ -0,0 +1,23 @@ +import { CertStatus } from "./enums"; + +export type TCertificate = { + id: string; + caId: string; + status: CertStatus; + friendlyName: string; + commonName: string; + serialNumber: string; + notBefore: string; + notAfter: string; +}; + +export type TDeleteCertDTO = { + projectSlug: string; + serialNumber: string; +}; + +export type TRevokeCertDTO = { + projectSlug: string; + serialNumber: string; + revocationReason: string; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index b76aac166..7e19ece33 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -5,6 +5,8 @@ export * from "./auditLogs"; export * from "./auditLogStreams"; export * from "./auth"; export * from "./bots"; +export * from "./ca"; +export * from "./certificates"; export * from "./dynamicSecret"; export * from "./dynamicSecretLease"; export * from "./groups"; diff --git a/frontend/src/hooks/api/secretSharing/mutations.ts b/frontend/src/hooks/api/secretSharing/mutations.ts index e21cc08f6..e0c1dcc3c 100644 --- a/frontend/src/hooks/api/secretSharing/mutations.ts +++ b/frontend/src/hooks/api/secretSharing/mutations.ts @@ -15,13 +15,23 @@ export const useCreateSharedSecret = () => { }); }; +export const useCreatePublicSharedSecret = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (inputData: TCreateSharedSecretRequest) => { + const { data } = await apiRequest.post( + "/api/v1/secret-sharing/public", + inputData + ); + return data; + }, + onSuccess: () => queryClient.invalidateQueries(["sharedSecrets"]) + }); +}; + export const useDeleteSharedSecret = () => { const queryClient = useQueryClient(); - return useMutation< - TSharedSecret, - { message: string }, - { sharedSecretId: string } - >({ + return useMutation({ mutationFn: async ({ sharedSecretId }: TDeleteSharedSecretRequest) => { const { data } = await apiRequest.delete( `/api/v1/secret-sharing/${sharedSecretId}` diff --git a/frontend/src/hooks/api/secretSharing/queries.ts b/frontend/src/hooks/api/secretSharing/queries.ts index c7970fabc..886b0a82e 100644 --- a/frontend/src/hooks/api/secretSharing/queries.ts +++ b/frontend/src/hooks/api/secretSharing/queries.ts @@ -17,6 +17,7 @@ export const useGetSharedSecrets = () => { export const useGetActiveSharedSecretByIdAndHashedHex = (id: string, hashedHex: string) => { return useQuery({ queryFn: async () => { + if(!id || !hashedHex) return Promise.resolve({ encryptedValue: "", iv: "", tag: "" }); const { data } = await apiRequest.get( `/api/v1/secret-sharing/public/${id}?hashedHex=${hashedHex}` ); diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 7ce074344..6b9425800 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -36,4 +36,5 @@ export type SubscriptionPlan = { | null; trial_end: number | null; has_used_trial: boolean; + caCrl: boolean; }; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index b0cadac23..f5e5855d4 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -1,6 +1,7 @@ export { useAddGroupToWorkspace, useDeleteGroupFromWorkspace, + useLeaveProject, useUpdateGroupWorkspaceRole } from "./mutations"; export { @@ -21,6 +22,8 @@ export { useGetWorkspaceIntegrations, useGetWorkspaceSecrets, useGetWorkspaceUsers, + useListWorkspaceCas, + useListWorkspaceCertificates, useListWorkspaceGroups, useNameWorkspaceSecrets, useRenameWorkspace, diff --git a/frontend/src/hooks/api/workspace/mutations.tsx b/frontend/src/hooks/api/workspace/mutations.tsx index 11853157f..5aba02098 100644 --- a/frontend/src/hooks/api/workspace/mutations.tsx +++ b/frontend/src/hooks/api/workspace/mutations.tsx @@ -62,3 +62,15 @@ export const useDeleteGroupFromWorkspace = () => { } }); }; + +export const useLeaveProject = () => { + const queryClient = useQueryClient(); + return useMutation<{}, {}, { workspaceId: string }>({ + mutationFn: ({ workspaceId }) => { + return apiRequest.delete(`/api/v1/workspace/${workspaceId}/leave`); + }, + onSuccess: () => { + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + } + }); +}; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 6462490bb..7f94bb498 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -2,6 +2,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { CaStatus } from "../ca/enums"; +import { TCertificateAuthority } from "../ca/types"; +import { TCertificate } from "../certificates/types"; import { TGroupMembership } from "../groups/types"; import { IdentityMembership } from "../identities/types"; import { IntegrationAuth } from "../integrationAuth/types"; @@ -40,7 +43,23 @@ export const workspaceKeys = { getWorkspaceIdentityMemberships: (workspaceId: string) => [{ workspaceId }, "workspace-identity-memberships"] as const, getWorkspaceGroupMemberships: (workspaceId: string) => - [{ workspaceId }, "workspace-groups"] as const + [{ workspaceId }, "workspace-groups"] as const, + getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) => + [{ projectSlug }, "workspace-cas"] as const, + specificWorkspaceCas: ({ projectSlug, status }: { projectSlug: string; status?: CaStatus }) => + [...workspaceKeys.getWorkspaceCas({ projectSlug }), { status }] as const, + allWorkspaceCertificates: () => ["workspace-certificates"] as const, + forWorkspaceCertificates: (slug: string) => + [...workspaceKeys.allWorkspaceCertificates(), slug] as const, + specificWorkspaceCertificates: ({ + slug, + offset, + limit + }: { + slug: string; + offset: number; + limit: number; + }) => [...workspaceKeys.forWorkspaceCertificates(slug), { offset, limit }] as const }; const fetchWorkspaceById = async (workspaceId: string) => { @@ -485,3 +504,70 @@ export const useListWorkspaceGroups = (projectSlug: string) => { enabled: true }); }; + +export const useListWorkspaceCas = ({ + projectSlug, + status +}: { + projectSlug: string; + status?: CaStatus; +}) => { + return useQuery({ + queryKey: workspaceKeys.specificWorkspaceCas({ + projectSlug, + status + }), + queryFn: async () => { + const params = new URLSearchParams({ + ...(status && { status }) + }); + + const { + data: { cas } + } = await apiRequest.get<{ cas: TCertificateAuthority[] }>( + `/api/v2/workspace/${projectSlug}/cas`, + { + params + } + ); + return cas; + }, + enabled: Boolean(projectSlug) + }); +}; + +export const useListWorkspaceCertificates = ({ + projectSlug, + offset, + limit +}: { + projectSlug: string; + offset: number; + limit: number; +}) => { + return useQuery({ + queryKey: workspaceKeys.specificWorkspaceCertificates({ + slug: projectSlug, + offset, + limit + }), + queryFn: async () => { + const params = new URLSearchParams({ + offset: String(offset), + limit: String(limit) + }); + + const { + data: { certificates, totalCount } + } = await apiRequest.get<{ certificates: TCertificate[]; totalCount: number }>( + `/api/v2/workspace/${projectSlug}/certificates`, + { + params + } + ); + + return { certificates, totalCount }; + }, + enabled: Boolean(projectSlug) + }); +}; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 1b5df0037..72351df88 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -519,6 +519,18 @@ export const AppLayout = ({ children }: LayoutProps) => { + + + + Internal PKI + + + { Secret Sharing diff --git a/frontend/src/pages/project/[id]/certificates/index.tsx b/frontend/src/pages/project/[id]/certificates/index.tsx new file mode 100644 index 000000000..ede2e75f0 --- /dev/null +++ b/frontend/src/pages/project/[id]/certificates/index.tsx @@ -0,0 +1,23 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { CertificatesPage } from "@app/views/Project/CertificatesPage"; + +const Certificates = () => { + const { t } = useTranslation(); + + return ( +
+ + {t("common.head-title", { title: "Certificates" })} + + + + +
+ ); +}; + +export default Certificates; + +Certificates.requireAuth = true; diff --git a/frontend/src/pages/share-secret/index.tsx b/frontend/src/pages/share-secret/index.tsx new file mode 100644 index 000000000..53b034650 --- /dev/null +++ b/frontend/src/pages/share-secret/index.tsx @@ -0,0 +1,24 @@ +import Head from "next/head"; + +import { ShareSecretPublicPage } from "@app/views/ShareSecretPublicPage"; + +const ShareNewPublicSecretPage = () => { + return ( + <> + + Securely Share Secrets | Infisical + + + + + +
+ +
+ + ); +}; + +export default ShareNewPublicSecretPage; + +ShareNewPublicSecretPage.requireAuth = false; diff --git a/frontend/src/pages/shared/secret/[id]/index.tsx b/frontend/src/pages/shared/secret/[id]/index.tsx index 7f53d962d..bda56347b 100644 --- a/frontend/src/pages/shared/secret/[id]/index.tsx +++ b/frontend/src/pages/shared/secret/[id]/index.tsx @@ -2,7 +2,7 @@ import Head from "next/head"; import { ShareSecretPublicPage } from "@app/views/ShareSecretPublicPage"; -const SecretApproval = () => { +const SecretSharedPublicPage = () => { return ( <> @@ -12,13 +12,13 @@ const SecretApproval = () => { -
- +
+
); }; -export default SecretApproval; +export default SecretSharedPublicPage; -SecretApproval.requireAuth = false; +SecretSharedPublicPage.requireAuth = false; diff --git a/frontend/src/pages/signupinvite.tsx b/frontend/src/pages/signupinvite.tsx index 17ac5b4ff..a0c2f2430 100644 --- a/frontend/src/pages/signupinvite.tsx +++ b/frontend/src/pages/signupinvite.tsx @@ -75,7 +75,12 @@ export default function SignupInvite() { // Verifies if the information that the users entered (name, workspace) is there, and if the password matched the criteria. const signupErrorCheck = async () => { setIsLoading(true); - let errorCheck = false; + + let errorCheck = await checkPassword({ + password, + setErrors + }); + if (!firstName) { setFirstNameError(true); errorCheck = true; @@ -89,11 +94,6 @@ export default function SignupInvite() { setLastNameError(false); } - errorCheck = await checkPassword({ - password, - setErrors - }); - if (!errorCheck) { // Generate a random pair of a public and a private key const pair = nacl.box.keyPair(); diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx index 57fdf46d4..011e1a915 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx @@ -179,8 +179,9 @@ export const LogsFilter = ({ control, reset }: Props) => { { - onChange(date); + onChange={(pickedDate) => { + pickedDate?.setHours(23, 59, 59, 999); // we choose the end of today not the start of it (going off of aws cloud watch) + onChange(pickedDate); setIsEndDatePickerOpen(false); }} popUpProps={{ diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx index fcf63bece..e47b423a4 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx @@ -23,7 +23,8 @@ export const LogsSection = () => { defaultValues: { page: 1, perPage: 10, - startDate: new Date(new Date().setDate(new Date().getDate() - 1)) + startDate: new Date(new Date().setDate(new Date().getDate() - 1)), // day before today + endDate: new Date(new Date(Date.now()).setHours(23, 59, 59, 999)) // end of today } }); diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx index 7fc69400e..f4b740e35 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx @@ -320,6 +320,30 @@ export const LogsTableRow = ({ auditLog }: Props) => { })} ); + case EventType.CREATE_CA: + case EventType.GET_CA: + case EventType.UPDATE_CA: + case EventType.DELETE_CA: + case EventType.GET_CA_CSR: + case EventType.GET_CA_CERT: + case EventType.IMPORT_CA_CERT: + case EventType.GET_CA_CRL: + case EventType.SIGN_INTERMEDIATE: + case EventType.ISSUE_CERT: + return ( + +

{`CA DN: ${event.metadata.dn}`}

+ + ); + case EventType.GET_CERT: + case EventType.DELETE_CERT: + case EventType.REVOKE_CERT: + case EventType.GET_CERT_BODY: + return ( + +

{`Cert CN: ${event.metadata.cn}`}

+ + ); default: return ; } diff --git a/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx b/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx new file mode 100644 index 000000000..30e531277 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx @@ -0,0 +1,35 @@ +import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; + +import { CaTab, CertificatesTab } from "./components"; + +enum TabSections { + Ca = "certificate-authorities", + Certificates = "certificates" +} + +export const CertificatesPage = withProjectPermission( + () => { + return ( +
+
+

Internal PKI

+ + + Certificates + Certificate Authorities + + + + + + + + +
+
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.AuditLogs } +); diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/CaTab.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/CaTab.tsx new file mode 100644 index 000000000..aadca4dfe --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/CaTab.tsx @@ -0,0 +1,17 @@ +import { motion } from "framer-motion"; + +import { CaSection } from "./components"; + +export const CaTab = () => { + return ( + + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCertModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCertModal.tsx new file mode 100644 index 000000000..97970d94f --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCertModal.tsx @@ -0,0 +1,34 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { useGetCaCert } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { CertificateContent } from "../../CertificatesTab/components/CertificateContent"; + +type Props = { + popUp: UsePopUpState<["caCert"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["caCert"]>, state?: boolean) => void; +}; + +export const CaCertModal = ({ popUp, handlePopUpToggle }: Props) => { + const { data } = useGetCaCert((popUp?.caCert?.data as { caId: string })?.caId || ""); + return ( + { + handlePopUpToggle("caCert", isOpen); + }} + > + + {data ? ( + + ) : ( +
+ )} + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCrlModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCrlModal.tsx new file mode 100644 index 000000000..77e67f32e --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCrlModal.tsx @@ -0,0 +1,106 @@ +import { useEffect } from "react"; +import { faCheck, faCopy, faDownload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton, Modal, ModalContent } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { useGetCaCrl } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["caCrl"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["caCrl"]>, state?: boolean) => void; +}; + +export const CaCrlModal = ({ popUp, handlePopUpToggle }: Props) => { + const [isCrlCopied, setIsCrlCopied] = useToggle(false); + const { data: crl } = useGetCaCrl((popUp?.caCrl?.data as { caId: string })?.caId || ""); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isCrlCopied) { + timer = setTimeout(() => setIsCrlCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [isCrlCopied]); + + const downloadTxtFile = (filename: string, content: string) => { + const blob = new Blob([content], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + return ( + { + handlePopUpToggle("caCrl", isOpen); + }} + > + +
+ {crl && ( + <> + {/*
+

Manual CRL Rotation

+ +
*/} +
+

Certificate Revocation List

+
+ { + navigator.clipboard.writeText(crl); + setIsCrlCopied.on(); + }} + > + + + Copy + + + { + downloadTxtFile("crl.pem", crl); + }} + > + + + Download + + +
+
+
+

{crl}

+
+ + )} +
+
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx new file mode 100644 index 000000000..4d2bcb243 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx @@ -0,0 +1,328 @@ +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format } from "date-fns"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + // DatePicker, + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + CaStatus, + useGetCaById, + useGetCaCsr, + useImportCaCertificate, + useListWorkspaceCas, + useSignIntermediate +} from "@app/hooks/api"; +import { caTypeToNameMap } from "@app/hooks/api/ca/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const isValidDate = (dateString: string) => { + const date = new Date(dateString); + return !Number.isNaN(date.getTime()); +}; + +const getMiddleDate = (date1: Date, date2: Date) => { + const timestamp1 = date1.getTime(); + const timestamp2 = date2.getTime(); + + const middleTimestamp = (timestamp1 + timestamp2) / 2; + + return new Date(middleTimestamp); +}; + +const schema = z.object({ + parentCaId: z.string(), + notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }), + maxPathLength: z.string() +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["installCaCert"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["installCaCert"]>, state?: boolean) => void; +}; + +enum ParentCaType { + Internal = "internal", + External = "external" +} + +export const CaInstallCertModal = ({ popUp, handlePopUpToggle }: Props) => { + const [parentCaType] = useState(ParentCaType.Internal); + const { currentWorkspace } = useWorkspace(); + const caId = (popUp?.installCaCert?.data as { caId: string })?.caId || ""; + + // const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); + const { data: cas } = useListWorkspaceCas({ + projectSlug: currentWorkspace?.slug ?? "", + status: CaStatus.ACTIVE + }); + const { data: ca } = useGetCaById(caId); + const { data: csr } = useGetCaCsr(caId); + + const { mutateAsync: signIntermediate } = useSignIntermediate(); + const { mutateAsync: importCaCertificate } = useImportCaCertificate(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting }, + setValue, + watch + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + maxPathLength: "0" + } + }); + + useEffect(() => { + if (cas?.length) { + setValue("parentCaId", cas[0].id); + } + }, [cas, setValue]); + + const parentCaId = watch("parentCaId"); + const { data: parentCa } = useGetCaById(parentCaId); + + useEffect(() => { + if (parentCa?.maxPathLength) { + setValue( + "maxPathLength", + (parentCa.maxPathLength === -1 ? 3 : parentCa.maxPathLength - 1).toString() + ); + } + + if (parentCa?.notAfter) { + const parentCaNotAfter = new Date(parentCa.notAfter); + const middleDate = getMiddleDate(new Date(), parentCaNotAfter); + setValue("notAfter", format(middleDate, "yyyy-MM-dd")); + } + }, [parentCa]); + + const onFormSubmit = async ({ notAfter, maxPathLength }: FormData) => { + try { + if (!csr || !caId || !currentWorkspace?.slug) return; + + const { certificate, certificateChain } = await signIntermediate({ + caId: parentCaId, + csr, + maxPathLength: Number(maxPathLength), + notAfter, + notBefore: new Date().toISOString() + }); + + await importCaCertificate({ + caId, + projectSlug: currentWorkspace?.slug, + certificate, + certificateChain + }); + + reset(); + + createNotification({ + text: "Successfully installed certificate for CA", + type: "success" + }); + handlePopUpToggle("installCaCert", false); + } catch (err) { + createNotification({ + text: "Failed to install certificate for CA", + type: "error" + }); + } + }; + + function generatePathLengthOpts(parentCaMaxPathLength: number): number[] { + if (parentCaMaxPathLength === -1) { + return [-1, 0, 1, 2, 3]; + } + + return Array.from({ length: parentCaMaxPathLength }, (_, index) => index); + } + + const renderForm = (parentCaTypeInput: ParentCaType) => { + switch (parentCaTypeInput) { + case ParentCaType.Internal: + return ( +
+ ( + + + + )} + /> + {/* { + return ( + + { + onChange(date); + setIsStartDatePickerOpen(false); + }} + popUpProps={{ + open: isStartDatePickerOpen, + onOpenChange: setIsStartDatePickerOpen + }} + popUpContentProps={{}} + /> + + ); + }} + /> */} + ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ + ); + default: + return
External TODO
; + } + }; + + return ( + { + handlePopUpToggle("installCaCert", isOpen); + reset(); + }} + > + + {/* + + */} + {renderForm(parentCaType)} + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/index.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/index.tsx new file mode 100644 index 000000000..9ad602c06 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/index.tsx @@ -0,0 +1 @@ +export { CaInstallCertModal } from "./CaInstallCertModal"; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaModal.tsx new file mode 100644 index 000000000..28fd1adbe --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaModal.tsx @@ -0,0 +1,433 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format } from "date-fns"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem + // DatePicker +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { CaType, useCreateCa, useGetCaById } from "@app/hooks/api/ca"; +import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants"; +import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const isValidDate = (dateString: string) => { + const date = new Date(dateString); + return !Number.isNaN(date.getTime()); +}; + +const getDateTenYearsFromToday = () => { + const date = new Date(); + date.setFullYear(date.getFullYear() + 10); + return format(date, "yyyy-MM-dd"); +}; + +const schema = z + .object({ + type: z.enum([CaType.ROOT, CaType.INTERMEDIATE]), + friendlyName: z.string(), + organization: z.string(), + ou: z.string(), + country: z.string(), + province: z.string(), + locality: z.string(), + commonName: z.string(), + notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }), + maxPathLength: z.string(), + keyAlgorithm: z.enum([ + CertKeyAlgorithm.RSA_2048, + CertKeyAlgorithm.RSA_4096, + CertKeyAlgorithm.ECDSA_P256, + CertKeyAlgorithm.ECDSA_P384 + ]) + }) + .required(); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["ca"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["ca"]>, state?: boolean) => void; +}; + +const caTypes = [ + { label: "Root", value: CaType.ROOT }, + { label: "Intermediate", value: CaType.INTERMEDIATE } +]; + +export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + // const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); + + const { data: ca } = useGetCaById((popUp?.ca?.data as { caId: string })?.caId || ""); + const { mutateAsync: createMutateAsync } = useCreateCa(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting }, + watch + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + type: CaType.ROOT, + friendlyName: "", + organization: "", + ou: "", + country: "", + province: "", + locality: "", + commonName: "", + notAfter: getDateTenYearsFromToday(), + maxPathLength: "-1", + keyAlgorithm: CertKeyAlgorithm.RSA_2048 + } + }); + + const caType = watch("type"); + + useEffect(() => { + if (ca) { + reset({ + type: ca.type, + friendlyName: ca.friendlyName, + organization: ca.organization, + ou: ca.ou, + country: ca.country, + province: ca.province, + locality: ca.locality, + commonName: ca.commonName, + notAfter: ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "", + maxPathLength: ca.maxPathLength ? String(ca.maxPathLength) : "", + keyAlgorithm: ca.keyAlgorithm + }); + } else { + reset({ + type: CaType.ROOT, + friendlyName: "", + organization: "", + ou: "", + country: "", + province: "", + locality: "", + commonName: "", + notAfter: getDateTenYearsFromToday(), + maxPathLength: "-1", + keyAlgorithm: CertKeyAlgorithm.RSA_2048 + }); + } + }, [ca]); + + const onFormSubmit = async ({ + type, + friendlyName, + commonName, + organization, + ou, + country, + locality, + province, + notAfter, + maxPathLength, + keyAlgorithm + }: FormData) => { + try { + if (!currentWorkspace?.slug) return; + + await createMutateAsync({ + projectSlug: currentWorkspace.slug, + type, + friendlyName, + commonName, + organization, + ou, + country, + province, + locality, + notAfter, + maxPathLength: Number(maxPathLength), + keyAlgorithm + }); + + reset(); + handlePopUpToggle("ca", false); + + createNotification({ + text: "Successfully created CA", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create CA", + type: "error" + }); + } + }; + + return ( + { + reset(); + handlePopUpToggle("ca", isOpen); + }} + > + +
+ ( + + + + )} + /> + {caType === CaType.ROOT && ( + <> + {/* { + return ( + + { + onChange(date); + setIsStartDatePickerOpen(false); + }} + popUpProps={{ + open: isStartDatePickerOpen, + onOpenChange: setIsStartDatePickerOpen + }} + popUpContentProps={{}} + /> + + ); + }} + /> */} + ( + + + + )} + /> + ( + + + + )} + /> + + )} + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + {!ca && ( +
+ + +
+ )} + +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaSection.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaSection.tsx new file mode 100644 index 000000000..86e617a56 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaSection.tsx @@ -0,0 +1,135 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { CaStatus, useDeleteCa, useUpdateCa } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { CaCertModal } from "./CaCertModal"; +import { CaCrlModal } from "./CaCrlModal"; +import { CaInstallCertModal } from "./CaInstallCertModal"; +import { CaModal } from "./CaModal"; +import { CaTable } from "./CaTable"; + +export const CaSection = () => { + const { currentWorkspace } = useWorkspace(); + const { mutateAsync: deleteCa } = useDeleteCa(); + const { mutateAsync: updateCa } = useUpdateCa(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "ca", + "caCert", + "installCaCert", + "deleteCa", + "caStatus", // enable / disable + "caCrl", // enable / disable + "upgradePlan" + ] as const); + + const onRemoveCaSubmit = async (caId: string) => { + try { + if (!currentWorkspace?.slug) return; + + await deleteCa({ caId, projectSlug: currentWorkspace.slug }); + + await createNotification({ + text: "Successfully deleted CA", + type: "success" + }); + + handlePopUpClose("deleteCa"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete CA", + type: "error" + }); + } + }; + + const onUpdateCaStatus = async ({ caId, status }: { caId: string; status: CaStatus }) => { + try { + if (!currentWorkspace?.slug) return; + + await updateCa({ caId, projectSlug: currentWorkspace.slug, status }); + + await createNotification({ + text: `Successfully ${status === CaStatus.ACTIVE ? "enabled" : "disabled"} CA`, + type: "success" + }); + + handlePopUpClose("caStatus"); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${status === CaStatus.ACTIVE ? "enabled" : "disabled"} CA`, + type: "error" + }); + } + }; + + return ( +
+
+

Certificate Authorities

+ + {(isAllowed) => ( + + )} + +
+ + + + + + handlePopUpToggle("deleteCa", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => onRemoveCaSubmit((popUp?.deleteCa?.data as { caId: string })?.caId)} + /> + handlePopUpToggle("caStatus", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onUpdateCaStatus(popUp?.caStatus?.data as { caId: string; status: CaStatus }) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> +
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx new file mode 100644 index 000000000..a35a4e432 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx @@ -0,0 +1,258 @@ +import { + faBan, + faCertificate, + faEllipsis, + faEye, + faFile, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useSubscription, + useWorkspace} from "@app/context"; +import { CaStatus, useListWorkspaceCas } from "@app/hooks/api"; +import { caStatusToNameMap, caTypeToNameMap } from "@app/hooks/api/ca/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState< + ["installCaCert", "caCert", "ca", "deleteCa", "caStatus", "caCrl", "upgradePlan"] + >, + data?: { + caId?: string; + dn?: string; + status?: CaStatus; + description?: string; + } + ) => void; +}; + +export const CaTable = ({ handlePopUpOpen }: Props) => { + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useListWorkspaceCas({ + projectSlug: currentWorkspace?.slug ?? "" + }); + return ( +
+ + + + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map((ca) => { + return ( + + + + + + + + ); + })} + +
Friendly NameStatusTypeValid Until +
{ca.friendlyName}{caStatusToNameMap[ca.status]}{caTypeToNameMap[ca.type]}{ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "-"} + + +
+ + + +
+
+ + {ca.status === CaStatus.PENDING_CERTIFICATE && ( + + {(isAllowed) => ( + { + handlePopUpOpen("installCaCert", { + caId: ca.id + }); + }} + disabled={!isAllowed} + icon={} + > + Install Certificate + + )} + + )} + {ca.status !== CaStatus.PENDING_CERTIFICATE && ( + + {(isAllowed) => ( + { + handlePopUpOpen("caCert", { + caId: ca.id + }); + }} + disabled={!isAllowed} + icon={} + > + View Certificate + + )} + + )} + {ca.status !== CaStatus.PENDING_CERTIFICATE && ( + + {(isAllowed) => ( + { + if (!subscription?.caCrl) { + handlePopUpOpen("upgradePlan", { + description: + "You can use the certificate revocation list (CRL) feature if you upgrade your Infisical plan." + }); + } else { + handlePopUpOpen("caCrl", { + caId: ca.id + }); + } + }} + disabled={!isAllowed} + icon={} + > + View CRL + + )} + + )} + + {(isAllowed) => ( + + handlePopUpOpen("ca", { + caId: ca.id + }) + } + disabled={!isAllowed} + icon={} + > + View CA + + )} + + {(ca.status === CaStatus.ACTIVE || ca.status === CaStatus.DISABLED) && ( + + {(isAllowed) => ( + + handlePopUpOpen("caStatus", { + caId: ca.id, + status: + ca.status === CaStatus.ACTIVE + ? CaStatus.DISABLED + : CaStatus.ACTIVE + }) + } + disabled={!isAllowed} + icon={} + > + {`${ca.status === CaStatus.ACTIVE ? "Disable" : "Enable"} CA`} + + )} + + )} + + {(isAllowed) => ( + + handlePopUpOpen("deleteCa", { + caId: ca.id, + dn: ca.dn + }) + } + disabled={!isAllowed} + icon={} + > + Delete CA + + )} + + +
+
+ {!isLoading && data?.length === 0 && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/index.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/index.tsx new file mode 100644 index 000000000..1f23813bc --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/index.tsx @@ -0,0 +1 @@ +export { CaSection } from "./CaSection"; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/index.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/index.tsx new file mode 100644 index 000000000..9e52be028 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/index.tsx @@ -0,0 +1 @@ +export { CaTab } from "./CaTab"; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx new file mode 100644 index 000000000..f054e2546 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx @@ -0,0 +1,17 @@ +import { motion } from "framer-motion"; + +import { CertificatesSection } from "./components"; + +export const CertificatesTab = () => { + return ( + + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateCertModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateCertModal.tsx new file mode 100644 index 000000000..01c79589c --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateCertModal.tsx @@ -0,0 +1,37 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { useGetCertBody } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { CertificateContent } from "./CertificateContent"; + +type Props = { + popUp: UsePopUpState<["certificateCert"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["certificateCert"]>, state?: boolean) => void; +}; + +export const CertificateCertModal = ({ popUp, handlePopUpToggle }: Props) => { + const { data } = useGetCertBody( + (popUp?.certificateCert?.data as { serialNumber: string })?.serialNumber || "" + ); + + return ( + { + handlePopUpToggle("certificateCert", isOpen); + }} + > + + {data ? ( + + ) : ( +
+ )} + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateContent.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateContent.tsx new file mode 100644 index 000000000..8c40af74f --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateContent.tsx @@ -0,0 +1,173 @@ +import { faCheck, faCopy, faDownload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import FileSaver from "file-saver"; + +import { IconButton, Tooltip } from "@app/components/v2"; +import { useTimedReset } from "@app/hooks"; + +type Props = { + serialNumber: string; + certificate: string; + certificateChain: string; + privateKey?: string; +}; + +export const CertificateContent = ({ + serialNumber, + certificate, + certificateChain, + privateKey +}: Props) => { + const [copyTextSerialNumber, isCopyingSerialNumber, setCopyTextSerialNumber] = + useTimedReset({ + initialState: "Copy to clipboard" + }); + const [copyTextCertificate, isCopyingCertificate, setCopyTextCertificate] = useTimedReset( + { + initialState: "Copy to clipboard" + } + ); + const [copyTextCertificateChain, isCopyingCertificateChain, setCopyTextCertificateChain] = + useTimedReset({ + initialState: "Copy to clipboard" + }); + + const [copyTextCertificateSk, isCopyingCertificateSk, setCopyTextCertificateSk] = + useTimedReset({ + initialState: "Copy to clipboard" + }); + + const downloadTxtFile = (filename: string, content: string) => { + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); + FileSaver.saveAs(blob, filename); + }; + + return ( +
+

Serial Number

+
+

{serialNumber}

+ + { + navigator.clipboard.writeText(serialNumber); + setCopyTextSerialNumber("Copied"); + }} + > + + + +
+
+

Certificate Body

+
+ + { + navigator.clipboard.writeText(certificate); + setCopyTextCertificate("Copied"); + }} + > + + + + + { + downloadTxtFile("cert.pem", certificate); + }} + > + + + +
+
+
+

{certificate}

+
+ {certificateChain && ( + <> +
+

Certificate Chain

+
+ + { + navigator.clipboard.writeText(certificateChain); + setCopyTextCertificateChain("Copied"); + }} + > + + + + + { + downloadTxtFile("chain.pem", certificateChain); + }} + > + + + +
+
+
+

{certificateChain}

+
+ + )} + {privateKey && ( + <> +
+

Certificate Private Key

+
+ + { + navigator.clipboard.writeText(privateKey); + setCopyTextCertificateSk("Copied"); + }} + > + + + + + { + downloadTxtFile("private_key.txt", privateKey); + }} + > + + + +
+
+
+

{privateKey}

+
+ + )} +
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx new file mode 100644 index 000000000..03e70a80c --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx @@ -0,0 +1,241 @@ +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { CaStatus, useCreateCertificate, useGetCert, useListWorkspaceCas } from "@app/hooks/api"; +import { caTypeToNameMap } from "@app/hooks/api/ca/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { CertificateContent } from "./CertificateContent"; + +const schema = z.object({ + caId: z.string(), + friendlyName: z.string(), + commonName: z.string().trim().min(1), + ttl: z.string().trim() +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["certificate"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["certificate"]>, state?: boolean) => void; +}; + +type TCertificateDetails = { + serialNumber: string; + certificate: string; + certificateChain: string; + privateKey: string; +}; + +export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { + const [certificateDetails, setCertificateDetails] = useState(null); + const { currentWorkspace } = useWorkspace(); + const { data: cert } = useGetCert( + (popUp?.certificate?.data as { serialNumber: string })?.serialNumber || "" + ); + + const { data: cas } = useListWorkspaceCas({ + projectSlug: currentWorkspace?.slug ?? "", + status: CaStatus.ACTIVE + }); + + const { mutateAsync: createCertificate } = useCreateCertificate(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting }, + setValue + } = useForm({ + resolver: zodResolver(schema) + }); + + useEffect(() => { + if (cert) { + reset({ + caId: cert.caId, + friendlyName: cert.friendlyName, + commonName: cert.commonName, + ttl: "" + }); + } else { + reset({ + caId: "", + friendlyName: "", + commonName: "", + ttl: "" + }); + } + }, [cert]); + + const onFormSubmit = async ({ caId, friendlyName, commonName, ttl }: FormData) => { + try { + if (!currentWorkspace?.slug) return; + + const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate({ + projectSlug: currentWorkspace.slug, + caId, + friendlyName, + commonName, + ttl + }); + + reset(); + + setCertificateDetails({ + serialNumber, + certificate, + certificateChain, + privateKey + }); + + createNotification({ + text: "Successfully created certificate", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create certificate", + type: "error" + }); + } + }; + + useEffect(() => { + if (cas?.length) { + setValue("caId", cas[0].id); + } + }, [cas]); + + return ( + { + handlePopUpToggle("certificate", isOpen); + reset(); + setCertificateDetails(null); + }} + > + + {!certificateDetails ? ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + {!cert && ( +
+ + +
+ )} + + ) : ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateRevocationModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateRevocationModal.tsx new file mode 100644 index 000000000..cd5e2118c --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateRevocationModal.tsx @@ -0,0 +1,131 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useRevokeCert } from "@app/hooks/api"; +import { crlReasons } from "@app/hooks/api/certificates/constants"; +import { CrlReason } from "@app/hooks/api/certificates/enums"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z.object({ + revocationReason: z.enum([ + CrlReason.UNSPECIFIED, + CrlReason.KEY_COMPROMISE, + CrlReason.CA_COMPROMISE, + CrlReason.AFFILIATION_CHANGED, + CrlReason.SUPERSEDED, + CrlReason.CESSATION_OF_OPERATION, + CrlReason.CERTIFICATE_HOLD, + CrlReason.PRIVILEGE_WITHDRAWN, + CrlReason.A_A_COMPROMISE + ]) +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["revokeCertificate"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["revokeCertificate"]>, + state?: boolean + ) => void; +}; + +export const CertificateRevocationModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { mutateAsync: revokeCertificate } = useRevokeCert(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const onFormSubmit = async ({ revocationReason }: FormData) => { + try { + if (!currentWorkspace?.slug) return; + + const {serialNumber} = popUp.revokeCertificate.data as { serialNumber: string }; + + await revokeCertificate({ + projectSlug: currentWorkspace.slug, + serialNumber, + revocationReason + }); + + reset(); + handlePopUpToggle("revokeCertificate", false); + + createNotification({ + text: "Successfully revoked certificate", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to revoke certificate", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("revokeCertificate", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx new file mode 100644 index 000000000..58bb8ef4c --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx @@ -0,0 +1,88 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useDeleteCert } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { CertificateCertModal } from "./CertificateCertModal"; +import { CertificateModal } from "./CertificateModal"; +import { CertificateRevocationModal } from "./CertificateRevocationModal"; +import { CertificatesTable } from "./CertificatesTable"; + +export const CertificatesSection = () => { + const { currentWorkspace } = useWorkspace(); + const { mutateAsync: deleteCert } = useDeleteCert(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "certificate", + "certificateCert", + "deleteCertificate", + "revokeCertificate" + ] as const); + + const onRemoveCertificateSubmit = async (serialNumber: string) => { + try { + if (!currentWorkspace?.slug) return; + + await deleteCert({ serialNumber, projectSlug: currentWorkspace.slug }); + + await createNotification({ + text: "Successfully deleted certificate", + type: "success" + }); + + handlePopUpClose("deleteCertificate"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete certificate", + type: "error" + }); + } + }; + + return ( +
+
+

Certificates

+ + {(isAllowed) => ( + + )} + +
+ + + + + handlePopUpToggle("deleteCertificate", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveCertificateSubmit( + (popUp?.deleteCertificate?.data as { serialNumber: string })?.serialNumber + ) + } + /> +
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx new file mode 100644 index 000000000..f23299aa8 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx @@ -0,0 +1,205 @@ +import { useState } from "react"; +import { + faBan, + faCertificate, + faEllipsis, + faEye, + faFileExport, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useListWorkspaceCertificates } from "@app/hooks/api"; +import { certStatusToNameMap } from "@app/hooks/api/certificates/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState< + ["certificate", "deleteCertificate", "revokeCertificate", "certificateCert"] + >, + data?: { + serialNumber?: string; + commonName?: string; + } + ) => void; +}; + +const PER_PAGE_INIT = 25; + +export const CertificatesTable = ({ handlePopUpOpen }: Props) => { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(PER_PAGE_INIT); + + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useListWorkspaceCertificates({ + projectSlug: currentWorkspace?.slug ?? "", + offset: (page - 1) * perPage, + limit: perPage + }); + + return ( +
+ + + + + + + + + + + {isLoading && } + {!isLoading && + data?.certificates.map((certificate) => { + return ( + + + + + + + ); + })} + +
Friendly NameStatusValid Until +
{certificate.friendlyName}{certStatusToNameMap[certificate.status]} + {certificate.notAfter + ? format(new Date(certificate.notAfter), "yyyy-MM-dd") + : "-"} + + + +
+ + + +
+
+ + + {(isAllowed) => ( + + handlePopUpOpen("certificateCert", { + serialNumber: certificate.serialNumber + }) + } + disabled={!isAllowed} + icon={} + > + Export Certificate + + )} + + + {(isAllowed) => ( + + handlePopUpOpen("certificate", { + serialNumber: certificate.serialNumber + }) + } + disabled={!isAllowed} + icon={} + > + View Details + + )} + + + {(isAllowed) => ( + + handlePopUpOpen("revokeCertificate", { + serialNumber: certificate.serialNumber + }) + } + disabled={!isAllowed} + icon={} + > + Revoke Certificate + + )} + + + {(isAllowed) => ( + + handlePopUpOpen("deleteCertificate", { + serialNumber: certificate.serialNumber, + commonName: certificate.commonName + }) + } + disabled={!isAllowed} + icon={} + > + Delete Certificate + + )} + + +
+
+ {!isLoading && data?.totalCount !== undefined && data.totalCount >= PER_PAGE_INIT && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isLoading && !data?.certificates?.length && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/index.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/index.tsx new file mode 100644 index 000000000..7854a6f8b --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/index.tsx @@ -0,0 +1 @@ +export { CertificatesSection } from "./CertificatesSection"; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/index.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/index.tsx new file mode 100644 index 000000000..277134d56 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/index.tsx @@ -0,0 +1 @@ +export { CertificatesTab } from "./CertificatesTab"; diff --git a/frontend/src/views/Project/CertificatesPage/components/index.tsx b/frontend/src/views/Project/CertificatesPage/components/index.tsx new file mode 100644 index 000000000..9dbd8694f --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/index.tsx @@ -0,0 +1,2 @@ +export { CaTab } from "./CaTab"; +export { CertificatesTab } from "./CertificatesTab"; diff --git a/frontend/src/views/Project/CertificatesPage/index.tsx b/frontend/src/views/Project/CertificatesPage/index.tsx new file mode 100644 index 000000000..a72556644 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/index.tsx @@ -0,0 +1 @@ +export { CertificatesPage } from "./CertificatesPage"; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx index cd2eaeee0..2140887de 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx @@ -4,6 +4,7 @@ import { faAnchorLock, faArrowLeft, faBook, + faCertificate, faCog, faKey, faLock, @@ -13,8 +14,7 @@ import { faShield, faTags, faUser, - faUsers -} from "@fortawesome/free-solid-svg-icons"; + faUsers} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -117,6 +117,18 @@ const SINGLE_PERMISSION_LIST = [ subtitle: "IP allowlist management control", icon: faNetworkWired, formName: "ip-allowlist" + }, + { + title: "Certificate Authorities", + subtitle: "CA management control", + icon: faCertificate, + formName: "certificate-authorities" + }, + { + title: "Certificates", + subtitle: "Certificate management control", + icon: faCertificate, + formName: "certificates" } ] as const; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts index 4d4d45e64..0b534347d 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts @@ -48,6 +48,8 @@ export const formSchema = z.object({ tags: generalPermissionSchema, "audit-logs": generalPermissionSchema, "ip-allowlist": generalPermissionSchema, + "certificate-authorities": generalPermissionSchema, + certificates: generalPermissionSchema, // akhilmhdh: refactor all keys like below [ProjectPermissionSub.SecretApproval]: generalPermissionSchema, workspace: z diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx index 51a17d7fe..763aa32f4 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx @@ -25,6 +25,8 @@ type Props = { | "audit-logs" | "ip-allowlist" | "identity" + | "certificate-authorities" + | "certificates" | ProjectPermissionSub.SecretApproval; isNonEditable?: boolean; setValue: UseFormSetValue; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx index 12d735952..edbb78c2e 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx @@ -1,29 +1,52 @@ +import { useMemo } from "react"; import { useRouter } from "next/router"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal } from "@app/components/v2"; +import { LeaveProjectModal } from "@app/components/v2/LeaveProjectModal"; import { ProjectPermissionActions, ProjectPermissionSub, useOrganization, + useProjectPermission, useWorkspace } from "@app/context"; import { useToggle } from "@app/hooks"; -import { useDeleteWorkspace } from "@app/hooks/api"; +import { useDeleteWorkspace, useGetWorkspaceUsers, useLeaveProject } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; export const DeleteProjectSection = () => { const router = useRouter(); - + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "deleteWorkspace" + "deleteWorkspace", + "leaveWorkspace" ] as const); const { currentOrg } = useOrganization(); + const { hasProjectRole, membership } = useProjectPermission(); const { currentWorkspace } = useWorkspace(); const [isDeleting, setIsDeleting] = useToggle(); + const [isLeaving, setIsLeaving] = useToggle(); const deleteWorkspace = useDeleteWorkspace(); + const leaveProject = useLeaveProject(); + const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers( + currentWorkspace?.id || "" + ); + + // If isNoAccessMember is true, then the user can't read the workspace members. So we need to handle this case separately. + const isNoAccessMember = hasProjectRole("no-access"); + + const isOnlyAdminMember = useMemo(() => { + if (!members || !membership || !hasProjectRole("admin")) return false; + + const adminMembers = members.filter( + (member) => member.roles.map((r) => r.role).includes("admin") && member.id !== membership.id // exclude the current user + ); + + return !adminMembers.length; + }, [members, membership]); const handleDeleteWorkspaceSubmit = async () => { setIsDeleting.on(); @@ -53,23 +76,97 @@ export const DeleteProjectSection = () => { } }; + const handleLeaveWorkspaceSubmit = async () => { + console.log({ + currentWorkspace, + currentOrg, + members, + isNoAccessMember, + membership + }); + + try { + setIsLeaving.on(); + + if (!currentWorkspace?.id || !currentOrg?.id) return; + + // If there's no members, and the user has access to read members, something went wrong. + if (!members && !isNoAccessMember) return; + + // If the user has elevated permissions and can read members: + if (!isNoAccessMember) { + if (!members) return; + + if (members.length < 2) { + createNotification({ + text: "You can't leave the project as you are the only member", + type: "error" + }); + return; + } + // If the user has access to read members, and there's less than 1 admin member excluding the current user, they can't leave the project. + if (isOnlyAdminMember) { + createNotification({ + text: "You can't leave a project with no admin members left. Promote another member to admin first.", + type: "error" + }); + return; + } + } + + // If it's actually a no-access member, then we don't really care about the members. + + await leaveProject.mutateAsync({ + workspaceId: currentWorkspace.id + }); + + router.push(`/org/${currentOrg.id}/overview`); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to leave project", + type: "error" + }); + } finally { + setIsLeaving.off(); + } + }; + return (

Danger Zone

- - {(isAllowed) => ( +
+ + {(isAllowed) => ( + + )} + + {!isOnlyAdminMember && ( )} - +
+ { buttonText="Delete Project" onDeleteApproved={handleDeleteWorkspaceSubmit} /> + + handlePopUpToggle("leaveWorkspace", isOpen)} + deleteKey="confirm" + buttonText="Leave Project" + onLeaveApproved={handleLeaveWorkspaceSubmit} + />
); }; diff --git a/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx b/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx new file mode 100644 index 000000000..f8201fb9e --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx @@ -0,0 +1,229 @@ +import crypto from "crypto"; + +import { Controller } from "react-hook-form"; +import { AxiosError } from "axios"; +import * as yup from "yup"; + +import { createNotification } from "@app/components/notifications"; +import { encryptSymmetric } from "@app/components/utilities/cryptography/crypto"; +import { + Button, + FormControl, + Input, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api/secretSharing"; + +const schema = yup.object({ + value: yup.string().max(10000).required().label("Shared Secret Value"), + expiresAfterViews: yup.number().min(1).required().label("Expires After Views"), + expiresInValue: yup.number().min(1).required().label("Expiration Value"), + expiresInUnit: yup.string().required().label("Expiration Unit") +}); + +export type FormData = yup.InferType; + +export const AddShareSecretForm = ({ + isPublic, + inModal, + handleSubmit, + control, + isSubmitting, + setNewSharedSecret +}: { + isPublic: boolean; + inModal: boolean; + handleSubmit: any; + control: any; + isSubmitting: boolean; + setNewSharedSecret: (value: string) => void; +}) => { + const publicSharedSecretCreator = useCreatePublicSharedSecret(); + const privateSharedSecretCreator = useCreateSharedSecret(); + const createSharedSecret = isPublic ? publicSharedSecretCreator : privateSharedSecretCreator; + + const expirationUnitsAndActions = [ + { + unit: "Minutes", + action: (expiresAt: Date, expiresInValue: number) => + expiresAt.setMinutes(expiresAt.getMinutes() + expiresInValue) + }, + { + unit: "Hours", + action: (expiresAt: Date, expiresInValue: number) => + expiresAt.setHours(expiresAt.getHours() + expiresInValue) + }, + { + unit: "Days", + action: (expiresAt: Date, expiresInValue: number) => + expiresAt.setDate(expiresAt.getDate() + expiresInValue) + }, + { + unit: "Weeks", + action: (expiresAt: Date, expiresInValue: number) => + expiresAt.setDate(expiresAt.getDate() + expiresInValue * 7) + } + ]; + const onFormSubmit = async ({ + value, + expiresInValue, + expiresInUnit, + expiresAfterViews + }: FormData) => { + try { + const key = crypto.randomBytes(16).toString("hex"); + const hashedHex = crypto.createHash("sha256").update(key).digest("hex"); + const { ciphertext, iv, tag } = encryptSymmetric({ + plaintext: value, + key + }); + + const expiresAt = new Date(); + const updateExpiresAt = expirationUnitsAndActions.find( + (item) => item.unit === expiresInUnit + )?.action; + if (updateExpiresAt && expiresInValue) { + updateExpiresAt(expiresAt, expiresInValue); + } + + const { id } = await createSharedSecret.mutateAsync({ + encryptedValue: ciphertext, + iv, + tag, + hashedHex, + expiresAt, + expiresAfterViews + }); + setNewSharedSecret( + `${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent( + hashedHex + )}-${encodeURIComponent(key)}` + ); + + createNotification({ + text: "Successfully created a shared secret", + type: "success" + }); + } catch (err) { + console.error(err); + const axiosError = err as AxiosError; + if (axiosError?.response?.status === 401) { + createNotification({ + text: "You do not have access to create shared secrets", + type: "error" + }); + } else { + createNotification({ + text: "Failed to create a shared secret", + type: "error" + }); + } + } + }; + return ( +
+
+
+ ( + + + + )} + /> +
+
+
+ ( + + + + )} + /> +
+
+

OR

+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+
+
+
+ + {inModal && ( + + + + )} +
+
+
+ ); +}; diff --git a/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx b/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx index 7ec9f95ad..d30432982 100644 --- a/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx +++ b/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx @@ -1,54 +1,14 @@ -import crypto from "crypto"; - import { useEffect, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useForm } from "react-hook-form"; import { yupResolver } from "@hookform/resolvers/yup"; -import { AxiosError } from "axios"; import * as yup from "yup"; -import { createNotification } from "@app/components/notifications"; -import { encryptSymmetric } from "@app/components/utilities/cryptography/crypto"; -import { - Button, - FormControl, - IconButton, - Input, - Modal, - ModalClose, - ModalContent, - SecretInput, - Select, - SelectItem -} from "@app/components/v2"; -import { useOrganization } from "@app/context"; +import { Modal, ModalContent } from "@app/components/v2"; import { useTimedReset } from "@app/hooks"; -import { useCreateSharedSecret } from "@app/hooks/api/secretSharing"; import { UsePopUpState } from "@app/hooks/usePopUp"; -const expirationUnitsAndActions = [ - { - unit: "Minutes", - action: (expiresAt: Date, expiresInValue: number) => - expiresAt.setMinutes(expiresAt.getMinutes() + expiresInValue) - }, - { - unit: "Hours", - action: (expiresAt: Date, expiresInValue: number) => - expiresAt.setHours(expiresAt.getHours() + expiresInValue) - }, - { - unit: "Days", - action: (expiresAt: Date, expiresInValue: number) => - expiresAt.setDate(expiresAt.getDate() + expiresInValue) - }, - { - unit: "Weeks", - action: (expiresAt: Date, expiresInValue: number) => - expiresAt.setDate(expiresAt.getDate() + expiresInValue * 7) - } -]; +import { AddShareSecretForm } from "./AddShareSecretForm"; +import { ViewAndCopySharedSecret } from "./ViewAndCopySharedSecret"; const schema = yup.object({ value: yup.string().max(10000).required().label("Shared Secret Value"), @@ -65,9 +25,11 @@ type Props = { popUpName: keyof UsePopUpState<["createSharedSecret"]>, state?: boolean ) => void; + isPublic: boolean; + inModal: boolean; }; -export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => { +export const AddShareSecretModal = ({ popUp, handlePopUpToggle, isPublic, inModal }: Props) => { const { control, reset, @@ -76,9 +38,8 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => { } = useForm({ resolver: yupResolver(schema) }); - const createSharedSecret = useCreateSharedSecret(); - const { currentOrg } = useOrganization(); - const [newSharedSecret, setnewSharedSecret] = useState(""); + + const [newSharedSecret, setNewSharedSecret] = useState(""); const hasSharedSecret = Boolean(newSharedSecret); const [isUrlCopied, , setIsUrlCopied] = useTimedReset({ initialState: false @@ -94,199 +55,54 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => { } }, [isUrlCopied]); - const onFormSubmit = async ({ - value, - expiresInValue, - expiresInUnit, - expiresAfterViews - }: FormData) => { - try { - if (!currentOrg?.id) return; - const key = crypto.randomBytes(16).toString("hex"); - const hashedHex = crypto.createHash("sha256").update(key).digest("hex"); - const { ciphertext, iv, tag } = encryptSymmetric({ - plaintext: value, - key - }); - - const expiresAt = new Date(); - const updateExpiresAt = expirationUnitsAndActions.find( - (item) => item.unit === expiresInUnit - )?.action; - if (updateExpiresAt && expiresInValue) { - updateExpiresAt(expiresAt, expiresInValue); - } - - const { id } = await createSharedSecret.mutateAsync({ - encryptedValue: ciphertext, - iv, - tag, - hashedHex, - expiresAt, - expiresAfterViews - }); - setnewSharedSecret( - `${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent( - hashedHex - )}-${encodeURIComponent(key)}` - ); - - createNotification({ - text: "Successfully created a shared secret", - type: "success" - }); - } catch (err) { - console.error(err); - const axiosError = err as AxiosError; - if (axiosError?.response?.status === 401) { - createNotification({ - text: "You do not have access to create shared secrets", - type: "error" - }); - } else { - createNotification({ - text: "Failed to create a shared secret", - type: "error" - }); - } - } - }; - - return ( + // eslint-disable-next-line no-nested-ternary + return inModal ? ( { handlePopUpToggle("createSharedSecret", open); reset(); - setnewSharedSecret(""); + setNewSharedSecret(""); }} > {!hasSharedSecret ? ( -
- ( - - - - )} - /> -
-
- ( - - - - )} - /> -
-
-

OR

-
-
-
-
- ( - - - - )} - /> -
-
- ( - - - - )} - /> -
-
-
-
-
- - - - -
- + ) : ( -
-

{newSharedSecret}

- - - - Click to Copy - - -
+ )}
+ ) : !hasSharedSecret ? ( + + ) : ( + ); }; diff --git a/frontend/src/views/ShareSecretPage/components/ShareSecretSection.tsx b/frontend/src/views/ShareSecretPage/components/ShareSecretSection.tsx index c71b9830f..a450d61f5 100644 --- a/frontend/src/views/ShareSecretPage/components/ShareSecretSection.tsx +++ b/frontend/src/views/ShareSecretPage/components/ShareSecretSection.tsx @@ -22,7 +22,7 @@ export const ShareSecretSection = () => { const onDeleteApproved = async () => { try { deleteSharedSecret.mutateAsync({ - sharedSecretId: (popUp?.deleteSharedSecretConfirmation?.data as DeleteModalData)?.id, + sharedSecretId: (popUp?.deleteSharedSecretConfirmation?.data as DeleteModalData)?.id }); createNotification({ text: "Successfully deleted shared secret", @@ -40,7 +40,6 @@ export const ShareSecretSection = () => { }; return ( -
Secret Sharing @@ -60,14 +59,18 @@ export const ShareSecretSection = () => { Share Secret
- + - handlePopUpToggle("deleteSharedSecretConfirmation", isOpen)} deleteKey={(popUp?.deleteSharedSecretConfirmation?.data as DeleteModalData)?.name} onClose={() => handlePopUpClose("deleteSharedSecretConfirmation")} @@ -75,4 +78,4 @@ export const ShareSecretSection = () => { />
); -}; \ No newline at end of file +}; diff --git a/frontend/src/views/ShareSecretPage/components/ViewAndCopySharedSecret.tsx b/frontend/src/views/ShareSecretPage/components/ViewAndCopySharedSecret.tsx new file mode 100644 index 000000000..1efec0c36 --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/ViewAndCopySharedSecret.tsx @@ -0,0 +1,37 @@ +import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton } from "@app/components/v2"; + +export const ViewAndCopySharedSecret = ({ + inModal, + newSharedSecret, + isUrlCopied, + copyUrlToClipboard +}: { + inModal: boolean; + newSharedSecret: string; + isUrlCopied: boolean; + copyUrlToClipboard: () => void; +}) => { + return ( +
+
+
+

{newSharedSecret}

+ + + + Click to Copy + + +
+
+
+ ); +}; diff --git a/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx b/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx index cd4d8b5c3..e21400817 100644 --- a/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx +++ b/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx @@ -1,26 +1,27 @@ import { useEffect, useMemo } from "react"; import Head from "next/head"; import Image from "next/image"; +import Link from "next/link"; import { useRouter } from "next/router"; +import { faArrowRight, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { decryptSymmetric } from "@app/components/utilities/cryptography/crypto"; -import { useTimedReset } from "@app/hooks"; +import { Button } from "@app/components/v2"; +import { usePopUp, useTimedReset } from "@app/hooks"; import { useGetActiveSharedSecretByIdAndHashedHex } from "@app/hooks/api/secretSharing"; +import { AddShareSecretModal } from "../ShareSecretPage/components/AddShareSecretModal"; import { SecretTable } from "./components"; -export const ShareSecretPublicPage = () => { +export const ShareSecretPublicPage = ({ isNewSession }: { isNewSession: boolean }) => { const router = useRouter(); const { id, key: urlEncodedPublicKey } = router.query; - const [hashedHex, key] = urlEncodedPublicKey!.toString().split("-"); + const [hashedHex, key] = urlEncodedPublicKey + ? urlEncodedPublicKey.toString().split("-") + : ["", ""]; const publicKey = decodeURIComponent(urlEncodedPublicKey as string); - useEffect(() => { - if (!id || !publicKey) { - router.push("/404"); - } - }, [id, publicKey]); - const { isLoading, data } = useGetActiveSharedSecretByIdAndHashedHex( id as string, hashedHex as string @@ -53,35 +54,107 @@ export const ShareSecretPublicPage = () => { navigator.clipboard.writeText(decryptedSecret); setIsUrlCopied(true); }; + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["createSharedSecret"] as const); return ( -
+
Secret Shared | Infisical - -
- Infisical logo -
-

- A secret has been shared with you securely via Infisical -

-
- {/* */} -
-

- Shared Secret -

-
+
+
+ + Infisical logo + +
+

+ {id ? "Someone shared a secret on Infisical with you." : "Share Secrets with Infisical"} +

+
+ {id && ( + )} +
+ + {isNewSession && ( + + )} + +
); diff --git a/frontend/src/views/ShareSecretPublicPage/components/MainImage.tsx b/frontend/src/views/ShareSecretPublicPage/components/MainImage.tsx deleted file mode 100644 index 49a7e17ed..000000000 --- a/frontend/src/views/ShareSecretPublicPage/components/MainImage.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import Image from "next/image"; - -export const DragonMainImage = () => { - return ( -
- Infisical Dragon - Came to send you a secret! -
- ); -}; diff --git a/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx b/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx index 14c5092e7..d2a17e566 100644 --- a/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx +++ b/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx @@ -1,7 +1,7 @@ import { faCheck, faCopy, faKey } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { EmptyState, IconButton, SecretInput, Td, Tr } from "@app/components/v2"; +import { EmptyState, IconButton, Td, Tr } from "@app/components/v2"; type Props = { isLoading: boolean; @@ -16,7 +16,7 @@ export const SecretTable = ({ isUrlCopied, copyUrlToClipboard }: Props) => ( -
+
{isLoading &&
Loading...
} {!isLoading && !decryptedSecret && ( @@ -26,19 +26,23 @@ export const SecretTable = ({ )} {!isLoading && decryptedSecret && ( - <> -
- +
+
+
+ {decryptedSecret} +
- + Copy - +
)}
); diff --git a/frontend/src/views/ShareSecretPublicPage/components/index.tsx b/frontend/src/views/ShareSecretPublicPage/components/index.tsx index 5a7b53a0d..530af7c2f 100644 --- a/frontend/src/views/ShareSecretPublicPage/components/index.tsx +++ b/frontend/src/views/ShareSecretPublicPage/components/index.tsx @@ -1,2 +1 @@ -export { DragonMainImage } from "./MainImage"; export { SecretTable } from "./SecretTable"; diff --git a/frontend/src/views/admin/DashboardPage/RateLimitPanel.tsx b/frontend/src/views/admin/DashboardPage/RateLimitPanel.tsx index eacb26203..3979e002b 100644 --- a/frontend/src/views/admin/DashboardPage/RateLimitPanel.tsx +++ b/frontend/src/views/admin/DashboardPage/RateLimitPanel.tsx @@ -3,7 +3,9 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, ContentLoader, FormControl, Input } from "@app/components/v2"; +import { Button, ContentLoader, FormControl, Input, UpgradePlanModal } from "@app/components/v2"; +import { useSubscription } from "@app/context"; +import { usePopUp } from "@app/hooks"; import { useGetRateLimit, useUpdateRateLimit } from "@app/hooks/api"; const formSchema = z.object({ @@ -21,7 +23,9 @@ type TRateLimitForm = z.infer; export const RateLimitPanel = () => { const { data: rateLimit, isLoading } = useGetRateLimit(); + const { subscription } = useSubscription(); const { mutateAsync: updateRateLimit } = useUpdateRateLimit(); + const { handlePopUpToggle, handlePopUpOpen, popUp } = usePopUp(["upgradePlan"] as const); const { control, @@ -44,6 +48,11 @@ export const RateLimitPanel = () => { const onRateLimitFormSubmit = async (formData: TRateLimitForm) => { try { + if (subscription && !subscription.customRateLimits) { + handlePopUpOpen("upgradePlan"); + return; + } + const { readRateLimit, writeRateLimit, @@ -86,9 +95,7 @@ export const RateLimitPanel = () => { onSubmit={handleSubmit(onRateLimitFormSubmit)} >
-
- Configure rate limits -
+
Configure rate limits
{ + handlePopUpToggle("upgradePlan", isOpen)} + text="You can configure custom rate limits if you switch to Infisical's Enterprise plan." + /> ); }; diff --git a/helm-charts/secrets-operator/Chart.yaml b/helm-charts/secrets-operator/Chart.yaml index 1588f9c12..28bc3403f 100644 --- a/helm-charts/secrets-operator/Chart.yaml +++ b/helm-charts/secrets-operator/Chart.yaml @@ -13,7 +13,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: v0.6.0 +version: v0.6.1 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. diff --git a/helm-charts/secrets-operator/templates/manager-rbac.yaml b/helm-charts/secrets-operator/templates/manager-rbac.yaml index 758bc7dfe..ca6fd36e1 100644 --- a/helm-charts/secrets-operator/templates/manager-rbac.yaml +++ b/helm-charts/secrets-operator/templates/manager-rbac.yaml @@ -27,6 +27,14 @@ rules: - list - update - watch +- apiGroups: + - "" + resources: + - serviceaccounts + verbs: + - get + - list + - watch - apiGroups: - apps resources: diff --git a/k8-operator/config/rbac/role.yaml b/k8-operator/config/rbac/role.yaml index 68ea375b7..10c2af414 100644 --- a/k8-operator/config/rbac/role.yaml +++ b/k8-operator/config/rbac/role.yaml @@ -27,6 +27,14 @@ rules: - list - update - watch +- apiGroups: + - "" + resources: + - serviceaccounts + verbs: + - get + - list + - watch - apiGroups: - apps resources: diff --git a/k8-operator/controllers/infisicalsecret_controller.go b/k8-operator/controllers/infisicalsecret_controller.go index 73da45034..2078341d8 100644 --- a/k8-operator/controllers/infisicalsecret_controller.go +++ b/k8-operator/controllers/infisicalsecret_controller.go @@ -28,6 +28,7 @@ type InfisicalSecretReconciler struct { //+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;delete //+kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;delete //+kubebuilder:rbac:groups=apps,resources=deployments,verbs=list;watch;get;update +//+kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -75,7 +76,6 @@ func (r *InfisicalSecretReconciler) Reconcile(ctx context.Context, req ctrl.Requ err := r.Get(ctx, req.NamespacedName, &infisicalSecretCR) if err != nil { if errors.IsNotFound(err) { - fmt.Printf("\nInfisical Secret CRD not found [err=%v]", err) return ctrl.Result{ Requeue: false, }, nil