diff --git a/.github/workflows/run-cli-tests.yml b/.github/workflows/run-cli-tests.yml index f8e9d7797..da6f507a7 100644 --- a/.github/workflows/run-cli-tests.yml +++ b/.github/workflows/run-cli-tests.yml @@ -50,6 +50,6 @@ jobs: CLI_TESTS_ENV_SLUG: ${{ secrets.CLI_TESTS_ENV_SLUG }} CLI_TESTS_USER_EMAIL: ${{ secrets.CLI_TESTS_USER_EMAIL }} CLI_TESTS_USER_PASSWORD: ${{ secrets.CLI_TESTS_USER_PASSWORD }} - INFISICAL_VAULT_FILE_PASSPHRASE: ${{ secrets.CLI_TESTS_INFISICAL_VAULT_FILE_PASSPHRASE }} + # INFISICAL_VAULT_FILE_PASSPHRASE: ${{ secrets.CLI_TESTS_INFISICAL_VAULT_FILE_PASSPHRASE }} run: go test -v -count=1 ./test diff --git a/Makefile b/Makefile index 11143162e..aec2dad74 100644 --- a/Makefile +++ b/Makefile @@ -15,3 +15,16 @@ up-prod: down: docker compose -f docker-compose.dev.yml down + +reviewable-ui: + cd frontend && \ + npm run lint:fix && \ + npm run type:check + +reviewable-api: + cd backend && \ + npm run lint:fix && \ + npm run type:check + +reviewable: reviewable-ui reviewable-api + diff --git a/backend/package-lock.json b/backend/package-lock.json index 3863cc25d..dd7f69b22 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,6 +25,7 @@ "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "^2.1.0", "@node-saml/passport-saml": "^4.0.4", + "@octokit/plugin-retry": "^5.0.5", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", "@peculiar/asn1-schema": "^2.3.8", @@ -76,6 +77,7 @@ "pkijs": "^3.2.4", "posthog-node": "^3.6.2", "probot": "^13.0.0", + "safe-regex": "^2.1.1", "smee-client": "^2.0.0", "tedious": "^18.2.1", "tweetnacl": "^1.0.3", @@ -107,6 +109,7 @@ "@types/picomatch": "^2.3.3", "@types/prompt-sync": "^4.2.3", "@types/resolve": "^1.20.6", + "@types/safe-regex": "^1.1.6", "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", @@ -7824,19 +7827,45 @@ } }, "node_modules/@octokit/plugin-retry": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-6.0.1.tgz", - "integrity": "sha512-SKs+Tz9oj0g4p28qkZwl/topGcb0k0qPNX/i7vBKmDsjoeqnVfFUquqrE/O9oJY7+oLzdCtkiWSXLpLjvl6uog==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-5.0.5.tgz", + "integrity": "sha512-sB1RWMhSrre02Atv95K6bhESlJ/sPdZkK/wE/w1IdSCe0yM6FxSjksLa6T7aAvxvxlLKzQEC4KIiqpqyov1Tbg==", "dependencies": { - "@octokit/request-error": "^5.0.0", - "@octokit/types": "^12.0.0", + "@octokit/request-error": "^4.0.1", + "@octokit/types": "^10.0.0", "bottleneck": "^2.15.3" }, "engines": { "node": ">= 18" }, "peerDependencies": { - "@octokit/core": ">=5" + "@octokit/core": ">=3" + } + }, + "node_modules/@octokit/plugin-retry/node_modules/@octokit/openapi-types": { + "version": "18.1.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-18.1.1.tgz", + "integrity": "sha512-VRaeH8nCDtF5aXWnjPuEMIYf1itK/s3JYyJcWFJT8X9pSNnBtriDf7wlEWsGuhPLl4QIH4xM8fqTXDwJ3Mu6sw==" + }, + "node_modules/@octokit/plugin-retry/node_modules/@octokit/request-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-4.0.2.tgz", + "integrity": "sha512-uqwUEmZw3x4I9DGYq9fODVAAvcLsPQv97NRycP6syEFu5916M189VnNBW2zANNwqg3OiligNcAey7P0SET843w==", + "dependencies": { + "@octokit/types": "^10.0.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/plugin-retry/node_modules/@octokit/types": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-10.0.0.tgz", + "integrity": "sha512-Vm8IddVmhCgU1fxC1eyinpwqzXPEYu0NrYzD3YZjlGjyftdLBTeqNblRC0jmJmgxbJIsQlyogVeGnrNaaMVzIg==", + "dependencies": { + "@octokit/openapi-types": "^18.0.0" } }, "node_modules/@octokit/plugin-throttling": { @@ -9786,6 +9815,12 @@ "integrity": "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==", "dev": true }, + "node_modules/@types/safe-regex": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@types/safe-regex/-/safe-regex-1.1.6.tgz", + "integrity": "sha512-CQ/uPB9fLOPKwDsrTeVbNIkwfUthTWOx0l6uIGwVFjZxv7e68pCW5gtTYFzdJi3EBJp8h8zYhJbTasAbX7gEMQ==", + "dev": true + }, "node_modules/@types/semver": { "version": "7.5.6", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", @@ -17432,6 +17467,22 @@ "node": ">=18" } }, + "node_modules/probot/node_modules/@octokit/plugin-retry": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-6.0.1.tgz", + "integrity": "sha512-SKs+Tz9oj0g4p28qkZwl/topGcb0k0qPNX/i7vBKmDsjoeqnVfFUquqrE/O9oJY7+oLzdCtkiWSXLpLjvl6uog==", + "dependencies": { + "@octokit/request-error": "^5.0.0", + "@octokit/types": "^12.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=5" + } + }, "node_modules/probot/node_modules/commander": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", @@ -17814,6 +17865,14 @@ "@babel/runtime": "^7.8.4" } }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", @@ -18130,6 +18189,14 @@ } ] }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, "node_modules/safe-regex-test": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 6e265acdb..ee70d9c9f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -78,6 +78,7 @@ "@types/picomatch": "^2.3.3", "@types/prompt-sync": "^4.2.3", "@types/resolve": "^1.20.6", + "@types/safe-regex": "^1.1.6", "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", @@ -121,6 +122,7 @@ "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "^2.1.0", "@node-saml/passport-saml": "^4.0.4", + "@octokit/plugin-retry": "^5.0.5", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", "@peculiar/asn1-schema": "^2.3.8", @@ -172,6 +174,7 @@ "pkijs": "^3.2.4", "posthog-node": "^3.6.2", "probot": "^13.0.0", + "safe-regex": "^2.1.1", "smee-client": "^2.0.0", "tedious": "^18.2.1", "tweetnacl": "^1.0.3", diff --git a/backend/scripts/create-backend-file.ts b/backend/scripts/create-backend-file.ts index fb71994ce..39e9c816c 100644 --- a/backend/scripts/create-backend-file.ts +++ b/backend/scripts/create-backend-file.ts @@ -7,14 +7,33 @@ const prompt = promptSync({ sigint: true }); +type ComponentType = 1 | 2 | 3; + console.log(` Component List -------------- +0. Exit 1. Service component 2. DAL component 3. Router component `); -const componentType = parseInt(prompt("Select a component: "), 10); + +function getComponentType(): ComponentType { + while (true) { + const input = prompt("Select a component (0-3): "); + const componentType = parseInt(input, 10); + + if (componentType === 0) { + console.log("Exiting the program. Goodbye!"); + process.exit(0); + } else if (componentType === 1 || componentType === 2 || componentType === 3) { + return componentType; + } else { + console.log("Invalid input. Please enter 0, 1, 2, or 3."); + } + } +} +const componentType = getComponentType(); if (componentType === 1) { const componentName = prompt("Enter service name: "); diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index bd9e483ae..e5c2e19e4 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -18,6 +18,7 @@ import { TOidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-ser 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 { RateLimitConfiguration } from "@app/ee/services/rate-limit/rate-limit-types"; 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"; @@ -35,6 +36,7 @@ import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; +import { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-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"; @@ -91,6 +93,7 @@ declare module "fastify" { id: string; orgId: string; }; + rateLimits: RateLimitConfiguration; // passport data passportUser: { isUserCompleted: string; @@ -154,6 +157,7 @@ declare module "fastify" { auditLog: TAuditLogServiceFactory; auditLogStream: TAuditLogStreamServiceFactory; certificate: TCertificateServiceFactory; + certificateTemplate: TCertificateTemplateServiceFactory; certificateAuthority: TCertificateAuthorityServiceFactory; certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; pkiCollection: TPkiCollectionServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 3de092bda..d8c1a2ede 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -53,6 +53,9 @@ import { TCertificateSecretsUpdate, TCertificatesInsert, TCertificatesUpdate, + TCertificateTemplates, + TCertificateTemplatesInsert, + TCertificateTemplatesUpdate, TDynamicSecretLeases, TDynamicSecretLeasesInsert, TDynamicSecretLeasesUpdate, @@ -369,6 +372,11 @@ declare module "knex/types/tables" { TCertificateAuthorityCrlUpdate >; [TableName.Certificate]: KnexOriginal.CompositeTableType; + [TableName.CertificateTemplate]: KnexOriginal.CompositeTableType< + TCertificateTemplates, + TCertificateTemplatesInsert, + TCertificateTemplatesUpdate + >; [TableName.CertificateBody]: KnexOriginal.CompositeTableType< TCertificateBodies, TCertificateBodiesInsert, diff --git a/backend/src/db/migrations/20240802181855_ca-cert-version.ts b/backend/src/db/migrations/20240802181855_ca-cert-version.ts index 62637cbab..24eca185d 100644 --- a/backend/src/db/migrations/20240802181855_ca-cert-version.ts +++ b/backend/src/db/migrations/20240802181855_ca-cert-version.ts @@ -4,16 +4,19 @@ import { TableName } from "../schemas"; export async function up(knex: Knex): Promise { if (await knex.schema.hasTable(TableName.CertificateAuthority)) { - const hasActiveCaCertVersionColumn = await knex.schema.hasColumn( - TableName.CertificateAuthority, - "activeCaCertVersion" - ); - if (!hasActiveCaCertVersionColumn) { + const hasActiveCaCertIdColumn = await knex.schema.hasColumn(TableName.CertificateAuthority, "activeCaCertId"); + if (!hasActiveCaCertIdColumn) { await knex.schema.alterTable(TableName.CertificateAuthority, (t) => { - t.integer("activeCaCertVersion").nullable(); + t.uuid("activeCaCertId").nullable(); + t.foreign("activeCaCertId").references("id").inTable(TableName.CertificateAuthorityCert); }); - await knex(TableName.CertificateAuthority).where("status", "active").update({ activeCaCertVersion: 1 }); + await knex.raw(` + UPDATE "${TableName.CertificateAuthority}" ca + SET "activeCaCertId" = cac.id + FROM "${TableName.CertificateAuthorityCert}" cac + WHERE ca.id = cac."caId" + `); } } @@ -22,7 +25,7 @@ export async function up(knex: Knex): Promise { if (!hasVersionColumn) { await knex.schema.alterTable(TableName.CertificateAuthorityCert, (t) => { t.integer("version").nullable(); - // t.dropUnique(["caId"]); + t.dropUnique(["caId"]); }); await knex(TableName.CertificateAuthorityCert).update({ version: 1 }).whereNull("version"); @@ -54,18 +57,38 @@ export async function up(knex: Knex): Promise { } } - // if (await knex.schema.hasTable(TableName.CertificateAuthoritySecret)) { - // await knex.schema.alterTable(TableName.CertificateAuthoritySecret, (t) => { - // t.dropUnique(["caId"]); - // }); - // } + if (await knex.schema.hasTable(TableName.CertificateAuthoritySecret)) { + await knex.schema.alterTable(TableName.CertificateAuthoritySecret, (t) => { + t.dropUnique(["caId"]); + }); + } + + if (await knex.schema.hasTable(TableName.Certificate)) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.uuid("caCertId").nullable(); + t.foreign("caCertId").references("id").inTable(TableName.CertificateAuthorityCert); + }); + + await knex.raw(` + UPDATE "${TableName.Certificate}" cert + SET "caCertId" = ( + SELECT caCert.id + FROM "${TableName.CertificateAuthorityCert}" caCert + WHERE caCert."caId" = cert."caId" + ) + `); + + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.uuid("caCertId").notNullable().alter(); + }); + } } export async function down(knex: Knex): Promise { if (await knex.schema.hasTable(TableName.CertificateAuthority)) { - if (await knex.schema.hasColumn(TableName.CertificateAuthority, "activeCaCertVersion")) { + if (await knex.schema.hasColumn(TableName.CertificateAuthority, "activeCaCertId")) { await knex.schema.alterTable(TableName.CertificateAuthority, (t) => { - t.dropColumn("activeCaCertVersion"); + t.dropColumn("activeCaCertId"); }); } } @@ -83,4 +106,12 @@ export async function down(knex: Knex): Promise { }); } } + + if (await knex.schema.hasTable(TableName.Certificate)) { + if (await knex.schema.hasColumn(TableName.Certificate, "caCertId")) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.dropColumn("caCertId"); + }); + } + } } diff --git a/backend/src/db/migrations/20240806113425_remove-creation-limit-rate-limit.ts b/backend/src/db/migrations/20240806113425_remove-creation-limit-rate-limit.ts new file mode 100644 index 000000000..d82e4d65d --- /dev/null +++ b/backend/src/db/migrations/20240806113425_remove-creation-limit-rate-limit.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasCreationLimitCol = await knex.schema.hasColumn(TableName.RateLimit, "creationLimit"); + await knex.schema.alterTable(TableName.RateLimit, (t) => { + if (hasCreationLimitCol) { + t.dropColumn("creationLimit"); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasCreationLimitCol = await knex.schema.hasColumn(TableName.RateLimit, "creationLimit"); + await knex.schema.alterTable(TableName.RateLimit, (t) => { + if (!hasCreationLimitCol) { + t.integer("creationLimit").defaultTo(30).notNullable(); + } + }); +} diff --git a/backend/src/db/migrations/20240806185442_drop-tag-name.ts b/backend/src/db/migrations/20240806185442_drop-tag-name.ts new file mode 100644 index 000000000..db85ab216 --- /dev/null +++ b/backend/src/db/migrations/20240806185442_drop-tag-name.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasNameField = await knex.schema.hasColumn(TableName.SecretTag, "name"); + if (hasNameField) { + await knex.schema.alterTable(TableName.SecretTag, (t) => { + t.dropColumn("name"); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasNameField = await knex.schema.hasColumn(TableName.SecretTag, "name"); + if (!hasNameField) { + await knex.schema.alterTable(TableName.SecretTag, (t) => { + t.string("name"); + }); + } +} diff --git a/backend/src/db/migrations/20240806173521_cert-alerting.ts b/backend/src/db/migrations/20240818024923_cert-alerting.ts similarity index 95% rename from backend/src/db/migrations/20240806173521_cert-alerting.ts rename to backend/src/db/migrations/20240818024923_cert-alerting.ts index a946eb5ce..f60ce8c02 100644 --- a/backend/src/db/migrations/20240806173521_cert-alerting.ts +++ b/backend/src/db/migrations/20240818024923_cert-alerting.ts @@ -11,9 +11,12 @@ export async function up(knex: Knex): Promise { t.string("projectId").notNullable(); t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); t.string("name").notNullable(); + t.string("description").notNullable(); }); } + await createOnUpdateTrigger(knex, TableName.PkiCollection); + if (!(await knex.schema.hasTable(TableName.PkiCollectionItem))) { await knex.schema.createTable(TableName.PkiCollectionItem, (t) => { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); @@ -27,6 +30,8 @@ export async function up(knex: Knex): Promise { }); } + await createOnUpdateTrigger(knex, TableName.PkiCollectionItem); + if (!(await knex.schema.hasTable(TableName.PkiAlert))) { await knex.schema.createTable(TableName.PkiAlert, (t) => { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); @@ -42,7 +47,6 @@ export async function up(knex: Knex): Promise { }); } - await createOnUpdateTrigger(knex, TableName.PkiCollection); await createOnUpdateTrigger(knex, TableName.PkiAlert); } diff --git a/backend/src/db/migrations/20240818184238_add-certificate-template.ts b/backend/src/db/migrations/20240818184238_add-certificate-template.ts new file mode 100644 index 000000000..7135c24e0 --- /dev/null +++ b/backend/src/db/migrations/20240818184238_add-certificate-template.ts @@ -0,0 +1,55 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + const hasCertificateTemplateTable = await knex.schema.hasTable(TableName.CertificateTemplate); + if (!hasCertificateTemplateTable) { + await knex.schema.createTable(TableName.CertificateTemplate, (tb) => { + tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + tb.uuid("caId").notNullable(); + tb.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + tb.uuid("pkiCollectionId"); + tb.foreign("pkiCollectionId").references("id").inTable(TableName.PkiCollection).onDelete("SET NULL"); + tb.string("name").notNullable(); + tb.string("commonName").notNullable(); + tb.string("subjectAlternativeName").notNullable(); + tb.string("ttl").notNullable(); + tb.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.CertificateTemplate); + } + + const doesCertificateTableHaveTemplateId = await knex.schema.hasColumn( + TableName.Certificate, + "certificateTemplateId" + ); + + if (!doesCertificateTableHaveTemplateId) { + await knex.schema.alterTable(TableName.Certificate, (tb) => { + tb.uuid("certificateTemplateId"); + tb.foreign("certificateTemplateId").references("id").inTable(TableName.CertificateTemplate).onDelete("SET NULL"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesCertificateTableHaveTemplateId = await knex.schema.hasColumn( + TableName.Certificate, + "certificateTemplateId" + ); + + if (doesCertificateTableHaveTemplateId) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.dropColumn("certificateTemplateId"); + }); + } + + const hasCertificateTemplateTable = await knex.schema.hasTable(TableName.CertificateTemplate); + if (hasCertificateTemplateTable) { + await knex.schema.dropTable(TableName.CertificateTemplate); + await dropOnUpdateTrigger(knex, TableName.CertificateTemplate); + } +} diff --git a/backend/src/db/schemas/certificate-authorities.ts b/backend/src/db/schemas/certificate-authorities.ts index 9255b037e..e59a9225c 100644 --- a/backend/src/db/schemas/certificate-authorities.ts +++ b/backend/src/db/schemas/certificate-authorities.ts @@ -28,7 +28,7 @@ export const CertificateAuthoritiesSchema = z.object({ keyAlgorithm: z.string(), notBefore: z.date().nullable().optional(), notAfter: z.date().nullable().optional(), - activeCaCertVersion: z.number().nullable().optional() + activeCaCertId: z.string().uuid().nullable().optional() }); export type TCertificateAuthorities = z.infer; diff --git a/backend/src/db/schemas/certificate-templates.ts b/backend/src/db/schemas/certificate-templates.ts new file mode 100644 index 000000000..6e1989195 --- /dev/null +++ b/backend/src/db/schemas/certificate-templates.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 { TImmutableDBKeys } from "./models"; + +export const CertificateTemplatesSchema = z.object({ + id: z.string().uuid(), + caId: z.string().uuid(), + pkiCollectionId: z.string().uuid().nullable().optional(), + name: z.string(), + commonName: z.string(), + subjectAlternativeName: z.string(), + ttl: z.string(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TCertificateTemplates = z.infer; +export type TCertificateTemplatesInsert = Omit, TImmutableDBKeys>; +export type TCertificateTemplatesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index 833396fb1..7ef56e505 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -20,7 +20,9 @@ export const CertificatesSchema = z.object({ notAfter: z.date(), revokedAt: z.date().nullable().optional(), revocationReason: z.number().nullable().optional(), - altNames: z.string().default("").nullable().optional() + altNames: z.string().default("").nullable().optional(), + caCertId: z.string().uuid(), + certificateTemplateId: z.string().uuid().nullable().optional() }); export type TCertificates = z.infer; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 03146f204..bfa3a0a0e 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -14,6 +14,7 @@ export * from "./certificate-authority-crl"; export * from "./certificate-authority-secret"; export * from "./certificate-bodies"; export * from "./certificate-secrets"; +export * from "./certificate-templates"; export * from "./certificates"; export * from "./dynamic-secret-leases"; export * from "./dynamic-secrets"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 83e3d066d..f8c68e671 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -10,6 +10,7 @@ export enum TableName { Certificate = "certificates", CertificateBody = "certificate_bodies", CertificateSecret = "certificate_secrets", + CertificateTemplate = "certificate_templates", PkiAlert = "pki_alerts", PkiCollection = "pki_collections", PkiCollectionItem = "pki_collection_items", diff --git a/backend/src/db/schemas/pki-collections.ts b/backend/src/db/schemas/pki-collections.ts index 4470c513c..0f9a5675f 100644 --- a/backend/src/db/schemas/pki-collections.ts +++ b/backend/src/db/schemas/pki-collections.ts @@ -12,7 +12,8 @@ export const PkiCollectionsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), projectId: z.string(), - name: z.string() + name: z.string(), + description: z.string() }); export type TPkiCollections = z.infer; diff --git a/backend/src/db/schemas/rate-limit.ts b/backend/src/db/schemas/rate-limit.ts index 86b8776cc..233f6cdbc 100644 --- a/backend/src/db/schemas/rate-limit.ts +++ b/backend/src/db/schemas/rate-limit.ts @@ -15,7 +15,6 @@ export const RateLimitSchema = z.object({ authRateLimit: z.number().default(60), inviteUserRateLimit: z.number().default(30), mfaRateLimit: z.number().default(20), - creationLimit: z.number().default(30), publicEndpointLimit: z.number().default(30), createdAt: z.date(), updatedAt: z.date() diff --git a/backend/src/db/schemas/secret-tags.ts b/backend/src/db/schemas/secret-tags.ts index 04bb7b752..7ba7204a4 100644 --- a/backend/src/db/schemas/secret-tags.ts +++ b/backend/src/db/schemas/secret-tags.ts @@ -9,7 +9,6 @@ import { TImmutableDBKeys } from "./models"; export const SecretTagsSchema = z.object({ id: z.string().uuid(), - name: z.string(), slug: z.string(), color: z.string().nullable().optional(), createdAt: z.date(), diff --git a/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts b/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts index 5ef9f7eeb..c19af4d22 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts @@ -131,7 +131,7 @@ export const registerDynamicSecretLeaseRouter = async (server: FastifyZodProvide .default("/") .transform(removeTrailingSlash) .describe(DYNAMIC_SECRET_LEASES.RENEW.path), - environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.RENEW.ttl) + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.RENEW.environmentSlug) }), response: { 200: z.object({ diff --git a/backend/src/ee/routes/v1/rate-limit-router.ts b/backend/src/ee/routes/v1/rate-limit-router.ts index 2b08a0c32..66ea62ece 100644 --- a/backend/src/ee/routes/v1/rate-limit-router.ts +++ b/backend/src/ee/routes/v1/rate-limit-router.ts @@ -58,7 +58,6 @@ export const registerRateLimitRouter = async (server: FastifyZodProvider) => { authRateLimit: z.number(), inviteUserRateLimit: z.number(), mfaRateLimit: z.number(), - creationLimit: z.number(), publicEndpointLimit: z.number() }), response: { diff --git a/backend/src/ee/services/audit-log/audit-log-dal.ts b/backend/src/ee/services/audit-log/audit-log-dal.ts index 316cf34a5..249e239c5 100644 --- a/backend/src/ee/services/audit-log/audit-log-dal.ts +++ b/backend/src/ee/services/audit-log/audit-log-dal.ts @@ -75,15 +75,16 @@ export const auditLogDALFactory = (db: TDbClient) => { .del() .returning("id"); numberOfRetryOnFailure = 0; // reset - // eslint-disable-next-line no-await-in-loop - await new Promise((resolve) => { - setTimeout(resolve, 100); // time to breathe for db - }); } catch (error) { numberOfRetryOnFailure += 1; logger.error(error, "Failed to delete audit log on pruning"); + } finally { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, 10); // time to breathe for db + }); } - } while (deletedAuditLogIds.length > 0 && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE); + } while (deletedAuditLogIds.length > 0 || numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE); }; return { ...auditLogOrm, pruneAuditLog, find }; diff --git a/backend/src/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index 1564c6dcb..2916eb412 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -1,5 +1,6 @@ import { ForbiddenError } from "@casl/ability"; +import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { TPermissionServiceFactory } from "../permission/permission-service"; @@ -61,6 +62,10 @@ export const auditLogServiceFactory = ({ }; const createAuditLog = async (data: TCreateAuditLogDTO) => { + const appCfg = getConfig(); + if (appCfg.DISABLE_AUDIT_LOG_GENERATION) { + return; + } // add all cases in which project id or org id cannot be added if (data.event.type !== EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH) { if (!data.projectId && !data.orgId) throw new BadRequestError({ message: "Must either project id or org id" }); 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 41186756a..0d07d005b 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -162,7 +162,11 @@ export enum EventType { UPDATE_PROJECT_KMS = "update-project-kms", GET_PROJECT_KMS_BACKUP = "get-project-kms-backup", LOAD_PROJECT_KMS_BACKUP = "load-project-kms-backup", - ORG_ADMIN_ACCESS_PROJECT = "org-admin-accessed-project" + ORG_ADMIN_ACCESS_PROJECT = "org-admin-accessed-project", + CREATE_CERTIFICATE_TEMPLATE = "create-certificate-template", + UPDATE_CERTIFICATE_TEMPLATE = "update-certificate-template", + DELETE_CERTIFICATE_TEMPLATE = "delete-certificate-template", + GET_CERTIFICATE_TEMPLATE = "get-certificate-template" } interface UserActorMetadata { @@ -352,6 +356,7 @@ interface DeleteIntegrationEvent { targetServiceId?: string; path?: string; region?: string; + shouldDeleteIntegrationSecrets?: boolean; }; } @@ -1365,6 +1370,46 @@ interface LoadProjectKmsBackupEvent { metadata: Record; // no metadata yet } +interface CreateCertificateTemplate { + type: EventType.CREATE_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + caId: string; + pkiCollectionId?: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + }; +} + +interface GetCertificateTemplate { + type: EventType.GET_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + }; +} + +interface UpdateCertificateTemplate { + type: EventType.UPDATE_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + caId: string; + pkiCollectionId?: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + }; +} + +interface DeleteCertificateTemplate { + type: EventType.DELETE_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + }; +} + interface OrgAdminAccessProjectEvent { type: EventType.ORG_ADMIN_ACCESS_PROJECT; metadata: { @@ -1498,4 +1543,8 @@ export type Event = | UpdateProjectKmsEvent | GetProjectKmsBackupEvent | LoadProjectKmsBackupEvent - | OrgAdminAccessProjectEvent; + | OrgAdminAccessProjectEvent + | CreateCertificateTemplate + | UpdateCertificateTemplate + | GetCertificateTemplate + | DeleteCertificateTemplate; diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index fc0fb54a7..bd40f75cb 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -40,7 +40,12 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ secretRotation: true, caCrl: false, instanceUserManagement: false, - externalKms: false + externalKms: false, + rateLimits: { + readLimit: 60, + writeLimit: 200, + secretsLimit: 40 + } }); export const setupLicenceRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index df85f008f..70db8e4d9 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -58,6 +58,11 @@ export type TFeatureSet = { caCrl: false; instanceUserManagement: false; externalKms: false; + rateLimits: { + readLimit: number; + writeLimit: number; + secretsLimit: number; + }; }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 7ea2ba39f..f8f19a295 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -30,6 +30,7 @@ export enum ProjectPermissionSub { Identity = "identity", CertificateAuthorities = "certificate-authorities", Certificates = "certificates", + CertificateTemplates = "certificate-templates", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms" @@ -65,6 +66,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.Identity] | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.Certificates] + | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] @@ -165,6 +167,11 @@ const buildAdminPermissionRules = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Certificates); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateTemplates); + can(ProjectPermissionActions.Create, ProjectPermissionSub.CertificateTemplates); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.CertificateTemplates); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.CertificateTemplates); + can(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts); can(ProjectPermissionActions.Create, ProjectPermissionSub.PkiAlerts); can(ProjectPermissionActions.Edit, ProjectPermissionSub.PkiAlerts); @@ -251,6 +258,8 @@ const buildMemberPermissionRules = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Certificates); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateTemplates); + can(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts); can(ProjectPermissionActions.Read, ProjectPermissionSub.PkiCollections); diff --git a/backend/src/ee/services/rate-limit/rate-limit-service.ts b/backend/src/ee/services/rate-limit/rate-limit-service.ts index df90ca03f..208fa8428 100644 --- a/backend/src/ee/services/rate-limit/rate-limit-service.ts +++ b/backend/src/ee/services/rate-limit/rate-limit-service.ts @@ -4,17 +4,16 @@ 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"; +import { RateLimitConfiguration, TRateLimit, TRateLimitUpdateDTO } from "./rate-limit-types"; -let rateLimitMaxConfiguration = { +let rateLimitMaxConfiguration: RateLimitConfiguration = { readLimit: 60, publicEndpointLimit: 30, writeLimit: 200, secretsLimit: 60, authRateLimit: 60, inviteUserRateLimit: 30, - mfaRateLimit: 20, - creationLimit: 30 + mfaRateLimit: 20 }; Object.freeze(rateLimitMaxConfiguration); @@ -67,8 +66,7 @@ export const rateLimitServiceFactory = ({ rateLimitDAL, licenseService }: TRateL secretsLimit: rateLimit.secretsRateLimit, authRateLimit: rateLimit.authRateLimit, inviteUserRateLimit: rateLimit.inviteUserRateLimit, - mfaRateLimit: rateLimit.mfaRateLimit, - creationLimit: rateLimit.creationLimit + mfaRateLimit: rateLimit.mfaRateLimit }; logger.info(`syncRateLimitConfiguration: rate limit configuration: %o`, newRateLimitMaxConfiguration); diff --git a/backend/src/ee/services/rate-limit/rate-limit-types.ts b/backend/src/ee/services/rate-limit/rate-limit-types.ts index 19519aafb..d924dce51 100644 --- a/backend/src/ee/services/rate-limit/rate-limit-types.ts +++ b/backend/src/ee/services/rate-limit/rate-limit-types.ts @@ -5,7 +5,6 @@ export type TRateLimitUpdateDTO = { authRateLimit: number; inviteUserRateLimit: number; mfaRateLimit: number; - creationLimit: number; publicEndpointLimit: number; }; @@ -14,3 +13,13 @@ export type TRateLimit = { createdAt: Date; updatedAt: Date; } & TRateLimitUpdateDTO; + +export type RateLimitConfiguration = { + readLimit: number; + publicEndpointLimit: number; + writeLimit: number; + secretsLimit: number; + authRateLimit: number; + inviteUserRateLimit: number; + mfaRateLimit: number; +}; diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts index 366ad69ea..8c974b05d 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts @@ -8,6 +8,7 @@ import { removeTrailingSlash } from "@app/lib/fn"; import { containsGlobPatterns } from "@app/lib/picomatch"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; +import { TLicenseServiceFactory } from "../license/license-service"; import { TSecretApprovalPolicyApproverDALFactory } from "./secret-approval-policy-approver-dal"; import { TSecretApprovalPolicyDALFactory } from "./secret-approval-policy-dal"; import { @@ -28,6 +29,7 @@ type TSecretApprovalPolicyServiceFactoryDep = { secretApprovalPolicyDAL: TSecretApprovalPolicyDALFactory; projectEnvDAL: Pick; secretApprovalPolicyApproverDAL: TSecretApprovalPolicyApproverDALFactory; + licenseService: Pick; }; export type TSecretApprovalPolicyServiceFactory = ReturnType; @@ -36,7 +38,8 @@ export const secretApprovalPolicyServiceFactory = ({ secretApprovalPolicyDAL, permissionService, secretApprovalPolicyApproverDAL, - projectEnvDAL + projectEnvDAL, + licenseService }: TSecretApprovalPolicyServiceFactoryDep) => { const createSecretApprovalPolicy = async ({ name, @@ -65,6 +68,15 @@ export const secretApprovalPolicyServiceFactory = ({ ProjectPermissionActions.Create, ProjectPermissionSub.SecretApproval ); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.secretApproval) { + throw new BadRequestError({ + message: + "Failed to create secret approval policy due to plan restriction. Upgrade plan to create secret approval policy." + }); + } + const env = await projectEnvDAL.findOne({ slug: environment, projectId }); if (!env) throw new BadRequestError({ message: "Environment not found" }); @@ -115,6 +127,14 @@ export const secretApprovalPolicyServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.secretApproval) { + throw new BadRequestError({ + message: + "Failed to update secret approval policy due to plan restriction. Upgrade plan to update secret approval policy." + }); + } + const updatedSap = await secretApprovalPolicyDAL.transaction(async (tx) => { const doc = await secretApprovalPolicyDAL.updateById( secretApprovalPolicy.id, @@ -167,6 +187,14 @@ export const secretApprovalPolicyServiceFactory = ({ ProjectPermissionSub.SecretApproval ); + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.secretApproval) { + throw new BadRequestError({ + message: + "Failed to update secret approval policy due to plan restriction. Upgrade plan to update secret approval policy." + }); + } + await secretApprovalPolicyDAL.deleteById(secretPolicyId); return sapPolicy; }; diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts index b827f6a21..592f6ccd0 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts @@ -81,15 +81,13 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { .select({ secVerTagId: "secVerTag.id", secVerTagColor: "secVerTag.color", - secVerTagSlug: "secVerTag.slug", - secVerTagName: "secVerTag.name" + secVerTagSlug: "secVerTag.slug" }) .select( db.ref("id").withSchema(TableName.SecretTag).as("tagId"), db.ref("id").withSchema(TableName.SecretApprovalRequestSecretTag).as("tagJnId"), db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), - db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), - db.ref("name").withSchema(TableName.SecretTag).as("tagName") + db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug") ) .select( db.ref("secretBlindIndex").withSchema(TableName.Secret).as("orgSecBlindIndex"), @@ -124,9 +122,9 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { { key: "tagJnId", label: "tags" as const, - mapper: ({ tagId: id, tagName: name, tagSlug: slug, tagColor: color }) => ({ + mapper: ({ tagId: id, tagSlug: slug, tagColor: color }) => ({ id, - name, + name: slug, slug, color }) @@ -200,11 +198,11 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { { key: "secVerTagId", label: "tags" as const, - mapper: ({ secVerTagId: id, secVerTagName: name, secVerTagSlug: slug, secVerTagColor: color }) => ({ + mapper: ({ secVerTagId: id, secVerTagSlug: slug, secVerTagColor: color }) => ({ // eslint-disable-next-line id, // eslint-disable-next-line - name, + name: slug, // eslint-disable-next-line slug, // eslint-disable-next-line @@ -262,15 +260,13 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { .select({ secVerTagId: "secVerTag.id", secVerTagColor: "secVerTag.color", - secVerTagSlug: "secVerTag.slug", - secVerTagName: "secVerTag.name" + secVerTagSlug: "secVerTag.slug" }) .select( db.ref("id").withSchema(TableName.SecretTag).as("tagId"), db.ref("id").withSchema(TableName.SecretApprovalRequestSecretTagV2).as("tagJnId"), db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), - db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), - db.ref("name").withSchema(TableName.SecretTag).as("tagName") + db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug") ) .select( db.ref("version").withSchema(TableName.SecretV2).as("orgSecVersion"), @@ -292,9 +288,9 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { { key: "tagJnId", label: "tags" as const, - mapper: ({ tagId: id, tagName: name, tagSlug: slug, tagColor: color }) => ({ + mapper: ({ tagId: id, tagSlug: slug, tagColor: color }) => ({ id, - name, + name: slug, slug, color }) @@ -330,11 +326,11 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { { key: "secVerTagId", label: "tags" as const, - mapper: ({ secVerTagId: id, secVerTagName: name, secVerTagSlug: slug, secVerTagColor: color }) => ({ + mapper: ({ secVerTagId: id, secVerTagSlug: slug, secVerTagColor: color }) => ({ // eslint-disable-next-line id, // eslint-disable-next-line - name, + name: slug, // eslint-disable-next-line slug, // eslint-disable-next-line diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 4ae4d25ea..f6bb33168 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -50,6 +50,7 @@ import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/se import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TLicenseServiceFactory } from "../license/license-service"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; import { TSecretSnapshotServiceFactory } from "../secret-snapshot/secret-snapshot-service"; @@ -97,6 +98,7 @@ type TSecretApprovalRequestServiceFactoryDep = { >; secretVersionV2BridgeDAL: Pick; secretVersionTagV2BridgeDAL: Pick; + licenseService: Pick; }; export type TSecretApprovalRequestServiceFactory = ReturnType; @@ -122,7 +124,8 @@ export const secretApprovalRequestServiceFactory = ({ kmsService, secretV2BridgeDAL, secretVersionV2BridgeDAL, - secretVersionTagV2BridgeDAL + secretVersionTagV2BridgeDAL, + licenseService }: TSecretApprovalRequestServiceFactoryDep) => { const requestCount = async ({ projectId, actor, actorId, actorOrgId, actorAuthMethod }: TApprovalRequestCountDTO) => { if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); @@ -224,12 +227,10 @@ export const secretApprovalRequestServiceFactory = ({ secretKey: el.key, id: el.id, version: el.version, - secretValue: el.encryptedValue - ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() - : undefined, + secretValue: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", secretComment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() - : undefined, + : "", secret: el.secret ? { secretKey: el.secret.key, @@ -237,10 +238,10 @@ export const secretApprovalRequestServiceFactory = ({ version: el.secret.version, secretValue: el.secret.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedValue }).toString() - : undefined, + : "", secretComment: el.secret.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedComment }).toString() - : undefined + : "" } : undefined, secretVersion: el.secretVersion @@ -250,10 +251,10 @@ export const secretApprovalRequestServiceFactory = ({ version: el.secretVersion.version, secretValue: el.secretVersion.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedValue }).toString() - : undefined, + : "", secretComment: el.secretVersion.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedComment }).toString() - : undefined + : "" } : undefined })); @@ -297,6 +298,14 @@ export const secretApprovalRequestServiceFactory = ({ if (!secretApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" }); if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.secretApproval) { + throw new BadRequestError({ + message: + "Failed to review secret approval request due to plan restriction. Upgrade plan to review secret approval request." + }); + } + const { policy } = secretApprovalRequest; const { hasRole } = await permissionService.getProjectPermission( ActorType.USER, @@ -347,6 +356,14 @@ export const secretApprovalRequestServiceFactory = ({ if (!secretApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" }); if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.secretApproval) { + throw new BadRequestError({ + message: + "Failed to update secret approval request due to plan restriction. Upgrade plan to update secret approval request." + }); + } + const { policy } = secretApprovalRequest; const { hasRole } = await permissionService.getProjectPermission( ActorType.USER, @@ -388,6 +405,14 @@ export const secretApprovalRequestServiceFactory = ({ if (!secretApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" }); if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.secretApproval) { + throw new BadRequestError({ + message: + "Failed to merge secret approval request due to plan restriction. Upgrade plan to merge secret approval request." + }); + } + const { policy, folderId, projectId } = secretApprovalRequest; const { hasRole } = await permissionService.getProjectPermission( ActorType.USER, diff --git a/backend/src/ee/services/secret-replication/secret-replication-service.ts b/backend/src/ee/services/secret-replication/secret-replication-service.ts index 52d10b690..b77d6cbc2 100644 --- a/backend/src/ee/services/secret-replication/secret-replication-service.ts +++ b/backend/src/ee/services/secret-replication/secret-replication-service.ts @@ -257,7 +257,7 @@ export const secretReplicationServiceFactory = ({ secretDAL: secretV2BridgeDAL, folderDAL, secretImportDAL, - decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : undefined) + decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : "") }); // secrets that gets replicated across imports const sourceDecryptedLocalSecrets = sourceLocalSecrets.map((el) => ({ @@ -449,7 +449,7 @@ export const secretReplicationServiceFactory = ({ }); } if (locallyDeletedSecrets.length) { - await secretDAL.delete( + await secretV2BridgeDAL.delete( { $in: { id: locallyDeletedSecrets.map(({ id }) => id) diff --git a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts index c5d8bd4fa..225339104 100644 --- a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts +++ b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts @@ -164,10 +164,10 @@ export const secretSnapshotServiceFactory = ({ secretKey: el.key, secretValue: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() - : undefined, + : "", secretComment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() - : undefined + : "" })) }; } else { diff --git a/backend/src/ee/services/secret-snapshot/snapshot-dal.ts b/backend/src/ee/services/secret-snapshot/snapshot-dal.ts index 8e5014fdd..e4d341c1a 100644 --- a/backend/src/ee/services/secret-snapshot/snapshot-dal.ts +++ b/backend/src/ee/services/secret-snapshot/snapshot-dal.ts @@ -100,8 +100,7 @@ export const snapshotDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.SecretTag).as("tagId"), db.ref("id").withSchema(TableName.SecretVersionTag).as("tagVersionId"), db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), - db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), - db.ref("name").withSchema(TableName.SecretTag).as("tagName") + db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug") ); return sqlNestRelationships({ data, @@ -132,9 +131,9 @@ export const snapshotDALFactory = (db: TDbClient) => { { key: "tagVersionId", label: "tags" as const, - mapper: ({ tagId: id, tagName: name, tagSlug: slug, tagColor: color, tagVersionId: vId }) => ({ + mapper: ({ tagId: id, tagSlug: slug, tagColor: color, tagVersionId: vId }) => ({ id, - name, + name: slug, slug, color, vId @@ -195,8 +194,7 @@ export const snapshotDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.SecretTag).as("tagId"), db.ref("id").withSchema(TableName.SecretVersionV2Tag).as("tagVersionId"), db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), - db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), - db.ref("name").withSchema(TableName.SecretTag).as("tagName") + db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug") ); return sqlNestRelationships({ data, @@ -227,9 +225,9 @@ export const snapshotDALFactory = (db: TDbClient) => { { key: "tagVersionId", label: "tags" as const, - mapper: ({ tagId: id, tagName: name, tagSlug: slug, tagColor: color, tagVersionId: vId }) => ({ + mapper: ({ tagId: id, tagSlug: slug, tagColor: color, tagVersionId: vId }) => ({ id, - name, + name: slug, slug, color, vId @@ -353,8 +351,7 @@ export const snapshotDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.SecretTag).as("tagId"), db.ref("id").withSchema(TableName.SecretVersionTag).as("tagVersionId"), db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), - db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), - db.ref("name").withSchema(TableName.SecretTag).as("tagName") + db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug") ); const formated = sqlNestRelationships({ @@ -377,9 +374,9 @@ export const snapshotDALFactory = (db: TDbClient) => { { key: "tagVersionId", label: "tags" as const, - mapper: ({ tagId: id, tagName: name, tagSlug: slug, tagColor: color, tagVersionId: vId }) => ({ + mapper: ({ tagId: id, tagSlug: slug, tagColor: color, tagVersionId: vId }) => ({ id, - name, + name: slug, slug, color, vId @@ -508,8 +505,7 @@ export const snapshotDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.SecretTag).as("tagId"), db.ref("id").withSchema(TableName.SecretVersionV2Tag).as("tagVersionId"), db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), - db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), - db.ref("name").withSchema(TableName.SecretTag).as("tagName") + db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug") ); const formated = sqlNestRelationships({ @@ -532,9 +528,9 @@ export const snapshotDALFactory = (db: TDbClient) => { { key: "tagVersionId", label: "tags" as const, - mapper: ({ tagId: id, tagName: name, tagSlug: slug, tagColor: color, tagVersionId: vId }) => ({ + mapper: ({ tagId: id, tagSlug: slug, tagColor: color, tagVersionId: vId }) => ({ id, - name, + name: slug, slug, color, vId diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index d7bbb0c79..f6d5d1c6e 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -5,17 +5,26 @@ import { Redlock, Settings } from "@app/lib/red-lock"; export type TKeyStoreFactory = ReturnType; // all the key prefixes used must be set here to avoid conflict -export enum KeyStorePrefixes { - SecretReplication = "secret-replication-import-lock", - KmsProjectDataKeyCreation = "kms-project-data-key-creation-lock", - KmsProjectKeyCreation = "kms-project-key-creation-lock", - WaitUntilReadyKmsProjectDataKeyCreation = "wait-until-ready-kms-project-data-key-creation-", - WaitUntilReadyKmsProjectKeyCreation = "wait-until-ready-kms-project-key-creation-", - KmsOrgKeyCreation = "kms-org-key-creation-lock", - KmsOrgDataKeyCreation = "kms-org-data-key-creation-lock", - WaitUntilReadyKmsOrgKeyCreation = "wait-until-ready-kms-org-key-creation-", - WaitUntilReadyKmsOrgDataKeyCreation = "wait-until-ready-kms-org-data-key-creation-" -} +export const KeyStorePrefixes = { + SecretReplication: "secret-replication-import-lock", + KmsProjectDataKeyCreation: "kms-project-data-key-creation-lock", + KmsProjectKeyCreation: "kms-project-key-creation-lock", + WaitUntilReadyKmsProjectDataKeyCreation: "wait-until-ready-kms-project-data-key-creation-", + WaitUntilReadyKmsProjectKeyCreation: "wait-until-ready-kms-project-key-creation-", + KmsOrgKeyCreation: "kms-org-key-creation-lock", + KmsOrgDataKeyCreation: "kms-org-data-key-creation-lock", + WaitUntilReadyKmsOrgKeyCreation: "wait-until-ready-kms-org-key-creation-", + WaitUntilReadyKmsOrgDataKeyCreation: "wait-until-ready-kms-org-data-key-creation-", + + SyncSecretIntegrationLock: (projectId: string, environmentSlug: string, secretPath: string) => + `sync-integration-mutex-${projectId}-${environmentSlug}-${secretPath}` as const, + SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) => + `sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const +}; + +export const KeyStoreTtls = { + SetSyncSecretIntegrationLastRunTimestampInSeconds: 10 +}; type TWaitTillReady = { key: string; @@ -37,10 +46,10 @@ export const keyStoreFactory = (redisUrl: string) => { const setItemWithExpiry = async ( key: string, - exp: number | string, + expiryInSeconds: number | string, value: string | number | Buffer, prefix?: string - ) => redis.set(prefix ? `${prefix}:${key}` : key, value, "EX", exp); + ) => redis.set(prefix ? `${prefix}:${key}` : key, value, "EX", expiryInSeconds); const deleteItem = async (key: string) => redis.del(key); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 336776dbf..f4d645165 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -596,7 +596,8 @@ export const RAW_SECRETS = { "The slug of the project to list secrets from. This parameter is only applicable by machine identities.", environment: "The slug of the environment to list secrets from.", secretPath: "The secret path to list secrets from.", - includeImports: "Weather to include imported secrets or not." + includeImports: "Weather to include imported secrets or not.", + tagSlugs: "The comma separated tag slugs to filter secrets" }, CREATE: { secretName: "The name of the secret to create.", @@ -1088,6 +1089,8 @@ export const CERTIFICATE_AUTHORITIES = { }, ISSUE_CERT: { caId: "The ID of the CA to issue the certificate from", + certificateTemplateId: "The ID of the certificate template to issue the certificate from", + pkiCollectionId: "The ID of the PKI collection to add the certificate to", friendlyName: "A friendly name for the certificate", commonName: "The common name (CN) for the certificate", altNames: @@ -1103,6 +1106,7 @@ export const CERTIFICATE_AUTHORITIES = { }, SIGN_CERT: { caId: "The ID of the CA to issue the certificate from", + pkiCollectionId: "The ID of the PKI collection to add the certificate to", csr: "The pem-encoded CSR to sign with the CA to be used for certificate issuance", friendlyName: "A friendly name for the certificate", commonName: "The common name (CN) for the certificate", @@ -1144,6 +1148,32 @@ export const CERTIFICATES = { } }; +export const CERTIFICATE_TEMPLATES = { + CREATE: { + caId: "The ID of the certificate authority to associate the template with", + pkiCollectionId: "The ID of the PKI collection to bind to the template", + name: "The name of the template", + commonName: "The regular expression string to use for validating common names", + subjectAlternativeName: "The regular expression string to use for validating subject alternative names", + ttl: "The max TTL for the template" + }, + GET: { + certificateTemplateId: "The ID of the certificate template to get" + }, + UPDATE: { + certificateTemplateId: "The ID of the certificate template to update", + caId: "The ID of the certificate authority to update the association with the template", + pkiCollectionId: "The ID of the PKI collection to update the binding to the template", + name: "The updated name of the template", + commonName: "The updated regular expression string for validating common names", + subjectAlternativeName: "The updated regular expression string for validating subject alternative names", + ttl: "The updated max TTL for the template" + }, + DELETE: { + certificateTemplateId: "The ID of the certificate template to delete" + } +}; + export const ALERTS = { CREATE: { projectId: "The ID of the project to create the alert in", @@ -1170,14 +1200,16 @@ export const ALERTS = { export const PKI_COLLECTIONS = { CREATE: { projectId: "The ID of the project to create the PKI collection in", - name: "The name of the PKI collection" + name: "The name of the PKI collection", + description: "A description for the PKI collection" }, GET: { collectionId: "The ID of the PKI collection to get" }, UPDATE: { collectionId: "The ID of the PKI collection to update", - name: "The name of the PKI collection to update to" + name: "The name of the PKI collection to update to", + description: "The description for the PKI collection to update to" }, DELETE: { collectionId: "The ID of the PKI collection to delete" diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 90f04d952..8a2b961e9 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -140,7 +140,8 @@ const envSchema = z MAINTENANCE_MODE: zodStrBool.default("false"), CAPTCHA_SECRET: zpStr(z.string().optional()), PLAIN_API_KEY: zpStr(z.string().optional()), - PLAIN_WISH_LABEL_IDS: zpStr(z.string().optional()) + PLAIN_WISH_LABEL_IDS: zpStr(z.string().optional()), + DISABLE_AUDIT_LOG_GENERATION: zodStrBool.default("false") }) .transform((data) => ({ ...data, diff --git a/backend/src/lib/fn/dates.ts b/backend/src/lib/fn/dates.ts index f9ea4db10..cd5ca5c12 100644 --- a/backend/src/lib/fn/dates.ts +++ b/backend/src/lib/fn/dates.ts @@ -1,2 +1,8 @@ export const getLastMidnightDateISO = (last = 1) => `${new Date(new Date().setDate(new Date().getDate() - last)).toISOString().slice(0, 10)}T00:00:00Z`; + +export const getTimeDifferenceInSeconds = (lhsTimestamp: string, rhsTimestamp: string) => { + const lhs = new Date(lhsTimestamp); + const rhs = new Date(rhsTimestamp); + return Math.floor((Number(lhs) - Number(rhs)) / 1000); +}; diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index dcab16218..cbf5db3b0 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -128,6 +128,16 @@ export const ormify = (db: Kne throw new DatabaseError({ error, name: "Create" }); } }, + // This spilit the insert into multiple chunk + batchInsert: async (data: readonly Tables[Tname]["insert"][], tx?: Knex) => { + try { + if (!data.length) return []; + const res = await (tx || db).batchInsert(tableName, data as never).returning("*"); + return res as Tables[Tname]["base"][]; + } catch (error) { + throw new DatabaseError({ error, name: "batchInsert" }); + } + }, upsert: async (data: readonly Tables[Tname]["insert"][], onConflictField: keyof Tables[Tname]["base"], tx?: Knex) => { try { if (!data.length) return []; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index ec222f0fa..48330cf04 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -16,6 +16,7 @@ export enum QueueName { // TODO(akhilmhdh): This will get removed later. For now this is kept to stop the repeatable queue AuditLogPrune = "audit-log-prune", DailyResourceCleanUp = "daily-resource-cleanup", + DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert", TelemetryInstanceStats = "telemtry-self-hosted-stats", IntegrationSync = "sync-integrations", SecretWebhook = "secret-webhook", @@ -36,6 +37,7 @@ export enum QueueJobs { // TODO(akhilmhdh): This will get removed later. For now this is kept to stop the repeatable queue AuditLogPrune = "audit-log-prune-job", DailyResourceCleanUp = "daily-resource-cleanup-job", + DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert", SecWebhook = "secret-webhook-trigger", TelemetryInstanceStats = "telemetry-self-hosted-stats", IntegrationSync = "secret-integration-pull", @@ -71,6 +73,10 @@ export type TQueueJobTypes = { name: QueueJobs.DailyResourceCleanUp; payload: undefined; }; + [QueueName.DailyExpiringPkiItemAlert]: { + name: QueueJobs.DailyExpiringPkiItemAlert; + payload: undefined; + }; [QueueName.AuditLogPrune]: { name: QueueJobs.AuditLogPrune; payload: undefined; diff --git a/backend/src/server/config/rateLimiter.ts b/backend/src/server/config/rateLimiter.ts index 79b709ee6..bdbf80371 100644 --- a/backend/src/server/config/rateLimiter.ts +++ b/backend/src/server/config/rateLimiter.ts @@ -1,7 +1,6 @@ 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"; export const globalRateLimiterCfg = (): RateLimitPluginOptions => { @@ -22,14 +21,16 @@ export const globalRateLimiterCfg = (): RateLimitPluginOptions => { // GET endpoints export const readLimit: RateLimitOptions = { timeWindow: 60 * 1000, - max: () => getRateLimiterConfig().readLimit, + hook: "preValidation", + max: (req) => req.rateLimits.readLimit, keyGenerator: (req) => req.realIp }; // POST, PATCH, PUT, DELETE endpoints export const writeLimit: RateLimitOptions = { timeWindow: 60 * 1000, - max: () => getRateLimiterConfig().writeLimit, + hook: "preValidation", + max: (req) => req.rateLimits.writeLimit, keyGenerator: (req) => req.realIp }; @@ -37,42 +38,40 @@ export const writeLimit: RateLimitOptions = { export const secretsLimit: RateLimitOptions = { // secrets, folders, secret imports timeWindow: 60 * 1000, - max: () => getRateLimiterConfig().secretsLimit, + hook: "preValidation", + max: (req) => req.rateLimits.secretsLimit, keyGenerator: (req) => req.realIp }; export const authRateLimit: RateLimitOptions = { timeWindow: 60 * 1000, - max: () => getRateLimiterConfig().authRateLimit, + hook: "preValidation", + max: (req) => req.rateLimits.authRateLimit, keyGenerator: (req) => req.realIp }; export const inviteUserRateLimit: RateLimitOptions = { timeWindow: 60 * 1000, - max: () => getRateLimiterConfig().inviteUserRateLimit, + hook: "preValidation", + max: (req) => req.rateLimits.inviteUserRateLimit, keyGenerator: (req) => req.realIp }; export const mfaRateLimit: RateLimitOptions = { timeWindow: 60 * 1000, - max: () => getRateLimiterConfig().mfaRateLimit, + hook: "preValidation", + max: (req) => req.rateLimits.mfaRateLimit, keyGenerator: (req) => { return req.headers.authorization?.split(" ")[1] || req.realIp; } }; -export const creationLimit: RateLimitOptions = { - // identity, project, org - timeWindow: 60 * 1000, - max: () => getRateLimiterConfig().creationLimit, - keyGenerator: (req) => req.realIp -}; - // Public endpoints to avoid brute force attacks export const publicEndpointLimit: RateLimitOptions = { // Read Shared Secrets timeWindow: 60 * 1000, - max: () => getRateLimiterConfig().publicEndpointLimit, + hook: "preValidation", + max: (req) => req.rateLimits.publicEndpointLimit, keyGenerator: (req) => req.realIp }; diff --git a/backend/src/server/plugins/inject-rate-limits.ts b/backend/src/server/plugins/inject-rate-limits.ts new file mode 100644 index 000000000..1674ea542 --- /dev/null +++ b/backend/src/server/plugins/inject-rate-limits.ts @@ -0,0 +1,38 @@ +import fp from "fastify-plugin"; + +import { getRateLimiterConfig } from "@app/ee/services/rate-limit/rate-limit-service"; +import { getConfig } from "@app/lib/config/env"; + +export const injectRateLimits = fp(async (server) => { + server.decorateRequest("rateLimits", null); + server.addHook("onRequest", async (req) => { + const appCfg = getConfig(); + + const instanceRateLimiterConfig = getRateLimiterConfig(); + if (!req.auth?.orgId) { + // for public endpoints, we always use the instance-wide default rate limits + req.rateLimits = instanceRateLimiterConfig; + return; + } + + const { rateLimits, customRateLimits } = await server.services.license.getPlan(req.auth.orgId); + + if (customRateLimits && !appCfg.isCloud) { + // we do this because for self-hosted/dedicated instances, we want custom rate limits to be based on admin configuration + // note that the syncing of custom rate limit happens on the instanceRateLimiterConfig object + req.rateLimits = instanceRateLimiterConfig; + return; + } + + // we're using the null coalescing operator in order to handle outdated licenses + req.rateLimits = { + readLimit: rateLimits?.readLimit ?? instanceRateLimiterConfig.readLimit, + writeLimit: rateLimits?.writeLimit ?? instanceRateLimiterConfig.writeLimit, + secretsLimit: rateLimits?.secretsLimit ?? instanceRateLimiterConfig.secretsLimit, + publicEndpointLimit: instanceRateLimiterConfig.publicEndpointLimit, + authRateLimit: instanceRateLimiterConfig.authRateLimit, + inviteUserRateLimit: instanceRateLimiterConfig.inviteUserRateLimit, + mfaRateLimit: instanceRateLimiterConfig.mfaRateLimit + }; + }); +}); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index f0c3cbd5b..d5735ff9f 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -90,6 +90,8 @@ import { certificateAuthorityEstConfigDALFactory } from "@app/services/certifica 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 { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; +import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-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"; @@ -132,6 +134,7 @@ import { orgRoleServiceFactory } from "@app/services/org/org-role-service"; import { orgServiceFactory } from "@app/services/org/org-service"; import { orgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; +import { dailyExpiringPkiItemAlertQueueServiceFactory } from "@app/services/pki-alert/expiring-pki-item-alert-queue"; import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal"; import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; import { pkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal"; @@ -190,6 +193,7 @@ import { webhookServiceFactory } from "@app/services/webhook/webhook-service"; import { injectAuditLogInfo } from "../plugins/audit-log"; import { injectIdentity } from "../plugins/auth/inject-identity"; import { injectPermission } from "../plugins/auth/inject-permission"; +import { injectRateLimits } from "../plugins/inject-rate-limits"; import { registerSecretScannerGhApp } from "../plugins/secret-scanner"; import { registerCertificateEstRouter } from "./est/certificate-est-router"; import { registerV1Routes } from "./v1"; @@ -362,7 +366,8 @@ export const registerRoutes = async ( projectEnvDAL, secretApprovalPolicyApproverDAL: sapApproverDAL, permissionService, - secretApprovalPolicyDAL + secretApprovalPolicyDAL, + licenseService }); const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL, orgMembershipDAL }); @@ -587,6 +592,7 @@ export const registerRoutes = async ( const certificateAuthoritySecretDAL = certificateAuthoritySecretDALFactory(db); const certificateAuthorityCrlDAL = certificateAuthorityCrlDALFactory(db); const certificateAuthorityEstConfigDAL = certificateAuthorityEstConfigDALFactory(db); + const certificateTemplateDAL = certificateTemplateDALFactory(db); const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); @@ -622,9 +628,12 @@ export const registerRoutes = async ( certificateAuthorityCertDAL, certificateAuthoritySecretDAL, certificateAuthorityCrlDAL, + certificateTemplateDAL, certificateAuthorityQueue, certificateDAL, certificateBodyDAL, + pkiCollectionDAL, + pkiCollectionItemDAL, projectDAL, kmsService, permissionService, @@ -640,6 +649,12 @@ export const registerRoutes = async ( licenseService }); + const certificateTemplateService = certificateTemplateServiceFactory({ + certificateTemplateDAL, + certificateAuthorityDAL, + permissionService + }); + const pkiAlertService = pkiAlertServiceFactory({ pkiAlertDAL, pkiCollectionDAL, @@ -677,7 +692,8 @@ export const registerRoutes = async ( identityProjectMembershipRoleDAL, keyStore, kmsService, - projectBotDAL + projectBotDAL, + certificateTemplateDAL }); const projectEnvService = projectEnvServiceFactory({ @@ -740,6 +756,7 @@ export const registerRoutes = async ( kmsService }); const secretQueueService = secretQueueFactory({ + keyStore, queueService, secretDAL, folderDAL, @@ -825,7 +842,8 @@ export const registerRoutes = async ( secretVersionTagV2BridgeDAL, smtpService, projectEnvDAL, - userDAL + userDAL, + licenseService }); const secretService = secretServiceFactory({ @@ -926,8 +944,15 @@ export const registerRoutes = async ( folderDAL, integrationDAL, integrationAuthDAL, - secretQueueService + secretQueueService, + integrationAuthService, + projectBotService, + secretV2BridgeDAL, + secretImportDAL, + secretDAL, + kmsService }); + const serviceTokenService = serviceTokenServiceFactory({ projectEnvDAL, serviceTokenDAL, @@ -1054,13 +1079,18 @@ export const registerRoutes = async ( const dailyResourceCleanUp = dailyResourceCleanUpQueueServiceFactory({ auditLogDAL, queueService, - pkiAlertService, secretVersionDAL, secretFolderVersionDAL: folderVersionDAL, snapshotDAL, identityAccessTokenDAL, secretSharingDAL, - secretVersionV2DAL: secretVersionV2BridgeDAL + secretVersionV2DAL: secretVersionV2BridgeDAL, + identityUniversalAuthClientSecretDAL: identityUaClientSecretDAL + }); + + const dailyExpiringPkiItemAlert = dailyExpiringPkiItemAlertQueueServiceFactory({ + queueService, + pkiAlertService }); const oidcService = oidcConfigServiceFactory({ @@ -1087,6 +1117,7 @@ export const registerRoutes = async ( await telemetryQueue.startTelemetryCheck(); await dailyResourceCleanUp.startCleanUp(); + await dailyExpiringPkiItemAlert.startSendingAlerts(); await kmsService.startService(); // inject all services @@ -1144,6 +1175,7 @@ export const registerRoutes = async ( auditLogStream: auditLogStreamService, certificate: certificateService, certificateAuthority: certificateAuthorityService, + certificateTemplate: certificateTemplateService, certificateAuthorityCrl: certificateAuthorityCrlService, pkiAlert: pkiAlertService, pkiCollection: pkiCollectionService, @@ -1175,6 +1207,7 @@ export const registerRoutes = async ( await server.register(injectIdentity, { userDAL, serviceTokenDAL }); await server.register(injectPermission); + await server.register(injectRateLimits); await server.register(injectAuditLogInfo); server.route({ diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index a10a962cf..02d9e37e0 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -63,8 +63,8 @@ export const secretRawSchema = z.object({ version: z.number(), type: z.string(), secretKey: z.string(), - secretValue: z.string().optional(), - secretComment: z.string().optional(), + secretValue: z.string(), + secretComment: z.string(), secretReminderNote: z.string().nullable().optional(), secretReminderRepeatDays: z.number().nullable().optional(), skipMultilineEncoding: z.boolean().default(false).nullable().optional(), diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts index a5df73bbf..896dbf18e 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -284,7 +284,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - description: "Renew CA certificate for CA", + description: "Perform CA certificate renewal", params: z.object({ caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.RENEW_CA_CERT.caId) }), @@ -557,6 +557,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }), body: z .object({ + pkiCollectionId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.pkiCollectionId), friendlyName: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.friendlyName), commonName: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.commonName), altNames: validateAltNamesField.describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.altNames), @@ -636,6 +637,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { body: z .object({ csr: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.csr), + pkiCollectionId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.pkiCollectionId), friendlyName: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.friendlyName), commonName: z.string().trim().min(1).optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.commonName), altNames: validateAltNamesField.describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.altNames), diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index 938fbf7fe..c492e0667 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -1,12 +1,17 @@ +import ms from "ms"; 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 { CERTIFICATE_AUTHORITIES, 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"; +import { + validateAltNamesField, + validateCaDateField +} from "@app/services/certificate-authority/certificate-authority-validators"; export const registerCertRouter = async (server: FastifyZodProvider) => { server.route({ @@ -55,6 +60,185 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/issue-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Issue certificate", + body: z + .object({ + caId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.caId), + certificateTemplateId: z + .string() + .trim() + .optional() + .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateTemplateId), + pkiCollectionId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.pkiCollectionId), + friendlyName: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.friendlyName), + commonName: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.commonName), + altNames: validateAltNamesField.describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.altNames), + 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"] + } + ) + .refine( + (data) => + (data.caId !== undefined && data.certificateTemplateId === undefined) || + (data.caId === undefined && data.certificateTemplateId !== undefined), + { + message: "Either CA ID or Certificate Template ID must be present, but not both", + path: ["caId", "certificateTemplateId"] + } + ), + 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({ + 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 + }; + } + }); + + server.route({ + method: "POST", + url: "/sign-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Sign certificate", + body: z + .object({ + caId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.caId), + certificateTemplateId: z + .string() + .trim() + .optional() + .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateTemplateId), + pkiCollectionId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.pkiCollectionId), + csr: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.csr), + friendlyName: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.friendlyName), + commonName: z.string().trim().min(1).optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.commonName), + altNames: validateAltNamesField.describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.altNames), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.ttl), + notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.notBefore), + notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_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"] + } + ) + .refine( + (data) => + (data.caId !== undefined && data.certificateTemplateId === undefined) || + (data.caId === undefined && data.certificateTemplateId !== undefined), + { + message: "Either CA ID or Certificate Template ID must be present, but not both", + path: ["caId", "certificateTemplateId"] + } + ), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.certificate), + issuingCaCertificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.issuingCaCertificate), + certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateChain), + serialNumber: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca } = + await server.services.certificateAuthority.signCertFromCa({ + 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_CERT, + metadata: { + caId: ca.id, + dn: ca.dn, + serialNumber + } + } + }); + + return { + certificate, + certificateChain, + issuingCaCertificate, + serialNumber + }; + } + }); + server.route({ method: "POST", url: "/:serialNumber/revoke", diff --git a/backend/src/server/routes/v1/certificate-template-router.ts b/backend/src/server/routes/v1/certificate-template-router.ts new file mode 100644 index 000000000..3f3ecd5b3 --- /dev/null +++ b/backend/src/server/routes/v1/certificate-template-router.ts @@ -0,0 +1,205 @@ +import ms from "ms"; +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { CERTIFICATE_TEMPLATES } 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 { sanitizedCertificateTemplate } from "@app/services/certificate-template/certificate-template-schema"; +import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; + +export const registerCertificateTemplateRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:certificateTemplateId", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + certificateTemplateId: z.string().describe(CERTIFICATE_TEMPLATES.GET.certificateTemplateId) + }), + response: { + 200: sanitizedCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplate.getCertTemplate({ + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.GET_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + caId: z.string().describe(CERTIFICATE_TEMPLATES.CREATE.caId), + pkiCollectionId: z.string().optional().describe(CERTIFICATE_TEMPLATES.CREATE.pkiCollectionId), + name: z.string().min(1).describe(CERTIFICATE_TEMPLATES.CREATE.name), + commonName: validateTemplateRegexField.describe(CERTIFICATE_TEMPLATES.CREATE.commonName), + subjectAlternativeName: validateTemplateRegexField.describe( + CERTIFICATE_TEMPLATES.CREATE.subjectAlternativeName + ), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .describe(CERTIFICATE_TEMPLATES.CREATE.ttl) + }), + response: { + 200: sanitizedCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplate.createCertTemplate({ + 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: certificateTemplate.projectId, + event: { + type: EventType.CREATE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + caId: certificateTemplate.caId, + pkiCollectionId: certificateTemplate.pkiCollectionId as string, + name: certificateTemplate.name, + commonName: certificateTemplate.commonName, + subjectAlternativeName: certificateTemplate.subjectAlternativeName, + ttl: certificateTemplate.ttl + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "PATCH", + url: "/:certificateTemplateId", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + caId: z.string().optional().describe(CERTIFICATE_TEMPLATES.UPDATE.caId), + pkiCollectionId: z.string().optional().describe(CERTIFICATE_TEMPLATES.UPDATE.pkiCollectionId), + name: z.string().min(1).optional().describe(CERTIFICATE_TEMPLATES.UPDATE.name), + commonName: validateTemplateRegexField.optional().describe(CERTIFICATE_TEMPLATES.UPDATE.commonName), + subjectAlternativeName: validateTemplateRegexField + .optional() + .describe(CERTIFICATE_TEMPLATES.UPDATE.subjectAlternativeName), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(CERTIFICATE_TEMPLATES.UPDATE.ttl) + }), + params: z.object({ + certificateTemplateId: z.string().describe(CERTIFICATE_TEMPLATES.UPDATE.certificateTemplateId) + }), + response: { + 200: sanitizedCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplate.updateCertTemplate({ + ...req.body, + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.UPDATE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + caId: certificateTemplate.caId, + pkiCollectionId: certificateTemplate.pkiCollectionId as string, + name: certificateTemplate.name, + commonName: certificateTemplate.commonName, + subjectAlternativeName: certificateTemplate.subjectAlternativeName, + ttl: certificateTemplate.ttl + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "DELETE", + url: "/:certificateTemplateId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + certificateTemplateId: z.string().describe(CERTIFICATE_TEMPLATES.DELETE.certificateTemplateId) + }), + response: { + 200: sanitizedCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplate.deleteCertTemplate({ + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.DELETE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id + } + } + }); + + return certificateTemplate; + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index b1cd6cd9d..23aca9625 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { IdentitiesSchema, IdentityOrgMembershipsSchema, OrgMembershipRole, OrgRolesSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { IDENTITIES } from "@app/lib/api-docs"; -import { creationLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { 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"; @@ -16,7 +16,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/", config: { - rateLimit: creationLimit + rateLimit: writeLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 1a5a56bd0..e5f35565f 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -3,6 +3,7 @@ import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; import { registerCaRouter } from "./certificate-authority-router"; import { registerCertRouter } from "./certificate-router"; +import { registerCertificateTemplateRouter } from "./certificate-template-router"; import { registerIdentityAccessTokenRouter } from "./identity-access-token-router"; import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router"; import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router"; @@ -76,8 +77,9 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { async (pkiRouter) => { await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); await pkiRouter.register(registerCertRouter, { prefix: "/certificates" }); - await server.register(registerPkiAlertRouter, { prefix: "/alerts" }); - await server.register(registerPkiCollectionRouter, { prefix: "/collections" }); + await pkiRouter.register(registerCertificateTemplateRouter, { prefix: "/certificate-templates" }); + await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" }); + await pkiRouter.register(registerPkiCollectionRouter, { prefix: "/collections" }); }, { prefix: "/pki" } ); diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index 97a7f4d7a..6526dd940 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -170,6 +170,12 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { params: z.object({ integrationId: z.string().trim().describe(INTEGRATION.DELETE.integrationId) }), + querystring: z.object({ + shouldDeleteIntegrationSecrets: z + .enum(["true", "false"]) + .optional() + .transform((val) => val === "true") + }), response: { 200: z.object({ integration: IntegrationsSchema @@ -183,7 +189,8 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { actorAuthMethod: req.permission.authMethod, actor: req.permission.type, actorOrgId: req.permission.orgId, - id: req.params.integrationId + id: req.params.integrationId, + shouldDeleteIntegrationSecrets: req.query.shouldDeleteIntegrationSecrets }); await server.services.auditLog.createAuditLog({ @@ -205,7 +212,8 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { targetService: integration.targetService, targetServiceId: integration.targetServiceId, path: integration.path, - region: integration.region + region: integration.region, + shouldDeleteIntegrationSecrets: req.query.shouldDeleteIntegrationSecrets // eslint-disable-next-line }) as any } diff --git a/backend/src/server/routes/v1/pki-alert-router.ts b/backend/src/server/routes/v1/pki-alert-router.ts index afaa99bdf..f64ec9e47 100644 --- a/backend/src/server/routes/v1/pki-alert-router.ts +++ b/backend/src/server/routes/v1/pki-alert-router.ts @@ -22,7 +22,11 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { pkiCollectionId: z.string().trim().describe(ALERTS.CREATE.pkiCollectionId), name: z.string().trim().describe(ALERTS.CREATE.name), alertBeforeDays: z.number().describe(ALERTS.CREATE.alertBeforeDays), - emails: z.array(z.string().trim().email({ message: "Invalid email address" })).describe(ALERTS.CREATE.emails) + emails: z + .array(z.string().trim().email({ message: "Invalid email address" })) + .min(1, { message: "You must specify at least 1 email" }) + .max(5, { message: "You can specify a maximum of 5 emails" }) + .describe(ALERTS.CREATE.emails) }), response: { 200: PkiAlertsSchema @@ -114,6 +118,8 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { pkiCollectionId: z.string().trim().optional().describe(ALERTS.UPDATE.pkiCollectionId), emails: z .array(z.string().trim().email({ message: "Invalid email address" })) + .min(1, { message: "You must specify at least 1 email" }) + .max(5, { message: "You can specify a maximum of 5 emails" }) .optional() .describe(ALERTS.UPDATE.emails) }), diff --git a/backend/src/server/routes/v1/pki-collection-router.ts b/backend/src/server/routes/v1/pki-collection-router.ts index 6a48b5e76..2f2add5c1 100644 --- a/backend/src/server/routes/v1/pki-collection-router.ts +++ b/backend/src/server/routes/v1/pki-collection-router.ts @@ -20,7 +20,8 @@ export const registerPkiCollectionRouter = async (server: FastifyZodProvider) => description: "Create PKI collection", body: z.object({ projectId: z.string().trim().describe(PKI_COLLECTIONS.CREATE.projectId), - name: z.string().trim().describe(PKI_COLLECTIONS.CREATE.name) + name: z.string().trim().describe(PKI_COLLECTIONS.CREATE.name), + description: z.string().trim().default("").describe(PKI_COLLECTIONS.CREATE.description) }), response: { 200: PkiCollectionsSchema @@ -104,7 +105,8 @@ export const registerPkiCollectionRouter = async (server: FastifyZodProvider) => collectionId: z.string().trim().describe(PKI_COLLECTIONS.UPDATE.collectionId) }), body: z.object({ - name: z.string().trim().optional().describe(PKI_COLLECTIONS.UPDATE.name) + name: z.string().trim().optional().describe(PKI_COLLECTIONS.UPDATE.name), + description: z.string().trim().optional().describe(PKI_COLLECTIONS.UPDATE.description) }), response: { 200: PkiCollectionsSchema diff --git a/backend/src/server/routes/v1/secret-tag-router.ts b/backend/src/server/routes/v1/secret-tag-router.ts index ce92409f6..7d696999e 100644 --- a/backend/src/server/routes/v1/secret-tag-router.ts +++ b/backend/src/server/routes/v1/secret-tag-router.ts @@ -1,3 +1,4 @@ +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { SecretTagsSchema } from "@app/db/schemas"; @@ -49,7 +50,8 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - workspaceTag: SecretTagsSchema + // akhilmhdh: for terraform backward compatiability + workspaceTag: SecretTagsSchema.extend({ name: z.string() }) }) } }, @@ -79,7 +81,8 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - workspaceTag: SecretTagsSchema + // akhilmhdh: for terraform backward compatiability + workspaceTag: SecretTagsSchema.extend({ name: z.string() }) }) } }, @@ -108,8 +111,14 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { projectId: z.string().trim().describe(SECRET_TAGS.CREATE.projectId) }), body: z.object({ - name: z.string().trim().describe(SECRET_TAGS.CREATE.name), - slug: z.string().trim().describe(SECRET_TAGS.CREATE.slug), + slug: z + .string() + .toLowerCase() + .trim() + .describe(SECRET_TAGS.CREATE.slug) + .refine((v) => slugify(v) === v, { + message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens." + }), color: z.string().trim().describe(SECRET_TAGS.CREATE.color) }), response: { @@ -144,8 +153,14 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { 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), + slug: z + .string() + .toLowerCase() + .trim() + .describe(SECRET_TAGS.UPDATE.slug) + .refine((v) => slugify(v) === v, { + message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens." + }), color: z.string().trim().describe(SECRET_TAGS.UPDATE.color) }), response: { diff --git a/backend/src/server/routes/v2/organization-router.ts b/backend/src/server/routes/v2/organization-router.ts index 49488776b..e6ea094c3 100644 --- a/backend/src/server/routes/v2/organization-router.ts +++ b/backend/src/server/routes/v2/organization-router.ts @@ -9,7 +9,7 @@ import { UsersSchema } from "@app/db/schemas"; import { ORGANIZATIONS } from "@app/lib/api-docs"; -import { creationLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode } from "@app/services/auth/auth-type"; @@ -307,7 +307,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/", config: { - rateLimit: creationLimit + rateLimit: writeLimit }, schema: { body: z.object({ diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 85043ecee..61e175cfd 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -10,11 +10,12 @@ import { } 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 { 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 { sanitizedCertificateTemplate } from "@app/services/certificate-template/certificate-template-schema"; import { ProjectFilterType } from "@app/services/project/project-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; @@ -148,7 +149,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/", config: { - rateLimit: creationLimit + rateLimit: writeLimit }, schema: { description: "Create a new project", @@ -458,4 +459,34 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return { collections: pkiCollections }; } }); + + server.route({ + method: "GET", + url: "/:projectId/certificate-templates", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim() + }), + response: { + 200: z.object({ + certificateTemplates: sanitizedCertificateTemplate.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplates } = await server.services.project.listProjectCertificateTemplates({ + projectId: req.params.projectId, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type + }); + + return { certificateTemplates }; + } + }); }; diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index b0776d150..7a1868fef 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -59,9 +59,10 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { tags: SecretTagsSchema.pick({ id: true, slug: true, - name: true, color: true - }).array() + }) + .extend({ name: z.string() }) + .array() }) ) }) @@ -116,16 +117,15 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - secret: SecretsSchema.omit({ secretBlindIndex: true }).merge( - z.object({ - tags: SecretTagsSchema.pick({ - id: true, - slug: true, - name: true, - color: true - }).array() + secret: SecretsSchema.omit({ secretBlindIndex: true }).extend({ + tags: SecretTagsSchema.pick({ + id: true, + slug: true, + color: true }) - ) + .extend({ name: z.string() }) + .array() + }) }) } }, @@ -180,7 +180,13 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { .enum(["true", "false"]) .default("false") .transform((value) => value === "true") - .describe(RAW_SECRETS.LIST.includeImports) + .describe(RAW_SECRETS.LIST.includeImports), + tagSlugs: z + .string() + .describe(RAW_SECRETS.LIST.tagSlugs) + .optional() + // split by comma and trim the strings + .transform((el) => (el ? el.split(",").map((i) => i.trim()) : [])) }), response: { 200: z.object({ @@ -190,9 +196,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { tags: SecretTagsSchema.pick({ id: true, slug: true, - name: true, color: true }) + .extend({ name: z.string() }) .array() .optional() }) @@ -251,7 +257,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { projectId: workspaceId, path: secretPath, includeImports: req.query.include_imports, - recursive: req.query.recursive + recursive: req.query.recursive, + tagSlugs: req.query.tagSlugs }); await server.services.auditLog.createAuditLog({ @@ -325,9 +332,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { tags: SecretTagsSchema.pick({ id: true, slug: true, - name: true, color: true }) + .extend({ name: z.string() }) .array() .optional() }) @@ -731,9 +738,10 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { tags: SecretTagsSchema.pick({ id: true, slug: true, - name: true, color: true - }).array() + }) + .extend({ name: z.string() }) + .array() }) .array(), imports: z diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts index 84d8d263d..b1fd87a26 100644 --- a/backend/src/services/certificate-authority/certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -1,7 +1,7 @@ import * as x509 from "@peculiar/x509"; import crypto from "crypto"; -import { BadRequestError } from "@app/lib/errors"; +import { NotFoundError } from "@app/lib/errors"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; import { CertKeyAlgorithm, CertStatus } from "../certificate/certificate-types"; @@ -106,10 +106,10 @@ export const getCaCredentials = async ({ kmsService }: TGetCaCredentialsDTO) => { const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new BadRequestError({ message: "CA not found" }); + if (!ca) throw new NotFoundError({ message: "CA not found" }); const caSecret = await certificateAuthoritySecretDAL.findOne({ caId }); - if (!caSecret) throw new BadRequestError({ message: "CA secret not found" }); + if (!caSecret) throw new NotFoundError({ message: "CA secret not found" }); const keyId = await getProjectKmsCertificateKeyId({ projectId: ca.projectId, @@ -158,7 +158,7 @@ export const getCaCertChains = async ({ kmsService }: TGetCaCertChainsDTO) => { const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new BadRequestError({ message: "CA not found" }); + if (!ca) throw new NotFoundError({ message: "CA not found" }); const keyId = await getProjectKmsCertificateKeyId({ projectId: ca.projectId, @@ -195,19 +195,18 @@ export const getCaCertChains = async ({ /** * Return the decrypted pem-encoded certificate and certificate chain - * for CA with id [caId]. + * corresponding to CA certificate with id [caCertId]. */ export const getCaCertChain = async ({ - caId, + caCertId, 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 caCert = await certificateAuthorityCertDAL.findById(caCertId); + if (!caCert) throw new NotFoundError({ message: "CA certificate not found" }); + const ca = await certificateAuthorityDAL.findById(caCert.caId); const keyId = await getProjectKmsCertificateKeyId({ projectId: ca.projectId, @@ -250,7 +249,7 @@ export const rebuildCaCrl = async ({ kmsService }: TRebuildCaCrlDTO) => { const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new BadRequestError({ message: "CA not found" }); + if (!ca) throw new NotFoundError({ message: "CA not found" }); const caSecret = await certificateAuthoritySecretDAL.findOne({ caId: ca.id }); diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index dfbb895f9..ddbade5f5 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -6,6 +6,7 @@ import crypto, { KeyObject } from "crypto"; import ms from "ms"; import { z } from "zod"; +import { TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas"; import { TCertificateAuthorityEstConfigsUpdate } from "@app/db/schemas/certificate-authority-est-configs"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; @@ -14,11 +15,15 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TPkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal"; +import { TPkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal"; import { CertKeyAlgorithm, CertStatus } from "../certificate/certificate-types"; +import { TCertificateTemplateDALFactory } from "../certificate-template/certificate-template-dal"; +import { validateCertificateDetailsAgainstTemplate } from "../certificate-template/certificate-template-fns"; import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; import { TCertificateAuthorityEstConfigDALFactory } from "./certificate-authority-est-config-dal"; @@ -58,13 +63,19 @@ type TCertificateAuthorityServiceFactoryDep = { TCertificateAuthorityDALFactory, "transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" >; - certificateAuthorityCertDAL: Pick; + certificateAuthorityCertDAL: Pick< + TCertificateAuthorityCertDALFactory, + "create" | "findOne" | "transaction" | "find" | "findById" + >; certificateAuthoritySecretDAL: Pick; certificateAuthorityCrlDAL: Pick; + certificateTemplateDAL: Pick; certificateAuthorityQueue: TCertificateAuthorityQueueFactory; // TODO: Pick certificateAuthorityEstConfigDAL: Pick; certificateDAL: Pick; certificateBodyDAL: Pick; + pkiCollectionDAL: Pick; + pkiCollectionItemDAL: Pick; projectDAL: Pick; kmsService: Pick; permissionService: Pick; @@ -78,8 +89,11 @@ export const certificateAuthorityServiceFactory = ({ certificateAuthoritySecretDAL, certificateAuthorityEstConfigDAL, certificateAuthorityCrlDAL, + certificateTemplateDAL, certificateDAL, certificateBodyDAL, + pkiCollectionDAL, + pkiCollectionItemDAL, projectDAL, kmsService, permissionService @@ -162,8 +176,7 @@ export const certificateAuthorityServiceFactory = ({ maxPathLength, notBefore: notBeforeDate, notAfter: notAfterDate, - serialNumber, - activeCaCertVersion: 1 + serialNumber }) }, tx @@ -222,7 +235,7 @@ export const certificateAuthorityServiceFactory = ({ plainText: Buffer.alloc(0) }); - await certificateAuthorityCertDAL.create( + const caCert = await certificateAuthorityCertDAL.create( { caId: ca.id, encryptedCertificate, @@ -232,6 +245,14 @@ export const certificateAuthorityServiceFactory = ({ }, tx ); + + await certificateAuthorityDAL.updateById( + ca.id, + { + activeCaCertId: caCert.id + }, + tx + ); } // create empty CRL @@ -356,9 +377,7 @@ export const certificateAuthorityServiceFactory = ({ ); if (ca.type === CaType.ROOT) throw new BadRequestError({ message: "Root CA cannot generate CSR" }); - - const [caCert] = await certificateAuthorityCertDAL.find({ caId: ca.id }, { sort: [["version", "desc"]] }); - if (caCert) throw new BadRequestError({ message: "CA already has a certificate installed" }); + if (ca.activeCaCertId) throw new BadRequestError({ message: "CA already has a certificate installed" }); const { caPrivateKey, caPublicKey } = await getCaCredentials({ caId, @@ -403,6 +422,8 @@ export const certificateAuthorityServiceFactory = ({ const ca = await certificateAuthorityDAL.findById(caId); if (!ca) throw new BadRequestError({ message: "CA not found" }); + if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -419,8 +440,7 @@ export const certificateAuthorityServiceFactory = ({ if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); // get latest CA certificate - const [caCert] = await certificateAuthorityCertDAL.find({ caId: ca.id }, { sort: [["version", "desc"]] }); - if (!caCert) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + const caCert = await certificateAuthorityCertDAL.findById(ca.activeCaCertId); const serialNumber = crypto.randomBytes(32).toString("hex"); @@ -498,13 +518,12 @@ export const certificateAuthorityServiceFactory = ({ }); await certificateAuthorityDAL.transaction(async (tx) => { - const newActiveCaCertVersion = caCert.version + 1; - await certificateAuthorityCertDAL.create( + const newCaCert = await certificateAuthorityCertDAL.create( { caId: ca.id, encryptedCertificate, encryptedCertificateChain, - version: newActiveCaCertVersion, + version: caCert.version + 1, caSecretId: caSecret.id }, tx @@ -513,7 +532,7 @@ export const certificateAuthorityServiceFactory = ({ await certificateAuthorityDAL.updateById( ca.id, { - activeCaCertVersion: newActiveCaCertVersion, + activeCaCertId: newCaCert.id, notBefore: notBeforeDate, notAfter: new Date(notAfter) }, @@ -542,10 +561,9 @@ export const certificateAuthorityServiceFactory = ({ }); // get latest parent CA certificate - const [parentCaCert] = await certificateAuthorityCertDAL.find( - { caId: parentCa.id }, - { sort: [["version", "desc"]] } - ); + if (!parentCa.activeCaCertId) + throw new BadRequestError({ message: "Parent CA does not have a certificate installed" }); + const parentCaCert = await certificateAuthorityCertDAL.findById(parentCa.activeCaCertId); const decryptedParentCaCert = await kmsDecryptor({ cipherTextBlob: parentCaCert.encryptedCertificate @@ -590,7 +608,7 @@ export const certificateAuthorityServiceFactory = ({ const intermediateCert = await x509.X509CertificateGenerator.create({ serialNumber, subject: csrObj.subject, - issuer: caCertObj.subject, + issuer: parentCaCertObj.subject, notBefore: notBeforeDate, notAfter: new Date(notAfter), signingKey: parentCaPrivateKey, @@ -609,7 +627,7 @@ export const certificateAuthorityServiceFactory = ({ ca.maxPathLength === -1 || !ca.maxPathLength ? undefined : ca.maxPathLength, true ), - await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.AuthorityKeyIdentifierExtension.create(parentCaCertObj, false), await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) ] }); @@ -619,7 +637,7 @@ export const certificateAuthorityServiceFactory = ({ }); const { caCert: parentCaCertificate, caCertChain: parentCaCertChain } = await getCaCertChain({ - caId: parentCa.id, + caCertId: parentCa.activeCaCertId, certificateAuthorityDAL, certificateAuthorityCertDAL, projectDAL, @@ -633,13 +651,12 @@ export const certificateAuthorityServiceFactory = ({ }); await certificateAuthorityDAL.transaction(async (tx) => { - const newActiveCaCertVersion = caCert.version + 1; - await certificateAuthorityCertDAL.create( + const newCaCert = await certificateAuthorityCertDAL.create( { caId: ca.id, encryptedCertificate, encryptedCertificateChain, - version: newActiveCaCertVersion, + version: caCert.version + 1, caSecretId: caSecret.id }, tx @@ -648,7 +665,7 @@ export const certificateAuthorityServiceFactory = ({ await certificateAuthorityDAL.updateById( ca.id, { - activeCaCertVersion: newActiveCaCertVersion, + activeCaCertId: newCaCert.id, notBefore: notBeforeDate, notAfter: new Date(notAfter) }, @@ -707,11 +724,11 @@ export const certificateAuthorityServiceFactory = ({ /** * Return current certificate and certificate chain for CA - * get latest?? ca cert */ const getCaCert = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCertDTO) => { const ca = await certificateAuthorityDAL.findById(caId); if (!ca) throw new BadRequestError({ message: "CA not found" }); + if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); const { permission } = await permissionService.getProjectPermission( actor, @@ -727,7 +744,7 @@ export const certificateAuthorityServiceFactory = ({ ); const { caCert, caCertChain, serialNumber } = await getCaCertChain({ - caId, + caCertId: ca.activeCaCertId, certificateAuthorityDAL, certificateAuthorityCertDAL, projectDAL, @@ -773,9 +790,9 @@ export const certificateAuthorityServiceFactory = ({ ); if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); + if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); - const [caCert] = await certificateAuthorityCertDAL.find({ caId: ca.id }, { sort: [["version", "desc"]] }); - if (!caCert) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + const caCert = await certificateAuthorityCertDAL.findById(ca.activeCaCertId); if (ca.notAfter && new Date() > new Date(ca.notAfter)) { throw new BadRequestError({ message: "CA is expired" }); @@ -863,7 +880,7 @@ export const certificateAuthorityServiceFactory = ({ }); const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ - caId, + caCertId: ca.activeCaCertId, certificateAuthorityDAL, certificateAuthorityCertDAL, projectDAL, @@ -909,8 +926,7 @@ export const certificateAuthorityServiceFactory = ({ ProjectPermissionSub.CertificateAuthorities ); - const [caCert] = await certificateAuthorityCertDAL.find({ caId: ca.id }, { sort: [["version", "desc"]] }); - if (caCert) throw new BadRequestError({ message: "CA has already imported a certificate" }); + if (ca.activeCaCertId) throw new BadRequestError({ message: "CA has already imported a certificate" }); const certObj = new x509.X509Certificate(certificate); const maxPathLength = certObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; @@ -976,7 +992,7 @@ export const certificateAuthorityServiceFactory = ({ } await certificateAuthorityCertDAL.transaction(async (tx) => { - await certificateAuthorityCertDAL.create( + const newCaCert = await certificateAuthorityCertDAL.create( { caId: ca.id, encryptedCertificate, @@ -995,7 +1011,8 @@ export const certificateAuthorityServiceFactory = ({ notBefore: new Date(certObj.notBefore), notAfter: new Date(certObj.notAfter), serialNumber: certObj.serialNumber, - parentCaId: parentCa?.id + parentCaId: parentCa?.id, + activeCaCertId: newCaCert.id }, tx ); @@ -1010,6 +1027,8 @@ export const certificateAuthorityServiceFactory = ({ */ const issueCertFromCa = async ({ caId, + certificateTemplateId, + pkiCollectionId, friendlyName, commonName, altNames, @@ -1021,8 +1040,27 @@ export const certificateAuthorityServiceFactory = ({ actor, actorOrgId }: TIssueCertFromCaDTO) => { - const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new BadRequestError({ message: "CA not found" }); + let ca: TCertificateAuthorities | undefined; + let certificateTemplate: TCertificateTemplates | undefined; + let collectionId = pkiCollectionId; + + if (caId) { + ca = await certificateAuthorityDAL.findById(caId); + } else if (certificateTemplateId) { + certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId); + if (!certificateTemplate) { + throw new NotFoundError({ + message: "Certificate template not found" + }); + } + + collectionId = certificateTemplate.pkiCollectionId as string; + ca = await certificateAuthorityDAL.findById(certificateTemplate.caId); + } + + if (!ca) { + throw new BadRequestError({ message: "CA not found" }); + } const { permission } = await permissionService.getProjectPermission( actor, @@ -1035,14 +1073,20 @@ export const certificateAuthorityServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); - - const [caCert] = await certificateAuthorityCertDAL.find({ caId: ca.id }, { sort: [["version", "desc"]] }); - if (!caCert) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + const caCert = await certificateAuthorityCertDAL.findById(ca.activeCaCertId); if (ca.notAfter && new Date() > new Date(ca.notAfter)) { throw new BadRequestError({ message: "CA is expired" }); } + // check PKI collection + if (collectionId) { + const pkiCollection = await pkiCollectionDAL.findById(collectionId); + if (!pkiCollection) throw new NotFoundError({ message: "PKI collection not found" }); + if (pkiCollection.projectId !== ca.projectId) throw new BadRequestError({ message: "Invalid PKI collection" }); + } + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ projectId: ca.projectId, projectDAL, @@ -1111,11 +1155,13 @@ export const certificateAuthorityServiceFactory = ({ await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) ]; + let altNamesArray: { + type: "email" | "dns"; + value: string; + }[] = []; + if (altNames) { - const altNamesArray: { - type: "email" | "dns"; - value: string; - }[] = altNames + altNamesArray = altNames .split(",") .map((name) => name.trim()) .map((altName) => { @@ -1143,6 +1189,18 @@ export const certificateAuthorityServiceFactory = ({ extensions.push(altNamesExtension); } + if (certificateTemplate) { + validateCertificateDetailsAgainstTemplate( + { + commonName, + notBeforeDate, + notAfterDate, + altNames: altNamesArray.map((entry) => entry.value) + }, + certificateTemplate + ); + } + const serialNumber = crypto.randomBytes(32).toString("hex"); const leafCert = await x509.X509CertificateGenerator.create({ serialNumber, @@ -1169,7 +1227,9 @@ export const certificateAuthorityServiceFactory = ({ await certificateDAL.transaction(async (tx) => { const cert = await certificateDAL.create( { - caId: ca.id, + caId: (ca as TCertificateAuthorities).id, + caCertId: caCert.id, + certificateTemplateId: certificateTemplate?.id, status: CertStatus.ACTIVE, friendlyName: friendlyName || commonName, commonName, @@ -1189,11 +1249,21 @@ export const certificateAuthorityServiceFactory = ({ tx ); + if (collectionId) { + await pkiCollectionItemDAL.create( + { + pkiCollectionId: collectionId, + certId: cert.id + }, + tx + ); + } + return cert; }); const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ - caId: ca.id, + caCertId: caCert.id, certificateAuthorityDAL, certificateAuthorityCertDAL, projectDAL, @@ -1215,8 +1285,41 @@ export const certificateAuthorityServiceFactory = ({ * Note: CSR is generated externally and submitted to Infisical. */ const signCertFromCa = async (dto: TSignCertFromCaDTO) => { - const ca = await certificateAuthorityDAL.findById(dto.caId); - if (!ca) throw new BadRequestError({ message: "CA not found" }); + let ca: TCertificateAuthorities | undefined; + let certificateTemplate: TCertificateTemplates | undefined; + + const { + caId, + certificateTemplateId, + csr, + pkiCollectionId, + friendlyName, + commonName, + altNames, + ttl, + notBefore, + notAfter + } = dto; + + let collectionId = pkiCollectionId; + + if (caId) { + ca = await certificateAuthorityDAL.findById(caId); + } else if (certificateTemplateId) { + certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId); + if (!certificateTemplate) { + throw new NotFoundError({ + message: "Certificate template not found" + }); + } + + collectionId = certificateTemplate.pkiCollectionId as string; + ca = await certificateAuthorityDAL.findById(certificateTemplate.caId); + } + + if (!ca) { + throw new BadRequestError({ message: "CA not found" }); + } if (!dto.isInternal) { const { permission } = await permissionService.getProjectPermission( @@ -1233,17 +1336,22 @@ export const certificateAuthorityServiceFactory = ({ ); } - const { csr, friendlyName, commonName, altNames, ttl, notBefore, notAfter } = dto; - if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); + if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); - const [caCert] = await certificateAuthorityCertDAL.find({ caId: ca.id }, { sort: [["version", "desc"]] }); - if (!caCert) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + const caCert = await certificateAuthorityCertDAL.findById(ca.activeCaCertId); if (ca.notAfter && new Date() > new Date(ca.notAfter)) { throw new BadRequestError({ message: "CA is expired" }); } + // check PKI collection + if (pkiCollectionId) { + const pkiCollection = await pkiCollectionDAL.findById(pkiCollectionId); + if (!pkiCollection) throw new NotFoundError({ message: "PKI collection not found" }); + if (pkiCollection.projectId !== ca.projectId) throw new BadRequestError({ message: "Invalid PKI collection" }); + } + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ projectId: ca.projectId, projectDAL, @@ -1311,11 +1419,12 @@ export const certificateAuthorityServiceFactory = ({ await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) ]; + let altNamesArray: { + type: "email" | "dns"; + value: string; + }[] = []; if (altNames) { - const altNamesArray: { - type: "email" | "dns"; - value: string; - }[] = altNames + altNamesArray = altNames .split(",") .map((name) => name.trim()) .map((altName) => { @@ -1343,6 +1452,18 @@ export const certificateAuthorityServiceFactory = ({ extensions.push(altNamesExtension); } + if (certificateTemplate) { + validateCertificateDetailsAgainstTemplate( + { + commonName: cn, + notBeforeDate, + notAfterDate, + altNames: altNamesArray.map((entry) => entry.value) + }, + certificateTemplate + ); + } + const serialNumber = crypto.randomBytes(32).toString("hex"); const leafCert = await x509.X509CertificateGenerator.create({ serialNumber, @@ -1366,7 +1487,9 @@ export const certificateAuthorityServiceFactory = ({ await certificateDAL.transaction(async (tx) => { const cert = await certificateDAL.create( { - caId: ca.id, + caId: (ca as TCertificateAuthorities).id, + caCertId: caCert.id, + certificateTemplateId: certificateTemplate?.id, status: CertStatus.ACTIVE, friendlyName: friendlyName || csrObj.subject, commonName: cn, @@ -1386,11 +1509,21 @@ export const certificateAuthorityServiceFactory = ({ tx ); + if (collectionId) { + await pkiCollectionItemDAL.create( + { + pkiCollectionId: collectionId, + certId: cert.id + }, + tx + ); + } + return cert; }); const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ - caId: ca.id, + caCertId: ca.activeCaCertId, certificateAuthorityDAL, certificateAuthorityCertDAL, projectDAL, diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index 2f0d789bb..9c2feea37 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -86,7 +86,9 @@ export type TImportCertToCaDTO = { } & Omit; export type TIssueCertFromCaDTO = { - caId: string; + caId?: string; + certificateTemplateId?: string; + pkiCollectionId?: string; friendlyName?: string; commonName: string; altNames: string; @@ -98,8 +100,10 @@ export type TIssueCertFromCaDTO = { export type TSignCertFromCaDTO = | { isInternal: true; - caId: string; + caId?: string; csr: string; + certificateTemplateId?: string; + pkiCollectionId?: string; friendlyName?: string; commonName?: string; altNames: string; @@ -109,8 +113,10 @@ export type TSignCertFromCaDTO = } | ({ isInternal: false; - caId: string; + caId?: string; csr: string; + certificateTemplateId?: string; + pkiCollectionId?: string; friendlyName?: string; commonName?: string; altNames: string; @@ -145,9 +151,9 @@ export type TGetCaCertChainsDTO = { }; export type TGetCaCertChainDTO = { - caId: string; + caCertId: string; certificateAuthorityDAL: Pick; - certificateAuthorityCertDAL: Pick; + certificateAuthorityCertDAL: Pick; projectDAL: Pick; kmsService: Pick; }; diff --git a/backend/src/services/certificate-template/certificate-template-dal.ts b/backend/src/services/certificate-template/certificate-template-dal.ts new file mode 100644 index 000000000..a30c4890d --- /dev/null +++ b/backend/src/services/certificate-template/certificate-template-dal.ts @@ -0,0 +1,57 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TCertificateTemplateDALFactory = ReturnType; + +export const certificateTemplateDALFactory = (db: TDbClient) => { + const certificateTemplateOrm = ormify(db, TableName.CertificateTemplate); + + const getCertTemplatesByProjectId = async (projectId: string) => { + try { + const certTemplates = await db + .replicaNode()(TableName.CertificateTemplate) + .join( + TableName.CertificateAuthority, + `${TableName.CertificateAuthority}.id`, + `${TableName.CertificateTemplate}.caId` + ) + .where(`${TableName.CertificateAuthority}.projectId`, "=", projectId) + .select(selectAllTableCols(TableName.CertificateTemplate)) + .select( + db.ref("friendlyName").as("caName").withSchema(TableName.CertificateAuthority), + db.ref("projectId").withSchema(TableName.CertificateAuthority) + ); + + return certTemplates; + } catch (error) { + throw new DatabaseError({ error, name: "Get certificate templates by project ID" }); + } + }; + + const getById = async (id: string) => { + try { + const certTemplate = await db + .replicaNode()(TableName.CertificateTemplate) + .join( + TableName.CertificateAuthority, + `${TableName.CertificateAuthority}.id`, + `${TableName.CertificateTemplate}.caId` + ) + .where(`${TableName.CertificateTemplate}.id`, "=", id) + .select(selectAllTableCols(TableName.CertificateTemplate)) + .select( + db.ref("projectId").withSchema(TableName.CertificateAuthority), + db.ref("friendlyName").as("caName").withSchema(TableName.CertificateAuthority) + ) + .first(); + + return certTemplate; + } catch (error) { + throw new DatabaseError({ error, name: "Get certificate template by ID" }); + } + }; + + return { ...certificateTemplateOrm, getCertTemplatesByProjectId, getById }; +}; diff --git a/backend/src/services/certificate-template/certificate-template-fns.ts b/backend/src/services/certificate-template/certificate-template-fns.ts new file mode 100644 index 000000000..597be7eb2 --- /dev/null +++ b/backend/src/services/certificate-template/certificate-template-fns.ts @@ -0,0 +1,36 @@ +import ms from "ms"; + +import { TCertificateTemplates } from "@app/db/schemas"; +import { BadRequestError } from "@app/lib/errors"; + +export const validateCertificateDetailsAgainstTemplate = ( + cert: { + commonName: string; + notBeforeDate: Date; + notAfterDate: Date; + altNames: string[]; + }, + template: TCertificateTemplates +) => { + const commonNameRegex = new RegExp(template.commonName); + if (!commonNameRegex.test(cert.commonName)) { + throw new BadRequestError({ + message: "Invalid common name based on template policy" + }); + } + + if (cert.notAfterDate.getTime() - cert.notBeforeDate.getTime() > ms(template.ttl)) { + throw new BadRequestError({ + message: "Invalid validity date based on template policy" + }); + } + + const subjectAlternativeNameRegex = new RegExp(template.subjectAlternativeName); + cert.altNames.forEach((altName) => { + if (!subjectAlternativeNameRegex.test(altName)) { + throw new BadRequestError({ + message: "Invalid subject alternative name based on template policy" + }); + } + }); +}; diff --git a/backend/src/services/certificate-template/certificate-template-schema.ts b/backend/src/services/certificate-template/certificate-template-schema.ts new file mode 100644 index 000000000..2ce787050 --- /dev/null +++ b/backend/src/services/certificate-template/certificate-template-schema.ts @@ -0,0 +1,18 @@ +import z from "zod"; + +import { CertificateTemplatesSchema } from "@app/db/schemas"; + +export const sanitizedCertificateTemplate = CertificateTemplatesSchema.pick({ + id: true, + caId: true, + name: true, + commonName: true, + subjectAlternativeName: true, + pkiCollectionId: true, + ttl: true +}).merge( + z.object({ + projectId: z.string(), + caName: z.string() + }) +); diff --git a/backend/src/services/certificate-template/certificate-template-service.ts b/backend/src/services/certificate-template/certificate-template-service.ts new file mode 100644 index 000000000..c49791175 --- /dev/null +++ b/backend/src/services/certificate-template/certificate-template-service.ts @@ -0,0 +1,196 @@ +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, NotFoundError } from "@app/lib/errors"; + +import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; +import { TCertificateTemplateDALFactory } from "./certificate-template-dal"; +import { + TCreateCertTemplateDTO, + TDeleteCertTemplateDTO, + TGetCertTemplateDTO, + TUpdateCertTemplateDTO +} from "./certificate-template-types"; + +type TCertificateTemplateServiceFactoryDep = { + certificateTemplateDAL: TCertificateTemplateDALFactory; + certificateAuthorityDAL: Pick; + permissionService: Pick; +}; + +export type TCertificateTemplateServiceFactory = ReturnType; + +export const certificateTemplateServiceFactory = ({ + certificateTemplateDAL, + certificateAuthorityDAL, + permissionService +}: TCertificateTemplateServiceFactoryDep) => { + const createCertTemplate = async ({ + caId, + pkiCollectionId, + name, + commonName, + subjectAlternativeName, + ttl, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateCertTemplateDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) { + throw new NotFoundError({ + message: "CA not found" + }); + } + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateTemplates + ); + + const { id } = await certificateTemplateDAL.create({ + caId, + pkiCollectionId, + name, + commonName, + subjectAlternativeName, + ttl + }); + + const certificateTemplate = await certificateTemplateDAL.getById(id); + if (!certificateTemplate) { + throw new NotFoundError({ + message: "Certificate template not found" + }); + } + + return certificateTemplate; + }; + + const updateCertTemplate = async ({ + id, + caId, + pkiCollectionId, + name, + commonName, + subjectAlternativeName, + ttl, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateCertTemplateDTO) => { + const certTemplate = await certificateTemplateDAL.getById(id); + if (!certTemplate) { + throw new NotFoundError({ + message: "Certificate template not found." + }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + certTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.CertificateTemplates + ); + + if (caId) { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca || ca.projectId !== certTemplate.projectId) { + throw new BadRequestError({ + message: "Invalid CA" + }); + } + } + + await certificateTemplateDAL.updateById(certTemplate.id, { + caId, + pkiCollectionId, + commonName, + subjectAlternativeName, + name, + ttl + }); + + const updatedTemplate = await certificateTemplateDAL.getById(id); + if (!updatedTemplate) { + throw new NotFoundError({ + message: "Certificate template not found" + }); + } + + return updatedTemplate; + }; + + const deleteCertTemplate = async ({ id, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteCertTemplateDTO) => { + const certTemplate = await certificateTemplateDAL.getById(id); + if (!certTemplate) { + throw new NotFoundError({ + message: "Certificate template not found." + }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + certTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.CertificateTemplates + ); + + await certificateTemplateDAL.deleteById(certTemplate.id); + + return certTemplate; + }; + + const getCertTemplate = async ({ id, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertTemplateDTO) => { + const certTemplate = await certificateTemplateDAL.getById(id); + if (!certTemplate) { + throw new NotFoundError({ + message: "Certificate template not found." + }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + certTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + + return certTemplate; + }; + + return { + createCertTemplate, + getCertTemplate, + deleteCertTemplate, + updateCertTemplate + }; +}; diff --git a/backend/src/services/certificate-template/certificate-template-types.ts b/backend/src/services/certificate-template/certificate-template-types.ts new file mode 100644 index 000000000..c203b386c --- /dev/null +++ b/backend/src/services/certificate-template/certificate-template-types.ts @@ -0,0 +1,28 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateCertTemplateDTO = { + caId: string; + pkiCollectionId?: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; +} & Omit; + +export type TUpdateCertTemplateDTO = { + id: string; + caId?: string; + pkiCollectionId?: string; + name?: string; + commonName?: string; + subjectAlternativeName?: string; + ttl?: string; +} & Omit; + +export type TGetCertTemplateDTO = { + id: string; +} & Omit; + +export type TDeleteCertTemplateDTO = { + id: string; +} & Omit; diff --git a/backend/src/services/certificate-template/certificate-template-validators.ts b/backend/src/services/certificate-template/certificate-template-validators.ts new file mode 100644 index 000000000..41a06b05d --- /dev/null +++ b/backend/src/services/certificate-template/certificate-template-validators.ts @@ -0,0 +1,14 @@ +import safe from "safe-regex"; +import z from "zod"; + +export const validateTemplateRegexField = z + .string() + .min(1) + .max(100) + .regex(/^[a-zA-Z0-9 *@\-\\.\\]+$/, { + message: "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed." + }) + // we ensure that the inputted pattern is computationally safe by limiting star height to 1 + .refine((v) => safe(v), { + message: "Unsafe REGEX pattern" + }); diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index f05ed8a87..8dc2de901 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -21,7 +21,7 @@ type TCertificateServiceFactoryDep = { certificateDAL: Pick; certificateBodyDAL: Pick; certificateAuthorityDAL: Pick; - certificateAuthorityCertDAL: Pick; + certificateAuthorityCertDAL: Pick; certificateAuthorityCrlDAL: Pick; certificateAuthoritySecretDAL: Pick; projectDAL: Pick; @@ -180,7 +180,7 @@ export const certificateServiceFactory = ({ const certObj = new x509.X509Certificate(decryptedCert); const { caCert, caCertChain } = await getCaCertChain({ - caId: ca.id, + caCertId: cert.caCertId, certificateAuthorityDAL, certificateAuthorityCertDAL, projectDAL, diff --git a/backend/src/services/identity-ua/identity-ua-client-secret-dal.ts b/backend/src/services/identity-ua/identity-ua-client-secret-dal.ts index 6310da97a..ec0fd6fbf 100644 --- a/backend/src/services/identity-ua/identity-ua-client-secret-dal.ts +++ b/backend/src/services/identity-ua/identity-ua-client-secret-dal.ts @@ -4,6 +4,7 @@ import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; +import { logger } from "@app/lib/logger"; export type TIdentityUaClientSecretDALFactory = ReturnType; @@ -23,5 +24,55 @@ export const identityUaClientSecretDALFactory = (db: TDbClient) => { } }; - return { ...uaClientSecretOrm, incrementUsage }; + const removeExpiredClientSecrets = async (tx?: Knex) => { + const BATCH_SIZE = 10000; + const MAX_RETRY_ON_FAILURE = 3; + + let deletedClientSecret: { id: string }[] = []; + let numberOfRetryOnFailure = 0; + + do { + try { + const findExpiredClientSecretQuery = (tx || db)(TableName.IdentityUaClientSecret) + .where({ + isClientSecretRevoked: true + }) + .orWhere((qb) => { + void qb + .where("clientSecretNumUses", ">", 0) + .andWhere( + "clientSecretNumUses", + ">=", + db.ref("clientSecretNumUsesLimit").withSchema(TableName.IdentityUaClientSecret) + ); + }) + .orWhere((qb) => { + void qb + .where("clientSecretTTL", ">", 0) + .andWhereRaw( + `"${TableName.IdentityUaClientSecret}"."createdAt" + make_interval(secs => "${TableName.IdentityUaClientSecret}"."clientSecretTTL") < NOW()` + ); + }) + .select("id") + .limit(BATCH_SIZE); + + // eslint-disable-next-line no-await-in-loop + deletedClientSecret = await (tx || db)(TableName.IdentityUaClientSecret) + .whereIn("id", findExpiredClientSecretQuery) + .del() + .returning("id"); + numberOfRetryOnFailure = 0; // reset + } catch (error) { + numberOfRetryOnFailure += 1; + logger.error(error, "Failed to delete client secret on pruning"); + } finally { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, 10); // time to breathe for db + }); + } + } while (deletedClientSecret.length > 0 || numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE); + }; + + return { ...uaClientSecretOrm, incrementUsage, removeExpiredClientSecrets }; }; diff --git a/backend/src/services/integration-auth/integration-delete-secret.ts b/backend/src/services/integration-auth/integration-delete-secret.ts new file mode 100644 index 000000000..5da48fb6e --- /dev/null +++ b/backend/src/services/integration-auth/integration-delete-secret.ts @@ -0,0 +1,357 @@ +import { retry } from "@octokit/plugin-retry"; +import { Octokit } from "@octokit/rest"; + +import { TIntegrationAuths, TIntegrations } from "@app/db/schemas"; +import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; + +import { IntegrationMetadataSchema } from "../integration/integration-schema"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { KmsDataKey } from "../kms/kms-types"; +import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; +import { TSecretDALFactory } from "../secret/secret-dal"; +import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; +import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns"; +import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; +import { TIntegrationAuthServiceFactory } from "./integration-auth-service"; +import { Integrations } from "./integration-list"; + +const MAX_SYNC_SECRET_DEPTH = 5; + +/** + * Return the secrets in a given [folderId] including secrets from + * nested imported folders recursively. + */ +const getIntegrationSecretsV2 = async ( + dto: { + projectId: string; + environment: string; + folderId: string; + depth: number; + decryptor: (value: Buffer | null | undefined) => string; + }, + secretV2BridgeDAL: Pick, + folderDAL: Pick, + secretImportDAL: Pick +) => { + const content: Record = {}; + if (dto.depth > MAX_SYNC_SECRET_DEPTH) { + logger.info( + `getIntegrationSecrets: secret depth exceeded for [projectId=${dto.projectId}] [folderId=${dto.folderId}] [depth=${dto.depth}]` + ); + return content; + } + + // process secrets in current folder + const secrets = await secretV2BridgeDAL.findByFolderId(dto.folderId); + + secrets.forEach((secret) => { + const secretKey = secret.key; + content[secretKey] = true; + }); + + // check if current folder has any imports from other folders + const secretImports = await secretImportDAL.find({ folderId: dto.folderId, isReplication: false }); + + // if no imports then return secrets in the current folder + if (!secretImports.length) return content; + const importedSecrets = await fnSecretsV2FromImports({ + decryptor: dto.decryptor, + folderDAL, + secretDAL: secretV2BridgeDAL, + secretImportDAL, + allowedImports: secretImports + }); + + for (let i = importedSecrets.length - 1; i >= 0; i -= 1) { + for (let j = 0; j < importedSecrets[i].secrets.length; j += 1) { + const importedSecret = importedSecrets[i].secrets[j]; + if (!content[importedSecret.key]) { + content[importedSecret.key] = true; + } + } + } + return content; +}; + +/** + * Return the secrets in a given [folderId] including secrets from + * nested imported folders recursively. + */ +const getIntegrationSecretsV1 = async ( + dto: { + projectId: string; + environment: string; + folderId: string; + key: string; + depth: number; + }, + secretDAL: Pick, + folderDAL: Pick, + secretImportDAL: Pick +) => { + let content: Record = {}; + if (dto.depth > MAX_SYNC_SECRET_DEPTH) { + logger.info( + `getIntegrationSecrets: secret depth exceeded for [projectId=${dto.projectId}] [folderId=${dto.folderId}] [depth=${dto.depth}]` + ); + return content; + } + + // process secrets in current folder + const secrets = await secretDAL.findByFolderId(dto.folderId); + secrets.forEach((secret) => { + const secretKey = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secret.secretKeyCiphertext, + iv: secret.secretKeyIV, + tag: secret.secretKeyTag, + key: dto.key + }); + + content[secretKey] = true; + }); + + // check if current folder has any imports from other folders + const secretImport = await secretImportDAL.find({ folderId: dto.folderId, isReplication: false }); + + // if no imports then return secrets in the current folder + if (!secretImport) return content; + + const importedFolders = await folderDAL.findByManySecretPath( + secretImport.map(({ importEnv, importPath }) => ({ + envId: importEnv.id, + secretPath: importPath + })) + ); + + for await (const folder of importedFolders) { + if (folder) { + // get secrets contained in each imported folder by recursively calling + // this function against the imported folder + const importedSecrets = await getIntegrationSecretsV1( + { + environment: dto.environment, + projectId: dto.projectId, + folderId: folder.id, + key: dto.key, + depth: dto.depth + 1 + }, + secretDAL, + folderDAL, + secretImportDAL + ); + + // add the imported secrets to the current folder secrets + content = { ...importedSecrets, ...content }; + } + } + + return content; +}; + +export const deleteGithubSecrets = async ({ + integration, + secrets, + accessToken +}: { + integration: Omit; + secrets: Record; + accessToken: string; +}) => { + interface GitHubSecret { + name: string; + created_at: string; + updated_at: string; + visibility?: "all" | "private" | "selected"; + selected_repositories_url?: string | undefined; + } + + const OctokitWithRetry = Octokit.plugin(retry); + const octokit = new OctokitWithRetry({ + auth: accessToken + }); + + enum GithubScope { + Repo = "github-repo", + Org = "github-org", + Env = "github-env" + } + + let encryptedGithubSecrets: GitHubSecret[]; + + switch (integration.scope) { + case GithubScope.Org: { + encryptedGithubSecrets = ( + await octokit.request("GET /orgs/{org}/actions/secrets", { + org: integration.owner as string + }) + ).data.secrets; + break; + } + case GithubScope.Env: { + encryptedGithubSecrets = ( + await octokit.request("GET /repositories/{repository_id}/environments/{environment_name}/secrets", { + repository_id: Number(integration.appId), + environment_name: integration.targetEnvironmentId as string + }) + ).data.secrets; + break; + } + default: { + encryptedGithubSecrets = ( + await octokit.request("GET /repos/{owner}/{repo}/actions/secrets", { + owner: integration.owner as string, + repo: integration.app as string + }) + ).data.secrets; + break; + } + } + + for await (const encryptedSecret of encryptedGithubSecrets) { + if (encryptedSecret.name in secrets) { + switch (integration.scope) { + case GithubScope.Org: { + await octokit.request("DELETE /orgs/{org}/actions/secrets/{secret_name}", { + org: integration.owner as string, + secret_name: encryptedSecret.name + }); + break; + } + case GithubScope.Env: { + await octokit.request( + "DELETE /repositories/{repository_id}/environments/{environment_name}/secrets/{secret_name}", + { + repository_id: Number(integration.appId), + environment_name: integration.targetEnvironmentId as string, + secret_name: encryptedSecret.name + } + ); + break; + } + default: { + await octokit.request("DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}", { + owner: integration.owner as string, + repo: integration.app as string, + secret_name: encryptedSecret.name + }); + break; + } + } + + // small delay to prevent hitting API rate limits + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + } + } +}; + +export const deleteIntegrationSecrets = async ({ + integration, + integrationAuth, + integrationAuthService, + projectBotService, + secretV2BridgeDAL, + folderDAL, + secretDAL, + secretImportDAL, + kmsService +}: { + integration: Omit & { + projectId: string; + environment: { + id: string; + name: string; + slug: string; + }; + secretPath: string; + }; + integrationAuth: TIntegrationAuths; + integrationAuthService: Pick; + projectBotService: Pick; + secretV2BridgeDAL: Pick; + folderDAL: Pick; + secretImportDAL: Pick; + secretDAL: Pick; + kmsService: Pick; +}) => { + const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(integration.projectId); + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: integration.projectId + }); + + const folder = await folderDAL.findBySecretPath( + integration.projectId, + integration.environment.slug, + integration.secretPath + ); + + if (!folder) { + throw new NotFoundError({ + message: "Folder not found." + }); + } + + const { accessToken } = await integrationAuthService.getIntegrationAccessToken( + integrationAuth, + shouldUseSecretV2Bridge, + botKey + ); + + const secrets = shouldUseSecretV2Bridge + ? await getIntegrationSecretsV2( + { + environment: integration.environment.id, + projectId: integration.projectId, + folderId: folder.id, + depth: 1, + decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : "") + }, + secretV2BridgeDAL, + folderDAL, + secretImportDAL + ) + : await getIntegrationSecretsV1( + { + environment: integration.environment.id, + projectId: integration.projectId, + folderId: folder.id, + key: botKey as string, + depth: 1 + }, + secretDAL, + folderDAL, + secretImportDAL + ); + + const suffixedSecrets: typeof secrets = {}; + const metadata = IntegrationMetadataSchema.parse(integration.metadata); + + if (metadata) { + Object.keys(secrets).forEach((key) => { + const prefix = metadata?.secretPrefix || ""; + const suffix = metadata?.secretSuffix || ""; + const newKey = prefix + key + suffix; + suffixedSecrets[newKey] = secrets[key]; + }); + } + + switch (integration.integration) { + case Integrations.GITHUB: { + await deleteGithubSecrets({ + integration, + accessToken, + secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets + }); + break; + } + default: + throw new BadRequestError({ + message: "Invalid integration" + }); + } +}; diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 4fc69b76c..b1f7d4cb8 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -538,19 +538,20 @@ const syncSecretsAWSParameterStore = async ({ integration, secrets, accessId, - accessToken + accessToken, + projectId }: { - integration: TIntegrations; + integration: TIntegrations & { secretPath: string; environment: { slug: string } }; secrets: Record; accessId: string | null; accessToken: string; + projectId?: string; }) => { 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, credentials: { @@ -567,7 +568,9 @@ const syncSecretsAWSParameterStore = async ({ const metadata = z.record(z.any()).parse(integration.metadata || {}); const awsParameterStoreSecretsObj: Record = {}; - + logger.info( + `getIntegrationSecrets: integration sync triggered for ssm with [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [shouldDisableDelete=${metadata.shouldDisableDelete}]` + ); // now fetch all aws parameter store secrets let hasNext = true; let nextToken: string | undefined; @@ -594,6 +597,18 @@ const syncSecretsAWSParameterStore = async ({ nextToken = parameters.NextToken; } + logger.info( + `getIntegrationSecrets: all fetched keys from AWS SSM [projectId=${projectId}] [environment=${ + integration.environment.slug + }] [secretPath=${integration.secretPath}] [awsParameterStoreSecretsObj=${Object.keys( + awsParameterStoreSecretsObj + ).join(",")}]` + ); + logger.info( + `getIntegrationSecrets: all secrets from Infisical to send to AWS SSM [projectId=${projectId}] [environment=${ + integration.environment.slug + }] [secretPath=${integration.secretPath}] [secrets=${Object.keys(secrets).join(",")}]` + ); // Identify secrets to create // don't use Promise.all() and promise map here // it will cause rate limit @@ -603,24 +618,56 @@ const syncSecretsAWSParameterStore = async ({ // case: secret does not exist in AWS parameter store // -> create secret if (secrets[key].value) { + logger.info( + `getIntegrationSecrets: create secret in AWS SSM for [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [key=${key}]` + ); await ssm .putParameter({ Name: `${integration.path}${key}`, Type: "SecureString", Value: secrets[key].value, ...(metadata.kmsKeyId && { KeyId: metadata.kmsKeyId }), - // Overwrite: true, - Tags: metadata.secretAWSTag - ? metadata.secretAWSTag.map((tag: { key: string; value: string }) => ({ - Key: tag.key, - Value: tag.value - })) - : [] + Overwrite: true }) .promise(); + if (metadata.secretAWSTag?.length) { + try { + await ssm + .addTagsToResource({ + ResourceType: "Parameter", + ResourceId: `${integration.path}${key}`, + Tags: metadata.secretAWSTag + ? metadata.secretAWSTag.map((tag: { key: string; value: string }) => ({ + Key: tag.key, + Value: tag.value + })) + : [] + }) + .promise(); + } catch (err) { + logger.error( + err, + `getIntegrationSecrets: create secret in AWS SSM for failed [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [key=${key}]` + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((err as any).code === "AccessDeniedException") { + logger.error( + `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" + }; + } + } } // case: secret exists in AWS parameter store } else { + logger.info( + `getIntegrationSecrets: update secret in AWS SSM for [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [key=${key}]` + ); // -> update secret if (awsParameterStoreSecretsObj[key].Value !== secrets[key].value) { await ssm @@ -648,6 +695,10 @@ const syncSecretsAWSParameterStore = async ({ }) .promise(); } catch (err) { + logger.error( + err, + `getIntegrationSecrets: update secret in AWS SSM for failed [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [key=${key}]` + ); // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((err as any).code === "AccessDeniedException") { logger.error( @@ -670,9 +721,18 @@ const syncSecretsAWSParameterStore = async ({ } if (!metadata.shouldDisableDelete) { + logger.info( + `getIntegrationSecrets: inside of shouldDisableDelete AWS SSM [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [step=1]` + ); for (const key in awsParameterStoreSecretsObj) { if (Object.hasOwn(awsParameterStoreSecretsObj, key)) { + logger.info( + `getIntegrationSecrets: inside of shouldDisableDelete AWS SSM [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [key=${key}] [step=2]` + ); if (!(key in secrets)) { + logger.info( + `getIntegrationSecrets: inside of shouldDisableDelete AWS SSM [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [key=${key}] [step=3]` + ); // case: // -> delete secret await ssm @@ -680,6 +740,9 @@ const syncSecretsAWSParameterStore = async ({ Name: awsParameterStoreSecretsObj[key].Name as string }) .promise(); + logger.info( + `getIntegrationSecrets: inside of shouldDisableDelete AWS SSM [projectId=${projectId}] [environment=${integration.environment.slug}] [secretPath=${integration.secretPath}] [key=${key}] [step=4]` + ); } await new Promise((resolve) => { setTimeout(resolve, 50); @@ -3656,7 +3719,8 @@ export const syncIntegrationSecrets = async ({ integration, secrets, accessId, - accessToken + accessToken, + projectId }); break; case Integrations.AWS_SECRET_MANAGER: diff --git a/backend/src/services/integration/integration-service.ts b/backend/src/services/integration/integration-service.ts index da9cfc71f..02e520c6e 100644 --- a/backend/src/services/integration/integration-service.ts +++ b/backend/src/services/integration/integration-service.ts @@ -6,8 +6,15 @@ import { BadRequestError } from "@app/lib/errors"; import { TProjectPermission } from "@app/lib/types"; import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth-dal"; +import { TIntegrationAuthServiceFactory } from "../integration-auth/integration-auth-service"; +import { deleteIntegrationSecrets } from "../integration-auth/integration-delete-secret"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; +import { TSecretDALFactory } from "../secret/secret-dal"; import { TSecretQueueFactory } from "../secret/secret-queue"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; +import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TIntegrationDALFactory } from "./integration-dal"; import { TCreateIntegrationDTO, @@ -19,9 +26,15 @@ import { type TIntegrationServiceFactoryDep = { integrationDAL: TIntegrationDALFactory; integrationAuthDAL: TIntegrationAuthDALFactory; - folderDAL: Pick; + integrationAuthService: TIntegrationAuthServiceFactory; + folderDAL: Pick; permissionService: Pick; + projectBotService: TProjectBotServiceFactory; secretQueueService: Pick; + secretV2BridgeDAL: Pick; + secretImportDAL: Pick; + kmsService: Pick; + secretDAL: Pick; }; export type TIntegrationServiceFactory = ReturnType; @@ -31,7 +44,13 @@ export const integrationServiceFactory = ({ integrationAuthDAL, folderDAL, permissionService, - secretQueueService + secretQueueService, + integrationAuthService, + projectBotService, + secretV2BridgeDAL, + secretImportDAL, + kmsService, + secretDAL }: TIntegrationServiceFactoryDep) => { const createIntegration = async ({ app, @@ -161,7 +180,14 @@ export const integrationServiceFactory = ({ return updatedIntegration; }; - const deleteIntegration = async ({ actorId, id, actor, actorAuthMethod, actorOrgId }: TDeleteIntegrationDTO) => { + const deleteIntegration = async ({ + actorId, + id, + actor, + actorAuthMethod, + actorOrgId, + shouldDeleteIntegrationSecrets + }: TDeleteIntegrationDTO) => { const integration = await integrationDAL.findById(id); if (!integration) throw new BadRequestError({ message: "Integration auth not found" }); @@ -174,6 +200,22 @@ export const integrationServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Integrations); + const integrationAuth = await integrationAuthDAL.findById(integration.integrationAuthId); + + if (shouldDeleteIntegrationSecrets) { + await deleteIntegrationSecrets({ + integration, + integrationAuth, + projectBotService, + integrationAuthService, + secretV2BridgeDAL, + folderDAL, + secretImportDAL, + secretDAL, + kmsService + }); + } + const deletedIntegration = await integrationDAL.transaction(async (tx) => { // delete integration const deletedIntegrationResult = await integrationDAL.deleteById(id, tx); diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index abbccbe90..cfb6d70a4 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -63,6 +63,7 @@ export type TUpdateIntegrationDTO = { export type TDeleteIntegrationDTO = { id: string; + shouldDeleteIntegrationSecrets?: boolean; } & Omit; export type TSyncIntegrationDTO = { diff --git a/backend/src/services/pki-alert/expiring-pki-item-alert-queue.ts b/backend/src/services/pki-alert/expiring-pki-item-alert-queue.ts new file mode 100644 index 000000000..a592e9271 --- /dev/null +++ b/backend/src/services/pki-alert/expiring-pki-item-alert-queue.ts @@ -0,0 +1,48 @@ +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { TPkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; + +type TDailyExpiringPkiItemAlertQueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + pkiAlertService: Pick; +}; + +export type TDailyExpiringPkiItemAlertQueueServiceFactory = ReturnType< + typeof dailyExpiringPkiItemAlertQueueServiceFactory +>; + +export const dailyExpiringPkiItemAlertQueueServiceFactory = ({ + queueService, + pkiAlertService +}: TDailyExpiringPkiItemAlertQueueServiceFactoryDep) => { + queueService.start(QueueName.DailyExpiringPkiItemAlert, async () => { + logger.info(`${QueueName.DailyExpiringPkiItemAlert}: queue task started`); + await pkiAlertService.sendPkiItemExpiryNotices(); + logger.info(`${QueueName.DailyExpiringPkiItemAlert}: queue task completed`); + }); + + // we do a repeat cron job in utc timezone at 12 Midnight each day + const startSendingAlerts = async () => { + // clear previous job + await queueService.stopRepeatableJob( + QueueName.DailyExpiringPkiItemAlert, + QueueJobs.DailyExpiringPkiItemAlert, + { pattern: "0 0 * * *", utc: true }, + QueueName.DailyExpiringPkiItemAlert // just a job id + ); + + await queueService.queue(QueueName.DailyExpiringPkiItemAlert, QueueJobs.DailyExpiringPkiItemAlert, undefined, { + delay: 5000, + jobId: QueueName.DailyExpiringPkiItemAlert, + repeat: { pattern: "0 0 * * *", utc: true } + }); + }; + + queueService.listen(QueueName.DailyExpiringPkiItemAlert, "failed", (_, err) => { + logger.error(err, `${QueueName.DailyExpiringPkiItemAlert}: Expiring PKI item alert failed`); + }); + + return { + startSendingAlerts + }; +}; diff --git a/backend/src/services/pki-alert/pki-alert-service.ts b/backend/src/services/pki-alert/pki-alert-service.ts index 44f029d42..26ab380fb 100644 --- a/backend/src/services/pki-alert/pki-alert-service.ts +++ b/backend/src/services/pki-alert/pki-alert-service.ts @@ -12,8 +12,11 @@ import { TPkiAlertDALFactory } from "./pki-alert-dal"; import { TCreateAlertDTO, TDeleteAlertDTO, TGetAlertByIdDTO, TUpdateAlertDTO } from "./pki-alert-types"; type TPkiAlertServiceFactoryDep = { - pkiAlertDAL: TPkiAlertDALFactory; - pkiCollectionDAL: TPkiCollectionDALFactory; + pkiAlertDAL: Pick< + TPkiAlertDALFactory, + "create" | "findById" | "updateById" | "deleteById" | "getExpiringPkiCollectionItemsForAlerting" + >; + pkiCollectionDAL: Pick; permissionService: Pick; smtpService: Pick; }; diff --git a/backend/src/services/pki-collection/pki-collection-item-dal.ts b/backend/src/services/pki-collection/pki-collection-item-dal.ts index 403fd928b..de896e15c 100644 --- a/backend/src/services/pki-collection/pki-collection-item-dal.ts +++ b/backend/src/services/pki-collection/pki-collection-item-dal.ts @@ -81,7 +81,7 @@ export const pkiCollectionItemDALFactory = (db: TDbClient) => { return parseInt((count as unknown as CountResult).count || "0", 10); } catch (error) { - throw new DatabaseError({ error, name: "Count all project certificates" }); + throw new DatabaseError({ error, name: "Count all PKI collection items" }); } }; diff --git a/backend/src/services/pki-collection/pki-collection-service.ts b/backend/src/services/pki-collection/pki-collection-service.ts index 87b1be2f9..f2a3fa0c4 100644 --- a/backend/src/services/pki-collection/pki-collection-service.ts +++ b/backend/src/services/pki-collection/pki-collection-service.ts @@ -22,10 +22,13 @@ import { } from "./pki-collection-types"; type TPkiCollectionServiceFactoryDep = { - pkiCollectionDAL: TPkiCollectionDALFactory; // TODO: Pick - pkiCollectionItemDAL: TPkiCollectionItemDALFactory; - certificateAuthorityDAL: TCertificateAuthorityDALFactory; - certificateDAL: TCertificateDALFactory; + pkiCollectionDAL: Pick; + pkiCollectionItemDAL: Pick< + TPkiCollectionItemDALFactory, + "findOne" | "create" | "deleteById" | "findPkiCollectionItems" | "countItemsInPkiCollection" + >; + certificateAuthorityDAL: Pick; + certificateDAL: Pick; permissionService: Pick; }; @@ -40,6 +43,7 @@ export const pkiCollectionServiceFactory = ({ }: TPkiCollectionServiceFactoryDep) => { const createPkiCollection = async ({ name, + description, projectId, actorId, actorAuthMethod, @@ -61,7 +65,8 @@ export const pkiCollectionServiceFactory = ({ const pkiCollection = await pkiCollectionDAL.create({ projectId, - name + name, + description }); return pkiCollection; @@ -92,6 +97,7 @@ export const pkiCollectionServiceFactory = ({ const updatePkiCollection = async ({ collectionId, name, + description, actorId, actorAuthMethod, actor, @@ -110,7 +116,8 @@ export const pkiCollectionServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PkiCollections); pkiCollection = await pkiCollectionDAL.updateById(collectionId, { - name + name, + description }); return pkiCollection; @@ -135,7 +142,7 @@ export const pkiCollectionServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionActions.Delete, ProjectPermissionSub.PkiCollections ); pkiCollection = await pkiCollectionDAL.deleteById(collectionId); diff --git a/backend/src/services/pki-collection/pki-collection-types.ts b/backend/src/services/pki-collection/pki-collection-types.ts index 52d489eb2..e82ca31d0 100644 --- a/backend/src/services/pki-collection/pki-collection-types.ts +++ b/backend/src/services/pki-collection/pki-collection-types.ts @@ -2,6 +2,7 @@ import { TProjectPermission } from "@app/lib/types"; export type TCreatePkiCollectionDTO = { name: string; + description: string; } & TProjectPermission; export type TGetPkiCollectionByIdDTO = { @@ -11,6 +12,7 @@ export type TGetPkiCollectionByIdDTO = { export type TUpdatePkiCollectionDTO = { collectionId: string; name?: string; + description?: string; } & Omit; export type TDeletePkiCollectionDTO = { diff --git a/backend/src/services/project-bot/project-bot-dal.ts b/backend/src/services/project-bot/project-bot-dal.ts index 81c177d21..ecb23f78b 100644 --- a/backend/src/services/project-bot/project-bot-dal.ts +++ b/backend/src/services/project-bot/project-bot-dal.ts @@ -46,6 +46,7 @@ export const projectBotDALFactory = (db: TDbClient) => { const doc = await db .replicaNode()(TableName.ProjectMembership) .where(`${TableName.ProjectMembership}.projectId` as "projectId", projectId) + .where(`${TableName.ProjectKeys}.projectId` as "projectId", projectId) .where(`${TableName.Users}.isGhost` as "isGhost", false) .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) .join(TableName.ProjectKeys, `${TableName.ProjectMembership}.userId`, `${TableName.ProjectKeys}.receiverId`) diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 9416a66a9..a7111d784 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -16,6 +16,7 @@ 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 { TCertificateTemplateDALFactory } from "../certificate-template/certificate-template-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"; @@ -41,6 +42,7 @@ import { TGetProjectKmsKey, TListProjectAlertsDTO, TListProjectCasDTO, + TListProjectCertificateTemplatesDTO, TListProjectCertsDTO, TLoadProjectKmsBackupDTO, TToggleProjectAutoCapitalizationDTO, @@ -73,6 +75,7 @@ type TProjectServiceFactoryDep = { projectUserMembershipRoleDAL: Pick; certificateAuthorityDAL: Pick; certificateDAL: Pick; + certificateTemplateDAL: Pick; pkiAlertDAL: Pick; pkiCollectionDAL: Pick; permissionService: TPermissionServiceFactory; @@ -112,6 +115,7 @@ export const projectServiceFactory = ({ identityProjectMembershipRoleDAL, certificateAuthorityDAL, certificateDAL, + certificateTemplateDAL, pkiCollectionDAL, pkiAlertDAL, keyStore, @@ -737,6 +741,36 @@ export const projectServiceFactory = ({ }; }; + /** + * Return list of certificate templates for project + */ + const listProjectCertificateTemplates = async ({ + projectId, + actorId, + actorOrgId, + actorAuthMethod, + actor + }: TListProjectCertificateTemplatesDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + + const certificateTemplates = await certificateTemplateDAL.getCertTemplatesByProjectId(projectId); + + return { + certificateTemplates + }; + }; + const updateProjectKmsKey = async ({ projectId, kms, @@ -857,6 +891,7 @@ export const projectServiceFactory = ({ listProjectCertificates, listProjectAlerts, listProjectPkiCollections, + listProjectCertificateTemplates, updateVersionLimit, updateAuditLogsRetention, updateProjectKmsKey, diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index ac53b3f71..ee2f1aea9 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -117,3 +117,5 @@ export type TLoadProjectKmsBackupDTO = { } & TProjectPermission; export type TGetProjectKmsKey = TProjectPermission; + +export type TListProjectCertificateTemplatesDTO = TProjectPermission; diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index 83725f536..dab70806f 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -2,9 +2,9 @@ import { TAuditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal"; import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; -import { TPkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityUaClientSecretDALFactory } from "../identity-ua/identity-ua-client-secret-dal"; import { TSecretVersionDALFactory } from "../secret/secret-version-dal"; import { TSecretFolderVersionDALFactory } from "../secret-folder/secret-folder-version-dal"; import { TSecretSharingDALFactory } from "../secret-sharing/secret-sharing-dal"; @@ -13,13 +13,13 @@ import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-d type TDailyResourceCleanUpQueueServiceFactoryDep = { auditLogDAL: Pick; identityAccessTokenDAL: Pick; + identityUniversalAuthClientSecretDAL: Pick; secretVersionDAL: Pick; secretVersionV2DAL: Pick; secretFolderVersionDAL: Pick; snapshotDAL: Pick; secretSharingDAL: Pick; queueService: TQueueServiceFactory; - pkiAlertService: Pick; }; export type TDailyResourceCleanUpQueueServiceFactory = ReturnType; @@ -27,24 +27,24 @@ export type TDailyResourceCleanUpQueueServiceFactory = ReturnType { queueService.start(QueueName.DailyResourceCleanUp, async () => { logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`); await auditLogDAL.pruneAuditLog(); await identityAccessTokenDAL.removeExpiredTokens(); + await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets(); await secretSharingDAL.pruneExpiredSharedSecrets(); await snapshotDAL.pruneExcessSnapshots(); await secretVersionDAL.pruneExcessVersions(); await secretVersionV2DAL.pruneExcessVersions(); await secretFolderVersionDAL.pruneExcessVersions(); - await pkiAlertService.sendPkiItemExpiryNotices(); logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`); }); diff --git a/backend/src/services/secret-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index 1ccaaefc2..f7c31ac61 100644 --- a/backend/src/services/secret-import/secret-import-fns.ts +++ b/backend/src/services/secret-import/secret-import-fns.ts @@ -36,8 +36,8 @@ type TSecretImportSecretsV2 = { secretKey: string; // akhilmhdh: yes i know you can put ?. // But for somereason ts consider ? and undefined explicit as different just ts things - secretValue: string | undefined; - secretComment: string | undefined; + secretValue: string; + secretComment: string; })[]; }; @@ -157,7 +157,7 @@ export const fnSecretsV2FromImports = async ({ secretImportDAL: Pick; depth?: number; cyclicDetector?: Set; - decryptor: (value?: Buffer | null) => string | undefined; + decryptor: (value?: Buffer | null) => string; expandSecretReferences?: ( secrets: Record ) => Promise>; @@ -231,6 +231,7 @@ export const fnSecretsV2FromImports = async ({ _id: item.id // The old Python SDK depends on the _id field being returned. We return this to keep the older Python SDK versions backwards compatible with the new Postgres backend. })) .concat(folderDeeperImportSecrets); + return { secretPath: importPath, environment: importEnv.slug, @@ -254,7 +255,7 @@ export const fnSecretsV2FromImports = async ({ }; return acc; }, - {} as Record + {} as Record ); // eslint-disable-next-line await expandSecretReferences(secretsGroupByKey); diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index c69fec6b6..c3d1a6791 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -507,7 +507,7 @@ export const secretImportServiceFactory = ({ folderDAL, secretDAL: secretV2BridgeDAL, secretImportDAL, - decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : undefined) + decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : "") }); return importedSecrets; } diff --git a/backend/src/services/secret-tag/secret-tag-dal.ts b/backend/src/services/secret-tag/secret-tag-dal.ts index d1f149f35..1df64afa2 100644 --- a/backend/src/services/secret-tag/secret-tag-dal.ts +++ b/backend/src/services/secret-tag/secret-tag-dal.ts @@ -51,7 +51,7 @@ export const secretTagDALFactory = (db: TDbClient) => { ...secretTagOrm, saveTagsToSecret: secretJnTagOrm.insertMany, deleteTagsToSecret: secretJnTagOrm.delete, - saveTagsToSecretV2: secretV2JnTagOrm.insertMany, + saveTagsToSecretV2: secretV2JnTagOrm.batchInsert, deleteTagsToSecretV2: secretV2JnTagOrm.delete, findSecretTagsByProjectId, deleteTagsManySecret, diff --git a/backend/src/services/secret-tag/secret-tag-service.ts b/backend/src/services/secret-tag/secret-tag-service.ts index 76b57dc90..dd595f046 100644 --- a/backend/src/services/secret-tag/secret-tag-service.ts +++ b/backend/src/services/secret-tag/secret-tag-service.ts @@ -22,16 +22,7 @@ type TSecretTagServiceFactoryDep = { export type TSecretTagServiceFactory = ReturnType; export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSecretTagServiceFactoryDep) => { - const createTag = async ({ - name, - slug, - actor, - color, - actorId, - actorOrgId, - actorAuthMethod, - projectId - }: TCreateTagDTO) => { + const createTag = async ({ slug, actor, color, actorId, actorOrgId, actorAuthMethod, projectId }: TCreateTagDTO) => { const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -46,7 +37,6 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe const newTag = await secretTagDAL.create({ projectId, - name, slug, color, createdBy: actorId, @@ -55,7 +45,7 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe return newTag; }; - const updateTag = async ({ actorId, actor, actorOrgId, actorAuthMethod, id, name, color, slug }: TUpdateTagDTO) => { + const updateTag = async ({ actorId, actor, actorOrgId, actorAuthMethod, id, color, slug }: TUpdateTagDTO) => { const tag = await secretTagDAL.findById(id); if (!tag) throw new BadRequestError({ message: "Tag doesn't exist" }); @@ -73,7 +63,7 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Tags); - const updatedTag = await secretTagDAL.updateById(tag.id, { name, color, slug }); + const updatedTag = await secretTagDAL.updateById(tag.id, { color, slug }); return updatedTag; }; @@ -107,7 +97,7 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); - return tag; + return { ...tag, name: tag.slug }; }; const getTagBySlug = async ({ actorId, actor, actorOrgId, actorAuthMethod, slug, projectId }: TGetTagBySlugDTO) => { @@ -123,7 +113,7 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); - return tag; + return { ...tag, name: tag.slug }; }; const getProjectTags = async ({ actor, actorId, actorOrgId, actorAuthMethod, projectId }: TListProjectTagsDTO) => { diff --git a/backend/src/services/secret-tag/secret-tag-types.ts b/backend/src/services/secret-tag/secret-tag-types.ts index f2ace0901..ede0c55a3 100644 --- a/backend/src/services/secret-tag/secret-tag-types.ts +++ b/backend/src/services/secret-tag/secret-tag-types.ts @@ -1,14 +1,12 @@ import { TProjectPermission } from "@app/lib/types"; export type TCreateTagDTO = { - name: string; color: string; slug: string; } & TProjectPermission; export type TUpdateTagDTO = { id: string; - name?: string; slug?: string; color?: string; } & Omit; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index b41e2c250..fe4d89bac 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -136,7 +136,6 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { .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")) .orderBy("id", "asc"); const data = sqlNestRelationships({ @@ -147,11 +146,11 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { { key: "tagId", label: "tags" as const, - mapper: ({ tagId: id, tagColor: color, tagSlug: slug, tagName: name }) => ({ + mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({ id, color, slug, - name + name: slug }) } ] @@ -169,14 +168,13 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { .where({ [`${TableName.SecretV2}Id` as const]: secretId }) .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")); + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")); return tags.map((el) => ({ id: el.tagId, color: el.tagColor, slug: el.tagSlug, - name: el.tagName + name: el.tagSlug })); } catch (error) { throw new DatabaseError({ error, name: "get secret tags" }); @@ -210,7 +208,6 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { .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")) .orderBy("id", "asc"); const data = sqlNestRelationships({ @@ -221,11 +218,11 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { { key: "tagId", label: "tags" as const, - mapper: ({ tagId: id, tagColor: color, tagSlug: slug, tagName: name }) => ({ + mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({ id, color, slug, - name + name: slug }) } ] @@ -290,7 +287,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { })) ); if (!newSecretReferences.length) return; - const secretReferences = await (tx || db)(TableName.SecretReferenceV2).insert(newSecretReferences); + const secretReferences = await (tx || db).batchInsert(TableName.SecretReferenceV2, newSecretReferences); return secretReferences; } catch (error) { throw new DatabaseError({ error, name: "UpsertSecretReference" }); @@ -350,8 +347,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { .select(selectAllTableCols(TableName.SecretV2)) .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")); + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")); const docs = sqlNestRelationships({ data: rawDocs, key: "id", @@ -360,11 +356,11 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { { key: "tagId", label: "tags" as const, - mapper: ({ tagId: id, tagColor: color, tagSlug: slug, tagName: name }) => ({ + mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({ id, color, slug, - name + name: slug }) } ] diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index 19aef06f3..e58960a4b 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -528,8 +528,8 @@ export const reshapeBridgeSecret = ( environment: string, secretPath: string, secret: Omit & { - value?: string; - comment?: string; + value: string; + comment: string; tags?: { id: string; slug: string; @@ -542,8 +542,8 @@ export const reshapeBridgeSecret = ( secretPath, workspace: workspaceId, environment, - secretValue: secret.value, - secretComment: secret.comment, + secretValue: secret.value || "", + secretComment: secret.comment || "", version: secret.version, type: secret.type, _id: secret.id, diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 2cb03cf6d..b594f98ca 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -196,7 +196,7 @@ export const secretV2BridgeServiceFactory = ({ return reshapeBridgeSecret(projectId, environment, secretPath, { ...secret[0], value: inputSecret.secretValue, - comment: inputSecret.secretComment + comment: inputSecret.secretComment || "" }); }; @@ -339,8 +339,8 @@ export const secretV2BridgeServiceFactory = ({ }); return reshapeBridgeSecret(projectId, environment, secretPath, { ...updatedSecret[0], - value: inputSecret.secretValue, - comment: inputSecret.secretComment + value: inputSecret.secretValue || "", + comment: inputSecret.secretComment || "" }); }; @@ -378,6 +378,18 @@ export const secretV2BridgeServiceFactory = ({ throw new BadRequestError({ message: "Must be user to delete personal secret" }); } + const secretToDelete = await secretDAL.findOne({ + key: inputSecret.secretName, + folderId, + ...(inputSecret.type === SecretType.Shared + ? {} + : { + type: SecretType.Personal, + userId: actorId + }) + }); + if (!secretToDelete) throw new NotFoundError({ message: "Secret not found" }); + const deletedSecret = await secretDAL.transaction(async (tx) => fnSecretBulkDelete({ projectId, @@ -412,10 +424,10 @@ export const secretV2BridgeServiceFactory = ({ ...deletedSecret[0], value: deletedSecret[0].encryptedValue ? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedValue }).toString() - : undefined, + : "", comment: deletedSecret[0].encryptedComment ? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedComment }).toString() - : undefined + : "" }); }; @@ -429,6 +441,7 @@ export const secretV2BridgeServiceFactory = ({ actorAuthMethod, includeImports, recursive, + tagSlugs = [], expandSecretReferences: shouldExpandSecretReferences }: TGetSecretsDTO) => { const { permission } = await permissionService.getProjectPermission( @@ -496,6 +509,9 @@ export const secretV2BridgeServiceFactory = ({ : "" }) ); + const filteredSecrets = tagSlugs.length + ? decryptedSecrets.filter((secret) => Boolean(secret.tags?.find((el) => tagSlugs.includes(el.slug)))) + : decryptedSecrets; const expandSecretReferences = expandSecretReferencesFactory({ projectId, folderDAL, @@ -504,7 +520,7 @@ export const secretV2BridgeServiceFactory = ({ }); if (shouldExpandSecretReferences) { - const secretsGroupByPath = groupBy(decryptedSecrets, (i) => i.secretPath); + const secretsGroupByPath = groupBy(filteredSecrets, (i) => i.secretPath); for (const secretPathKey in secretsGroupByPath) { if (Object.hasOwn(secretsGroupByPath, secretPathKey)) { const secretsGroupByKey = secretsGroupByPath[secretPathKey].reduce( @@ -522,7 +538,7 @@ export const secretV2BridgeServiceFactory = ({ await expandSecretReferences(secretsGroupByKey); secretsGroupByPath[secretPathKey].forEach((decryptedSecret) => { // eslint-disable-next-line no-param-reassign - decryptedSecret.secretValue = secretsGroupByKey[decryptedSecret.secretKey].value; + decryptedSecret.secretValue = secretsGroupByKey[decryptedSecret.secretKey].value || ""; }); } } @@ -530,7 +546,7 @@ export const secretV2BridgeServiceFactory = ({ if (!includeImports) { return { - secrets: decryptedSecrets + secrets: filteredSecrets }; } @@ -554,11 +570,11 @@ export const secretV2BridgeServiceFactory = ({ folderDAL, secretImportDAL, expandSecretReferences, - decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : undefined) + decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : "") }); return { - secrets: decryptedSecrets, + secrets: filteredSecrets, imports: importedSecrets }; }; @@ -654,7 +670,7 @@ export const secretV2BridgeServiceFactory = ({ secretDAL, folderDAL, secretImportDAL, - decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : undefined), + decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : ""), expandSecretReferences: shouldExpandSecretReferences ? expandSecretReferences : undefined }); @@ -662,12 +678,11 @@ export const secretV2BridgeServiceFactory = ({ for (let j = 0; j < importedSecrets[i].secrets.length; j += 1) { const importedSecret = importedSecrets[i].secrets[j]; if (secretName === importedSecret.key) { - return reshapeBridgeSecret( - projectId, - importedSecrets[i].environment, - importedSecrets[i].secretPath, - importedSecret - ); + return reshapeBridgeSecret(projectId, importedSecrets[i].environment, importedSecrets[i].secretPath, { + ...importedSecret, + value: importedSecret.secretValue || "", + comment: importedSecret.secretComment || "" + }); } } } @@ -676,7 +691,7 @@ export const secretV2BridgeServiceFactory = ({ let secretValue = secret.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString() - : undefined; + : ""; if (shouldExpandSecretReferences && secretValue) { const secretReferenceExpandedRecord = { [secret.key]: { value: secretValue } @@ -691,7 +706,7 @@ export const secretV2BridgeServiceFactory = ({ value: secretValue, comment: secret.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() - : undefined + : "" }); }; @@ -781,10 +796,8 @@ export const secretV2BridgeServiceFactory = ({ return newSecrets.map((el) => reshapeBridgeSecret(projectId, environment, secretPath, { ...el, - value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : undefined, - comment: el.encryptedComment - ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() - : undefined + value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", + comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" }) ); }; @@ -902,10 +915,8 @@ export const secretV2BridgeServiceFactory = ({ return secrets.map((el) => reshapeBridgeSecret(projectId, environment, secretPath, { ...el, - value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : undefined, - comment: el.encryptedComment - ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() - : undefined + value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", + comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" }) ); }; @@ -981,10 +992,8 @@ export const secretV2BridgeServiceFactory = ({ return secretsDeleted.map((el) => reshapeBridgeSecret(projectId, environment, secretPath, { ...el, - value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : undefined, - comment: el.encryptedComment - ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() - : undefined + value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", + comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" }) ); }; @@ -1020,10 +1029,8 @@ export const secretV2BridgeServiceFactory = ({ return secretVersions.map((el) => reshapeBridgeSecret(folder.projectId, folder.environment.envSlug, "/", { ...el, - value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : undefined, - comment: el.encryptedComment - ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() - : undefined + value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", + comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" }) ); }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index 1964aac05..8c7a32a7f 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -20,6 +20,7 @@ export type TGetSecretsDTO = { environment: string; includeImports?: boolean; recursive?: boolean; + tagSlugs?: string[]; } & TProjectPermission; export type TGetASecretDTO = { diff --git a/backend/src/services/secret/secret-dal.ts b/backend/src/services/secret/secret-dal.ts index aac01d393..290a9597a 100644 --- a/backend/src/services/secret/secret-dal.ts +++ b/backend/src/services/secret/secret-dal.ts @@ -123,7 +123,6 @@ export const secretDALFactory = (db: TDbClient) => { .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")) .orderBy("id", "asc"); const data = sqlNestRelationships({ data: secs, @@ -133,11 +132,11 @@ export const secretDALFactory = (db: TDbClient) => { { key: "tagId", label: "tags" as const, - mapper: ({ tagId: id, tagColor: color, tagSlug: slug, tagName: name }) => ({ + mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({ id, color, slug, - name + name: slug }) } ] @@ -155,14 +154,13 @@ export const secretDALFactory = (db: TDbClient) => { .where({ [`${TableName.Secret}Id` as const]: secretId }) .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")); + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")); return tags.map((el) => ({ id: el.tagId, color: el.tagColor, slug: el.tagSlug, - name: el.tagName + name: el.tagSlug })); } catch (error) { throw new DatabaseError({ error, name: "get secret tags" }); @@ -188,7 +186,6 @@ export const secretDALFactory = (db: TDbClient) => { .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")) .orderBy("id", "asc"); const data = sqlNestRelationships({ data: secs, @@ -198,11 +195,11 @@ export const secretDALFactory = (db: TDbClient) => { { key: "tagId", label: "tags" as const, - mapper: ({ tagId: id, tagColor: color, tagSlug: slug, tagName: name }) => ({ + mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({ id, color, slug, - name + name: slug }) } ] @@ -318,8 +315,7 @@ export const secretDALFactory = (db: TDbClient) => { .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")); + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")); const docs = sqlNestRelationships({ data: rawDocs, key: "id", @@ -328,11 +324,11 @@ export const secretDALFactory = (db: TDbClient) => { { key: "tagId", label: "tags" as const, - mapper: ({ tagId: id, tagColor: color, tagSlug: slug, tagName: name }) => ({ + mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({ id, color, slug, - name + name: slug }) } ] diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index 662067645..d41770846 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -370,7 +370,6 @@ export const decryptSecretRaw = ( id: string; slug: string; color?: string | null; - name: string; }[]; }, key: string @@ -412,7 +411,7 @@ export const decryptSecretRaw = ( _id: secret.id, id: secret.id, user: secret.userId, - tags: secret.tags, + tags: secret.tags?.map((el) => ({ ...el, name: el.slug })), skipMultilineEncoding: secret.skipMultilineEncoding, secretReminderRepeatDays: secret.secretReminderRepeatDays, secretReminderNote: secret.secretReminderNote, diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 28053f4b3..36edcf195 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -6,11 +6,12 @@ import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approv import { TSecretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal"; import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal"; import { TSnapshotSecretV2DALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-v2-dal"; +import { KeyStorePrefixes, KeyStoreTtls, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { daysToMillisecond, secondsToMillis } from "@app/lib/dates"; import { BadRequestError } from "@app/lib/errors"; -import { groupBy, isSamePath, unique } from "@app/lib/fn"; +import { getTimeDifferenceInSeconds, groupBy, isSamePath, unique } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; @@ -73,12 +74,13 @@ type TSecretQueueFactoryDep = { secretVersionTagDAL: TSecretVersionTagDALFactory; kmsService: Pick; secretV2BridgeDAL: TSecretV2BridgeDALFactory; - secretVersionV2BridgeDAL: Pick; - secretVersionTagV2BridgeDAL: Pick; + secretVersionV2BridgeDAL: Pick; + secretVersionTagV2BridgeDAL: Pick; secretRotationDAL: Pick; secretApprovalRequestDAL: Pick; snapshotDAL: Pick; - snapshotSecretV2BridgeDAL: Pick; + snapshotSecretV2BridgeDAL: Pick; + keyStore: Pick; }; export type TGetSecrets = { @@ -122,7 +124,8 @@ export const secretQueueFactory = ({ secretRotationDAL, snapshotDAL, snapshotSecretV2BridgeDAL, - secretApprovalRequestDAL + secretApprovalRequestDAL, + keyStore }: TSecretQueueFactoryDep) => { const removeSecretReminder = async (dto: TRemoveSecretReminderDTO) => { const appCfg = getConfig(); @@ -576,7 +579,6 @@ export const secretQueueFactory = ({ ) ); } - const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(projectId); const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, @@ -641,108 +643,157 @@ export const secretQueueFactory = ({ `getIntegrationSecrets: secret integration sync started [jobId=${job.id}] [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${job.data.depth}]` ); - const secrets = shouldUseSecretV2Bridge - ? await getIntegrationSecretsV2({ - environment, - projectId, - folderId: folder.id, - depth: 1, - decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : "") - }) - : await getIntegrationSecrets({ - environment, - projectId, - folderId: folder.id, - key: botKey as string, - depth: 1 - }); + const lock = await keyStore.acquireLock( + [KeyStorePrefixes.SyncSecretIntegrationLock(projectId, environment, secretPath)], + 10000, + { + retryCount: 3, + retryDelay: 2000 + } + ); + const lockAcquiredTime = new Date(); - for (const integration of toBeSyncedIntegrations) { - const integrationAuth = { - ...integration.integrationAuth, - createdAt: new Date(), - updatedAt: new Date(), - projectId: integration.projectId - }; + const lastRunSyncIntegrationTimestamp = await keyStore.getItem( + KeyStorePrefixes.SyncSecretIntegrationLastRunTimestamp(projectId, environment, secretPath) + ); - const { accessToken, accessId } = await integrationAuthService.getIntegrationAccessToken( - integrationAuth, - shouldUseSecretV2Bridge, - botKey + // check whether the integration should wait or not + if (lastRunSyncIntegrationTimestamp) { + const INTEGRATION_INTERVAL = 2000; + const isStaleSyncIntegration = new Date(job.timestamp) < new Date(lastRunSyncIntegrationTimestamp); + if (isStaleSyncIntegration) { + logger.info( + `getIntegrationSecrets: secret integration sync stale [jobId=${job.id}] [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${job.data.depth}]` + ); + return; + } + + const timeDifferenceWithLastIntegration = getTimeDifferenceInSeconds( + lockAcquiredTime.toISOString(), + lastRunSyncIntegrationTimestamp ); - let awsAssumeRoleArn = null; - if (shouldUseSecretV2Bridge) { - if (integrationAuth.encryptedAwsAssumeIamRoleArn) { - awsAssumeRoleArn = secretManagerDecryptor({ - cipherTextBlob: Buffer.from(integrationAuth.encryptedAwsAssumeIamRoleArn) - }).toString(); - } - } else if ( - integrationAuth.awsAssumeIamRoleArnTag && - integrationAuth.awsAssumeIamRoleArnIV && - integrationAuth.awsAssumeIamRoleArnCipherText - ) { - awsAssumeRoleArn = decryptSymmetric128BitHexKeyUTF8({ - ciphertext: integrationAuth.awsAssumeIamRoleArnCipherText, - iv: integrationAuth.awsAssumeIamRoleArnIV, - tag: integrationAuth.awsAssumeIamRoleArnTag, - key: botKey as string + if (timeDifferenceWithLastIntegration < INTEGRATION_INTERVAL && timeDifferenceWithLastIntegration > 0) + await new Promise((resolve) => { + setTimeout(resolve, 2000 - timeDifferenceWithLastIntegration * 1000); }); - } - - const suffixedSecrets: typeof secrets = {}; - const metadata = integration.metadata as Record; - if (metadata) { - Object.keys(secrets).forEach((key) => { - const prefix = metadata?.secretPrefix || ""; - const suffix = metadata?.secretSuffix || ""; - const newKey = prefix + key + suffix; - suffixedSecrets[newKey] = secrets[key]; - }); - } - - try { - // akhilmhdh: this needs to changed later to be more easier to use - // at present this is not at all extendable like to add a new parameter for just one integration need to modify multiple places - const response = await syncIntegrationSecrets({ - createManySecretsRawFn, - updateManySecretsRawFn, - integrationDAL, - integration, - integrationAuth, - secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets, - accessId: accessId as string, - awsAssumeRoleArn, - accessToken, - projectId, - appendices: { - prefix: metadata?.secretPrefix || "", - suffix: metadata?.secretSuffix || "" - } - }); - - await integrationDAL.updateById(integration.id, { - lastSyncJobId: job.id, - lastUsed: new Date(), - syncMessage: response?.syncMessage ?? "", - isSynced: response?.isSynced ?? true - }); - } catch (err) { - logger.info("Secret integration sync error: %o", err); - - const message = - (err instanceof AxiosError ? JSON.stringify(err?.response?.data) : (err as Error)?.message) || - "Unknown error occurred."; - - await integrationDAL.updateById(integration.id, { - lastSyncJobId: job.id, - lastUsed: new Date(), - syncMessage: message, - isSynced: false - }); - } } + // akhilmhdh: this try catch is for lock release + try { + const secrets = shouldUseSecretV2Bridge + ? await getIntegrationSecretsV2({ + environment, + projectId, + folderId: folder.id, + depth: 1, + decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : "") + }) + : await getIntegrationSecrets({ + environment, + projectId, + folderId: folder.id, + key: botKey as string, + depth: 1 + }); + + for (const integration of toBeSyncedIntegrations) { + const integrationAuth = { + ...integration.integrationAuth, + createdAt: new Date(), + updatedAt: new Date(), + projectId: integration.projectId + }; + + const { accessToken, accessId } = await integrationAuthService.getIntegrationAccessToken( + integrationAuth, + shouldUseSecretV2Bridge, + botKey + ); + let awsAssumeRoleArn = null; + if (shouldUseSecretV2Bridge) { + if (integrationAuth.encryptedAwsAssumeIamRoleArn) { + awsAssumeRoleArn = secretManagerDecryptor({ + cipherTextBlob: Buffer.from(integrationAuth.encryptedAwsAssumeIamRoleArn) + }).toString(); + } + } else if ( + integrationAuth.awsAssumeIamRoleArnTag && + integrationAuth.awsAssumeIamRoleArnIV && + integrationAuth.awsAssumeIamRoleArnCipherText + ) { + awsAssumeRoleArn = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: integrationAuth.awsAssumeIamRoleArnCipherText, + iv: integrationAuth.awsAssumeIamRoleArnIV, + tag: integrationAuth.awsAssumeIamRoleArnTag, + key: botKey as string + }); + } + + const suffixedSecrets: typeof secrets = {}; + const metadata = integration.metadata as Record; + if (metadata) { + Object.keys(secrets).forEach((key) => { + const prefix = metadata?.secretPrefix || ""; + const suffix = metadata?.secretSuffix || ""; + const newKey = prefix + key + suffix; + suffixedSecrets[newKey] = secrets[key]; + }); + } + + // akhilmhdh: this try catch is for catching integration error and saving it in db + try { + // akhilmhdh: this needs to changed later to be more easier to use + // at present this is not at all extendable like to add a new parameter for just one integration need to modify multiple places + const response = await syncIntegrationSecrets({ + createManySecretsRawFn, + updateManySecretsRawFn, + integrationDAL, + integration, + integrationAuth, + secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets, + accessId: accessId as string, + awsAssumeRoleArn, + accessToken, + projectId, + appendices: { + prefix: metadata?.secretPrefix || "", + suffix: metadata?.secretSuffix || "" + } + }); + + await integrationDAL.updateById(integration.id, { + lastSyncJobId: job.id, + lastUsed: new Date(), + syncMessage: response?.syncMessage ?? "", + isSynced: response?.isSynced ?? true + }); + } catch (err) { + logger.error( + err, + `Secret integration sync error [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}]` + ); + + const message = + (err instanceof AxiosError ? JSON.stringify(err?.response?.data) : (err as Error)?.message) || + "Unknown error occurred."; + + await integrationDAL.updateById(integration.id, { + lastSyncJobId: job.id, + lastUsed: new Date(), + syncMessage: message, + isSynced: false + }); + } + } + } finally { + await lock.release(); + } + + await keyStore.setItemWithExpiry( + KeyStorePrefixes.SyncSecretIntegrationLastRunTimestamp(projectId, environment, secretPath), + KeyStoreTtls.SetSyncSecretIntegrationLastRunTimestampInSeconds, + lockAcquiredTime.toISOString() + ); logger.info("Secret integration sync ended: %s", job.id); }); @@ -828,7 +879,7 @@ export const secretQueueFactory = ({ secretId: string; references: { environment: string; secretPath: string; secretKey: string }[]; }[] = []; - await secretV2BridgeDAL.insertMany( + await secretV2BridgeDAL.batchInsert( projectV1Secrets.map((el) => { const key = decryptSymmetric128BitHexKeyUTF8({ ciphertext: el.secretKeyCiphertext, @@ -1004,14 +1055,14 @@ export const secretQueueFactory = ({ const projectV3SecretVersions = Object.values(projectV3SecretVersionsGroupById); if (projectV3SecretVersions.length) { - await secretVersionV2BridgeDAL.insertMany(projectV3SecretVersions, tx); + await secretVersionV2BridgeDAL.batchInsert(projectV3SecretVersions, tx); } if (projectV3SecretVersionTags.length) { - await secretVersionTagV2BridgeDAL.insertMany(projectV3SecretVersionTags, tx); + await secretVersionTagV2BridgeDAL.batchInsert(projectV3SecretVersionTags, tx); } if (projectV3SnapshotSecrets.length) { - await snapshotSecretV2BridgeDAL.insertMany(projectV3SnapshotSecrets, tx); + await snapshotSecretV2BridgeDAL.batchInsert(projectV3SnapshotSecrets, tx); } await snapshotDAL.deleteSnapshotsAboveLimit(folderId, SNAPSHOT_BATCH_SIZE, tx); } diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 3a14f0e30..c62a73f69 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -964,7 +964,8 @@ export const secretServiceFactory = ({ environment, includeImports, expandSecretReferences, - recursive + recursive, + tagSlugs = [] }: TGetSecretsRawDTO) => { const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); if (shouldUseSecretV2Bridge) { @@ -978,7 +979,8 @@ export const secretServiceFactory = ({ path, recursive, actorAuthMethod, - includeImports + includeImports, + tagSlugs }); return { secrets, imports }; } @@ -998,6 +1000,9 @@ export const secretServiceFactory = ({ }); const decryptedSecrets = secrets.map((el) => decryptSecretRaw(el, botKey)); + const filteredSecrets = tagSlugs.length + ? decryptedSecrets.filter((secret) => Boolean(secret.tags?.find((el) => tagSlugs.includes(el.slug)))) + : decryptedSecrets; const processedImports = (imports || [])?.map(({ secrets: importedSecrets, ...el }) => { const decryptedImportSecrets = importedSecrets.map((sec) => decryptSecretRaw( @@ -1106,14 +1111,14 @@ export const secretServiceFactory = ({ }; // expand secrets - await batchSecretsExpand(decryptedSecrets); + await batchSecretsExpand(filteredSecrets); // expand imports by batch await Promise.all(processedImports.map((processedImport) => batchSecretsExpand(processedImport.secrets))); } return { - secrets: decryptedSecrets, + secrets: filteredSecrets, imports: processedImports }; }; @@ -1149,6 +1154,7 @@ export const secretServiceFactory = ({ type, secretName }); + return secret; } @@ -2081,7 +2087,7 @@ export const secretServiceFactory = ({ return { ...updatedSecret[0], - tags: [...existingSecretTags, ...tags].map((t) => ({ id: t.id, slug: t.slug, name: t.name, color: t.color })) + tags: [...existingSecretTags, ...tags].map((t) => ({ id: t.id, slug: t.slug, name: t.slug, color: t.color })) }; }; diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 99bde05f2..1686ee488 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -149,6 +149,7 @@ export type TGetSecretsRawDTO = { environment: string; includeImports?: boolean; recursive?: boolean; + tagSlugs?: string[]; } & TProjectPermission; export type TGetASecretRawDTO = { diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index 9eab5e759..c6ecd8bcd 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -404,6 +404,10 @@ func CallGetRawSecretsV3(httpClient *resty.Client, request GetRawSecretsV3Reques SetQueryParam("environment", request.Environment). SetQueryParam("secretPath", request.SecretPath) + if request.TagSlugs != "" { + req.SetQueryParam("tagSlugs", request.TagSlugs) + } + if request.IncludeImport { req.SetQueryParam("include_imports", "true") } diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index dc577bf9d..f954531e2 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -574,6 +574,7 @@ type GetRawSecretsV3Request struct { SecretPath string `json:"secretPath"` IncludeImport bool `json:"include_imports"` Recursive bool `json:"recursive"` + TagSlugs string `json:"tagSlugs,omitempty"` } type GetRawSecretsV3Response struct { diff --git a/cli/packages/cmd/agent.go b/cli/packages/cmd/agent.go index 59e496ec7..0c99377ae 100644 --- a/cli/packages/cmd/agent.go +++ b/cli/packages/cmd/agent.go @@ -312,7 +312,7 @@ func ParseAgentConfig(configFile []byte) (*Config, error) { func secretTemplateFunction(accessToken string, existingEtag string, currentEtag *string) func(string, string, string) ([]models.SingleEnvironmentVariable, error) { return func(projectID, envSlug, secretPath string) ([]models.SingleEnvironmentVariable, error) { - res, err := util.GetPlainTextSecretsV3(accessToken, projectID, envSlug, secretPath, false, false) + res, err := util.GetPlainTextSecretsV3(accessToken, projectID, envSlug, secretPath, false, false, "") if err != nil { return nil, err } diff --git a/cli/packages/cmd/export.go b/cli/packages/cmd/export.go index c0fea738e..f6b028b7a 100644 --- a/cli/packages/cmd/export.go +++ b/cli/packages/cmd/export.go @@ -14,6 +14,7 @@ import ( "github.com/Infisical/infisical-merge/packages/util" "github.com/rs/zerolog/log" "github.com/spf13/cobra" + "gopkg.in/yaml.v2" ) const ( @@ -188,7 +189,7 @@ func formatEnvs(envs []models.SingleEnvironmentVariable, format string) (string, case FormatCSV: return formatAsCSV(envs), nil case FormatYaml: - return formatAsYaml(envs), nil + return formatAsYaml(envs) default: return "", fmt.Errorf("invalid format type: %s. Available format types are [%s]", format, []string{FormatDotenv, FormatJson, FormatCSV, FormatYaml, FormatDotEnvExport}) } @@ -224,12 +225,18 @@ func formatAsDotEnvExport(envs []models.SingleEnvironmentVariable) string { return dotenv } -func formatAsYaml(envs []models.SingleEnvironmentVariable) string { - var dotenv string +func formatAsYaml(envs []models.SingleEnvironmentVariable) (string, error) { + m := make(map[string]string) for _, env := range envs { - dotenv += fmt.Sprintf("%s: %s\n", env.Key, env.Value) + m[env.Key] = env.Value } - return dotenv + + yamlBytes, err := yaml.Marshal(m) + if err != nil { + return "", fmt.Errorf("failed to format environment variables as YAML: %w", err) + } + + return string(yamlBytes), nil } // Format environment variables as a JSON file diff --git a/cli/packages/cmd/export_test.go b/cli/packages/cmd/export_test.go new file mode 100644 index 000000000..1be0a7ed2 --- /dev/null +++ b/cli/packages/cmd/export_test.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "testing" + + "github.com/Infisical/infisical-merge/packages/models" + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v2" +) + +func TestFormatAsYaml(t *testing.T) { + tests := []struct { + name string + input []models.SingleEnvironmentVariable + expected string + }{ + { + name: "Empty input", + input: []models.SingleEnvironmentVariable{}, + expected: "{}\n", + }, + { + name: "Single environment variable", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "VALUE1"}, + }, + expected: "KEY1: VALUE1\n", + }, + { + name: "Multiple environment variables", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "VALUE1"}, + {Key: "KEY2", Value: "VALUE2"}, + {Key: "KEY3", Value: "VALUE3"}, + }, + expected: "KEY1: VALUE1\nKEY2: VALUE2\nKEY3: VALUE3\n", + }, + { + name: "Overwriting duplicate keys", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "VALUE1"}, + {Key: "KEY1", Value: "VALUE2"}, + }, + expected: "KEY1: VALUE2\n", + }, + { + name: "Special characters in values", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "Value with spaces"}, + {Key: "KEY2", Value: "Value:with:colons"}, + {Key: "KEY3", Value: "Value\nwith\nnewlines"}, + }, + expected: "KEY1: Value with spaces\nKEY2: Value:with:colons\nKEY3: |-\n Value\n with\n newlines\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := formatAsYaml(tt.input) + assert.NoError(t, err) + + // Compare the result with the expected output + assert.Equal(t, tt.expected, result) + + // Additionally, parse the result back into a map to ensure it's valid YAML + var resultMap map[string]string + err = yaml.Unmarshal([]byte(result), &resultMap) + assert.NoError(t, err) + + // Create an expected map from the input + expectedMap := make(map[string]string) + for _, env := range tt.input { + expectedMap[env.Key] = env.Value + } + + assert.Equal(t, expectedMap, resultMap) + }) + } +} diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index 2894765ae..c9ac36852 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -155,22 +155,24 @@ var secretsSetCmd = &cobra.Command{ DisableFlagsInUseLine: true, Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - util.RequireLocalWorkspaceFile() - - environmentName, _ := cmd.Flags().GetString("env") - if !cmd.Flags().Changed("env") { - environmentFromWorkspace := util.GetEnvFromWorkspaceFile() - if environmentFromWorkspace != "" { - environmentName = environmentFromWorkspace - } - } - token, err := util.GetInfisicalToken(cmd) if err != nil { util.HandleError(err, "Unable to parse flag") } - projectId, err := cmd.Flags().GetString("projectId") + if (token == nil) { + util.RequireLocalWorkspaceFile() + } + + environmentName, _ := cmd.Flags().GetString("env") + if !cmd.Flags().Changed("env") { + environmentFromWorkspace := util.GetEnvFromWorkspaceFile() + if environmentFromWorkspace != "" { + environmentName = environmentFromWorkspace + } + } + + projectId, err := cmd.Flags().GetString("projectId") if err != nil { util.HandleError(err, "Unable to parse flag") } @@ -374,6 +376,11 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse flag") } + secretOverriding, err := cmd.Flags().GetBool("secret-overriding") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + request := models.GetAllSecretsParameters{ Environment: environmentName, WorkspaceId: projectId, @@ -394,6 +401,12 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { util.HandleError(err, "To fetch all secrets") } + if secretOverriding { + secrets = util.OverrideSecrets(secrets, util.SECRET_TYPE_PERSONAL) + } else { + secrets = util.OverrideSecrets(secrets, util.SECRET_TYPE_SHARED) + } + if shouldExpand { authParams := models.ExpandSecretsAuthentication{} if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER { @@ -413,11 +426,13 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { if value, ok := secretsMap[secretKeyFromArg]; ok { requestedSecrets = append(requestedSecrets, value) } else { - requestedSecrets = append(requestedSecrets, models.SingleEnvironmentVariable{ - Key: secretKeyFromArg, - Type: "*not found*", - Value: "*not found*", - }) + if !(plainOutput || showOnlyValue) { + requestedSecrets = append(requestedSecrets, models.SingleEnvironmentVariable{ + Key: secretKeyFromArg, + Type: "*not found*", + Value: "*not found*", + }) + } } } @@ -688,6 +703,7 @@ func init() { secretsGetCmd.Flags().Bool("include-imports", true, "Imported linked secrets ") secretsGetCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets, and process your referenced secrets") secretsGetCmd.Flags().Bool("recursive", false, "Fetch secrets from all sub-folders") + secretsGetCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") secretsCmd.AddCommand(secretsGetCmd) secretsCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") secretsCmd.AddCommand(secretsSetCmd) diff --git a/cli/packages/cmd/vault.go b/cli/packages/cmd/vault.go index 4720e094e..6a92ef960 100644 --- a/cli/packages/cmd/vault.go +++ b/cli/packages/cmd/vault.go @@ -31,37 +31,52 @@ var AvailableVaults = []VaultBackendType{ } var vaultSetCmd = &cobra.Command{ - Example: `infisical vault set file --passphrase `, - Use: "set [file|auto] [flags]", + Example: `infisical vault set file`, + Use: "set [file|auto]", Short: "Used to configure the vault backends", DisableFlagsInUseLine: true, Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - - vaultType := args[0] - - passphrase, err := cmd.Flags().GetString("passphrase") + wantedVaultTypeName := args[0] + currentVaultBackend, err := util.GetCurrentVaultBackend() if err != nil { - util.HandleError(err, "Unable to get passphrase flag") - } - - if vaultType == util.VAULT_BACKEND_FILE_MODE && passphrase != "" { - setFileVaultPassphrase(passphrase) + log.Error().Msgf("Unable to set vault to [%s] because of [err=%s]", wantedVaultTypeName, err) return } - util.PrintWarning("This command has been deprecated. Please use 'infisical vault use [file|auto]' to select which vault to use.\n") - selectVaultTypeCmd(cmd, args) - }, -} + if wantedVaultTypeName == string(currentVaultBackend) { + log.Error().Msgf("You are already on vault backend [%s]", currentVaultBackend) + return + } -var vaultUseCmd = &cobra.Command{ - Example: `infisical vault use [file|auto]`, - Use: "use [file|auto]", - Short: "Used to select the the type of vault backend to store sensitive data securely at rest", - DisableFlagsInUseLine: true, - Args: cobra.MinimumNArgs(1), - Run: selectVaultTypeCmd, + if wantedVaultTypeName == util.VAULT_BACKEND_AUTO_MODE || wantedVaultTypeName == util.VAULT_BACKEND_FILE_MODE { + configFile, err := util.GetConfigFile() + if err != nil { + log.Error().Msgf("Unable to set vault to [%s] because of [err=%s]", wantedVaultTypeName, err) + return + } + + configFile.VaultBackendType = wantedVaultTypeName + configFile.LoggedInUserEmail = "" + configFile.VaultBackendPassphrase = base64.StdEncoding.EncodeToString([]byte(util.GenerateRandomString(10))) + + err = util.WriteConfigFile(&configFile) + if err != nil { + log.Error().Msgf("Unable to set vault to [%s] because an error occurred when saving the config file [err=%s]", wantedVaultTypeName, err) + return + } + + fmt.Printf("\nSuccessfully, switched vault backend from [%s] to [%s]. Please login in again to store your login details in the new vault with [infisical login]\n", currentVaultBackend, wantedVaultTypeName) + + Telemetry.CaptureEvent("cli-command:vault set", posthog.NewProperties().Set("currentVault", currentVaultBackend).Set("wantedVault", wantedVaultTypeName).Set("version", util.CLI_VERSION)) + } else { + var availableVaultsNames []string + for _, vault := range AvailableVaults { + availableVaultsNames = append(availableVaultsNames, vault.Name) + } + log.Error().Msgf("The requested vault type [%s] is not available on this system. Only the following vault backends are available for you system: %s", wantedVaultTypeName, strings.Join(availableVaultsNames, ", ")) + } + }, } // runCmd represents the run command @@ -75,26 +90,6 @@ var vaultCmd = &cobra.Command{ }, } -func setFileVaultPassphrase(passphrase string) { - configFile, err := util.GetConfigFile() - if err != nil { - log.Error().Msgf("Unable to set passphrase for file vault because of [err=%s]", err) - return - } - - // encode with base64 - encodedPassphrase := base64.StdEncoding.EncodeToString([]byte(passphrase)) - configFile.VaultBackendPassphrase = encodedPassphrase - - err = util.WriteConfigFile(&configFile) - if err != nil { - log.Error().Msgf("Unable to set passphrase for file vault because of [err=%s]", err) - return - } - - util.PrintSuccessMessage("\nSuccessfully, set passphrase for file vault.\n") -} - func printAvailableVaultBackends() { fmt.Printf("Vaults are used to securely store your login details locally. Available vaults:") for _, vaultType := range AvailableVaults { @@ -111,53 +106,8 @@ func printAvailableVaultBackends() { fmt.Printf("\n\nYou are currently using [%s] vault to store your login credentials\n", string(currentVaultBackend)) } -func selectVaultTypeCmd(cmd *cobra.Command, args []string) { - wantedVaultTypeName := args[0] - currentVaultBackend, err := util.GetCurrentVaultBackend() - if err != nil { - log.Error().Msgf("Unable to set vault to [%s] because of [err=%s]", wantedVaultTypeName, err) - return - } - - if wantedVaultTypeName == string(currentVaultBackend) { - log.Error().Msgf("You are already on vault backend [%s]", currentVaultBackend) - return - } - - if wantedVaultTypeName == util.VAULT_BACKEND_AUTO_MODE || wantedVaultTypeName == util.VAULT_BACKEND_FILE_MODE { - configFile, err := util.GetConfigFile() - if err != nil { - log.Error().Msgf("Unable to set vault to [%s] because of [err=%s]", wantedVaultTypeName, err) - return - } - - configFile.VaultBackendType = wantedVaultTypeName // save selected vault - configFile.LoggedInUserEmail = "" // reset the logged in user to prompt them to re login - - err = util.WriteConfigFile(&configFile) - if err != nil { - log.Error().Msgf("Unable to set vault to [%s] because an error occurred when saving the config file [err=%s]", wantedVaultTypeName, err) - return - } - - fmt.Printf("\nSuccessfully, switched vault backend from [%s] to [%s]. Please login in again to store your login details in the new vault with [infisical login]\n", currentVaultBackend, wantedVaultTypeName) - - Telemetry.CaptureEvent("cli-command:vault set", posthog.NewProperties().Set("currentVault", currentVaultBackend).Set("wantedVault", wantedVaultTypeName).Set("version", util.CLI_VERSION)) - } else { - var availableVaultsNames []string - for _, vault := range AvailableVaults { - availableVaultsNames = append(availableVaultsNames, vault.Name) - } - log.Error().Msgf("The requested vault type [%s] is not available on this system. Only the following vault backends are available for you system: %s", wantedVaultTypeName, strings.Join(availableVaultsNames, ", ")) - } -} - func init() { - - vaultSetCmd.Flags().StringP("passphrase", "p", "", "Set the passphrase for the file vault") - vaultCmd.AddCommand(vaultSetCmd) - vaultCmd.AddCommand(vaultUseCmd) rootCmd.AddCommand(vaultCmd) } diff --git a/cli/packages/util/constants.go b/cli/packages/util/constants.go index 5cd66f50b..1f9ec0c65 100644 --- a/cli/packages/util/constants.go +++ b/cli/packages/util/constants.go @@ -38,7 +38,8 @@ const ( SERVICE_TOKEN_IDENTIFIER = "service-token" UNIVERSAL_AUTH_TOKEN_IDENTIFIER = "universal-auth-token" - INFISICAL_BACKUP_SECRET = "infisical-backup-secrets" + INFISICAL_BACKUP_SECRET = "infisical-backup-secrets" // akhilmhdh: @depreciated remove in version v0.30 + INFISICAL_BACKUP_SECRET_ENCRYPTION_KEY = "infisical-backup-secret-encryption-key" ) var ( diff --git a/cli/packages/util/credentials.go b/cli/packages/util/credentials.go index be49e9267..cb5b94080 100644 --- a/cli/packages/util/credentials.go +++ b/cli/packages/util/credentials.go @@ -71,7 +71,7 @@ func GetCurrentLoggedInUserDetails() (LoggedInUserDetails, error) { if strings.Contains(err.Error(), "credentials not found in system keyring") { return LoggedInUserDetails{}, errors.New("we couldn't find your logged in details, try running [infisical login] then try again") } else { - return LoggedInUserDetails{}, fmt.Errorf("failed to fetch creditnals from keyring because [err=%s]", err) + return LoggedInUserDetails{}, fmt.Errorf("failed to fetch credentials from keyring because [err=%s]", err) } } diff --git a/cli/packages/util/helper.go b/cli/packages/util/helper.go index 9ce8c4a1d..69a310efa 100644 --- a/cli/packages/util/helper.go +++ b/cli/packages/util/helper.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/base64" "fmt" + "math/rand" "os" "os/exec" "path" @@ -25,6 +26,8 @@ type DecodedSymmetricEncryptionDetails = struct { Key []byte } +const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + func GetBase64DecodedSymmetricEncryptionDetails(key string, cipher string, IV string, tag string) (DecodedSymmetricEncryptionDetails, error) { cipherx, err := base64.StdEncoding.DecodeString(cipher) if err != nil { @@ -287,3 +290,11 @@ func GetCmdFlagOrEnv(cmd *cobra.Command, flag, envName string) (string, error) { } return value, nil } + +func GenerateRandomString(length int) string { + b := make([]byte, length) + for i := range b { + b[i] = charset[rand.Intn(len(charset))] + } + return string(b) +} diff --git a/cli/packages/util/keyringwrapper.go b/cli/packages/util/keyringwrapper.go index cadb72ebd..9c8211a3c 100644 --- a/cli/packages/util/keyringwrapper.go +++ b/cli/packages/util/keyringwrapper.go @@ -2,8 +2,9 @@ package util import ( "encoding/base64" + "fmt" - "github.com/manifoldco/promptui" + "github.com/rs/zerolog/log" "github.com/zalando/go-keyring" ) @@ -26,20 +27,13 @@ func SetValueInKeyring(key, value string) error { err = keyring.Set(currentVaultBackend, MAIN_KEYRING_SERVICE, key, value) if err != nil { + log.Debug().Msg(fmt.Sprintf("Error while setting default keyring: %v", err)) configFile, _ := GetConfigFile() if configFile.VaultBackendPassphrase == "" { - PrintWarning("System keyring could not be used, falling back to `file` vault for sensitive data storage.") - passphrasePrompt := promptui.Prompt{ - Label: "Enter the passphrase to use for keyring encryption", - } - passphrase, err := passphrasePrompt.Run() - if err != nil { - return err - } - - encodedPassphrase := base64.StdEncoding.EncodeToString([]byte(passphrase)) + encodedPassphrase := base64.StdEncoding.EncodeToString([]byte(GenerateRandomString(10))) // generate random passphrase configFile.VaultBackendPassphrase = encodedPassphrase + configFile.VaultBackendType = VAULT_BACKEND_FILE_MODE err = WriteConfigFile(&configFile) if err != nil { return err @@ -50,6 +44,7 @@ func SetValueInKeyring(key, value string) error { } err = keyring.Set(VAULT_BACKEND_FILE_MODE, MAIN_KEYRING_SERVICE, key, value) + log.Debug().Msg(fmt.Sprintf("Error while setting file keyring: %v", err)) } return err @@ -60,13 +55,7 @@ func GetValueInKeyring(key string) (string, error) { if err != nil { PrintErrorAndExit(1, err, "Unable to get current vault. Tip: run [infisical reset] then try again") } - - value, err := keyring.Get(currentVaultBackend, MAIN_KEYRING_SERVICE, key) - - if err != nil { - value, err = keyring.Get(VAULT_BACKEND_FILE_MODE, MAIN_KEYRING_SERVICE, key) - } - return value, err + return keyring.Get(currentVaultBackend, MAIN_KEYRING_SERVICE, key) } @@ -76,11 +65,5 @@ func DeleteValueInKeyring(key string) error { return err } - err = keyring.Delete(currentVaultBackend, MAIN_KEYRING_SERVICE, key) - - if err != nil { - err = keyring.Delete(VAULT_BACKEND_FILE_MODE, MAIN_KEYRING_SERVICE, key) - } - - return err + return keyring.Delete(currentVaultBackend, MAIN_KEYRING_SERVICE, key) } diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index c51338d61..b6ec7f752 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -1,14 +1,15 @@ package util import ( + "crypto/rand" "encoding/base64" + "encoding/hex" "encoding/json" "errors" "fmt" "os" "path" "regexp" - "slices" "strings" "unicode" @@ -20,7 +21,7 @@ import ( "github.com/zalando/go-keyring" ) -func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment string, secretPath string, includeImports bool, recursive bool) ([]models.SingleEnvironmentVariable, error) { +func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment string, secretPath string, includeImports bool, recursive bool, tagSlugs string) ([]models.SingleEnvironmentVariable, error) { serviceTokenParts := strings.SplitN(fullServiceToken, ".", 4) if len(serviceTokenParts) < 4 { return nil, fmt.Errorf("invalid service token entered. Please double check your service token and try again") @@ -53,6 +54,7 @@ func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment str SecretPath: secretPath, IncludeImport: includeImports, Recursive: recursive, + TagSlugs: tagSlugs, }) if err != nil { @@ -76,7 +78,7 @@ func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment str } -func GetPlainTextSecretsV3(accessToken string, workspaceId string, environmentName string, secretsPath string, includeImports bool, recursive bool) (models.PlaintextSecretResult, error) { +func GetPlainTextSecretsV3(accessToken string, workspaceId string, environmentName string, secretsPath string, includeImports bool, recursive bool, tagSlugs string) (models.PlaintextSecretResult, error) { httpClient := resty.New() httpClient.SetAuthToken(accessToken). SetHeader("Accept", "application/json") @@ -86,7 +88,7 @@ func GetPlainTextSecretsV3(accessToken string, workspaceId string, environmentNa Environment: environmentName, IncludeImport: includeImports, Recursive: recursive, - // TagSlugs: tagSlugs, + TagSlugs: tagSlugs, } if secretsPath != "" { @@ -281,29 +283,36 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo } res, err := GetPlainTextSecretsV3(loggedInUserDetails.UserCredentials.JTWToken, infisicalDotJson.WorkspaceId, - params.Environment, params.SecretsPath, params.IncludeImport, params.Recursive) + params.Environment, params.SecretsPath, params.IncludeImport, params.Recursive, params.TagSlugs) log.Debug().Msgf("GetAllEnvironmentVariables: Trying to fetch secrets JTW token [err=%s]", err) if err == nil { - WriteBackupSecrets(infisicalDotJson.WorkspaceId, params.Environment, params.SecretsPath, res.Secrets) + backupEncryptionKey, err := GetBackupEncryptionKey() + if err != nil { + return nil, err + } + WriteBackupSecrets(infisicalDotJson.WorkspaceId, params.Environment, params.SecretsPath, backupEncryptionKey, res.Secrets) } secretsToReturn = res.Secrets errorToReturn = err // only attempt to serve cached secrets if no internet connection and if at least one secret cached if !isConnected { - backedSecrets, err := ReadBackupSecrets(infisicalDotJson.WorkspaceId, params.Environment, params.SecretsPath) - if len(backedSecrets) > 0 { - PrintWarning("Unable to fetch latest secret(s) due to connection error, serving secrets from last successful fetch. For more info, run with --debug") - secretsToReturn = backedSecrets - errorToReturn = err + backupEncryptionKey, _ := GetBackupEncryptionKey() + if backupEncryptionKey != nil { + backedUpSecrets, err := ReadBackupSecrets(infisicalDotJson.WorkspaceId, params.Environment, params.SecretsPath, backupEncryptionKey) + if len(backedUpSecrets) > 0 { + PrintWarning("Unable to fetch the latest secret(s) due to connection error, serving secrets from last successful fetch. For more info, run with --debug") + secretsToReturn = backedUpSecrets + errorToReturn = err + } } } } else { if params.InfisicalToken != "" { log.Debug().Msg("Trying to fetch secrets using service token") - secretsToReturn, errorToReturn = GetPlainTextSecretsViaServiceToken(params.InfisicalToken, params.Environment, params.SecretsPath, params.IncludeImport, params.Recursive) + secretsToReturn, errorToReturn = GetPlainTextSecretsViaServiceToken(params.InfisicalToken, params.Environment, params.SecretsPath, params.IncludeImport, params.Recursive, params.TagSlugs) } else if params.UniversalAuthAccessToken != "" { if params.WorkspaceId == "" { @@ -311,7 +320,7 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo } log.Debug().Msg("Trying to fetch secrets using universal auth") - res, err := GetPlainTextSecretsV3(params.UniversalAuthAccessToken, params.WorkspaceId, params.Environment, params.SecretsPath, params.IncludeImport, params.Recursive) + res, err := GetPlainTextSecretsV3(params.UniversalAuthAccessToken, params.WorkspaceId, params.Environment, params.SecretsPath, params.IncludeImport, params.Recursive, params.TagSlugs) errorToReturn = err secretsToReturn = res.Secrets @@ -476,71 +485,99 @@ func OverrideSecrets(secrets []models.SingleEnvironmentVariable, secretType stri return secretsToReturn } -func WriteBackupSecrets(workspace string, environment string, secretsPath string, secrets []models.SingleEnvironmentVariable) error { - var backedUpSecrets []models.BackupSecretKeyRing - secretValueInKeyRing, err := GetValueInKeyring(INFISICAL_BACKUP_SECRET) +func GetBackupEncryptionKey() ([]byte, error) { + encryptionKey, err := GetValueInKeyring(INFISICAL_BACKUP_SECRET_ENCRYPTION_KEY) if err != nil { if err == keyring.ErrUnsupportedPlatform { - return errors.New("your OS does not support keyring. Consider using a service token https://infisical.com/docs/documentation/platform/token") - } else if err != keyring.ErrNotFound { - return fmt.Errorf("something went wrong, failed to retrieve value from system keyring [error=%v]", err) + return nil, errors.New("your OS does not support keyring. Consider using a service token https://infisical.com/docs/documentation/platform/token") + } else if err == keyring.ErrNotFound { + // generate a new key + randomizedKey := make([]byte, 16) + rand.Read(randomizedKey) + encryptionKey = hex.EncodeToString(randomizedKey) + if err := SetValueInKeyring(INFISICAL_BACKUP_SECRET_ENCRYPTION_KEY, encryptionKey); err != nil { + return nil, err + } + return []byte(encryptionKey), nil + } else { + return nil, fmt.Errorf("something went wrong, failed to retrieve value from system keyring [error=%v]", err) } } - _ = json.Unmarshal([]byte(secretValueInKeyRing), &backedUpSecrets) + return []byte(encryptionKey), nil +} - backedUpSecrets = slices.DeleteFunc(backedUpSecrets, func(e models.BackupSecretKeyRing) bool { - return e.SecretPath == secretsPath && e.ProjectID == workspace && e.Environment == environment - }) - newBackupSecret := models.BackupSecretKeyRing{ - ProjectID: workspace, - Environment: environment, - SecretPath: secretsPath, - Secrets: secrets, - } - backedUpSecrets = append(backedUpSecrets, newBackupSecret) +func WriteBackupSecrets(workspace string, environment string, secretsPath string, encryptionKey []byte, secrets []models.SingleEnvironmentVariable) error { + formattedPath := strings.ReplaceAll(secretsPath, "/", "-") + fileName := fmt.Sprintf("project_secrets_%s_%s_%s.json", workspace, environment, formattedPath) + secrets_backup_folder_name := "secrets-backup" - listOfSecretsMarshalled, err := json.Marshal(backedUpSecrets) + _, fullConfigFileDirPath, err := GetFullConfigFilePath() if err != nil { - return err + return fmt.Errorf("WriteBackupSecrets: unable to get full config folder path [err=%s]", err) } - err = SetValueInKeyring(INFISICAL_BACKUP_SECRET, string(listOfSecretsMarshalled)) + // create secrets backup directory + fullPathToSecretsBackupFolder := fmt.Sprintf("%s/%s", fullConfigFileDirPath, secrets_backup_folder_name) + if _, err := os.Stat(fullPathToSecretsBackupFolder); errors.Is(err, os.ErrNotExist) { + err := os.Mkdir(fullPathToSecretsBackupFolder, os.ModePerm) + if err != nil { + return err + } + } + marshaledSecrets, _ := json.Marshal(secrets) + result, err := crypto.EncryptSymmetric(marshaledSecrets, encryptionKey) if err != nil { - return fmt.Errorf("StoreUserCredsInKeyRing: unable to store user credentials because [err=%s]", err) + return fmt.Errorf("WriteBackupSecrets: Unable to encrypt local secret backup to file [err=%s]", err) + } + listOfSecretsMarshalled, _ := json.Marshal(result) + err = os.WriteFile(fmt.Sprintf("%s/%s", fullPathToSecretsBackupFolder, fileName), listOfSecretsMarshalled, 0600) + if err != nil { + return fmt.Errorf("WriteBackupSecrets: Unable to write backup secrets to file [err=%s]", err) } return nil } -func ReadBackupSecrets(workspace string, environment string, secretsPath string) ([]models.SingleEnvironmentVariable, error) { - secretValueInKeyRing, err := GetValueInKeyring(INFISICAL_BACKUP_SECRET) +func ReadBackupSecrets(workspace string, environment string, secretsPath string, encryptionKey []byte) ([]models.SingleEnvironmentVariable, error) { + formattedPath := strings.ReplaceAll(secretsPath, "/", "-") + fileName := fmt.Sprintf("project_secrets_%s_%s_%s.json", workspace, environment, formattedPath) + secrets_backup_folder_name := "secrets-backup" + + _, fullConfigFileDirPath, err := GetFullConfigFilePath() if err != nil { - if err == keyring.ErrUnsupportedPlatform { - return nil, errors.New("your OS does not support keyring. Consider using a service token https://infisical.com/docs/documentation/platform/token") - } else if err == keyring.ErrNotFound { - return nil, errors.New("credentials not found in system keyring") - } else { - return nil, fmt.Errorf("something went wrong, failed to retrieve value from system keyring [error=%v]", err) - } + return nil, fmt.Errorf("ReadBackupSecrets: unable to write config file because an error occurred when getting config file path [err=%s]", err) } - var backedUpSecrets []models.BackupSecretKeyRing - err = json.Unmarshal([]byte(secretValueInKeyRing), &backedUpSecrets) + fullPathToSecretsBackupFolder := fmt.Sprintf("%s/%s", fullConfigFileDirPath, secrets_backup_folder_name) + if _, err := os.Stat(fullPathToSecretsBackupFolder); errors.Is(err, os.ErrNotExist) { + return nil, nil + } + + encryptedBackupSecretsFilePath := fmt.Sprintf("%s/%s", fullPathToSecretsBackupFolder, fileName) + + encryptedBackupSecretsAsBytes, err := os.ReadFile(encryptedBackupSecretsFilePath) if err != nil { - return nil, fmt.Errorf("getUserCredsFromKeyRing: Something went wrong when unmarshalling user creds [err=%s]", err) + return nil, err } - for _, backupSecret := range backedUpSecrets { - if backupSecret.Environment == environment && backupSecret.ProjectID == workspace && backupSecret.SecretPath == secretsPath { - return backupSecret.Secrets, nil - } + var encryptedBackUpSecrets models.SymmetricEncryptionResult + err = json.Unmarshal(encryptedBackupSecretsAsBytes, &encryptedBackUpSecrets) + if err != nil { + return nil, fmt.Errorf("ReadBackupSecrets: unable to parse encrypted backup secrets. The secrets backup may be malformed [err=%s]", err) } - return nil, nil + result, err := crypto.DecryptSymmetric(encryptionKey, encryptedBackUpSecrets.CipherText, encryptedBackUpSecrets.AuthTag, encryptedBackUpSecrets.Nonce) + if err != nil { + return nil, fmt.Errorf("ReadBackupSecrets: unable to decrypt encrypted backup secrets [err=%s]", err) + } + var plainTextSecrets []models.SingleEnvironmentVariable + _ = json.Unmarshal(result, &plainTextSecrets) + + return plainTextSecrets, nil + } func DeleteBackupSecrets() error { - // keeping this logic for now. Need to remove it later as more users migrate keyring would be used and this folder will be removed completely by then secrets_backup_folder_name := "secrets-backup" _, fullConfigFileDirPath, err := GetFullConfigFilePath() @@ -549,8 +586,8 @@ func DeleteBackupSecrets() error { } fullPathToSecretsBackupFolder := fmt.Sprintf("%s/%s", fullConfigFileDirPath, secrets_backup_folder_name) - DeleteValueInKeyring(INFISICAL_BACKUP_SECRET) + DeleteValueInKeyring(INFISICAL_BACKUP_SECRET_ENCRYPTION_KEY) return os.RemoveAll(fullPathToSecretsBackupFolder) } diff --git a/cli/test/.snapshots/test-testUserAuth_SecretsGetAllWithoutConnection b/cli/test/.snapshots/test-testUserAuth_SecretsGetAllWithoutConnection index c48627f73..71a189a65 100644 --- a/cli/test/.snapshots/test-testUserAuth_SecretsGetAllWithoutConnection +++ b/cli/test/.snapshots/test-testUserAuth_SecretsGetAllWithoutConnection @@ -1,4 +1,4 @@ -Warning: Unable to fetch latest secret(s) due to connection error, serving secrets from last successful fetch. For more info, run with --debug +Warning: Unable to fetch the latest secret(s) due to connection error, serving secrets from last successful fetch. For more info, run with --debug ┌───────────────┬──────────────┬─────────────┐ │ SECRET NAME │ SECRET VALUE │ SECRET TYPE │ ├───────────────┼──────────────┼─────────────┤ diff --git a/cli/test/secrets_test.go b/cli/test/secrets_test.go index f5d5a7b1f..f11392f52 100644 --- a/cli/test/secrets_test.go +++ b/cli/test/secrets_test.go @@ -7,7 +7,6 @@ import ( "github.com/bradleyjkemp/cupaloy/v2" ) - func TestServiceToken_SecretsGetWithImportsAndRecursiveCmd(t *testing.T) { output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") @@ -94,7 +93,7 @@ func TestUserAuth_SecretsGetAll(t *testing.T) { } // explicitly called here because it should happen directly after successful secretsGetAll - testUserAuth_SecretsGetAllWithoutConnection(t) + // testUserAuth_SecretsGetAllWithoutConnection(t) } func testUserAuth_SecretsGetAllWithoutConnection(t *testing.T) { @@ -107,7 +106,7 @@ func testUserAuth_SecretsGetAllWithoutConnection(t *testing.T) { // set it to a URL that will always be unreachable newConfigFile.LoggedInUserDomain = "http://localhost:4999" util.WriteConfigFile(&newConfigFile) - + // restore config file defer util.WriteConfigFile(&originalConfigFile) @@ -121,4 +120,4 @@ func testUserAuth_SecretsGetAllWithoutConnection(t *testing.T) { if err != nil { t.Fatalf("snapshot failed: %v", err) } -} \ No newline at end of file +} diff --git a/company/handbook/spending-money.mdx b/company/handbook/spending-money.mdx index 667ed5ef2..046604399 100644 --- a/company/handbook/spending-money.mdx +++ b/company/handbook/spending-money.mdx @@ -1,14 +1,16 @@ --- -title: "Spenging Money" +title: "Spending Money" sidebarTitle: "Spending Money" description: "The guide to spending money at Infisical." --- Fairly frequently, you might run into situations when you need to spend company money. -**Please spend money in a way that you think is in the best interest of the company.** + +Please spend money in a way that you think is in the best interest of the company. + -## Trivial expenses +# Trivial expenses We don't want you to be slowed down because you're waiting for an approval to purchase some SaaS. For trivial expenses – **Just do it**. @@ -22,6 +24,35 @@ Make sure you keep copies for all receipts. If you expense something on a compan You should default to using your company card in all cases - it has no transaction fees. If using your personal card is unavoidable, please reach out to Maidul to get it reimbursed manually. + +# Equipment + +Infisical is a remote first company so we understand the importance of having a comfortable work setup. To support this, we provide allowances for essential office equipment. + +### Desk & Chair + +Most people already have a comfortable desk and chair, but if you need an upgrade, we offer the following allowances. +While we're not yet able to provide the latest and greatest, we strive to be reasonable given the stage of our company. + +**Desk**: $150 USD + +**Chair**: $150 USD + +### Laptop +Each team member will receive a company-issued Macbook Pro before they start their first day. + +### Notes + +1. All equipment purchased using company allowances remains the property of Infisical. +2. Keep all receipts for equipment purchases and submit them for reimbursement. +3. If you leave Infisical, you may be required to return company-owned equipment. + +Please note that we're unable to offer a split payment option where the Infisical pays half and you pay half for equipment exceeding the allowance. +This is because we don't yet have a formal HR department to handle such logistics. + +For any equipment related questions, please reach out to Maidul. + + ## Brex We use Brex as our primary credit card provider. Don't have a company card yet? Reach out to Maidul. \ No newline at end of file diff --git a/company/handbook/talking-to-customers.mdx b/company/handbook/talking-to-customers.mdx new file mode 100644 index 000000000..3f1009162 --- /dev/null +++ b/company/handbook/talking-to-customers.mdx @@ -0,0 +1,20 @@ +--- +title: "Talking to Customers" +sidebarTitle: "Talking to Customers" +description: "The guide to talking to customers at Infisical." +--- + +Everyone at Infisical talks to customers directly. We do this for a few reasons: +1. This helps us understand the needs of our customers and build the product they want. +2. This speeds up our iteration cycles (time from customer feedback to product improvements). +3. Our customers (developers) are able to talk directly to the best experts in Infisical (us) – which improves their satisfaction and success. + +## Customer Communication Etiquette + +1. When talking to customers (no matter whether it's on Slack, email, or any other channel), it is very important to use proper grammar (e.g., no typos, no missed question marks) and minimal colloquial language (no "yeap", "yah", etc.). +2. At the time of a crisis (e.g., customer-reported bug), it is very important to communicate often. Even if there is no update yet, it is good to reach out to the customer and let them know that we are still working on resolving a certain issue. + +## Community Slack + +Unfortunately, we are not able to help everyone in the community Slack. It is OK to politely decline questions about infrastructure management that are not directly related to the product itself. + diff --git a/company/mint.json b/company/mint.json index d5f6395e5..c29f6c237 100644 --- a/company/mint.json +++ b/company/mint.json @@ -60,7 +60,8 @@ "handbook/spending-money", "handbook/time-off", "handbook/hiring", - "handbook/meetings" + "handbook/meetings", + "handbook/talking-to-customers" ] } ], diff --git a/docs/api-reference/endpoints/certificate-templates/create.mdx b/docs/api-reference/endpoints/certificate-templates/create.mdx new file mode 100644 index 000000000..56fcf3791 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/pki/certificate-templates" +--- diff --git a/docs/api-reference/endpoints/certificate-templates/delete.mdx b/docs/api-reference/endpoints/certificate-templates/delete.mdx new file mode 100644 index 000000000..c4f13d470 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/pki/certificate-templates/{certificateTemplateId}" +--- diff --git a/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx b/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx new file mode 100644 index 000000000..802dc5326 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/pki/certificate-templates/{certificateTemplateId}" +--- diff --git a/docs/api-reference/endpoints/certificate-templates/update.mdx b/docs/api-reference/endpoints/certificate-templates/update.mdx new file mode 100644 index 000000000..53c5f6fdf --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/pki/certificate-templates/{certificateTemplateId}" +--- diff --git a/docs/api-reference/endpoints/certificates/issue-certificate.mdx b/docs/api-reference/endpoints/certificates/issue-certificate.mdx new file mode 100644 index 000000000..90a79a4af --- /dev/null +++ b/docs/api-reference/endpoints/certificates/issue-certificate.mdx @@ -0,0 +1,4 @@ +--- +title: "Issue Certificate" +openapi: "POST /api/v1/pki/certificates/issue-certificate" +--- diff --git a/docs/api-reference/endpoints/certificates/sign-certificate.mdx b/docs/api-reference/endpoints/certificates/sign-certificate.mdx new file mode 100644 index 000000000..3132d5846 --- /dev/null +++ b/docs/api-reference/endpoints/certificates/sign-certificate.mdx @@ -0,0 +1,4 @@ +--- +title: "Sign Certificate" +openapi: "POST /api/v1/pki/certificates/sign-certificate" +--- diff --git a/docs/api-reference/endpoints/dynamic-secrets/create-lease.mdx b/docs/api-reference/endpoints/dynamic-secrets/create-lease.mdx new file mode 100644 index 000000000..57e235f50 --- /dev/null +++ b/docs/api-reference/endpoints/dynamic-secrets/create-lease.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Lease" +openapi: "POST /api/v1/dynamic-secrets/leases" +--- diff --git a/docs/api-reference/endpoints/dynamic-secrets/create.mdx b/docs/api-reference/endpoints/dynamic-secrets/create.mdx new file mode 100644 index 000000000..1c591bd3d --- /dev/null +++ b/docs/api-reference/endpoints/dynamic-secrets/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/dynamic-secrets" +--- diff --git a/docs/api-reference/endpoints/dynamic-secrets/delete-lease.mdx b/docs/api-reference/endpoints/dynamic-secrets/delete-lease.mdx new file mode 100644 index 000000000..93e209995 --- /dev/null +++ b/docs/api-reference/endpoints/dynamic-secrets/delete-lease.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete Lease" +openapi: "DELETE /api/v1/dynamic-secrets/leases/{leaseId}" +--- diff --git a/docs/api-reference/endpoints/dynamic-secrets/delete.mdx b/docs/api-reference/endpoints/dynamic-secrets/delete.mdx new file mode 100644 index 000000000..e59ac4379 --- /dev/null +++ b/docs/api-reference/endpoints/dynamic-secrets/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/dynamic-secrets/{name}" +--- diff --git a/docs/api-reference/endpoints/dynamic-secrets/get-lease.mdx b/docs/api-reference/endpoints/dynamic-secrets/get-lease.mdx new file mode 100644 index 000000000..aff12207e --- /dev/null +++ b/docs/api-reference/endpoints/dynamic-secrets/get-lease.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Lease" +openapi: "GET /api/v1/dynamic-secrets/leases/{leaseId}" +--- diff --git a/docs/api-reference/endpoints/dynamic-secrets/get.mdx b/docs/api-reference/endpoints/dynamic-secrets/get.mdx new file mode 100644 index 000000000..bc3267e97 --- /dev/null +++ b/docs/api-reference/endpoints/dynamic-secrets/get.mdx @@ -0,0 +1,4 @@ +--- +title: "Get" +openapi: "GET /api/v1/dynamic-secrets/{name}" +--- diff --git a/docs/api-reference/endpoints/dynamic-secrets/list-leases.mdx b/docs/api-reference/endpoints/dynamic-secrets/list-leases.mdx new file mode 100644 index 000000000..26cad150d --- /dev/null +++ b/docs/api-reference/endpoints/dynamic-secrets/list-leases.mdx @@ -0,0 +1,4 @@ +--- +title: "List Leases" +openapi: "GET /api/v1/dynamic-secrets/{name}/leases" +--- diff --git a/docs/api-reference/endpoints/dynamic-secrets/list.mdx b/docs/api-reference/endpoints/dynamic-secrets/list.mdx new file mode 100644 index 000000000..d76c1167e --- /dev/null +++ b/docs/api-reference/endpoints/dynamic-secrets/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/dynamic-secrets" +--- diff --git a/docs/api-reference/endpoints/dynamic-secrets/renew-lease.mdx b/docs/api-reference/endpoints/dynamic-secrets/renew-lease.mdx new file mode 100644 index 000000000..ea1777e6b --- /dev/null +++ b/docs/api-reference/endpoints/dynamic-secrets/renew-lease.mdx @@ -0,0 +1,4 @@ +--- +title: "Renew Lease" +openapi: "POST /api/v1/dynamic-secrets/leases/{leaseId}/renew" +--- diff --git a/docs/api-reference/endpoints/dynamic-secrets/update.mdx b/docs/api-reference/endpoints/dynamic-secrets/update.mdx new file mode 100644 index 000000000..0ab4aaab4 --- /dev/null +++ b/docs/api-reference/endpoints/dynamic-secrets/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/dynamic-secrets/{name}" +--- diff --git a/docs/cli/commands/vault.mdx b/docs/cli/commands/vault.mdx index 803af127f..b09513c34 100644 --- a/docs/cli/commands/vault.mdx +++ b/docs/cli/commands/vault.mdx @@ -30,8 +30,5 @@ description: "Change the vault type in Infisical" ## Description -To safeguard your login details when using the CLI, Infisical places them in a system vault or an encrypted text file, protected by a passphrase that only the user knows. - -To avoid constantly entering your passphrase when using the `file` vault type, use the `infisical vault set file --passphrase ` CLI command to specify your password once. - +To safeguard your login details when using the CLI, Infisical attempts to store them in a system keyring. If a system keyring cannot be found on your machine, the data is stored in a config file. diff --git a/docs/documentation/guides/local-development.mdx b/docs/documentation/guides/local-development.mdx index 9ffc0fca0..6d606bafe 100644 --- a/docs/documentation/guides/local-development.mdx +++ b/docs/documentation/guides/local-development.mdx @@ -9,7 +9,7 @@ description: "Learn how to manage secrets in local development environments." There is a number of issues that arise with secret management in local development environment: 1. **Getting secrets onto local machines**. When new developers join or a new project is created, the process of getting the development set of secrets onto local machines is often unclear. As a result, developers end up spending a lot of time onboarding and risk potentially following insecure practices when sharing secrets from one developer to another. 2. **Syncing secrets with teammates**. One of the problems with .env files is that they become unsynced when one of the developers updates a secret or configuration. Even if the rest of the team is notified, developers don't make all the right changes immediately, and later on end up spending a lot of time debugging an issue due to missing environment variables. This leads to a lot of inefficiencies and lost time. -3. **Accidentally leaking secrets**. When developing locally, it's common for developers to accidentally leak a hardcoded as part of a commit. As soon as the secret is part of the git history, it becomes hard to get it removed and create a security vulnerability. +3. **Accidentally leaking secrets**. When developing locally, it's common for developers to accidentally leak a hardcoded secret as part of a commit. As soon as the secret is part of the git history, it becomes hard to get it removed and create a security vulnerability. ## Solution @@ -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 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 +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. diff --git a/docs/documentation/platform/pki/certificate-templates.mdx b/docs/documentation/platform/pki/certificate-templates.mdx new file mode 100644 index 000000000..a52bf4364 --- /dev/null +++ b/docs/documentation/platform/pki/certificate-templates.mdx @@ -0,0 +1,111 @@ +--- +title: "Certificate Templates" +sidebarTitle: "Certificate Templates" +description: "Learn how to use certificate templates to enforce policies." +--- + +## Concept + +In order to ensure your certificates follow certain policies, you can use certificate templates during the issuance and signing flows. + +A certificate template is linked to a certificate authority. It contains custom policies for certificate fields, allowing you to define rules based on your security policies. + +## Workflow + +The typical workflow for using certificate templates consists of the following steps: + +1. Creating a certificate template attached to an existing CA along with defining custom rules for certificate fields. +2. Selecting the certificate template during the creation of new certificates. + + + Note that this workflow can be executed via the Infisical UI or manually such + as via API. + + +## Guide to using Certificate Templates + +In the following steps, we explore how to issue a X.509 certificate using a certificate template. + + + + + + + To create a certificate template, head to your Project > Internal PKI > Certificate Templates and press **Create Certificate Template**. + + ![certificate-template create template dashboard](/images/platform/pki/certificate-template/create-template-dashboard.png) + + Here, set the **Issuing CA** to the CA you want to issue certificates under when the certificate template is used. + + ![certificate-template create template modal](/images/platform/pki/certificate-template/create-template-form.png) + + Here's some guidance on each field: + - Template Name: A descriptive name for the certificate template. + - Issuing CA: The Certificate Authority (CA) that will issue certificates based on this template. + - Certificate Collection: The collection where certificates issued with this template will be added. + - Common Name (CN): The regular expression used to validate the common name in certificate requests. + - Alternative Names (SANs): The regular expression used to validate subject alternative names in certificate requests. + - TTL: The maximum Time-to-Live (TTL) for certificates issued using this template. + + + + Once you have created the certificate template from step 1, you can select it when issuing certificates. + + ![certificate-template select template](/images/platform/pki/certificate-template/select-template.png) + + + + + + + To create a certificate template, make an API request to the [Create Certificate Template](/api-reference/endpoints/certificate-templates/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/pki/certificate-templates \ + --header 'Content-Type: application/json' \ + --data '{ + "caId": "", + "pkiCollectionId": "", + "name": "", + "commonName": "", + "subjectAlternativeName": "", + "ttl": "" + }' + ``` + + ### Sample response + + ```bash Response + { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "caId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "certificate-template-1", + "commonName": "", + ... + } + ``` + + + To use the certificate template, attach the certificate template ID when invoking the API endpoint for [issuing](/api-reference/endpoints/certificates/issue-certificate) or [signing](/api-reference/endpoints/certificates/sign-certificate) new certificates. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/pki/certificates/issue-certificate \ + --header 'Content-Type: application/json' \ + --data '{ + "certificateTemplateId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "friendlyName": "my-new-certificate", + "commonName": "CERT", + ... + }' + ``` + + + + + diff --git a/docs/documentation/platform/sso/google-saml.mdx b/docs/documentation/platform/sso/google-saml.mdx index 1897a651a..da34e076a 100644 --- a/docs/documentation/platform/sso/google-saml.mdx +++ b/docs/documentation/platform/sso/google-saml.mdx @@ -4,10 +4,10 @@ description: "Learn how to configure Google SAML for Infisical SSO." --- - Google SAML SSO feature is a paid feature. - - If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, - then you should contact sales@infisical.com to purchase an enterprise license to use it. + Google SAML SSO feature is a paid feature. If you're using Infisical Cloud, + then it is available under the **Pro Tier**. If you're self-hosting Infisical, + then you should contact sales@infisical.com to purchase an enterprise license + to use it. @@ -15,8 +15,9 @@ description: "Learn how to configure Google SAML for Infisical SSO." In Infisical, head to your Organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**. Next, note the **ACS URL** and **SP Entity ID** to use when configuring the Google SAML application. - + ![Google SAML initial configuration](../../../images/sso/google-saml/init-config.png) + 2.1. In your [Google Admin console](https://support.google.com/a/answer/182076), head to Menu > Apps > Web and mobile apps and @@ -32,7 +33,7 @@ description: "Learn how to configure Google SAML for Infisical SSO." ![Google SAML custom app details](../../../images/sso/google-saml/custom-saml-app-config.png) - 2.4. Back in Infisical, set **SSO URL**, **IdP Entity ID**, and **Certificate** to the corresponding items from step 2.3. + 2.4. Back in Infisical, set **SSO URL** and **Certificate** to the corresponding items from step 2.3. ![Google SAML Infisical config](../../../images/sso/google-saml/infisical-config.png) @@ -41,7 +42,7 @@ description: "Learn how to configure Google SAML for Infisical SSO." Also, check the **Signed response** checkbox. ![Google SAML app config 2](../../../images/sso/google-saml/custom-saml-app-config-2.png) - + 2.6. In the **Attribute mapping** tab, configure the following map: - **First name** -> **firstName** @@ -49,7 +50,7 @@ description: "Learn how to configure Google SAML for Infisical SSO." - **Primary email** -> **email** ![Google SAML attribute mapping](../../../images/sso/google-saml/attribute-mapping.png) - + Click **Finish**. @@ -57,11 +58,11 @@ description: "Learn how to configure Google SAML for Infisical SSO." and press on **User access**. ![Google SAML user access](../../../images/sso/google-saml/user-access.png) - + To assign everyone in your organization to the application, click **On for everyone** or **Off for everyone** and then click **Save**. - + You can also assign an organizational unit or set of users to an application; you can learn more about that [here](https://support.google.com/a/answer/6087519?hl=en#add_custom_saml&turn_on&verify_sso&&zippy=%2Cstep-add-the-custom-saml-app%2Cstep-turn-on-your-saml-app%2Cstep-verify-that-sso-is-working-with-your-custom-app). - + ![Google SAML user access assignment](../../../images/sso/google-saml/user-access-assign.png) @@ -75,21 +76,24 @@ description: "Learn how to configure Google SAML for Infisical SSO." To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one Google user with Infisical; Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO. - + We recommend ensuring that your account is provisioned the application in Google prior to enforcing SAML SSO to prevent any unintended issues. + - If you're configuring SAML SSO on a self-hosted instance of Infisical, make sure to - set the `AUTH_SECRET` and `SITE_URL` environment variable for it to work: - - - `AUTH_SECRET`: A secret key used for signing and verifying JWT. This can be a random 32-byte base64 string generated with `openssl rand -base64 32`. - - `SITE_URL`: The URL of your self-hosted instance of Infisical - should be an absolute URL including the protocol (e.g. https://app.infisical.com) + If you're configuring SAML SSO on a self-hosted instance of Infisical, make + sure to set the `AUTH_SECRET` and `SITE_URL` environment variable for it to + work: - `AUTH_SECRET`: A secret key used for signing and verifying JWT. This + can be a random 32-byte base64 string generated with `openssl rand -base64 + 32`. - `SITE_URL`: The URL of your self-hosted instance of Infisical - should + be an absolute URL including the protocol (e.g. https://app.infisical.com) References: -- Google's guide to [set up your own custom SAML app](https://support.google.com/a/answer/6087519?hl=en#add_custom_saml&turn_on&verify_sso&&zippy=%2Cstep-add-the-custom-saml-app%2Cstep-turn-on-your-saml-app%2Cstep-verify-that-sso-is-working-with-your-custom-app). \ No newline at end of file + +- Google's guide to [set up your own custom SAML app](https://support.google.com/a/answer/6087519?hl=en#add_custom_saml&turn_on&verify_sso&&zippy=%2Cstep-add-the-custom-saml-app%2Cstep-turn-on-your-saml-app%2Cstep-verify-that-sso-is-working-with-your-custom-app). diff --git a/docs/images/platform/pki/certificate-template/create-template-dashboard.png b/docs/images/platform/pki/certificate-template/create-template-dashboard.png new file mode 100644 index 000000000..6f193effa Binary files /dev/null and b/docs/images/platform/pki/certificate-template/create-template-dashboard.png differ diff --git a/docs/images/platform/pki/certificate-template/create-template-form.png b/docs/images/platform/pki/certificate-template/create-template-form.png new file mode 100644 index 000000000..e69791edd Binary files /dev/null and b/docs/images/platform/pki/certificate-template/create-template-form.png differ diff --git a/docs/images/platform/pki/certificate-template/select-template.png b/docs/images/platform/pki/certificate-template/select-template.png new file mode 100644 index 000000000..c10031a45 Binary files /dev/null and b/docs/images/platform/pki/certificate-template/select-template.png differ diff --git a/docs/images/sso/google-saml/infisical-config.png b/docs/images/sso/google-saml/infisical-config.png index 250b4ed37..31943df81 100644 Binary files a/docs/images/sso/google-saml/infisical-config.png and b/docs/images/sso/google-saml/infisical-config.png differ diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index dc76f2584..57329d4fd 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -1,5 +1,5 @@ --- -title: "Kubernetes" +title: "Kubernetes Operator" description: "How to use Infisical to inject secrets into Kubernetes clusters." --- @@ -9,6 +9,10 @@ The Infisical Secrets Operator is a Kubernetes controller that retrieves secrets It uses an `InfisicalSecret` resource to specify authentication and storage methods. The operator continuously updates secrets and can also reload dependent deployments automatically. + + If you are already using the External Secrets operator, you can view the integration documentation for it [here](https://external-secrets.io/latest/provider/infisical/). + + ## Install Operator The operator can be install via [Helm](https://helm.sh) or [kubectl](https://github.com/kubernetes/kubectl) diff --git a/docs/mint.json b/docs/mint.json index ac61ad5bd..7b8fcdc42 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -108,6 +108,7 @@ "documentation/platform/pki/overview", "documentation/platform/pki/private-ca", "documentation/platform/pki/certificates", + "documentation/platform/pki/certificate-templates", "documentation/platform/pki/alerting" ] }, @@ -618,6 +619,21 @@ "api-reference/endpoints/secrets/detach-tags" ] }, + { + "group": "Dynamic Secrets", + "pages": [ + "api-reference/endpoints/dynamic-secrets/create", + "api-reference/endpoints/dynamic-secrets/update", + "api-reference/endpoints/dynamic-secrets/delete", + "api-reference/endpoints/dynamic-secrets/get", + "api-reference/endpoints/dynamic-secrets/list", + "api-reference/endpoints/dynamic-secrets/list-leases", + "api-reference/endpoints/dynamic-secrets/create-lease", + "api-reference/endpoints/dynamic-secrets/delete-lease", + "api-reference/endpoints/dynamic-secrets/renew-lease", + "api-reference/endpoints/dynamic-secrets/get-lease" + ] + }, { "group": "Secret Imports", "pages": [ @@ -686,7 +702,18 @@ "api-reference/endpoints/certificates/read", "api-reference/endpoints/certificates/revoke", "api-reference/endpoints/certificates/delete", - "api-reference/endpoints/certificates/cert-body" + "api-reference/endpoints/certificates/cert-body", + "api-reference/endpoints/certificates/issue-certificate", + "api-reference/endpoints/certificates/sign-certificate" + ] + }, + { + "group": "Certificate Templates", + "pages": [ + "api-reference/endpoints/certificate-templates/create", + "api-reference/endpoints/certificate-templates/update", + "api-reference/endpoints/certificate-templates/get-by-id", + "api-reference/endpoints/certificate-templates/delete" ] }, { diff --git a/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx b/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx index c65229a86..acbcdcb2d 100644 --- a/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx +++ b/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx @@ -3,6 +3,7 @@ import { Controller, useForm } from "react-hook-form"; import { faCheck } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -87,7 +88,13 @@ type Props = { }; const createTagSchema = z.object({ - name: z.string().trim(), + slug: z + .string() + .trim() + .toLowerCase() + .refine((v) => slugify(v) === v, { + message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens." + }), color: z.string().trim() }); @@ -110,7 +117,7 @@ export const CreateTagModal = ({ isOpen, onToggle }: Props): JSX.Element => { } = useForm({ resolver: zodResolver(createTagSchema) }); - + const { currentWorkspace } = useWorkspace(); const workspaceId = currentWorkspace?.id || ""; @@ -123,13 +130,12 @@ export const CreateTagModal = ({ isOpen, onToggle }: Props): JSX.Element => { if (!isOpen) reset(); }, [isOpen]); - const onFormSubmit = async ({ name, color }: FormData) => { + const onFormSubmit = async ({ slug, color }: FormData) => { try { await createWsTag({ workspaceID: workspaceId, - tagName: name, tagColor: color, - tagSlug: name.replace(" ", "_") + tagSlug: slug }); onToggle(false); reset(); @@ -155,11 +161,11 @@ export const CreateTagModal = ({ isOpen, onToggle }: Props): JSX.Element => {
( - - + + )} /> diff --git a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx index 8e4bcbe71..299b23bb5 100644 --- a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx +++ b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { ReactNode, useEffect, useState } from "react"; import { useToggle } from "@app/hooks"; @@ -16,6 +16,7 @@ type Props = { subTitle?: string; onDeleteApproved: () => Promise; buttonText?: string; + children?: ReactNode; }; export const DeleteActionModal = ({ @@ -26,7 +27,8 @@ export const DeleteActionModal = ({ onDeleteApproved, title, subTitle = "This action is irreversible.", - buttonText = "Delete" + buttonText = "Delete", + children }: Props): JSX.Element => { const [inputData, setInputData] = useState(""); const [isLoading, setIsLoading] = useToggle(); @@ -97,6 +99,7 @@ export const DeleteActionModal = ({ placeholder={`Type ${deleteKey} here`} /> + {children} diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index c307688d6..6a4f84d02 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -28,6 +28,7 @@ export enum ProjectPermissionSub { Identity = "identity", CertificateAuthorities = "certificate-authorities", Certificates = "certificates", + CertificateTemplates = "certificate-templates", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms" @@ -59,6 +60,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.Certificates] + | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace] diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 8aadda2d8..210061f7c 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -68,7 +68,11 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.GET_PKI_COLLECTION_ITEMS]: "Get PKI collection items", [EventType.ADD_PKI_COLLECTION_ITEM]: "Add PKI collection item", [EventType.DELETE_PKI_COLLECTION_ITEM]: "Delete PKI collection item", - [EventType.ORG_ADMIN_ACCESS_PROJECT]: "Org admin accessed project" + [EventType.ORG_ADMIN_ACCESS_PROJECT]: "Org admin accessed project", + [EventType.CREATE_CERTIFICATE_TEMPLATE]: "Create certificate template", + [EventType.UPDATE_CERTIFICATE_TEMPLATE]: "Update certificate template", + [EventType.DELETE_CERTIFICATE_TEMPLATE]: "Delete certificate template", + [EventType.GET_CERTIFICATE_TEMPLATE]: "Get certificate template" }; 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 a6a2ada93..80c0ce431 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -82,5 +82,9 @@ export enum EventType { GET_PKI_COLLECTION_ITEMS = "get-pki-collection-items", ADD_PKI_COLLECTION_ITEM = "add-pki-collection-item", DELETE_PKI_COLLECTION_ITEM = "delete-pki-collection-item", - ORG_ADMIN_ACCESS_PROJECT = "org-admin-accessed-project" + ORG_ADMIN_ACCESS_PROJECT = "org-admin-accessed-project", + CREATE_CERTIFICATE_TEMPLATE = "create-certificate-template", + UPDATE_CERTIFICATE_TEMPLATE = "update-certificate-template", + DELETE_CERTIFICATE_TEMPLATE = "delete-certificate-template", + GET_CERTIFICATE_TEMPLATE = "get-certificate-template" } diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 0a34186f6..d7825637d 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -679,6 +679,46 @@ interface OrgAdminAccessProjectEvent { }; // no metadata yet } +interface CreateCertificateTemplate { + type: EventType.CREATE_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + caId: string; + pkiCollectionId?: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + }; +} + +interface GetCertificateTemplate { + type: EventType.GET_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + }; +} + +interface UpdateCertificateTemplate { + type: EventType.UPDATE_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + caId: string; + pkiCollectionId?: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + }; +} + +interface DeleteCertificateTemplate { + type: EventType.DELETE_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -747,7 +787,11 @@ export type Event = | GetPkiCollectionItems | AddPkiCollectionItem | DeletePkiCollectionItem - | OrgAdminAccessProjectEvent; + | OrgAdminAccessProjectEvent + | CreateCertificateTemplate + | UpdateCertificateTemplate + | GetCertificateTemplate + | DeleteCertificateTemplate; export type AuditLog = { id: string; diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index e77dfe2b8..9bb7b89d5 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -11,7 +11,7 @@ export const caStatusToNameMap: { [K in CaStatus]: string } = { [CaStatus.PENDING_CERTIFICATE]: "Pending Certificate" }; -export const getStatusBadgeVariant = (status: CaStatus) => { +export const getCaStatusBadgeVariant = (status: CaStatus) => { switch (status) { case CaStatus.ACTIVE: return "success"; diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx index 7a4952464..fc8ec24d6 100644 --- a/frontend/src/hooks/api/ca/mutations.tsx +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -101,9 +101,9 @@ export const useImportCaCertificate = () => { export const useCreateCertificate = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ caId, ...body }) => { + mutationFn: async (body) => { const { data } = await apiRequest.post( - `/api/v1/pki/ca/${caId}/issue-certificate`, + "/api/v1/pki/certificates/issue-certificate", body ); return data; @@ -126,6 +126,7 @@ export const useRenewCa = () => { }, onSuccess: (_, { caId, projectSlug }) => { queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug })); + queryClient.invalidateQueries(caKeys.getCaById(caId)); queryClient.invalidateQueries(caKeys.getCaCert(caId)); queryClient.invalidateQueries(caKeys.getCaCerts(caId)); queryClient.invalidateQueries(caKeys.getCaCsr(caId)); diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index 741eae2ef..320551a04 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -19,6 +19,7 @@ export type TCertificateAuthority = { notAfter?: string; notBefore?: string; keyAlgorithm: CertKeyAlgorithm; + activeCaCertId?: string; createdAt: string; updatedAt: string; }; @@ -78,7 +79,9 @@ export type TImportCaCertificateResponse = { export type TCreateCertificateDTO = { projectSlug: string; - caId: string; + caId?: string; + certificateTemplateId?: string; + pkiCollectionId?: string; friendlyName?: string; commonName: string; altNames: string; // sans diff --git a/frontend/src/hooks/api/certificateTemplates/index.tsx b/frontend/src/hooks/api/certificateTemplates/index.tsx new file mode 100644 index 000000000..b8145fbcc --- /dev/null +++ b/frontend/src/hooks/api/certificateTemplates/index.tsx @@ -0,0 +1,2 @@ +export { useCreateCertTemplate, useDeleteCertTemplate, useUpdateCertTemplate } from "./mutations"; +export { useGetCertTemplate } from "./queries"; diff --git a/frontend/src/hooks/api/certificateTemplates/mutations.tsx b/frontend/src/hooks/api/certificateTemplates/mutations.tsx new file mode 100644 index 000000000..269d885a6 --- /dev/null +++ b/frontend/src/hooks/api/certificateTemplates/mutations.tsx @@ -0,0 +1,59 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/queries"; +import { certTemplateKeys } from "./queries"; +import { + TCertificateTemplate, + TCreateCertificateTemplateDTO, + TDeleteCertificateTemplateDTO, + TUpdateCertificateTemplateDTO +} from "./types"; + +export const useCreateCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const { data: certificateTemplate } = await apiRequest.post( + "/api/v1/pki/certificate-templates", + data + ); + return certificateTemplate; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCertificateTemplates(projectId)); + } + }); +}; + +export const useUpdateCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const { data: certificateTemplate } = await apiRequest.patch( + `/api/v1/pki/certificate-templates/${data.id}`, + data + ); + + return certificateTemplate; + }, + onSuccess: (_, { projectId, id }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCertificateTemplates(projectId)); + queryClient.invalidateQueries(certTemplateKeys.getCertTemplateById(id)); + } + }); +}; + +export const useDeleteCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + return apiRequest.delete(`/api/v1/pki/certificate-templates/${data.id}`); + }, + onSuccess: (_, { projectId, id }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCertificateTemplates(projectId)); + queryClient.invalidateQueries(certTemplateKeys.getCertTemplateById(id)); + } + }); +}; diff --git a/frontend/src/hooks/api/certificateTemplates/queries.tsx b/frontend/src/hooks/api/certificateTemplates/queries.tsx new file mode 100644 index 000000000..ca5b088e8 --- /dev/null +++ b/frontend/src/hooks/api/certificateTemplates/queries.tsx @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TCertificateTemplate } from "./types"; + +export const certTemplateKeys = { + getCertTemplateById: (id: string) => [{ id }, "cert-template"] +}; + +export const useGetCertTemplate = (id: string) => { + return useQuery({ + queryKey: certTemplateKeys.getCertTemplateById(id), + queryFn: async () => { + const { data: certificateTemplate } = await apiRequest.get( + `/api/v1/pki/certificate-templates/${id}` + ); + return certificateTemplate; + }, + enabled: Boolean(id) + }); +}; diff --git a/frontend/src/hooks/api/certificateTemplates/types.ts b/frontend/src/hooks/api/certificateTemplates/types.ts new file mode 100644 index 000000000..237a86a5e --- /dev/null +++ b/frontend/src/hooks/api/certificateTemplates/types.ts @@ -0,0 +1,37 @@ +export type TCertificateTemplate = { + id: string; + caId: string; + caName: string; + projectId: string; + pkiCollectionId?: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; +}; + +export type TCreateCertificateTemplateDTO = { + caId: string; + pkiCollectionId?: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + projectId: string; +}; + +export type TUpdateCertificateTemplateDTO = { + id: string; + caId?: string; + pkiCollectionId?: string; + name?: string; + commonName?: string; + subjectAlternativeName?: string; + ttl?: string; + projectId: string; +}; + +export type TDeleteCertificateTemplateDTO = { + id: string; + projectId: string; +}; diff --git a/frontend/src/hooks/api/certificates/constants.tsx b/frontend/src/hooks/api/certificates/constants.tsx index e5a9b7a43..2f2972bbc 100644 --- a/frontend/src/hooks/api/certificates/constants.tsx +++ b/frontend/src/hooks/api/certificates/constants.tsx @@ -1,10 +1,21 @@ -import { CertKeyAlgorithm, CertStatus,CrlReason } from "./enums"; +import { CertKeyAlgorithm, CertStatus, CrlReason } from "./enums"; export const certStatusToNameMap: { [K in CertStatus]: string } = { [CertStatus.ACTIVE]: "Active", [CertStatus.REVOKED]: "Revoked" }; +export const getCertStatusBadgeVariant = (status: CertStatus) => { + switch (status) { + case CertStatus.ACTIVE: + return "success"; + case CertStatus.REVOKED: + return "danger"; + default: + return "primary"; + } +}; + export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = { [CertKeyAlgorithm.RSA_2048]: "RSA 2048", [CertKeyAlgorithm.RSA_4096]: "RSA 4096", diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index d1ba46910..8057998aa 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -3,6 +3,7 @@ import { CertStatus } from "./enums"; export type TCertificate = { id: string; caId: string; + certificateTemplateId?: string; status: CertStatus; friendlyName: string; commonName: string; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 94037cb62..a9d24a5c7 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -7,6 +7,7 @@ export * from "./auth"; export * from "./bots"; export * from "./ca"; export * from "./certificates"; +export * from "./certificateTemplates"; export * from "./dynamicSecret"; export * from "./dynamicSecretLease"; export * from "./groups"; diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index 81d0f00ca..de4c1a914 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -110,8 +110,15 @@ export const useCreateIntegration = () => { export const useDeleteIntegration = () => { const queryClient = useQueryClient(); - return useMutation<{}, {}, { id: string; workspaceId: string }>({ - mutationFn: ({ id }) => apiRequest.delete(`/api/v1/integration/${id}`), + return useMutation< + {}, + {}, + { id: string; workspaceId: string; shouldDeleteIntegrationSecrets: boolean } + >({ + mutationFn: ({ id, shouldDeleteIntegrationSecrets }) => + apiRequest.delete( + `/api/v1/integration/${id}?shouldDeleteIntegrationSecrets=${shouldDeleteIntegrationSecrets}` + ), onSuccess: (_, { workspaceId }) => { queryClient.invalidateQueries(workspaceKeys.getWorkspaceIntegrations(workspaceId)); queryClient.invalidateQueries(workspaceKeys.getWorkspaceAuthorization(workspaceId)); diff --git a/frontend/src/hooks/api/pkiCollections/types.ts b/frontend/src/hooks/api/pkiCollections/types.ts index 86c2471e9..98a98d520 100644 --- a/frontend/src/hooks/api/pkiCollections/types.ts +++ b/frontend/src/hooks/api/pkiCollections/types.ts @@ -1,6 +1,7 @@ export type TPkiCollection = { id: string; name: string; + description: string; projectId: string; createdAt: string; updatedAt: string; @@ -9,12 +10,14 @@ export type TPkiCollection = { export type TCreatePkiCollectionDTO = { projectId: string; name: string; + description: string; }; export type TUpdatePkiCollectionTO = { collectionId: string; projectId: string; name?: string; + description?: string; }; export type TDeletePkiCollectionDTO = { diff --git a/frontend/src/hooks/api/rateLimit/types.ts b/frontend/src/hooks/api/rateLimit/types.ts index 5697fc298..53b075ce4 100644 --- a/frontend/src/hooks/api/rateLimit/types.ts +++ b/frontend/src/hooks/api/rateLimit/types.ts @@ -5,6 +5,5 @@ export type TRateLimit = { authRateLimit: number; inviteUserRateLimit: number; mfaRateLimit: number; - creationLimit: number; publicEndpointLimit: number; }; diff --git a/frontend/src/hooks/api/tags/queries.tsx b/frontend/src/hooks/api/tags/queries.tsx index 311ed0941..d1c4b533d 100644 --- a/frontend/src/hooks/api/tags/queries.tsx +++ b/frontend/src/hooks/api/tags/queries.tsx @@ -28,11 +28,10 @@ export const useCreateWsTag = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ workspaceID, tagName, tagColor, tagSlug }) => { + mutationFn: async ({ workspaceID, tagColor, tagSlug }) => { const { data } = await apiRequest.post<{ workspaceTag: WsTag }>( `/api/v1/workspace/${workspaceID}/tags`, { - name: tagName, color: tagColor || "", slug: tagSlug } diff --git a/frontend/src/hooks/api/tags/types.ts b/frontend/src/hooks/api/tags/types.ts index 9b4f70587..72d710cfa 100644 --- a/frontend/src/hooks/api/tags/types.ts +++ b/frontend/src/hooks/api/tags/types.ts @@ -2,7 +2,6 @@ export type UserWsTags = WsTag[]; export type WsTag = { id: string; - name: string; slug: string; color?: string; projectId: string; @@ -16,7 +15,6 @@ export type WorkspaceTag = { id: string; name: string; slug: string }; export type CreateTagDTO = { workspaceID: string; tagSlug: string; - tagName: string; tagColor: string; }; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index 3da83e1b8..3f320d0d9 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -25,6 +25,7 @@ export { useGetWorkspaceUsers, useListWorkspaceCas, useListWorkspaceCertificates, + useListWorkspaceCertificateTemplates, useListWorkspaceGroups, useListWorkspacePkiAlerts, useListWorkspacePkiCollections, diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index d971f06e2..277499e9b 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -5,6 +5,7 @@ import { apiRequest } from "@app/config/request"; import { CaStatus } from "../ca/enums"; import { TCertificateAuthority } from "../ca/types"; import { TCertificate } from "../certificates/types"; +import { TCertificateTemplate } from "../certificateTemplates/types"; import { TGroupMembership } from "../groups/types"; import { identitiesKeys } from "../identities/queries"; import { IdentityMembership } from "../identities/types"; @@ -68,7 +69,9 @@ export const workspaceKeys = { getWorkspacePkiAlerts: (workspaceId: string) => [{ workspaceId }, "workspace-pki-alerts"] as const, getWorkspacePkiCollections: (workspaceId: string) => - [{ workspaceId }, "workspace-pki-collections"] as const + [{ workspaceId }, "workspace-pki-collections"] as const, + getWorkspaceCertificateTemplates: (workspaceId: string) => + [{ workspaceId }, "workspace-certificate-templates"] as const }; const fetchWorkspaceById = async (workspaceId: string) => { @@ -639,3 +642,19 @@ export const useListWorkspacePkiCollections = ({ workspaceId }: { workspaceId: s enabled: Boolean(workspaceId) }); }; + +export const useListWorkspaceCertificateTemplates = ({ workspaceId }: { workspaceId: string }) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceCertificateTemplates(workspaceId), + queryFn: async () => { + const { + data: { certificateTemplates } + } = await apiRequest.get<{ certificateTemplates: TCertificateTemplate[] }>( + `/api/v2/workspace/${workspaceId}/certificate-templates` + ); + + return { certificateTemplates }; + }, + enabled: Boolean(workspaceId) + }); +}; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 6a65e32e8..4e3602686 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -273,8 +273,10 @@ export const AppLayout = ({ children }: LayoutProps) => { const orgUsers = await fetchOrgUsers(currentOrg.id); await addUsersToProject.mutateAsync({ usernames: orgUsers - .map((member) => member.user.username) - .filter((username) => username !== user.username), + .filter( + (member) => member.user.username !== user.username && member.status === "accepted" + ) + .map((member) => member.user.username), projectId: newProjectId, orgId: currentOrg.id }); @@ -482,7 +484,7 @@ export const AppLayout = ({ children }: LayoutProps) => { )} - Organization Admin Console + Organization Admin Console
diff --git a/frontend/src/pages/integrations/aws-parameter-store/create.tsx b/frontend/src/pages/integrations/aws-parameter-store/create.tsx index 9c46840b5..5af4a86c2 100644 --- a/frontend/src/pages/integrations/aws-parameter-store/create.tsx +++ b/frontend/src/pages/integrations/aws-parameter-store/create.tsx @@ -277,18 +277,14 @@ export default function AWSParameterStoreCreateIntegrationPage() {
setShouldDisableDelete(!shouldDisableDelete)} + onCheckedChange={setShouldDisableDelete} isChecked={shouldDisableDelete} > Disable deleting secrets in AWS Parameter Store
- setShouldTag(!shouldTag)} - isChecked={shouldTag} - > + Tag in AWS Parameter Store
diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index babf1a15f..c93649f2c 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -552,8 +552,10 @@ const OrganizationPage = () => { await addUsersToProject.mutateAsync({ usernames: orgUsers - .map((member) => member.user.username) - .filter((username) => username !== user.username), + .filter( + (member) => member.user.username !== user.username && member.status === "accepted" + ) + .map((member) => member.user.username), projectId: newProjectId, orgId: currentOrg.id }); diff --git a/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx b/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx index 5601a7f68..e44fd2539 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx @@ -106,9 +106,13 @@ export const IntegrationsPage = withProjectPermission( handleProviderIntegration(provider); }; - const handleIntegrationDelete = async (integrationId: string, cb: () => void) => { + const handleIntegrationDelete = async ( + integrationId: string, + shouldDeleteIntegrationSecrets: boolean, + cb: () => void + ) => { try { - await deleteIntegration({ id: integrationId, workspaceId }); + await deleteIntegration({ id: integrationId, workspaceId, shouldDeleteIntegrationSecrets }); if (cb) cb(); createNotification({ type: "success", @@ -152,7 +156,7 @@ export const IntegrationsPage = withProjectPermission( isLoading={isIntegrationLoading} integrations={integrations} environments={environments} - onIntegrationDelete={({ id }, cb) => handleIntegrationDelete(id, cb)} + onIntegrationDelete={handleIntegrationDelete} workspaceId={workspaceId} /> ; integrations?: TIntegration[]; isLoading?: boolean; - onIntegrationDelete: (integration: TIntegration, cb: () => void) => void; + onIntegrationDelete: ( + integrationId: string, + shouldDeleteIntegrationSecrets: boolean, + cb: () => void + ) => Promise; workspaceId: string; }; @@ -37,10 +42,12 @@ export const IntegrationsSection = ({ workspaceId }: Props) => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "deleteConfirmation" + "deleteConfirmation", + "deleteSecretsConfirmation" ] as const); const { mutate: syncIntegration } = useSyncIntegration(); + const [shouldDeleteSecrets, setShouldDeleteSecrets] = useToggle(false); return (
@@ -249,7 +256,10 @@ export const IntegrationsSection = ({
handlePopUpOpen("deleteConfirmation", integration)} + onClick={() => { + setShouldDeleteSecrets.off(); + handlePopUpOpen("deleteConfirmation", integration); + }} ariaLabel="delete" isDisabled={!isAllowed} colorSchema="danger" @@ -281,11 +291,49 @@ export const IntegrationsSection = ({ (popUp?.deleteConfirmation?.data as TIntegration)?.integration || "" } - onDeleteApproved={async () => - onIntegrationDelete(popUp?.deleteConfirmation.data as TIntegration, () => - handlePopUpClose("deleteConfirmation") - ) - } + onDeleteApproved={async () => { + if (shouldDeleteSecrets) { + handlePopUpOpen("deleteSecretsConfirmation"); + return; + } + + await onIntegrationDelete( + (popUp?.deleteConfirmation.data as TIntegration).id, + false, + () => handlePopUpClose("deleteConfirmation") + ); + }} + > + {(popUp?.deleteConfirmation?.data as TIntegration)?.integration === "github" && ( +
+ setShouldDeleteSecrets.toggle()} + > + Delete previously synced secrets from the destination + +
+ )} + + handlePopUpToggle("deleteSecretsConfirmation", isOpen)} + deleteKey="confirm" + onDeleteApproved={async () => { + await onIntegrationDelete( + (popUp?.deleteConfirmation.data as TIntegration).id, + true, + () => { + handlePopUpClose("deleteSecretsConfirmation"); + handlePopUpClose("deleteConfirmation"); + } + ); + }} />
); diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx index f5e54ab47..ea0e687bb 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx @@ -406,6 +406,28 @@ export const LogsTableRow = ({ auditLog }: Props) => {

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

); + case EventType.CREATE_CERTIFICATE_TEMPLATE: + case EventType.UPDATE_CERTIFICATE_TEMPLATE: + return ( + +

{`Certificate Template ID: ${event.metadata.certificateTemplateId}`}

+

{`Certificate Authority ID: ${event.metadata.caId}`}

+

{`Name: ${event.metadata.name}`}

+

{`Common Name: ${event.metadata.commonName}`}

+

{`Subject Alternative Name: ${event.metadata.subjectAlternativeName}`}

+

{`TTL: ${event.metadata.ttl}`}

+ {event.metadata.pkiCollectionId && ( +

{`Collection ID: ${event.metadata.pkiCollectionId}`}

+ )} + + ); + case EventType.GET_CERTIFICATE_TEMPLATE: + case EventType.DELETE_CERTIFICATE_TEMPLATE: + return ( + +

{`Certificate Template ID: ${event.metadata.certificateTemplateId}`}

+ + ); default: return ; } diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx index f719365eb..2c0bc8706 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx @@ -39,7 +39,7 @@ import { CaStatus, useListWorkspaceCas } from "@app/hooks/api"; import { caStatusToNameMap, caTypeToNameMap, - getStatusBadgeVariant + getCaStatusBadgeVariant } from "@app/hooks/api/ca/constants"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -92,7 +92,7 @@ export const CaTable = ({ handlePopUpOpen }: Props) => { > {ca.friendlyName} - + {caStatusToNameMap[ca.status]} @@ -100,9 +100,6 @@ export const CaTable = ({ handlePopUpOpen }: Props) => {

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

- {/* - Expires Soon - */}
diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx index f054e2546..71576d39d 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx @@ -1,5 +1,7 @@ import { motion } from "framer-motion"; +import { PkiCollectionSection } from "../PkiAlertsTab/components"; +import { CertificateTemplatesSection } from "./components/CertificateTemplatesSection"; import { CertificatesSection } from "./components"; export const CertificatesTab = () => { @@ -11,6 +13,8 @@ export const CertificatesTab = () => { animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: 30 }} > + + ); diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx index 12bd8c47c..40645a07e 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx @@ -1,5 +1,7 @@ import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; +import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -7,21 +9,33 @@ import { createNotification } from "@app/components/notifications"; import { Button, FormControl, + FormLabel, Input, Modal, ModalContent, Select, - SelectItem + SelectItem, + Tooltip } from "@app/components/v2"; import { useWorkspace } from "@app/context"; -import { CaStatus, useCreateCertificate, useGetCert, useListWorkspaceCas } from "@app/hooks/api"; +import { + CaStatus, + useCreateCertificate, + useGetCert, + useGetCertTemplate, + useListWorkspaceCas, + useListWorkspaceCertificateTemplates, + useListWorkspacePkiCollections +} 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({ + certificateTemplateId: z.string().optional(), caId: z.string(), + collectionId: z.string().optional(), friendlyName: z.string(), commonName: z.string().trim().min(1), altNames: z.string(), @@ -42,6 +56,8 @@ type TCertificateDetails = { privateKey: string; }; +const CERT_TEMPLATE_NONE_VALUE = "none"; + export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { const [certificateDetails, setCertificateDetails] = useState(null); const { currentWorkspace } = useWorkspace(); @@ -54,6 +70,14 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { status: CaStatus.ACTIVE }); + const { data } = useListWorkspacePkiCollections({ + workspaceId: currentWorkspace?.id || "" + }); + + const { data: templatesData } = useListWorkspaceCertificateTemplates({ + workspaceId: currentWorkspace?.id || "" + }); + const { mutateAsync: createCertificate } = useCreateCertificate(); const { @@ -61,11 +85,20 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { handleSubmit, reset, formState: { isSubmitting }, - setValue + setValue, + watch } = useForm({ resolver: zodResolver(schema) }); + const selectedCertTemplateId = watch("certificateTemplateId"); + const hasCertTemplateSelected = + selectedCertTemplateId !== "" && selectedCertTemplateId !== CERT_TEMPLATE_NONE_VALUE; + + const { data: selectedCertTemplate } = useGetCertTemplate( + hasCertTemplateSelected ? (selectedCertTemplateId as string) : "" + ); + useEffect(() => { if (cert) { reset({ @@ -73,6 +106,7 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { friendlyName: cert.friendlyName, commonName: cert.commonName, altNames: cert.altNames, + certificateTemplateId: cert.certificateTemplateId ?? CERT_TEMPLATE_NONE_VALUE, ttl: "" }); } else { @@ -81,18 +115,34 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { friendlyName: "", commonName: "", altNames: "", - ttl: "" + ttl: "", + certificateTemplateId: CERT_TEMPLATE_NONE_VALUE }); } }, [cert]); - const onFormSubmit = async ({ caId, friendlyName, commonName, altNames, ttl }: FormData) => { + useEffect(() => { + if (!cert && selectedCertTemplate) { + setValue("ttl", selectedCertTemplate.ttl); + } + }, [selectedCertTemplate, cert]); + + const onFormSubmit = async ({ + caId, + friendlyName, + collectionId, + commonName, + altNames, + ttl + }: FormData) => { try { if (!currentWorkspace?.slug) return; const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate({ + caId: !selectedCertTemplate ? caId : undefined, + certificateTemplateId: selectedCertTemplate ? selectedCertTemplateId : undefined, projectSlug: currentWorkspace.slug, - caId, + pkiCollectionId: collectionId, friendlyName, commonName, altNames, @@ -141,11 +191,31 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
( + + When a template is selected, the details provided are validated + against the template policies. + + } + > + + + } + /> +
+ } errorText={error?.message} isError={Boolean(error)} className="mt-4" @@ -158,15 +228,77 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { className="w-full" isDisabled={Boolean(cert)} > - {(cas || []).map(({ id, type, dn }) => ( - - {`${caTypeToNameMap[type]}: ${dn}`} + + None + + {(templatesData?.certificateTemplates || []).map(({ id, name }) => ( + + {name} ))} )} /> + {(!selectedCertTemplateId || + selectedCertTemplateId === CERT_TEMPLATE_NONE_VALUE || + cert) && ( + <> + ( + + + + )} + /> + ( + + + + )} + /> + + )} ; + +type Props = { + popUp: UsePopUpState<["certificateTemplate"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["certificateTemplate"]>, + state?: boolean + ) => void; +}; + +export const CertificateTemplateModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data: certTemplate } = useGetCertTemplate( + (popUp?.certificateTemplate?.data as { id: string })?.id || "" + ); + + const { data: cas } = useListWorkspaceCas({ + projectSlug: currentWorkspace?.slug ?? "", + status: CaStatus.ACTIVE + }); + + const { data: collectionsData } = useListWorkspacePkiCollections({ + workspaceId: currentWorkspace?.id || "" + }); + + const { mutateAsync: createCertTemplate } = useCreateCertTemplate(); + const { mutateAsync: updateCertTemplate } = useUpdateCertTemplate(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + useEffect(() => { + if (certTemplate) { + reset({ + caId: certTemplate.caId, + name: certTemplate.name, + commonName: certTemplate.commonName, + subjectAlternativeName: certTemplate.subjectAlternativeName, + collectionId: certTemplate.pkiCollectionId ?? undefined, + ttl: certTemplate.ttl + }); + } else { + reset({ + caId: "", + name: "", + commonName: "", + ttl: "" + }); + } + }, [certTemplate]); + + const onFormSubmit = async ({ + caId, + collectionId, + name, + commonName, + subjectAlternativeName, + ttl + }: FormData) => { + if (!currentWorkspace?.id) { + return; + } + + try { + if (certTemplate) { + await updateCertTemplate({ + id: certTemplate.id, + projectId: currentWorkspace.id, + pkiCollectionId: collectionId, + caId, + name, + commonName, + subjectAlternativeName, + ttl + }); + + createNotification({ + text: "Successfully updated certificate template", + type: "success" + }); + } else { + await createCertTemplate({ + projectId: currentWorkspace.id, + pkiCollectionId: collectionId, + caId, + name, + commonName, + subjectAlternativeName, + ttl + }); + + createNotification({ + text: "Successfully created certificate template", + type: "success" + }); + } + + reset(); + handlePopUpToggle("certificateTemplate", false); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to save changes", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("certificateTemplate", isOpen); + reset(); + }} + > + + + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + This field accepts limited regular expressions: spaces, *, ., @, -, \ + (for escaping), and alphanumeric characters only + + } + > + + + } + /> +
+ } + isError={Boolean(error)} + errorText={error?.message} + isRequired + > + + + )} + /> + ( + + + This field accepts limited regular expressions: spaces, *, ., @, -, \ + (for escaping), and alphanumeric characters only + + } + > + + + } + /> + + } + isError={Boolean(error)} + errorText={error?.message} + isRequired + > + + + )} + /> + ( + + + + )} + /> +
+ + +
+ + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx new file mode 100644 index 000000000..3341e521c --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx @@ -0,0 +1,87 @@ +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 { usePopUp } from "@app/hooks"; +import { useDeleteCertTemplate } from "@app/hooks/api"; + +import { CertificateTemplateModal } from "./CertificateTemplateModal"; +import { CertificateTemplatesTable } from "./CertificateTemplatesTable"; + +export const CertificateTemplatesSection = () => { + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "certificateTemplate", + "deleteCertificateTemplate" + ] as const); + + const { currentWorkspace } = useWorkspace(); + const { mutateAsync: deleteCertTemplate } = useDeleteCertTemplate(); + + const onRemoveCertificateTemplateSubmit = async (id: string) => { + if (!currentWorkspace?.id) { + return; + } + + try { + await deleteCertTemplate({ + id, + projectId: currentWorkspace.id + }); + + await createNotification({ + text: "Successfully deleted certificate template", + type: "success" + }); + + handlePopUpClose("deleteCertificateTemplate"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete certificate template", + type: "error" + }); + } + }; + + return ( +
+
+

Certificate Templates

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("deleteCertificateTemplate", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveCertificateTemplateSubmit( + (popUp?.deleteCertificateTemplate?.data as { id: string })?.id + ) + } + /> +
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesTable.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesTable.tsx new file mode 100644 index 000000000..e504c9521 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesTable.tsx @@ -0,0 +1,117 @@ +import { faEllipsis, faFileAlt, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useListWorkspaceCertificateTemplates } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["certificateTemplate", "deleteCertificateTemplate"]>, + data?: { + id?: string; + name?: string; + } + ) => void; +}; + +export const CertificateTemplatesTable = ({ handlePopUpOpen }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useListWorkspaceCertificateTemplates({ + workspaceId: currentWorkspace?.id ?? "" + }); + + return ( +
+ + + + + + + + + + {isLoading && } + {!isLoading && + data?.certificateTemplates.map((certificateTemplate) => { + return ( + + + + + + ); + })} + +
NameCertificate Authority +
{certificateTemplate.name}{certificateTemplate.caName} + + +
+ + + +
+
+ + + handlePopUpOpen("certificateTemplate", { + id: certificateTemplate.id + }) + } + icon={} + > + Manage Policies + + + {(isAllowed) => ( + } + onClick={() => + handlePopUpOpen("deleteCertificateTemplate", { + id: certificateTemplate.id, + name: certificateTemplate.name + }) + } + > + Delete Template + + )} + + +
+
+ {!isLoading && !data?.certificateTemplates?.length && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx index 58bb8ef4c..962083b22 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx @@ -61,7 +61,7 @@ export const CertificatesSection = () => { onClick={() => handlePopUpOpen("certificate")} isDisabled={!isAllowed} > - Issue Certificate + Issue )} diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx index f23299aa8..a3a6106f3 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx @@ -13,6 +13,7 @@ import { twMerge } from "tailwind-merge"; import { ProjectPermissionCan } from "@app/components/permissions"; import { + Badge, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -31,9 +32,10 @@ import { } 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"; +import { getCertValidUntilBadgeDetails } from "./CertificatesTable.utils"; + type Props = { handlePopUpOpen: ( popUpName: keyof UsePopUpState< @@ -60,146 +62,155 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { }); 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 && ( - - )} -
-
+ + + + + + + + + + + + {isLoading && } + {!isLoading && + data?.certificates.map((certificate) => { + const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter); + return ( + + + + + + + + ); + })} + +
Friendly NameStatusNot BeforeNot After +
{certificate.friendlyName} + + {label} + + + {certificate.notBefore + ? format(new Date(certificate.notBefore), "yyyy-MM-dd") + : "-"} + + {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/CertificatesTable.utils.ts b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.utils.ts new file mode 100644 index 000000000..93b408bbe --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.utils.ts @@ -0,0 +1,28 @@ +import ms from "ms"; + +export const getCertValidUntilBadgeDetails = (notAfter: string) => { + const currentDate = new Date().getTime(); + const notAfterDate = new Date(notAfter).getTime(); + const diffInMs = notAfterDate - currentDate; + + let variant: "success" | "primary" | "danger" = "success"; + let label = "Healthy"; + + if (diffInMs > ms("60d")) { + variant = "success"; + } else if (diffInMs > ms("30d")) { + variant = "primary"; + } else { + variant = "danger"; + } + + if (diffInMs > ms("60d")) { + label = "Healthy"; + } else if (diffInMs > ms("0d")) { + label = `Expires in ${ms(diffInMs)}`; + } else { + label = "Expired"; + } + + return { variant, label }; +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/PkiAlertsTab/PkiAlertsTab.tsx b/frontend/src/views/Project/CertificatesPage/components/PkiAlertsTab/PkiAlertsTab.tsx index 974c2aa1b..3a5a04345 100644 --- a/frontend/src/views/Project/CertificatesPage/components/PkiAlertsTab/PkiAlertsTab.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/PkiAlertsTab/PkiAlertsTab.tsx @@ -1,6 +1,6 @@ import { motion } from "framer-motion"; -import { PkiAlertsSection, PkiCollectionSection } from "./components"; +import { PkiAlertsSection } from "./components"; export const PkiAlertsTab = () => { return ( @@ -11,7 +11,6 @@ export const PkiAlertsTab = () => { animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: 30 }} > - ); diff --git a/frontend/src/views/Project/CertificatesPage/components/PkiAlertsTab/components/PkiCollectionModal.tsx b/frontend/src/views/Project/CertificatesPage/components/PkiAlertsTab/components/PkiCollectionModal.tsx index 89fd51c92..926d1cec9 100644 --- a/frontend/src/views/Project/CertificatesPage/components/PkiAlertsTab/components/PkiCollectionModal.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/PkiAlertsTab/components/PkiCollectionModal.tsx @@ -15,7 +15,8 @@ import { import { UsePopUpState } from "@app/hooks/usePopUp"; const schema = z.object({ - name: z.string().trim().min(1) + name: z.string().trim().min(1), + description: z.string() }); export type FormData = z.infer; @@ -49,16 +50,18 @@ export const PkiCollectionModal = ({ popUp, handlePopUpToggle }: Props) => { useEffect(() => { if (pkiCollection) { reset({ - name: pkiCollection.name + name: pkiCollection.name, + description: pkiCollection.description }); } else { reset({ - name: "" + name: "", + description: "" }); } }, [pkiCollection]); - const onFormSubmit = async ({ name }: FormData) => { + const onFormSubmit = async ({ name, description }: FormData) => { try { if (!projectId) return; @@ -67,12 +70,14 @@ export const PkiCollectionModal = ({ popUp, handlePopUpToggle }: Props) => { await updatePkiCollection({ collectionId: pkiCollection.id, name, + description, projectId }); } else { // create const { id: createdId } = await createPkiCollection({ name, + description, projectId }); @@ -121,6 +126,19 @@ export const PkiCollectionModal = ({ popUp, handlePopUpToggle }: Props) => { )} /> + ( + + + + )} + />
-
+

Name

{pkiCollection.name}

+
+

Description

+

{pkiCollection.description}

+
) : ( diff --git a/frontend/src/views/Project/PkiCollectionPage/components/PkiCollectionItemsTable.tsx b/frontend/src/views/Project/PkiCollectionPage/components/PkiCollectionItemsTable.tsx index 2334cc81b..d8518e6a2 100644 --- a/frontend/src/views/Project/PkiCollectionPage/components/PkiCollectionItemsTable.tsx +++ b/frontend/src/views/Project/PkiCollectionPage/components/PkiCollectionItemsTable.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { faBoxesStacked, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { faBoxesStacked, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; @@ -85,7 +85,7 @@ export const PkiCollectionItemsTable = ({ collectionId, type, handlePopUpOpen }: }); }} > - + )} diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts index abab188b8..e3b05a41f 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts @@ -53,6 +53,7 @@ export const formSchema = z.object({ certificates: generalPermissionSchema, "pki-alerts": generalPermissionSchema, "pki-collections": generalPermissionSchema, + "certificate-templates": generalPermissionSchema, // akhilmhdh: refactor all keys like below [ProjectPermissionSub.SecretApproval]: generalPermissionSchema, workspace: z diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx index 40e4c260a..1de24ae71 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -81,6 +81,10 @@ const SINGLE_PERMISSION_LIST = [ title: "Certificates", formName: "certificates" }, + { + title: "Certificate Templates", + formName: "certificate-templates" + }, { title: "PKI Collections", formName: "pki-collections" diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx index 3fbe4357b..6adf591e7 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx @@ -97,7 +97,7 @@ export const SecretApprovalRequestChangeItem = ({ {secretVersion?.secretComment} - {secretVersion?.tags?.map(({ name, id: tagId, color }) => ( + {secretVersion?.tags?.map(({ slug, id: tagId, color }) => ( -
{name}
+
{slug}
))} @@ -119,7 +119,7 @@ export const SecretApprovalRequestChangeItem = ({ {newVersion?.secretComment} - {newVersion?.tags?.map(({ name, id: tagId, color }) => ( + {newVersion?.tags?.map(({ slug, id: tagId, color }) => ( -
{name}
+
{slug}
))} @@ -157,7 +157,7 @@ export const SecretApprovalRequestChangeItem = ({ {(op === CommitType.CREATE ? newVersion?.tags : secretVersion?.tags)?.map( - ({ name, id: tagId, color }) => ( + ({ slug, id: tagId, color }) => ( -
{name}
+
{slug}
) )} diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx index 705234f63..65cb0389f 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx @@ -327,7 +327,7 @@ export const ActionBar = ({ Apply tags to filter secrets - {tags.map(({ id, name, color }) => ( + {tags.map(({ id, slug, color }) => ( { evt.preventDefault(); @@ -342,7 +342,7 @@ export const ActionBar = ({ className="mr-2 h-2 w-2 rounded-full" style={{ background: color || "#bec2c8" }} /> - {name} + {slug} ))} diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx index 2e983bda6..e2a1c3b87 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx @@ -254,7 +254,7 @@ export const SecretDetailSidebar = ({ )}
- {fields.map(({ tagColor, id: formId, name, id }) => ( + {fields.map(({ tagColor, id: formId, slug, id }) => ( -
{name}
+
{slug}
))} @@ -296,7 +296,7 @@ export const SecretDetailSidebar = ({ Add tags to this secret {tags.map((tag) => { - const { id: tagId, name, color } = tag; + const { id: tagId, slug, color } = tag; const isSelected = selectedTagsGroupById?.[tagId]; return ( @@ -311,7 +311,7 @@ export const SecretDetailSidebar = ({ className="mr-2 h-2 w-2 rounded-full" style={{ background: color || "#bec2c8" }} /> - {name} + {slug}
); diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx index 22207cb90..5b62dd526 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx @@ -336,7 +336,7 @@ export const SecretItem = memo( Add tags to this secret {tags.map((tag) => { - const { id: tagId, name, color } = tag; + const { id: tagId, slug, color } = tag; const isTagSelected = selectedTagsGroupById?.[tagId]; return ( @@ -358,7 +358,7 @@ export const SecretItem = memo( className="mr-2 h-2 w-2 rounded-full" style={{ background: color || "#bec2c8" }} /> - {name} + {slug} ); diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.utils.ts b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.utils.ts index a3040b43c..eb9e4d195 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.utils.ts +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.utils.ts @@ -49,7 +49,6 @@ export const formSchema = z.object({ tags: z .object({ id: z.string(), - name: z.string(), slug: z.string(), tagColor: z.string().optional() }) diff --git a/frontend/src/views/SecretMainPage/components/SnapshotView/SecretItem.tsx b/frontend/src/views/SecretMainPage/components/SnapshotView/SecretItem.tsx index 2a7397ec9..d6680bcef 100644 --- a/frontend/src/views/SecretMainPage/components/SnapshotView/SecretItem.tsx +++ b/frontend/src/views/SecretMainPage/components/SnapshotView/SecretItem.tsx @@ -151,7 +151,7 @@ export const SecretItem = ({ mode, preSecret, postSecret }: Props) => { Tags {isModified && ( - {preSecret?.tags?.map(({ name, id: tagId, color }) => ( + {preSecret?.tags?.map(({ slug, id: tagId, color }) => ( { className="h-3 w-3 rounded-full" style={{ backgroundColor: color || "#bec2c8" }} /> -
{name}
+
{slug}
))} )} - {postSecret?.tags?.map(({ name, id: tagId, color }) => ( + {postSecret?.tags?.map(({ slug, id: tagId, color }) => ( { className="h-3 w-3 rounded-full" style={{ backgroundColor: color || "#bec2c8" }} /> -
{name}
+
{slug}
))} diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/SSOModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/SSOModal.tsx index 582205214..6257ffa6f 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/SSOModal.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/SSOModal.tsx @@ -62,7 +62,7 @@ export const SSOModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDelet const [isDeletePopupOpen, setIsDeletePopupOpen] = useToggle(); const { data } = useGetSSOConfig(currentOrg?.id ?? ""); - const { control, handleSubmit, reset, watch } = useForm({ + const { control, handleSubmit, reset, watch, setValue, getValues } = useForm({ defaultValues: { authProvider: AuthProvider.OKTA_SAML }, @@ -188,8 +188,8 @@ export const SSOModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDelet entityId: "SP Entity ID", entryPoint: "SSO URL", entryPointPlaceholder: "https://accounts.google.com/o/saml2/idp?idpid=xxx", - issuer: "IdP Entity ID", - issuerPlaceholder: "https://accounts.google.com/o/saml2/idp?idpid=xxx" + issuer: "Issuer", + issuerPlaceholder: window.origin }; default: return { @@ -204,6 +204,11 @@ export const SSOModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDelet }; const authProvider = watch("authProvider"); + useEffect(() => { + if (authProvider === AuthProvider.GOOGLE_SAML && getValues("issuer") === "") { + setValue("issuer", window.origin); + } + }, [authProvider]); return ( <> diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx index 7cc1a926e..75f6b69bf 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx @@ -1,6 +1,7 @@ import { Controller, useForm } from "react-hook-form"; -import { yupResolver } from "@hookform/resolvers/yup"; -import * as yup from "yup"; +import { zodResolver } from "@hookform/resolvers/zod"; +import slugify from "@sindresorhus/slugify"; +import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Input, Modal, ModalClose, ModalContent } from "@app/components/v2"; @@ -8,11 +9,13 @@ import { useWorkspace } from "@app/context"; import { useCreateWsTag } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; -const schema = yup.object({ - name: yup.string().required().label("Tag Name") +const schema = z.object({ + slug: z.string().refine((v) => slugify(v) === v, { + message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens." + }) }); -export type FormData = yup.InferType; +export type FormData = z.infer; type Props = { popUp: UsePopUpState<["CreateSecretTag", "deleteTagConfirmation"]>; @@ -26,7 +29,6 @@ type Props = { }; export const AddSecretTagModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { - const { currentWorkspace } = useWorkspace(); const createWsTag = useCreateWsTag(); const { @@ -35,17 +37,16 @@ export const AddSecretTagModal = ({ popUp, handlePopUpClose, handlePopUpToggle } handleSubmit, formState: { isSubmitting } } = useForm({ - resolver: yupResolver(schema) + resolver: zodResolver(schema) }); - const onFormSubmit = async ({ name }: FormData) => { + const onFormSubmit = async ({ slug }: FormData) => { try { if (!currentWorkspace?.id) return; await createWsTag.mutateAsync({ workspaceID: currentWorkspace?.id, - tagName: name, - tagSlug: name.replace(/\s+/g, " ").replace(" ", "_"), + tagSlug: slug, tagColor: "" }); @@ -80,11 +81,11 @@ export const AddSecretTagModal = ({ popUp, handlePopUpClose, handlePopUpToggle }
( - - + + )} /> diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx index d0655f984..cc68b0700 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx @@ -40,7 +40,6 @@ export const SecretTagsTable = ({ handlePopUpOpen }: Props) => { - @@ -49,9 +48,8 @@ export const SecretTagsTable = ({ handlePopUpOpen }: Props) => { {isLoading && } {!isLoading && data && - data.map(({ id, name, slug }) => ( - - + data.map(({ id, slug }) => ( +
Tag Slug
{name}
{slug} { handlePopUpOpen("deleteTagConfirmation", { - name, + name: slug, id }) } diff --git a/frontend/src/views/ShareSecretPage/components/ShareSecretsTable.tsx b/frontend/src/views/ShareSecretPage/components/ShareSecretsTable.tsx index 33dfa297a..bdb2b2ffa 100644 --- a/frontend/src/views/ShareSecretPage/components/ShareSecretsTable.tsx +++ b/frontend/src/views/ShareSecretPage/components/ShareSecretsTable.tsx @@ -61,7 +61,7 @@ export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => {
{!isLoading && data?.secrets && - data.secrets.length >= perPage && + data?.totalCount >= perPage && data?.totalCount !== undefined && ( { authRateLimit: rateLimit?.authRateLimit ?? 60, inviteUserRateLimit: rateLimit?.inviteUserRateLimit ?? 30, mfaRateLimit: rateLimit?.mfaRateLimit ?? 20, - creationLimit: rateLimit?.creationLimit ?? 30, publicEndpointLimit: rateLimit?.publicEndpointLimit ?? 30 } }); @@ -60,7 +58,6 @@ export const RateLimitPanel = () => { authRateLimit, inviteUserRateLimit, mfaRateLimit, - creationLimit, publicEndpointLimit } = formData; @@ -71,7 +68,6 @@ export const RateLimitPanel = () => { authRateLimit, inviteUserRateLimit, mfaRateLimit, - creationLimit, publicEndpointLimit }); createNotification({ @@ -210,25 +206,6 @@ export const RateLimitPanel = () => {
)} /> - ( - - field.onChange(Number(e.target.value))} - /> - - )} - />