diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 8f608c40c..e3147d650 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -162,6 +162,24 @@ scoop: description: "The official Infisical CLI" license: MIT +winget: + - name: infisical + publisher: infisical + license: MIT + homepage: https://infisical.com + short_description: "The official Infisical CLI" + repository: + owner: infisical + name: winget-pkgs + branch: "infisical-{{.Version}}" + pull_request: + enabled: true + draft: false + base: + owner: microsoft + name: winget-pkgs + branch: master + aurs: - name: infisical-bin homepage: "https://infisical.com" diff --git a/.infisicalignore b/.infisicalignore index b80ecaad5..a88bdccbd 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -14,3 +14,11 @@ docs/self-hosting/guides/automated-bootstrapping.mdx:jwt:74 frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx:generic-api-key:72 k8-operator/config/samples/crd/pushsecret/source-secret-with-templating.yaml:private-key:11 k8-operator/config/samples/crd/pushsecret/push-secret-with-template.yaml:private-key:52 +backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts:generic-api-key:125 +frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx:generic-api-key:67 +frontend/src/components/secret-rotations-v2/RotateSecretRotationV2Modal.tsx:generic-api-key:14 +frontend/src/components/secret-rotations-v2/SecretRotationV2StatusBadge.tsx:generic-api-key:11 +frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx:generic-api-key:23 +frontend/src/hooks/api/secretRotationsV2/types/index.ts:generic-api-key:28 +frontend/src/hooks/api/secretRotationsV2/types/index.ts:generic-api-key:65 +frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationItem.tsx:generic-api-key:26 diff --git a/backend/Dockerfile b/backend/Dockerfile index 0edfdfb84..b9edf8b98 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -8,7 +8,8 @@ RUN apt-get update && apt-get install -y \ python3 \ make \ g++ \ - openssh-client + openssh-client \ + openssl # Install dependencies for TDS driver (required for SAP ASE dynamic secrets) RUN apt-get install -y \ diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index adb5157f5..3435672e7 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -19,6 +19,7 @@ RUN apt-get update && apt-get install -y \ make \ g++ \ openssh-client \ + openssl \ curl \ pkg-config diff --git a/backend/e2e-test/mocks/keystore.ts b/backend/e2e-test/mocks/keystore.ts index 05753995c..48f52f9e7 100644 --- a/backend/e2e-test/mocks/keystore.ts +++ b/backend/e2e-test/mocks/keystore.ts @@ -9,6 +9,7 @@ export const mockKeyStore = (): TKeyStoreFactory => { store[key] = value; return "OK"; }, + setExpiry: async () => 0, setItemWithExpiry: async (key, value) => { store[key] = value; return "OK"; diff --git a/backend/package-lock.json b/backend/package-lock.json index 47e9014ce..3cd03cd6e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -132,7 +132,7 @@ "@types/jsrp": "^0.2.6", "@types/libsodium-wrappers": "^0.7.13", "@types/lodash.isequal": "^4.5.8", - "@types/node": "^20.9.5", + "@types/node": "^20.17.30", "@types/nodemailer": "^6.4.14", "@types/passport-github": "^1.1.12", "@types/passport-google-oauth20": "^2.0.14", @@ -9753,11 +9753,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.9.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.5.tgz", - "integrity": "sha512-Uq2xbNq0chGg+/WQEU0LJTSs/1nKxz6u1iemLcGomkSnKokbW1fbLqc3HOqCf2JP7KjlL4QkS7oZZTrOQHQYgQ==", + "version": "20.17.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz", + "integrity": "sha512-7zf4YyHA+jvBNfVrk2Gtvs6x7E8V+YDW05bNfG2XkWDJfYRXrTiP/DsB2zSYTaHX0bGIujTBQdMVAhb+j7mwpg==", + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/node-fetch": { @@ -20081,11 +20082,6 @@ "undici-types": "~6.19.2" } }, - "node_modules/scim-patch/node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" - }, "node_modules/scim2-parse-filter": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/scim2-parse-filter/-/scim2-parse-filter-0.2.10.tgz", @@ -22442,9 +22438,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", diff --git a/backend/package.json b/backend/package.json index c575722fd..66eddcc10 100644 --- a/backend/package.json +++ b/backend/package.json @@ -89,7 +89,7 @@ "@types/jsrp": "^0.2.6", "@types/libsodium-wrappers": "^0.7.13", "@types/lodash.isequal": "^4.5.8", - "@types/node": "^20.9.5", + "@types/node": "^20.17.30", "@types/nodemailer": "^6.4.14", "@types/passport-github": "^1.1.12", "@types/passport-google-oauth20": "^2.0.14", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index d3aed3543..e3261db70 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -38,6 +38,7 @@ import { TSecretScanningServiceFactory } from "@app/ee/services/secret-scanning/ import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { TSshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service"; import { TSshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service"; +import { TSshHostServiceFactory } from "@app/ee/services/ssh-host/ssh-host-service"; import { TTrustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { TAuthMode } from "@app/server/plugins/auth/inject-identity"; import { TApiKeyServiceFactory } from "@app/services/api-key/api-key-service"; @@ -206,6 +207,7 @@ declare module "fastify" { certificateTemplate: TCertificateTemplateServiceFactory; sshCertificateAuthority: TSshCertificateAuthorityServiceFactory; sshCertificateTemplate: TSshCertificateTemplateServiceFactory; + sshHost: TSshHostServiceFactory; certificateAuthority: TCertificateAuthorityServiceFactory; certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; certificateEst: TCertificateEstServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 82582bfed..dc0e5ee67 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -232,6 +232,9 @@ import { TProjectSplitBackfillIds, TProjectSplitBackfillIdsInsert, TProjectSplitBackfillIdsUpdate, + TProjectSshConfigs, + TProjectSshConfigsInsert, + TProjectSshConfigsUpdate, TProjectsUpdate, TProjectTemplates, TProjectTemplatesInsert, @@ -380,6 +383,15 @@ import { TSshCertificateTemplates, TSshCertificateTemplatesInsert, TSshCertificateTemplatesUpdate, + TSshHostLoginUserMappings, + TSshHostLoginUserMappingsInsert, + TSshHostLoginUserMappingsUpdate, + TSshHostLoginUsers, + TSshHostLoginUsersInsert, + TSshHostLoginUsersUpdate, + TSshHosts, + TSshHostsInsert, + TSshHostsUpdate, TSuperAdmin, TSuperAdminInsert, TSuperAdminUpdate, @@ -425,6 +437,7 @@ declare module "knex/types/tables" { interface Tables { [TableName.Users]: KnexOriginal.CompositeTableType; [TableName.Groups]: KnexOriginal.CompositeTableType; + [TableName.SshHost]: KnexOriginal.CompositeTableType; [TableName.SshCertificateAuthority]: KnexOriginal.CompositeTableType< TSshCertificateAuthorities, TSshCertificateAuthoritiesInsert, @@ -450,6 +463,16 @@ declare module "knex/types/tables" { TSshCertificateBodiesInsert, TSshCertificateBodiesUpdate >; + [TableName.SshHostLoginUser]: KnexOriginal.CompositeTableType< + TSshHostLoginUsers, + TSshHostLoginUsersInsert, + TSshHostLoginUsersUpdate + >; + [TableName.SshHostLoginUserMapping]: KnexOriginal.CompositeTableType< + TSshHostLoginUserMappings, + TSshHostLoginUserMappingsInsert, + TSshHostLoginUserMappingsUpdate + >; [TableName.CertificateAuthority]: KnexOriginal.CompositeTableType< TCertificateAuthorities, TCertificateAuthoritiesInsert, @@ -554,6 +577,11 @@ declare module "knex/types/tables" { [TableName.SuperAdmin]: KnexOriginal.CompositeTableType; [TableName.ApiKey]: KnexOriginal.CompositeTableType; [TableName.Project]: KnexOriginal.CompositeTableType; + [TableName.ProjectSshConfig]: KnexOriginal.CompositeTableType< + TProjectSshConfigs, + TProjectSshConfigsInsert, + TProjectSshConfigsUpdate + >; [TableName.ProjectMembership]: KnexOriginal.CompositeTableType< TProjectMemberships, TProjectMembershipsInsert, diff --git a/backend/src/db/migrations/20250402000941_add-type-to-kms-keys.ts b/backend/src/db/migrations/20250402000941_add-type-to-kms-keys.ts new file mode 100644 index 000000000..fd99938db --- /dev/null +++ b/backend/src/db/migrations/20250402000941_add-type-to-kms-keys.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { KmsKeyUsage } from "@app/services/kms/kms-types"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasTypeColumn = await knex.schema.hasColumn(TableName.KmsKey, "type"); + + await knex.schema.alterTable(TableName.KmsKey, (t) => { + if (!hasTypeColumn) t.string("keyUsage").notNullable().defaultTo(KmsKeyUsage.ENCRYPT_DECRYPT); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.KmsKey, (t) => { + t.dropColumn("keyUsage"); + }); +} diff --git a/backend/src/db/migrations/20250404022310_ssh-ca-key-source.ts b/backend/src/db/migrations/20250404022310_ssh-ca-key-source.ts new file mode 100644 index 000000000..dc05eb9e5 --- /dev/null +++ b/backend/src/db/migrations/20250404022310_ssh-ca-key-source.ts @@ -0,0 +1,32 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.SshCertificateAuthority, "keySource"))) { + await knex.schema.alterTable(TableName.SshCertificateAuthority, (t) => { + t.string("keySource"); + }); + + // Backfilling the keySource to internal + await knex(TableName.SshCertificateAuthority).update({ keySource: "internal" }); + + await knex.schema.alterTable(TableName.SshCertificateAuthority, (t) => { + t.string("keySource").notNullable().alter(); + }); + } + + if (await knex.schema.hasColumn(TableName.SshCertificate, "sshCaId")) { + await knex.schema.alterTable(TableName.SshCertificate, (t) => { + t.uuid("sshCaId").nullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SshCertificateAuthority, "keySource")) { + await knex.schema.alterTable(TableName.SshCertificateAuthority, (t) => { + t.dropColumn("keySource"); + }); + } +} diff --git a/backend/src/db/migrations/20250405185753_ssh-mgmt-v2.ts b/backend/src/db/migrations/20250405185753_ssh-mgmt-v2.ts new file mode 100644 index 000000000..560fca9b1 --- /dev/null +++ b/backend/src/db/migrations/20250405185753_ssh-mgmt-v2.ts @@ -0,0 +1,93 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SshHost))) { + await knex.schema.createTable(TableName.SshHost, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.string("hostname").notNullable(); + t.string("userCertTtl").notNullable(); + t.string("hostCertTtl").notNullable(); + t.uuid("userSshCaId").notNullable(); + t.foreign("userSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE"); + t.uuid("hostSshCaId").notNullable(); + t.foreign("hostSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE"); + t.unique(["projectId", "hostname"]); + }); + await createOnUpdateTrigger(knex, TableName.SshHost); + } + + if (!(await knex.schema.hasTable(TableName.SshHostLoginUser))) { + await knex.schema.createTable(TableName.SshHostLoginUser, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshHostId").notNullable(); + t.foreign("sshHostId").references("id").inTable(TableName.SshHost).onDelete("CASCADE"); + t.string("loginUser").notNullable(); // e.g. ubuntu, root, ec2-user, ... + t.unique(["sshHostId", "loginUser"]); + }); + await createOnUpdateTrigger(knex, TableName.SshHostLoginUser); + } + + if (!(await knex.schema.hasTable(TableName.SshHostLoginUserMapping))) { + await knex.schema.createTable(TableName.SshHostLoginUserMapping, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshHostLoginUserId").notNullable(); + t.foreign("sshHostLoginUserId").references("id").inTable(TableName.SshHostLoginUser).onDelete("CASCADE"); + t.uuid("userId").nullable(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.unique(["sshHostLoginUserId", "userId"]); + }); + await createOnUpdateTrigger(knex, TableName.SshHostLoginUserMapping); + } + + if (!(await knex.schema.hasTable(TableName.ProjectSshConfig))) { + // new table to store configuration for projects of type SSH (i.e. Infisical SSH) + await knex.schema.createTable(TableName.ProjectSshConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("defaultUserSshCaId"); + t.foreign("defaultUserSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE"); + t.uuid("defaultHostSshCaId"); + t.foreign("defaultHostSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE"); + }); + await createOnUpdateTrigger(knex, TableName.ProjectSshConfig); + } + + const hasColumn = await knex.schema.hasColumn(TableName.SshCertificate, "sshHostId"); + if (!hasColumn) { + await knex.schema.alterTable(TableName.SshCertificate, (t) => { + t.uuid("sshHostId").nullable(); + t.foreign("sshHostId").references("id").inTable(TableName.SshHost).onDelete("SET NULL"); + }); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.ProjectSshConfig); + await dropOnUpdateTrigger(knex, TableName.ProjectSshConfig); + + await knex.schema.dropTableIfExists(TableName.SshHostLoginUserMapping); + await dropOnUpdateTrigger(knex, TableName.SshHostLoginUserMapping); + + await knex.schema.dropTableIfExists(TableName.SshHostLoginUser); + await dropOnUpdateTrigger(knex, TableName.SshHostLoginUser); + + const hasColumn = await knex.schema.hasColumn(TableName.SshCertificate, "sshHostId"); + if (hasColumn) { + await knex.schema.alterTable(TableName.SshCertificate, (t) => { + t.dropColumn("sshHostId"); + }); + } + + await knex.schema.dropTableIfExists(TableName.SshHost); + await dropOnUpdateTrigger(knex, TableName.SshHost); +} diff --git a/backend/src/db/migrations/20250410203010_add-comment-to-access-request.ts b/backend/src/db/migrations/20250410203010_add-comment-to-access-request.ts new file mode 100644 index 000000000..0e0b46fd8 --- /dev/null +++ b/backend/src/db/migrations/20250410203010_add-comment-to-access-request.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "note"); + if (!hasCol) { + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.string("note").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "note"); + if (hasCol) { + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.dropColumn("note"); + }); + } +} diff --git a/backend/src/db/schemas/access-approval-requests.ts b/backend/src/db/schemas/access-approval-requests.ts index 0b20202f5..bfe990b3a 100644 --- a/backend/src/db/schemas/access-approval-requests.ts +++ b/backend/src/db/schemas/access-approval-requests.ts @@ -17,7 +17,8 @@ export const AccessApprovalRequestsSchema = z.object({ permissions: z.unknown(), createdAt: z.date(), updatedAt: z.date(), - requestedByUserId: z.string().uuid() + requestedByUserId: z.string().uuid(), + note: z.string().nullable().optional() }); export type TAccessApprovalRequests = z.infer; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 5b78cf86f..8543417cf 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -75,6 +75,7 @@ export * from "./project-memberships"; export * from "./project-roles"; export * from "./project-slack-configs"; export * from "./project-split-backfill-ids"; +export * from "./project-ssh-configs"; export * from "./project-templates"; export * from "./project-user-additional-privilege"; export * from "./project-user-membership-roles"; @@ -125,6 +126,9 @@ export * from "./ssh-certificate-authority-secrets"; export * from "./ssh-certificate-bodies"; export * from "./ssh-certificate-templates"; export * from "./ssh-certificates"; +export * from "./ssh-host-login-user-mappings"; +export * from "./ssh-host-login-users"; +export * from "./ssh-hosts"; export * from "./super-admin"; export * from "./totp-configs"; export * from "./trusted-ips"; diff --git a/backend/src/db/schemas/kms-keys.ts b/backend/src/db/schemas/kms-keys.ts index b56fab7bf..ccb779d57 100644 --- a/backend/src/db/schemas/kms-keys.ts +++ b/backend/src/db/schemas/kms-keys.ts @@ -16,7 +16,8 @@ export const KmsKeysSchema = z.object({ name: z.string(), createdAt: z.date(), updatedAt: z.date(), - projectId: z.string().nullable().optional() + projectId: z.string().nullable().optional(), + keyUsage: z.string().default("encrypt-decrypt") }); export type TKmsKeys = z.infer; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index e2a0f153f..95561c14a 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -2,6 +2,9 @@ import { z } from "zod"; export enum TableName { Users = "users", + SshHost = "ssh_hosts", + SshHostLoginUser = "ssh_host_login_users", + SshHostLoginUserMapping = "ssh_host_login_user_mappings", SshCertificateAuthority = "ssh_certificate_authorities", SshCertificateAuthoritySecret = "ssh_certificate_authority_secrets", SshCertificateTemplate = "ssh_certificate_templates", @@ -38,6 +41,7 @@ export enum TableName { SuperAdmin = "super_admin", RateLimit = "rate_limit", ApiKey = "api_keys", + ProjectSshConfig = "project_ssh_configs", Project = "projects", ProjectBot = "project_bots", Environment = "project_environments", diff --git a/backend/src/db/schemas/project-ssh-configs.ts b/backend/src/db/schemas/project-ssh-configs.ts new file mode 100644 index 000000000..d0be89ee3 --- /dev/null +++ b/backend/src/db/schemas/project-ssh-configs.ts @@ -0,0 +1,21 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ProjectSshConfigsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + projectId: z.string(), + defaultUserSshCaId: z.string().uuid().nullable().optional(), + defaultHostSshCaId: z.string().uuid().nullable().optional() +}); + +export type TProjectSshConfigs = z.infer; +export type TProjectSshConfigsInsert = Omit, TImmutableDBKeys>; +export type TProjectSshConfigsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/ssh-certificate-authorities.ts b/backend/src/db/schemas/ssh-certificate-authorities.ts index 81e789288..75603406f 100644 --- a/backend/src/db/schemas/ssh-certificate-authorities.ts +++ b/backend/src/db/schemas/ssh-certificate-authorities.ts @@ -14,7 +14,8 @@ export const SshCertificateAuthoritiesSchema = z.object({ projectId: z.string(), status: z.string(), friendlyName: z.string(), - keyAlgorithm: z.string() + keyAlgorithm: z.string(), + keySource: z.string() }); export type TSshCertificateAuthorities = z.infer; diff --git a/backend/src/db/schemas/ssh-certificates.ts b/backend/src/db/schemas/ssh-certificates.ts index 6fe5bc261..1bfd1fe6e 100644 --- a/backend/src/db/schemas/ssh-certificates.ts +++ b/backend/src/db/schemas/ssh-certificates.ts @@ -11,14 +11,15 @@ export const SshCertificatesSchema = z.object({ id: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - sshCaId: z.string().uuid(), + sshCaId: z.string().uuid().nullable().optional(), sshCertificateTemplateId: z.string().uuid().nullable().optional(), serialNumber: z.string(), certType: z.string(), principals: z.string().array(), keyId: z.string(), notBefore: z.date(), - notAfter: z.date() + notAfter: z.date(), + sshHostId: z.string().uuid().nullable().optional() }); export type TSshCertificates = z.infer; diff --git a/backend/src/db/schemas/ssh-host-login-user-mappings.ts b/backend/src/db/schemas/ssh-host-login-user-mappings.ts new file mode 100644 index 000000000..6edb0d5a3 --- /dev/null +++ b/backend/src/db/schemas/ssh-host-login-user-mappings.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SshHostLoginUserMappingsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshHostLoginUserId: z.string().uuid(), + userId: z.string().uuid().nullable().optional() +}); + +export type TSshHostLoginUserMappings = z.infer; +export type TSshHostLoginUserMappingsInsert = Omit, TImmutableDBKeys>; +export type TSshHostLoginUserMappingsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/ssh-host-login-users.ts b/backend/src/db/schemas/ssh-host-login-users.ts new file mode 100644 index 000000000..62454d3c9 --- /dev/null +++ b/backend/src/db/schemas/ssh-host-login-users.ts @@ -0,0 +1,20 @@ +// 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 SshHostLoginUsersSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshHostId: z.string().uuid(), + loginUser: z.string() +}); + +export type TSshHostLoginUsers = z.infer; +export type TSshHostLoginUsersInsert = Omit, TImmutableDBKeys>; +export type TSshHostLoginUsersUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/ssh-hosts.ts b/backend/src/db/schemas/ssh-hosts.ts new file mode 100644 index 000000000..7577e065b --- /dev/null +++ b/backend/src/db/schemas/ssh-hosts.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 SshHostsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + projectId: z.string(), + hostname: z.string(), + userCertTtl: z.string(), + hostCertTtl: z.string(), + userSshCaId: z.string().uuid(), + hostSshCaId: z.string().uuid() +}); + +export type TSshHosts = z.infer; +export type TSshHostsInsert = Omit, TImmutableDBKeys>; +export type TSshHostsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 6a6ec3c07..8a7ccfdef 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -22,7 +22,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv body: z.object({ permissions: z.any().array(), isTemporary: z.boolean(), - temporaryRange: z.string().optional() + temporaryRange: z.string().optional(), + note: z.string().max(255).optional() }), querystring: z.object({ projectSlug: z.string().trim() @@ -43,7 +44,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv actorOrgId: req.permission.orgId, projectSlug: req.query.projectSlug, temporaryRange: req.body.temporaryRange, - isTemporary: req.body.isTemporary + isTemporary: req.body.isTemporary, + note: req.body.note }); return { approval: request }; } diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index e793c687d..2bf85e9c4 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -32,6 +32,7 @@ import { registerSnapshotRouter } from "./snapshot-router"; import { registerSshCaRouter } from "./ssh-certificate-authority-router"; import { registerSshCertRouter } from "./ssh-certificate-router"; import { registerSshCertificateTemplateRouter } from "./ssh-certificate-template-router"; +import { registerSshHostRouter } from "./ssh-host-router"; import { registerTrustedIpRouter } from "./trusted-ip-router"; import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router"; @@ -82,6 +83,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await sshRouter.register(registerSshCaRouter, { prefix: "/ca" }); await sshRouter.register(registerSshCertRouter, { prefix: "/certificates" }); await sshRouter.register(registerSshCertificateTemplateRouter, { prefix: "/certificate-templates" }); + await sshRouter.register(registerSshHostRouter, { prefix: "/hosts" }); }, { prefix: "/ssh" } ); diff --git a/backend/src/ee/routes/v1/kmip-spec-router.ts b/backend/src/ee/routes/v1/kmip-spec-router.ts index c9899c98e..9a1f4902c 100644 --- a/backend/src/ee/routes/v1/kmip-spec-router.ts +++ b/backend/src/ee/routes/v1/kmip-spec-router.ts @@ -2,7 +2,7 @@ import z from "zod"; import { KmsKeysSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -74,7 +74,7 @@ export const registerKmipSpecRouter = async (server: FastifyZodProvider) => { schema: { description: "KMIP endpoint for creating managed objects", body: z.object({ - algorithm: z.nativeEnum(SymmetricEncryption) + algorithm: z.nativeEnum(SymmetricKeyAlgorithm) }), response: { 200: KmsKeysSchema @@ -433,7 +433,7 @@ export const registerKmipSpecRouter = async (server: FastifyZodProvider) => { body: z.object({ key: z.string(), name: z.string(), - algorithm: z.nativeEnum(SymmetricEncryption) + algorithm: z.nativeEnum(SymmetricKeyAlgorithm) }), response: { 200: z.object({ diff --git a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts index ab80888d7..783cb9b72 100644 --- a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts +++ b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts @@ -1,14 +1,15 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { normalizeSshPrivateKey } from "@app/ee/services/ssh/ssh-certificate-authority-fns"; import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-schema"; -import { SshCaStatus } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { SshCaKeySource, SshCaStatus } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; import { SSH_CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; export const registerSshCaRouter = async (server: FastifyZodProvider) => { server.route({ @@ -20,14 +21,34 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { description: "Create SSH CA", - body: z.object({ - projectId: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.projectId), - friendlyName: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.friendlyName), - keyAlgorithm: z - .nativeEnum(CertKeyAlgorithm) - .default(CertKeyAlgorithm.RSA_2048) - .describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.keyAlgorithm) - }), + body: z + .object({ + projectId: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.projectId), + friendlyName: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.friendlyName), + keyAlgorithm: z + .nativeEnum(SshCertKeyAlgorithm) + .default(SshCertKeyAlgorithm.ED25519) + .describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.keyAlgorithm), + publicKey: z.string().trim().optional().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.publicKey), + privateKey: z + .string() + .trim() + .optional() + .transform((val) => (val ? normalizeSshPrivateKey(val) : undefined)) + .describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.privateKey), + keySource: z + .nativeEnum(SshCaKeySource) + .default(SshCaKeySource.INTERNAL) + .describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.keySource) + }) + .refine((data) => data.keySource === SshCaKeySource.INTERNAL || (!!data.publicKey && !!data.privateKey), { + message: "publicKey and privateKey are required when keySource is external", + path: ["publicKey"] + }) + .refine((data) => data.keySource === SshCaKeySource.EXTERNAL || !!data.keyAlgorithm, { + message: "keyAlgorithm is required when keySource is internal", + path: ["keyAlgorithm"] + }), response: { 200: z.object({ ca: sanitizedSshCa.extend({ diff --git a/backend/src/ee/routes/v1/ssh-certificate-router.ts b/backend/src/ee/routes/v1/ssh-certificate-router.ts index 249a96b50..eb0fc158a 100644 --- a/backend/src/ee/routes/v1/ssh-certificate-router.ts +++ b/backend/src/ee/routes/v1/ssh-certificate-router.ts @@ -2,13 +2,13 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { SSH_CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; import { 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 { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; export const registerSshCertRouter = async (server: FastifyZodProvider) => { @@ -108,8 +108,8 @@ export const registerSshCertRouter = async (server: FastifyZodProvider) => { .min(1) .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.certificateTemplateId), keyAlgorithm: z - .nativeEnum(CertKeyAlgorithm) - .default(CertKeyAlgorithm.RSA_2048) + .nativeEnum(SshCertKeyAlgorithm) + .default(SshCertKeyAlgorithm.ED25519) .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyAlgorithm), certType: z .nativeEnum(SshCertType) @@ -133,7 +133,7 @@ export const registerSshCertRouter = async (server: FastifyZodProvider) => { privateKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.privateKey), publicKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.publicKey), keyAlgorithm: z - .nativeEnum(CertKeyAlgorithm) + .nativeEnum(SshCertKeyAlgorithm) .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyAlgorithm) }) } diff --git a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts index a85e6b0ca..a7dc55661 100644 --- a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts +++ b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts @@ -92,8 +92,8 @@ export const registerSshCertificateTemplateRouter = async (server: FastifyZodPro allowHostCertificates: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowHostCertificates), allowCustomKeyIds: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowCustomKeyIds) }) - .refine((data) => ms(data.maxTTL) > ms(data.ttl), { - message: "Max TLL must be greater than TTL", + .refine((data) => ms(data.maxTTL) >= ms(data.ttl), { + message: "Max TLL must be greater than or equal to TTL", path: ["maxTTL"] }), response: { diff --git a/backend/src/ee/routes/v1/ssh-host-router.ts b/backend/src/ee/routes/v1/ssh-host-router.ts new file mode 100644 index 000000000..1dab5dd2f --- /dev/null +++ b/backend/src/ee/routes/v1/ssh-host-router.ts @@ -0,0 +1,444 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; +import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; +import { isValidHostname } from "@app/ee/services/ssh-host/ssh-host-validators"; +import { SSH_HOSTS } from "@app/lib/api-docs"; +import { ms } from "@app/lib/ms"; +import { publicSshCaLimit, 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 { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; + +export const registerSshHostRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.array( + sanitizedSshHost.extend({ + loginMappings: z.array(loginMappingSchema) + }) + ) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const hosts = await server.services.sshHost.listSshHosts({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return hosts; + } + }); + + server.route({ + method: "GET", + url: "/:sshHostId", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + sshHostId: z.string().describe(SSH_HOSTS.GET.sshHostId) + }), + response: { + 200: sanitizedSshHost.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const host = await server.services.sshHost.getSshHost({ + sshHostId: req.params.sshHostId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: host.projectId, + event: { + type: EventType.GET_SSH_HOST, + metadata: { + sshHostId: host.id, + hostname: host.hostname + } + } + }); + + return host; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Add an SSH Host", + body: z.object({ + projectId: z.string().describe(SSH_HOSTS.CREATE.projectId), + hostname: z + .string() + .min(1) + .refine((v) => isValidHostname(v), { + message: "Hostname must be a valid hostname" + }) + .describe(SSH_HOSTS.CREATE.hostname), + userCertTtl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .default("8h") + .describe(SSH_HOSTS.CREATE.userCertTtl), + hostCertTtl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .default("1y") + .describe(SSH_HOSTS.CREATE.hostCertTtl), + loginMappings: z.array(loginMappingSchema).default([]).describe(SSH_HOSTS.CREATE.loginMappings), + userSshCaId: z.string().describe(SSH_HOSTS.CREATE.userSshCaId).optional(), + hostSshCaId: z.string().describe(SSH_HOSTS.CREATE.hostSshCaId).optional() + }), + response: { + 200: sanitizedSshHost.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const host = await server.services.sshHost.createSshHost({ + ...req.body, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: host.projectId, + event: { + type: EventType.CREATE_SSH_HOST, + metadata: { + sshHostId: host.id, + hostname: host.hostname, + userCertTtl: host.userCertTtl, + hostCertTtl: host.hostCertTtl, + loginMappings: host.loginMappings, + userSshCaId: host.userSshCaId, + hostSshCaId: host.hostSshCaId + } + } + }); + + return host; + } + }); + + server.route({ + method: "PATCH", + url: "/:sshHostId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update SSH Host", + params: z.object({ + sshHostId: z.string().trim().describe(SSH_HOSTS.UPDATE.sshHostId) + }), + body: z.object({ + hostname: z + .string() + .min(1) + .refine((v) => isValidHostname(v), { + message: "Hostname must be a valid hostname" + }) + .optional() + .describe(SSH_HOSTS.UPDATE.hostname), + userCertTtl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(SSH_HOSTS.UPDATE.userCertTtl), + hostCertTtl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(SSH_HOSTS.UPDATE.hostCertTtl), + loginMappings: z.array(loginMappingSchema).optional().describe(SSH_HOSTS.UPDATE.loginMappings) + }), + response: { + 200: sanitizedSshHost.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + handler: async (req) => { + const host = await server.services.sshHost.updateSshHost({ + sshHostId: req.params.sshHostId, + 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: host.projectId, + event: { + type: EventType.UPDATE_SSH_HOST, + metadata: { + sshHostId: host.id, + hostname: host.hostname, + userCertTtl: host.userCertTtl, + hostCertTtl: host.hostCertTtl, + loginMappings: host.loginMappings, + userSshCaId: host.userSshCaId, + hostSshCaId: host.hostSshCaId + } + } + }); + + return host; + } + }); + + server.route({ + method: "DELETE", + url: "/:sshHostId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + sshHostId: z.string().describe(SSH_HOSTS.DELETE.sshHostId) + }), + response: { + 200: sanitizedSshHost.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const host = await server.services.sshHost.deleteSshHost({ + sshHostId: req.params.sshHostId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: host.projectId, + event: { + type: EventType.DELETE_SSH_HOST, + metadata: { + sshHostId: host.id, + hostname: host.hostname + } + } + }); + + return host; + } + }); + + server.route({ + method: "POST", + url: "/:sshHostId/issue-user-cert", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + description: "Issue SSH certificate for user", + params: z.object({ + sshHostId: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.sshHostId) + }), + body: z.object({ + loginUser: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.loginUser) + }), + response: { + 200: z.object({ + serialNumber: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.serialNumber), + signedKey: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.signedKey), + privateKey: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.privateKey), + publicKey: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.publicKey), + keyAlgorithm: z.nativeEnum(SshCertKeyAlgorithm).describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.keyAlgorithm) + }) + } + }, + handler: async (req) => { + const { serialNumber, signedPublicKey, privateKey, publicKey, keyAlgorithm, host, principals } = + await server.services.sshHost.issueSshHostUserCert({ + sshHostId: req.params.sshHostId, + loginUser: req.body.loginUser, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.ISSUE_SSH_HOST_USER_CERT, + metadata: { + sshHostId: req.params.sshHostId, + hostname: host.hostname, + loginUser: req.body.loginUser, + principals, + ttl: host.userCertTtl + } + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.IssueSshHostUserCert, + distinctId: getTelemetryDistinctId(req), + properties: { + sshHostId: req.params.sshHostId, + hostname: host.hostname, + principals, + ...req.auditLogInfo + } + }); + + return { + serialNumber, + signedKey: signedPublicKey, + privateKey, + publicKey, + keyAlgorithm + }; + } + }); + + server.route({ + method: "POST", + url: "/:sshHostId/issue-host-cert", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Issue SSH certificate for host", + params: z.object({ + sshHostId: z.string().describe(SSH_HOSTS.ISSUE_HOST_CERT.sshHostId) + }), + body: z.object({ + publicKey: z.string().describe(SSH_HOSTS.ISSUE_HOST_CERT.publicKey) + }), + response: { + 200: z.object({ + serialNumber: z.string().describe(SSH_HOSTS.ISSUE_HOST_CERT.serialNumber), + signedKey: z.string().describe(SSH_HOSTS.ISSUE_HOST_CERT.signedKey) + }) + } + }, + handler: async (req) => { + const { host, principals, serialNumber, signedPublicKey } = await server.services.sshHost.issueSshHostHostCert({ + sshHostId: req.params.sshHostId, + publicKey: req.body.publicKey, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.ISSUE_SSH_HOST_HOST_CERT, + metadata: { + sshHostId: req.params.sshHostId, + hostname: host.hostname, + principals, + serialNumber, + ttl: host.hostCertTtl + } + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.IssueSshHostHostCert, + distinctId: getTelemetryDistinctId(req), + properties: { + sshHostId: req.params.sshHostId, + hostname: host.hostname, + principals, + ...req.auditLogInfo + } + }); + + return { + serialNumber, + signedKey: signedPublicKey + }; + } + }); + + server.route({ + method: "GET", + url: "/:sshHostId/user-ca-public-key", + config: { + rateLimit: publicSshCaLimit + }, + schema: { + description: "Get public key of the user SSH CA linked to the host", + params: z.object({ + sshHostId: z.string().trim().describe(SSH_HOSTS.GET_USER_CA_PUBLIC_KEY.sshHostId) + }), + response: { + 200: z.string().describe(SSH_HOSTS.GET_USER_CA_PUBLIC_KEY.publicKey) + } + }, + handler: async (req) => { + const publicKey = await server.services.sshHost.getSshHostUserCaPk(req.params.sshHostId); + return publicKey; + } + }); + + server.route({ + method: "GET", + url: "/:sshHostId/host-ca-public-key", + config: { + rateLimit: publicSshCaLimit + }, + schema: { + description: "Get public key of the host SSH CA linked to the host", + params: z.object({ + sshHostId: z.string().trim().describe(SSH_HOSTS.GET_HOST_CA_PUBLIC_KEY.sshHostId) + }), + response: { + 200: z.string().describe(SSH_HOSTS.GET_HOST_CA_PUBLIC_KEY.publicKey) + } + }, + handler: async (req) => { + const publicKey = await server.services.sshHost.getSshHostHostCaPk(req.params.sshHostId); + return publicKey; + } + }); +}; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 50670cb49..3606b4bdc 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -94,7 +94,8 @@ export const accessApprovalRequestServiceFactory = ({ actor, actorOrgId, actorAuthMethod, - projectSlug + projectSlug, + note }: TCreateAccessApprovalRequestDTO) => { const cfg = getConfig(); const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); @@ -209,7 +210,8 @@ export const accessApprovalRequestServiceFactory = ({ requestedByUserId: actorId, temporaryRange: temporaryRange || null, permissions: JSON.stringify(requestedPermissions), - isTemporary + isTemporary, + note: note || null }, tx ); @@ -232,7 +234,8 @@ export const accessApprovalRequestServiceFactory = ({ secretPath, environment: envSlug, permissions: accessTypes, - approvalUrl + approvalUrl, + note } } }); @@ -252,7 +255,8 @@ export const accessApprovalRequestServiceFactory = ({ secretPath, environment: envSlug, permissions: accessTypes, - approvalUrl + approvalUrl, + note }, template: SmtpTemplates.AccessApprovalRequest }); diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts index e11ca58d5..51a5e0ca2 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts @@ -24,6 +24,7 @@ export type TCreateAccessApprovalRequestDTO = { permissions: unknown; isTemporary: boolean; temporaryRange?: string; + note?: string; } & Omit; export type TListApprovalRequestsDTO = { 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 2ab46b6ad..91464bc0b 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -10,8 +10,10 @@ import { TUpdateSecretRotationV2DTO } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; +import { AsymmetricKeyAlgorithm, SigningAlgorithm } from "@app/lib/crypto/sign/types"; import { TProjectPermission } from "@app/lib/types"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { TCreateAppConnectionDTO, TUpdateAppConnectionDTO } from "@app/services/app-connection/app-connection-types"; @@ -189,6 +191,12 @@ export enum EventType { UPDATE_SSH_CERTIFICATE_TEMPLATE = "update-ssh-certificate-template", DELETE_SSH_CERTIFICATE_TEMPLATE = "delete-ssh-certificate-template", GET_SSH_CERTIFICATE_TEMPLATE = "get-ssh-certificate-template", + CREATE_SSH_HOST = "create-ssh-host", + UPDATE_SSH_HOST = "update-ssh-host", + DELETE_SSH_HOST = "delete-ssh-host", + GET_SSH_HOST = "get-ssh-host", + ISSUE_SSH_HOST_USER_CERT = "issue-ssh-host-user-cert", + ISSUE_SSH_HOST_HOST_CERT = "issue-ssh-host-host-cert", CREATE_CA = "create-certificate-authority", GET_CA = "get-certificate-authority", UPDATE_CA = "update-certificate-authority", @@ -248,6 +256,11 @@ export enum EventType { GET_CMEK = "get-cmek", CMEK_ENCRYPT = "cmek-encrypt", CMEK_DECRYPT = "cmek-decrypt", + CMEK_SIGN = "cmek-sign", + CMEK_VERIFY = "cmek-verify", + CMEK_LIST_SIGNING_ALGORITHMS = "cmek-list-signing-algorithms", + CMEK_GET_PUBLIC_KEY = "cmek-get-public-key", + UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "update-external-group-org-role-mapping", GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "get-external-group-org-role-mapping", GET_PROJECT_TEMPLATES = "get-project-templates", @@ -1377,7 +1390,7 @@ interface IssueSshCreds { type: EventType.ISSUE_SSH_CREDS; metadata: { certificateTemplateId: string; - keyAlgorithm: CertKeyAlgorithm; + keyAlgorithm: SshCertKeyAlgorithm; certType: SshCertType; principals: string[]; ttl: string; @@ -1473,6 +1486,80 @@ interface DeleteSshCertificateTemplate { }; } +interface CreateSshHost { + type: EventType.CREATE_SSH_HOST; + metadata: { + sshHostId: string; + hostname: string; + userCertTtl: string; + hostCertTtl: string; + loginMappings: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; + userSshCaId: string; + hostSshCaId: string; + }; +} + +interface UpdateSshHost { + type: EventType.UPDATE_SSH_HOST; + metadata: { + sshHostId: string; + hostname?: string; + userCertTtl?: string; + hostCertTtl?: string; + loginMappings?: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; + userSshCaId?: string; + hostSshCaId?: string; + }; +} + +interface DeleteSshHost { + type: EventType.DELETE_SSH_HOST; + metadata: { + sshHostId: string; + hostname: string; + }; +} + +interface GetSshHost { + type: EventType.GET_SSH_HOST; + metadata: { + sshHostId: string; + hostname: string; + }; +} + +interface IssueSshHostUserCert { + type: EventType.ISSUE_SSH_HOST_USER_CERT; + metadata: { + sshHostId: string; + hostname: string; + loginUser: string; + principals: string[]; + ttl: string; + }; +} + +interface IssueSshHostHostCert { + type: EventType.ISSUE_SSH_HOST_HOST_CERT; + metadata: { + sshHostId: string; + hostname: string; + serialNumber: string; + principals: string[]; + ttl: string; + }; +} + interface CreateCa { type: EventType.CREATE_CA; metadata: { @@ -1916,7 +2003,7 @@ interface CreateCmekEvent { keyId: string; name: string; description?: string; - encryptionAlgorithm: SymmetricEncryption; + encryptionAlgorithm: SymmetricKeyAlgorithm | AsymmetricKeyAlgorithm; }; } @@ -1964,6 +2051,39 @@ interface CmekDecryptEvent { }; } +interface CmekSignEvent { + type: EventType.CMEK_SIGN; + metadata: { + keyId: string; + signingAlgorithm: SigningAlgorithm; + signature: string; + }; +} + +interface CmekVerifyEvent { + type: EventType.CMEK_VERIFY; + metadata: { + keyId: string; + signingAlgorithm: SigningAlgorithm; + signature: string; + signatureValid: boolean; + }; +} + +interface CmekListSigningAlgorithmsEvent { + type: EventType.CMEK_LIST_SIGNING_ALGORITHMS; + metadata: { + keyId: string; + }; +} + +interface CmekGetPublicKeyEvent { + type: EventType.CMEK_GET_PUBLIC_KEY; + metadata: { + keyId: string; + }; +} + interface GetExternalGroupOrgRoleMappingsEvent { type: EventType.GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS; metadata?: Record; // not needed, based off orgId @@ -2493,6 +2613,12 @@ export type Event = | UpdateSshCertificateTemplate | GetSshCertificateTemplate | DeleteSshCertificateTemplate + | CreateSshHost + | UpdateSshHost + | DeleteSshHost + | GetSshHost + | IssueSshHostUserCert + | IssueSshHostHostCert | CreateCa | GetCa | UpdateCa @@ -2552,6 +2678,10 @@ export type Event = | GetCmeksEvent | CmekEncryptEvent | CmekDecryptEvent + | CmekSignEvent + | CmekVerifyEvent + | CmekListSigningAlgorithmsEvent + | CmekGetPublicKeyEvent | GetExternalGroupOrgRoleMappingsEvent | UpdateExternalGroupOrgRoleMappingsEvent | GetProjectTemplatesEvent diff --git a/backend/src/ee/services/external-kms/external-kms-service.ts b/backend/src/ee/services/external-kms/external-kms-service.ts index faaace343..49ac293ed 100644 --- a/backend/src/ee/services/external-kms/external-kms-service.ts +++ b/backend/src/ee/services/external-kms/external-kms-service.ts @@ -7,7 +7,7 @@ import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/er import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TKmsKeyDALFactory } from "@app/services/kms/kms-key-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; -import { KmsDataKey } from "@app/services/kms/kms-types"; +import { KmsDataKey, KmsKeyUsage } from "@app/services/kms/kms-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; @@ -115,6 +115,7 @@ export const externalKmsServiceFactory = ({ { isReserved: false, description, + keyUsage: KmsKeyUsage.ENCRYPT_DECRYPT, name: kmsName, orgId: actorOrgId }, diff --git a/backend/src/ee/services/external-kms/providers/gcp-kms.ts b/backend/src/ee/services/external-kms/providers/gcp-kms.ts index b3b61694b..bee1eb24b 100644 --- a/backend/src/ee/services/external-kms/providers/gcp-kms.ts +++ b/backend/src/ee/services/external-kms/providers/gcp-kms.ts @@ -92,7 +92,7 @@ export const GcpKmsProviderFactory = async ({ inputs }: GcpKmsProviderArgs): Pro plaintext: data }); if (!encryptedText[0].ciphertext) throw new Error("encryption failed"); - return { encryptedBlob: Buffer.from(encryptedText[0].ciphertext) }; + return { encryptedBlob: Buffer.from(encryptedText[0].ciphertext as Uint8Array) }; }; const decrypt = async (encryptedBlob: Buffer) => { @@ -101,7 +101,7 @@ export const GcpKmsProviderFactory = async ({ inputs }: GcpKmsProviderArgs): Pro ciphertext: encryptedBlob }); if (!decryptedText[0].plaintext) throw new Error("decryption failed"); - return { data: Buffer.from(decryptedText[0].plaintext) }; + return { data: Buffer.from(decryptedText[0].plaintext as Uint8Array) }; }; return { diff --git a/backend/src/ee/services/hsm/hsm-service.ts b/backend/src/ee/services/hsm/hsm-service.ts index d35d17a24..0ed4c5faf 100644 --- a/backend/src/ee/services/hsm/hsm-service.ts +++ b/backend/src/ee/services/hsm/hsm-service.ts @@ -258,7 +258,7 @@ export const hsmServiceFactory = ({ hsmModule: { isInitialized, pkcs11 }, envCon const decrypt: { (encryptedBlob: Buffer, providedSession: pkcs11js.Handle): Promise; (encryptedBlob: Buffer): Promise; - } = async (encryptedBlob: Buffer, providedSession?: pkcs11js.Handle) => { + } = async (encryptedBlob: Buffer, providedSession?: pkcs11js.Handle): Promise => { if (!pkcs11 || !isInitialized) { throw new Error("PKCS#11 module is not initialized"); } @@ -309,10 +309,10 @@ export const hsmServiceFactory = ({ hsmModule: { isInitialized, pkcs11 }, envCon pkcs11.C_DecryptInit(sessionHandle, decryptMechanism, aesKey); - const tempBuffer = Buffer.alloc(encryptedData.length); + const tempBuffer: Buffer = Buffer.alloc(encryptedData.length); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const decryptedData = pkcs11.C_Decrypt(sessionHandle, encryptedData, tempBuffer); - // Create a new buffer from the decrypted data return Buffer.from(decryptedData); } catch (error) { logger.error(error, "HSM: Failed to perform decryption"); diff --git a/backend/src/ee/services/kmip/kmip-operation-service.ts b/backend/src/ee/services/kmip/kmip-operation-service.ts index 66c3a1d46..45f201498 100644 --- a/backend/src/ee/services/kmip/kmip-operation-service.ts +++ b/backend/src/ee/services/kmip/kmip-operation-service.ts @@ -3,6 +3,7 @@ import { ForbiddenError } from "@casl/ability"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { TKmsKeyDALFactory } from "@app/services/kms/kms-key-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsKeyUsage } from "@app/services/kms/kms-types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { OrgPermissionKmipActions, OrgPermissionSubjects } from "../permission/org-permission"; @@ -403,6 +404,7 @@ export const kmipOperationServiceFactory = ({ algorithm, isReserved: false, projectId, + keyUsage: KmsKeyUsage.ENCRYPT_DECRYPT, orgId: project.orgId }); diff --git a/backend/src/ee/services/kmip/kmip-types.ts b/backend/src/ee/services/kmip/kmip-types.ts index a259a79b8..81d0d8766 100644 --- a/backend/src/ee/services/kmip/kmip-types.ts +++ b/backend/src/ee/services/kmip/kmip-types.ts @@ -1,4 +1,4 @@ -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; import { OrderByDirection, TOrgPermission, TProjectPermission } from "@app/lib/types"; import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; @@ -49,7 +49,7 @@ type KmipOperationBaseDTO = { } & Omit; export type TKmipCreateDTO = { - algorithm: SymmetricEncryption; + algorithm: SymmetricKeyAlgorithm; } & KmipOperationBaseDTO; export type TKmipGetDTO = { @@ -77,7 +77,7 @@ export type TKmipLocateDTO = KmipOperationBaseDTO; export type TKmipRegisterDTO = { name: string; key: string; - algorithm: SymmetricEncryption; + algorithm: SymmetricKeyAlgorithm; } & KmipOperationBaseDTO; export type TSetupOrgKmipDTO = { diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 4d3fff12a..c14bbb518 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -32,7 +32,9 @@ export enum ProjectPermissionCmekActions { Edit = "edit", Delete = "delete", Encrypt = "encrypt", - Decrypt = "decrypt" + Decrypt = "decrypt", + Sign = "sign", + Verify = "verify" } export enum ProjectPermissionDynamicSecretActions { @@ -67,6 +69,14 @@ export enum ProjectPermissionGroupActions { GrantPrivileges = "grant-privileges" } +export enum ProjectPermissionSshHostActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + IssueHostCert = "issue-host-cert" +} + export enum ProjectPermissionSecretSyncActions { Read = "read", Create = "create", @@ -121,6 +131,7 @@ export enum ProjectPermissionSub { SshCertificateAuthorities = "ssh-certificate-authorities", SshCertificates = "ssh-certificates", SshCertificateTemplates = "ssh-certificate-templates", + SshHosts = "ssh-hosts", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", @@ -160,6 +171,10 @@ export type IdentityManagementSubjectFields = { identityId: string; }; +export type SshHostSubjectFields = { + hostname: string; +}; + export type ProjectPermissionSet = | [ ProjectPermissionSecretActions, @@ -215,6 +230,10 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] + | [ + ProjectPermissionSshHostActions, + ProjectPermissionSub.SshHosts | (ForcedSubject & SshHostSubjectFields) + ] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs] @@ -313,6 +332,21 @@ const IdentityManagementConditionSchema = z }) .partial(); +const SshHostConditionSchema = z + .object({ + hostname: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + }) + .partial() + ]) + }) + .partial(); + const GeneralPermissionSchema = [ z.object({ subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."), @@ -561,6 +595,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + z.object({ + subject: z.literal(ProjectPermissionSub.SshHosts).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSshHostActions).describe( + "Describe what action an entity can take." + ), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + conditions: SshHostConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), z.object({ subject: z.literal(ProjectPermissionSub.SecretRotation).describe("The entity this permission pertains to."), inverted: z.boolean().optional().describe("Whether rule allows or forbids."), @@ -613,6 +657,17 @@ const buildAdminPermissionRules = () => { ); }); + can( + [ + ProjectPermissionSshHostActions.Edit, + ProjectPermissionSshHostActions.Read, + ProjectPermissionSshHostActions.Create, + ProjectPermissionSshHostActions.Delete, + ProjectPermissionSshHostActions.IssueHostCert + ], + ProjectPermissionSub.SshHosts + ); + can( [ ProjectPermissionMemberActions.Create, @@ -679,7 +734,9 @@ const buildAdminPermissionRules = () => { ProjectPermissionCmekActions.Delete, ProjectPermissionCmekActions.Read, ProjectPermissionCmekActions.Encrypt, - ProjectPermissionCmekActions.Decrypt + ProjectPermissionCmekActions.Decrypt, + ProjectPermissionCmekActions.Sign, + ProjectPermissionCmekActions.Verify ], ProjectPermissionSub.Cmek ); @@ -873,6 +930,8 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionActions.Create], ProjectPermissionSub.SshCertificates); can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateTemplates); + can([ProjectPermissionSshHostActions.Read], ProjectPermissionSub.SshHosts); + can( [ ProjectPermissionCmekActions.Create, @@ -880,7 +939,9 @@ const buildMemberPermissionRules = () => { ProjectPermissionCmekActions.Delete, ProjectPermissionCmekActions.Read, ProjectPermissionCmekActions.Encrypt, - ProjectPermissionCmekActions.Decrypt + ProjectPermissionCmekActions.Decrypt, + ProjectPermissionCmekActions.Sign, + ProjectPermissionCmekActions.Verify ], ProjectPermissionSub.Cmek ); diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index c9f2837df..84cced88f 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -594,6 +594,7 @@ export const scimServiceFactory = ({ }, tx ); + await orgMembershipDAL.updateById( membership.id, { 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 e24cd923e..2f340626b 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 @@ -113,7 +113,13 @@ type TSecretApprovalRequestServiceFactoryDep = { kmsService: Pick; secretV2BridgeDAL: Pick< TSecretV2BridgeDALFactory, - "insertMany" | "upsertSecretReferences" | "findBySecretKeys" | "bulkUpdate" | "deleteMany" | "find" + | "insertMany" + | "upsertSecretReferences" + | "findBySecretKeys" + | "bulkUpdate" + | "deleteMany" + | "find" + | "invalidateSecretCacheByProjectId" >; secretVersionV2BridgeDAL: Pick; secretVersionTagV2BridgeDAL: Pick; @@ -262,13 +268,14 @@ export const secretApprovalRequestServiceFactory = ({ id: el.id, version: el.version, secretMetadata: el.secretMetadata as ResourceMetadataDTO, - isRotatedSecret: el.secret.isRotatedSecret, - // eslint-disable-next-line no-nested-ternary - secretValue: el.secret.isRotatedSecret - ? undefined - : el.encryptedValue - ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() - : "", + isRotatedSecret: el.secret?.isRotatedSecret ?? false, + secretValue: + // eslint-disable-next-line no-nested-ternary + el.secret && el.secret.isRotatedSecret + ? undefined + : el.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() + : "", secretComment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "", @@ -615,7 +622,7 @@ export const secretApprovalRequestServiceFactory = ({ tx, inputSecrets: secretUpdationCommits.map((el) => { const encryptedValue = - !el.secret.isRotatedSecret && typeof el.encryptedValue !== "undefined" + !el.secret?.isRotatedSecret && typeof el.encryptedValue !== "undefined" ? { encryptedValue: el.encryptedValue as Buffer, references: el.encryptedValue @@ -863,6 +870,7 @@ export const secretApprovalRequestServiceFactory = ({ }); } + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folderId); const [folder] = await folderDAL.findSecretPathByFolderIds(projectId, [folderId]); if (!folder) { 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 5fae2675d..480e80028 100644 --- a/backend/src/ee/services/secret-replication/secret-replication-service.ts +++ b/backend/src/ee/services/secret-replication/secret-replication-service.ts @@ -45,7 +45,14 @@ type TSecretReplicationServiceFactoryDep = { secretVersionDAL: Pick; secretV2BridgeDAL: Pick< TSecretV2BridgeDALFactory, - "find" | "findBySecretKeys" | "insertMany" | "bulkUpdate" | "delete" | "upsertSecretReferences" | "transaction" + | "find" + | "findBySecretKeys" + | "insertMany" + | "bulkUpdate" + | "delete" + | "upsertSecretReferences" + | "transaction" + | "invalidateSecretCacheByProjectId" >; secretVersionV2BridgeDAL: Pick< TSecretVersionV2DALFactory, @@ -260,6 +267,7 @@ export const secretReplicationServiceFactory = ({ const sourceLocalSecrets = await secretV2BridgeDAL.find({ folderId: folder.id, type: SecretType.Shared }); const sourceSecretImports = await secretImportDAL.find({ folderId: folder.id }); const sourceImportedSecrets = await fnSecretsV2FromImports({ + projectId, secretImports: sourceSecretImports, secretDAL: secretV2BridgeDAL, folderDAL, @@ -497,6 +505,7 @@ export const secretReplicationServiceFactory = ({ } }); + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await secretQueueService.syncSecrets({ projectId, orgId, diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts index d0b38808e..1f55ac526 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts @@ -88,7 +88,7 @@ export type TSecretRotationV2ServiceFactoryDep = { folderDAL: Pick; secretV2BridgeDAL: Pick< TSecretV2BridgeDALFactory, - "bulkUpdate" | "insertMany" | "deleteMany" | "upsertSecretReferences" | "find" + "bulkUpdate" | "insertMany" | "deleteMany" | "upsertSecretReferences" | "find" | "invalidateSecretCacheByProjectId" >; secretVersionV2BridgeDAL: Pick; secretVersionTagV2BridgeDAL: Pick; @@ -515,6 +515,7 @@ export const secretRotationV2ServiceFactory = ({ }); }); + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folder.id); await secretQueueService.syncSecrets({ orgId: connection.orgId, @@ -651,6 +652,7 @@ export const secretRotationV2ServiceFactory = ({ }); if (secretsMappingUpdated) { + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folder.id); await secretQueueService.syncSecrets({ orgId: connection.orgId, @@ -777,6 +779,7 @@ export const secretRotationV2ServiceFactory = ({ } if (deleteSecrets) { + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folder.id); await secretQueueService.syncSecrets({ orgId: connection.orgId, @@ -935,6 +938,7 @@ export const secretRotationV2ServiceFactory = ({ } }); + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folder.id); await secretQueueService.syncSecrets({ orgId: connection.orgId, diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts index ac8fcc9f2..2c6124348 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts @@ -48,7 +48,7 @@ type TSecretRotationQueueFactoryDep = { secretRotationDAL: TSecretRotationDALFactory; projectBotService: Pick; secretDAL: Pick; - secretV2BridgeDAL: Pick; + secretV2BridgeDAL: Pick; secretVersionDAL: Pick; secretVersionV2BridgeDAL: Pick; telemetryService: Pick; @@ -339,6 +339,8 @@ export const secretRotationQueueFactory = ({ tx ); }); + + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(secretRotation.projectId); } else { if (!botKey) throw new NotFoundError({ diff --git a/backend/src/ee/services/ssh-certificate/ssh-certificate-types.ts b/backend/src/ee/services/ssh-certificate/ssh-certificate-types.ts new file mode 100644 index 000000000..14e2755ee --- /dev/null +++ b/backend/src/ee/services/ssh-certificate/ssh-certificate-types.ts @@ -0,0 +1,7 @@ +export enum SshCertKeyAlgorithm { + RSA_2048 = "RSA_2048", + RSA_4096 = "RSA_4096", + ECDSA_P256 = "EC_prime256v1", + ECDSA_P384 = "EC_secp384r1", + ED25519 = "ED25519" +} diff --git a/backend/src/ee/services/ssh-host/ssh-host-dal.ts b/backend/src/ee/services/ssh-host/ssh-host-dal.ts new file mode 100644 index 000000000..4baeca503 --- /dev/null +++ b/backend/src/ee/services/ssh-host/ssh-host-dal.ts @@ -0,0 +1,193 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { groupBy, unique } from "@app/lib/fn"; +import { ormify } from "@app/lib/knex"; + +export type TSshHostDALFactory = ReturnType; + +export const sshHostDALFactory = (db: TDbClient) => { + const sshHostOrm = ormify(db, TableName.SshHost); + + const findUserAccessibleSshHosts = async (projectIds: string[], userId: string, tx?: Knex) => { + try { + const user = await (tx || db.replicaNode())(TableName.Users).where({ id: userId }).select("username").first(); + + if (!user) { + throw new DatabaseError({ name: `${TableName.Users}: UserNotFound`, error: new Error("User not found") }); + } + + const rows = await (tx || db.replicaNode())(TableName.SshHost) + .leftJoin(TableName.SshHostLoginUser, `${TableName.SshHost}.id`, `${TableName.SshHostLoginUser}.sshHostId`) + .leftJoin( + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` + ) + .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SshHostLoginUserMapping}.userId`) + .whereIn(`${TableName.SshHost}.projectId`, projectIds) + .andWhere(`${TableName.SshHostLoginUserMapping}.userId`, userId) + .select( + db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), + db.ref("projectId").withSchema(TableName.SshHost), + db.ref("hostname").withSchema(TableName.SshHost), + db.ref("userCertTtl").withSchema(TableName.SshHost), + db.ref("hostCertTtl").withSchema(TableName.SshHost), + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("username").withSchema(TableName.Users), + db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), + db.ref("userSshCaId").withSchema(TableName.SshHost), + db.ref("hostSshCaId").withSchema(TableName.SshHost) + ) + .orderBy(`${TableName.SshHost}.updatedAt`, "desc"); + + const grouped = groupBy(rows, (r) => r.sshHostId); + return Object.values(grouped).map((hostRows) => { + const { sshHostId, hostname, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId, projectId } = hostRows[0]; + + const loginMappingGrouped = groupBy(hostRows, (r) => r.loginUser); + + const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser]) => ({ + loginUser, + allowedPrincipals: { + usernames: [user.username] + } + })); + + return { + id: sshHostId, + hostname, + projectId, + userCertTtl, + hostCertTtl, + loginMappings, + userSshCaId, + hostSshCaId + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.SshHost}: FindSshHostsWithPrincipalsAcrossProjects` }); + } + }; + + const findSshHostsWithLoginMappings = async (projectId: string, tx?: Knex) => { + try { + const rows = await (tx || db.replicaNode())(TableName.SshHost) + .leftJoin(TableName.SshHostLoginUser, `${TableName.SshHost}.id`, `${TableName.SshHostLoginUser}.sshHostId`) + .leftJoin( + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` + ) + .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .where(`${TableName.SshHost}.projectId`, projectId) + .select( + db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), + db.ref("projectId").withSchema(TableName.SshHost), + db.ref("hostname").withSchema(TableName.SshHost), + db.ref("userCertTtl").withSchema(TableName.SshHost), + db.ref("hostCertTtl").withSchema(TableName.SshHost), + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("username").withSchema(TableName.Users), + db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), + db.ref("userSshCaId").withSchema(TableName.SshHost), + db.ref("hostSshCaId").withSchema(TableName.SshHost) + ) + .orderBy(`${TableName.SshHost}.updatedAt`, "desc"); + + const hostsGrouped = groupBy(rows, (r) => r.sshHostId); + return Object.values(hostsGrouped).map((hostRows) => { + const { sshHostId, hostname, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = hostRows[0]; + + const loginMappingGrouped = groupBy( + hostRows.filter((r) => r.loginUser), + (r) => r.loginUser + ); + + const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ + loginUser, + allowedPrincipals: { + usernames: unique(entries.map((e) => e.username)).filter(Boolean) + } + })); + + return { + id: sshHostId, + hostname, + projectId, + userCertTtl, + hostCertTtl, + loginMappings, + userSshCaId, + hostSshCaId + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.SshHost}: FindSshHostsWithLoginMappings` }); + } + }; + + const findSshHostByIdWithLoginMappings = async (sshHostId: string, tx?: Knex) => { + try { + const rows = await (tx || db.replicaNode())(TableName.SshHost) + .leftJoin(TableName.SshHostLoginUser, `${TableName.SshHost}.id`, `${TableName.SshHostLoginUser}.sshHostId`) + .leftJoin( + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` + ) + .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .where(`${TableName.SshHost}.id`, sshHostId) + .select( + db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), + db.ref("projectId").withSchema(TableName.SshHost), + db.ref("hostname").withSchema(TableName.SshHost), + db.ref("userCertTtl").withSchema(TableName.SshHost), + db.ref("hostCertTtl").withSchema(TableName.SshHost), + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("username").withSchema(TableName.Users), + db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), + db.ref("userSshCaId").withSchema(TableName.SshHost), + db.ref("hostSshCaId").withSchema(TableName.SshHost) + ); + + if (rows.length === 0) return null; + + const { sshHostId: id, projectId, hostname, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = rows[0]; + + const loginMappingGrouped = groupBy( + rows.filter((r) => r.loginUser), + (r) => r.loginUser + ); + + const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ + loginUser, + allowedPrincipals: { + usernames: unique(entries.map((e) => e.username)).filter(Boolean) + } + })); + + return { + id, + projectId, + hostname, + userCertTtl, + hostCertTtl, + loginMappings, + userSshCaId, + hostSshCaId + }; + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.SshHost}: FindSshHostByIdWithLoginMappings` }); + } + }; + + return { + ...sshHostOrm, + findSshHostsWithLoginMappings, + findUserAccessibleSshHosts, + findSshHostByIdWithLoginMappings + }; +}; diff --git a/backend/src/ee/services/ssh-host/ssh-host-login-user-mapping-dal.ts b/backend/src/ee/services/ssh-host/ssh-host-login-user-mapping-dal.ts new file mode 100644 index 000000000..0d9e8013b --- /dev/null +++ b/backend/src/ee/services/ssh-host/ssh-host-login-user-mapping-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TSshHostLoginUserMappingDALFactory = ReturnType; + +export const sshHostLoginUserMappingDALFactory = (db: TDbClient) => { + const sshHostLoginUserMappingOrm = ormify(db, TableName.SshHostLoginUserMapping); + return sshHostLoginUserMappingOrm; +}; diff --git a/backend/src/ee/services/ssh-host/ssh-host-schema.ts b/backend/src/ee/services/ssh-host/ssh-host-schema.ts new file mode 100644 index 000000000..4eeb90881 --- /dev/null +++ b/backend/src/ee/services/ssh-host/ssh-host-schema.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +import { SshHostsSchema } from "@app/db/schemas"; + +export const sanitizedSshHost = SshHostsSchema.pick({ + id: true, + projectId: true, + hostname: true, + userCertTtl: true, + hostCertTtl: true, + userSshCaId: true, + hostSshCaId: true +}); + +export const loginMappingSchema = z.object({ + loginUser: z.string().trim(), + allowedPrincipals: z.object({ + usernames: z.array(z.string().trim()).transform((usernames) => Array.from(new Set(usernames))) + }) +}); diff --git a/backend/src/ee/services/ssh-host/ssh-host-service.ts b/backend/src/ee/services/ssh-host/ssh-host-service.ts new file mode 100644 index 000000000..69807431a --- /dev/null +++ b/backend/src/ee/services/ssh-host/ssh-host-service.ts @@ -0,0 +1,694 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { ActionProjectType, ProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionSshHostActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; +import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal"; +import { TSshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-body-dal"; +import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; +import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; +import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal"; +import { TSshHostLoginUserMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-user-mapping-dal"; +import { TSshHostLoginUserDALFactory } from "@app/ee/services/ssh-host/ssh-login-user-dal"; +import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectSshConfigDALFactory } from "@app/services/project/project-ssh-config-dal"; +import { TUserDALFactory } from "@app/services/user/user-dal"; + +import { + convertActorToPrincipals, + createSshCert, + createSshKeyPair, + getSshPublicKey +} from "../ssh/ssh-certificate-authority-fns"; +import { SshCertType } from "../ssh/ssh-certificate-authority-types"; +import { + TCreateSshHostDTO, + TDeleteSshHostDTO, + TGetSshHostDTO, + TIssueSshHostHostCertDTO, + TIssueSshHostUserCertDTO, + TListSshHostsDTO, + TUpdateSshHostDTO +} from "./ssh-host-types"; + +type TSshHostServiceFactoryDep = { + userDAL: Pick; + projectDAL: Pick; + projectSshConfigDAL: Pick; + sshCertificateAuthorityDAL: Pick; + sshCertificateAuthoritySecretDAL: Pick; + sshCertificateDAL: Pick; + sshCertificateBodyDAL: Pick; + sshHostDAL: Pick< + TSshHostDALFactory, + | "transaction" + | "create" + | "findById" + | "updateById" + | "deleteById" + | "findOne" + | "findSshHostByIdWithLoginMappings" + | "findUserAccessibleSshHosts" + >; + sshHostLoginUserDAL: TSshHostLoginUserDALFactory; + sshHostLoginUserMappingDAL: TSshHostLoginUserMappingDALFactory; + permissionService: Pick; + kmsService: Pick; +}; + +export type TSshHostServiceFactory = ReturnType; + +export const sshHostServiceFactory = ({ + userDAL, + projectDAL, + projectSshConfigDAL, + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + sshCertificateDAL, + sshCertificateBodyDAL, + sshHostDAL, + sshHostLoginUserMappingDAL, + sshHostLoginUserDAL, + permissionService, + kmsService +}: TSshHostServiceFactoryDep) => { + /** + * Return list of all SSH hosts that a user can issue user SSH certificates for + * (i.e. is able to access / connect to) across all SSH projects in the organization + */ + const listSshHosts = async ({ actorId, actorAuthMethod, actor, actorOrgId }: TListSshHostsDTO) => { + if (actor !== ActorType.USER) { + // (dangtony98): only support user for now + throw new BadRequestError({ message: `Actor type ${actor} not supported` }); + } + + const sshProjects = await projectDAL.find({ + orgId: actorOrgId, + type: ProjectType.SSH + }); + + const allowedHosts = []; + + for await (const project of sshProjects) { + try { + await permissionService.getProjectPermission({ + actor, + actorId, + projectId: project.id, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + const projectHosts = await sshHostDAL.findUserAccessibleSshHosts([project.id], actorId); + + allowedHosts.push(...projectHosts); + } catch { + // intentionally ignore projects where user lacks access + } + } + + return allowedHosts; + }; + + const createSshHost = async ({ + projectId, + hostname, + userCertTtl, + hostCertTtl, + loginMappings, + userSshCaId: requestedUserSshCaId, + hostSshCaId: requestedHostSshCaId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateSshHostDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSshHostActions.Create, + subject(ProjectPermissionSub.SshHosts, { + hostname + }) + ); + + const resolveSshCaId = async ({ + requestedId, + fallbackId, + label + }: { + requestedId?: string; + fallbackId?: string | null; + label: "User" | "Host"; + }) => { + const finalId = requestedId ?? fallbackId; + if (!finalId) { + throw new BadRequestError({ message: `Missing ${label.toLowerCase()} SSH CA` }); + } + + const ca = await sshCertificateAuthorityDAL.findOne({ + id: finalId, + projectId + }); + + if (!ca) { + throw new BadRequestError({ + message: `${label} SSH CA with ID '${finalId}' not found in project '${projectId}'` + }); + } + + return ca.id; + }; + + const projectSshConfig = await projectSshConfigDAL.findOne({ projectId }); + + const userSshCaId = await resolveSshCaId({ + requestedId: requestedUserSshCaId, + fallbackId: projectSshConfig?.defaultUserSshCaId, + label: "User" + }); + + const hostSshCaId = await resolveSshCaId({ + requestedId: requestedHostSshCaId, + fallbackId: projectSshConfig?.defaultHostSshCaId, + label: "Host" + }); + + const newSshHost = await sshHostDAL.transaction(async (tx) => { + const host = await sshHostDAL.create( + { + projectId, + hostname, + userCertTtl, + hostCertTtl, + userSshCaId, + hostSshCaId + }, + tx + ); + + // (dangtony98): room to optimize + for await (const { loginUser, allowedPrincipals } of loginMappings) { + const sshHostLoginUser = await sshHostLoginUserDAL.create( + { + sshHostId: host.id, + loginUser + }, + tx + ); + + if (allowedPrincipals.usernames.length > 0) { + const users = await userDAL.find( + { + $in: { + username: allowedPrincipals.usernames + } + }, + { tx } + ); + + const foundUsernames = new Set(users.map((u) => u.username)); + + for (const uname of allowedPrincipals.usernames) { + if (!foundUsernames.has(uname)) { + throw new BadRequestError({ + message: `Invalid username: ${uname}` + }); + } + } + + for await (const user of users) { + // check that each user has access to the SSH project + await permissionService.getUserProjectPermission({ + userId: user.id, + projectId, + authMethod: actorAuthMethod, + userOrgId: actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + } + + await sshHostLoginUserMappingDAL.insertMany( + users.map((user) => ({ + sshHostLoginUserId: sshHostLoginUser.id, + userId: user.id + })), + tx + ); + } + } + + const newSshHostWithLoginMappings = await sshHostDAL.findSshHostByIdWithLoginMappings(host.id, tx); + if (!newSshHostWithLoginMappings) { + throw new NotFoundError({ message: `SSH host with ID '${host.id}' not found` }); + } + + return newSshHostWithLoginMappings; + }); + + return newSshHost; + }; + + const updateSshHost = async ({ + sshHostId, + hostname, + userCertTtl, + hostCertTtl, + loginMappings, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateSshHostDTO) => { + const host = await sshHostDAL.findById(sshHostId); + if (!host) throw new NotFoundError({ message: `SSH host with ID '${sshHostId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: host.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSshHostActions.Edit, + subject(ProjectPermissionSub.SshHosts, { + hostname: host.hostname + }) + ); + + const updatedHost = await sshHostDAL.transaction(async (tx) => { + await sshHostDAL.updateById( + sshHostId, + { + hostname, + userCertTtl, + hostCertTtl + }, + tx + ); + + if (loginMappings) { + await sshHostLoginUserDAL.delete({ sshHostId: host.id }, tx); + if (loginMappings.length) { + for await (const { loginUser, allowedPrincipals } of loginMappings) { + const sshHostLoginUser = await sshHostLoginUserDAL.create( + { + sshHostId: host.id, + loginUser + }, + tx + ); + + if (allowedPrincipals.usernames.length > 0) { + const users = await userDAL.find( + { + $in: { + username: allowedPrincipals.usernames + } + }, + { tx } + ); + + const foundUsernames = new Set(users.map((u) => u.username)); + + for (const uname of allowedPrincipals.usernames) { + if (!foundUsernames.has(uname)) { + throw new BadRequestError({ + message: `Invalid username: ${uname}` + }); + } + } + + for await (const user of users) { + await permissionService.getUserProjectPermission({ + userId: user.id, + projectId: host.projectId, + authMethod: actorAuthMethod, + userOrgId: actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + } + + await sshHostLoginUserMappingDAL.insertMany( + users.map((user) => ({ + sshHostLoginUserId: sshHostLoginUser.id, + userId: user.id + })), + tx + ); + } + } + } + } + + const updatedHostWithLoginMappings = await sshHostDAL.findSshHostByIdWithLoginMappings(sshHostId, tx); + if (!updatedHostWithLoginMappings) { + throw new NotFoundError({ message: `SSH host with ID '${sshHostId}' not found` }); + } + + return updatedHostWithLoginMappings; + }); + + return updatedHost; + }; + + const deleteSshHost = async ({ sshHostId, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteSshHostDTO) => { + const host = await sshHostDAL.findSshHostByIdWithLoginMappings(sshHostId); + if (!host) throw new NotFoundError({ message: `SSH host with ID '${sshHostId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: host.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSshHostActions.Delete, + subject(ProjectPermissionSub.SshHosts, { + hostname: host.hostname + }) + ); + + await sshHostDAL.deleteById(sshHostId); + + return host; + }; + + const getSshHost = async ({ sshHostId, actorId, actorAuthMethod, actor, actorOrgId }: TGetSshHostDTO) => { + const host = await sshHostDAL.findSshHostByIdWithLoginMappings(sshHostId); + if (!host) { + throw new NotFoundError({ + message: `SSH host with ID ${sshHostId} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: host.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSshHostActions.Read, + subject(ProjectPermissionSub.SshHosts, { + hostname: host.hostname + }) + ); + + return host; + }; + + /** + * Return SSH certificate and corresponding new SSH public-private key pair where + * SSH public key is signed using CA behind SSH certificate with name [templateName]. + * + * Note: Used for issuing SSH credentials as part of request against a specific SSH Host. + */ + const issueSshHostUserCert = async ({ + sshHostId, + loginUser, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TIssueSshHostUserCertDTO) => { + const host = await sshHostDAL.findSshHostByIdWithLoginMappings(sshHostId); + if (!host) { + throw new NotFoundError({ + message: `SSH host with ID ${sshHostId} not found` + }); + } + + await permissionService.getProjectPermission({ + actor, + actorId, + projectId: host.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + const internalPrincipals = await convertActorToPrincipals({ + actor, + actorId, + userDAL + }); + + const mapping = host.loginMappings.find( + (m) => + m.loginUser === loginUser && + m.allowedPrincipals.usernames.some((allowed) => internalPrincipals.includes(allowed)) + ); + + if (!mapping) { + throw new UnauthorizedError({ + message: `You are not allowed to login as ${loginUser} on this host` + }); + } + + const keyId = `${actor}-${actorId}`; + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: host.userSshCaId }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: host.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + // (dangtony98): will support more algorithms in the future + const keyAlgorithm = SshCertKeyAlgorithm.ED25519; + const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm); + + // (dangtony98): include the loginUser as a principal on the issued certificate + const principals = [...internalPrincipals, loginUser]; + + const { serialNumber, signedPublicKey, ttl } = await createSshCert({ + caPrivateKey: decryptedCaPrivateKey.toString("utf8"), + clientPublicKey: publicKey, + keyId, + principals, + requestedTtl: host.userCertTtl, + certType: SshCertType.USER + }); + + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: host.projectId + }); + + const encryptedCertificate = secretManagerEncryptor({ + plainText: Buffer.from(signedPublicKey, "utf8") + }).cipherTextBlob; + + await sshCertificateDAL.transaction(async (tx) => { + const cert = await sshCertificateDAL.create( + { + sshCaId: host.userSshCaId, + sshHostId: host.id, + serialNumber, + certType: SshCertType.USER, + principals, + keyId, + notBefore: new Date(), + notAfter: new Date(Date.now() + ttl * 1000) + }, + tx + ); + + await sshCertificateBodyDAL.create( + { + sshCertId: cert.id, + encryptedCertificate + }, + tx + ); + }); + + return { + host, + principals, + serialNumber, + signedPublicKey, + privateKey, + publicKey, + ttl, + keyAlgorithm + }; + }; + + const issueSshHostHostCert = async ({ + sshHostId, + publicKey, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TIssueSshHostHostCertDTO) => { + const host = await sshHostDAL.findSshHostByIdWithLoginMappings(sshHostId); + if (!host) { + throw new NotFoundError({ + message: `SSH host with ID ${sshHostId} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: host.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSshHostActions.IssueHostCert, + subject(ProjectPermissionSub.SshHosts, { + hostname: host.hostname + }) + ); + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: host.hostSshCaId }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: host.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const principals = [host.hostname]; + const keyId = `host-${host.id}`; + + const { serialNumber, signedPublicKey, ttl } = await createSshCert({ + caPrivateKey: decryptedCaPrivateKey.toString("utf8"), + clientPublicKey: publicKey, + keyId, + principals, + requestedTtl: host.hostCertTtl, + certType: SshCertType.HOST + }); + + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: host.projectId + }); + + const encryptedCertificate = secretManagerEncryptor({ + plainText: Buffer.from(signedPublicKey, "utf8") + }).cipherTextBlob; + + await sshCertificateDAL.transaction(async (tx) => { + const cert = await sshCertificateDAL.create( + { + sshCaId: host.hostSshCaId, + sshHostId: host.id, + serialNumber, + certType: SshCertType.HOST, + principals, + keyId, + notBefore: new Date(), + notAfter: new Date(Date.now() + ttl * 1000) + }, + tx + ); + + await sshCertificateBodyDAL.create( + { + sshCertId: cert.id, + encryptedCertificate + }, + tx + ); + }); + + return { host, principals, serialNumber, signedPublicKey }; + }; + + const getSshHostUserCaPk = async (sshHostId: string) => { + const host = await sshHostDAL.findById(sshHostId); + if (!host) { + throw new NotFoundError({ + message: `SSH host with ID ${sshHostId} not found` + }); + } + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: host.userSshCaId }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: host.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + + return publicKey; + }; + + const getSshHostHostCaPk = async (sshHostId: string) => { + const host = await sshHostDAL.findById(sshHostId); + if (!host) { + throw new NotFoundError({ + message: `SSH host with ID ${sshHostId} not found` + }); + } + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: host.hostSshCaId }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: host.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + + return publicKey; + }; + + return { + listSshHosts, + createSshHost, + updateSshHost, + deleteSshHost, + getSshHost, + issueSshHostUserCert, + issueSshHostHostCert, + getSshHostUserCaPk, + getSshHostHostCaPk + }; +}; diff --git a/backend/src/ee/services/ssh-host/ssh-host-types.ts b/backend/src/ee/services/ssh-host/ssh-host-types.ts new file mode 100644 index 000000000..0c7cb25e1 --- /dev/null +++ b/backend/src/ee/services/ssh-host/ssh-host-types.ts @@ -0,0 +1,48 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TListSshHostsDTO = Omit; + +export type TCreateSshHostDTO = { + hostname: string; + userCertTtl: string; + hostCertTtl: string; + loginMappings: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; + userSshCaId?: string; + hostSshCaId?: string; +} & TProjectPermission; + +export type TUpdateSshHostDTO = { + sshHostId: string; + hostname?: string; + userCertTtl?: string; + hostCertTtl?: string; + loginMappings?: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; +} & Omit; + +export type TGetSshHostDTO = { + sshHostId: string; +} & Omit; + +export type TDeleteSshHostDTO = { + sshHostId: string; +} & Omit; + +export type TIssueSshHostUserCertDTO = { + sshHostId: string; + loginUser: string; +} & Omit; + +export type TIssueSshHostHostCertDTO = { + sshHostId: string; + publicKey: string; +} & Omit; diff --git a/backend/src/ee/services/ssh-host/ssh-host-validators.ts b/backend/src/ee/services/ssh-host/ssh-host-validators.ts new file mode 100644 index 000000000..7b739b9cb --- /dev/null +++ b/backend/src/ee/services/ssh-host/ssh-host-validators.ts @@ -0,0 +1,15 @@ +import { isFQDN } from "@app/lib/validator/validate-url"; + +export const isValidHostname = (value: string): boolean => { + if (typeof value !== "string") return false; + if (value.length > 255) return false; + + // Only allow strict FQDNs, no wildcards or IPs + return isFQDN(value, { + require_tld: true, + allow_underscores: false, + allow_trailing_dot: false, + allow_numeric_tld: true, + allow_wildcard: false + }); +}; diff --git a/backend/src/ee/services/ssh-host/ssh-login-user-dal.ts b/backend/src/ee/services/ssh-host/ssh-login-user-dal.ts new file mode 100644 index 000000000..88a9bf59a --- /dev/null +++ b/backend/src/ee/services/ssh-host/ssh-login-user-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TSshHostLoginUserDALFactory = ReturnType; + +export const sshHostLoginUserDALFactory = (db: TDbClient) => { + const sshHostLoginUserOrm = ormify(db, TableName.SshHostLoginUser); + return sshHostLoginUserOrm; +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts index deb77cecc..92f946747 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts @@ -1,21 +1,31 @@ import { execFile } from "child_process"; import crypto from "crypto"; import { promises as fs } from "fs"; +import { Knex } from "knex"; import os from "os"; import path from "path"; import { promisify } from "util"; import { TSshCertificateTemplates } from "@app/db/schemas"; +import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { BadRequestError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; -import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; +import { ActorType } from "@app/services/auth/auth-type"; +import { KmsDataKey } from "@app/services/kms/kms-types"; import { isValidHostPattern, isValidUserPattern } from "../ssh-certificate-template/ssh-certificate-template-validators"; -import { SshCertType, TCreateSshCertDTO } from "./ssh-certificate-authority-types"; +import { + SshCaKeySource, + SshCaStatus, + SshCertType, + TConvertActorToPrincipalsDTO, + TCreateSshCaHelperDTO, + TCreateSshCertDTO +} from "./ssh-certificate-authority-types"; const execFileAsync = promisify(execFile); @@ -31,31 +41,35 @@ export const createSshCertSerialNumber = () => { * Return a pair of SSH CA keys based on the specified key algorithm [keyAlgorithm]. * We use this function because the key format generated by `ssh-keygen` is unique. */ -export const createSshKeyPair = async (keyAlgorithm: CertKeyAlgorithm) => { +export const createSshKeyPair = async (keyAlgorithm: SshCertKeyAlgorithm) => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-key-")); const privateKeyFile = path.join(tempDir, "id_key"); const publicKeyFile = `${privateKeyFile}.pub`; let keyType: string; - let keyBits: string; + let keyBits: string | null; switch (keyAlgorithm) { - case CertKeyAlgorithm.RSA_2048: + case SshCertKeyAlgorithm.RSA_2048: keyType = "rsa"; keyBits = "2048"; break; - case CertKeyAlgorithm.RSA_4096: + case SshCertKeyAlgorithm.RSA_4096: keyType = "rsa"; keyBits = "4096"; break; - case CertKeyAlgorithm.ECDSA_P256: + case SshCertKeyAlgorithm.ECDSA_P256: keyType = "ecdsa"; keyBits = "256"; break; - case CertKeyAlgorithm.ECDSA_P384: + case SshCertKeyAlgorithm.ECDSA_P384: keyType = "ecdsa"; keyBits = "384"; break; + case SshCertKeyAlgorithm.ED25519: + keyType = "ed25519"; + keyBits = null; + break; default: throw new BadRequestError({ message: "Failed to produce SSH CA key pair generation command due to unrecognized key algorithm" @@ -63,10 +77,16 @@ export const createSshKeyPair = async (keyAlgorithm: CertKeyAlgorithm) => { } try { + const args = ["-t", keyType]; + if (keyBits !== null) { + args.push("-b", keyBits); + } + args.push("-f", privateKeyFile, "-N", ""); + // Generate the SSH key pair // The "-N ''" sets an empty passphrase // The keys are created in the temporary directory - await execFileAsync("ssh-keygen", ["-t", keyType, "-b", keyBits, "-f", privateKeyFile, "-N", ""], { + await execFileAsync("ssh-keygen", args, { timeout: EXEC_TIMEOUT_MS }); @@ -280,7 +300,12 @@ export const validateSshCertificateTtl = (template: TSshCertificateTemplates, tt * that it only contains alphanumeric characters with no spaces. */ export const validateSshCertificateKeyId = (keyId: string) => { - const regex = characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen]); + const regex = characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Hyphen, + CharacterType.Colon, + CharacterType.Period + ]); if (!regex(keyId)) { throw new BadRequestError({ message: @@ -322,6 +347,96 @@ const validateSshPublicKey = async (publicKey: string) => { } }; +export const getKeyAlgorithmFromFingerprintOutput = (output: string): SshCertKeyAlgorithm | undefined => { + const parts = output.trim().split(" "); + const bitsInt = parseInt(parts[0], 10); + const keyTypeRaw = parts.at(-1)?.replace(/[()]/g, ""); // remove surrounding parentheses + + if (keyTypeRaw === "RSA") { + return bitsInt === 2048 ? SshCertKeyAlgorithm.RSA_2048 : SshCertKeyAlgorithm.RSA_4096; + } + + if (keyTypeRaw === "ECDSA") { + return bitsInt === 256 ? SshCertKeyAlgorithm.ECDSA_P256 : SshCertKeyAlgorithm.ECDSA_P384; + } + + if (keyTypeRaw === "ED25519") { + return SshCertKeyAlgorithm.ED25519; + } + + return undefined; +}; + +export const normalizeSshPrivateKey = (raw: string): string => { + return `${raw + .replace(/\r\n/g, "\n") // Windows CRLF → LF + .replace(/\r/g, "\n") // Old Mac CR → LF + .replace(/\\n/g, "\n") // Double-escaped \n + .trim()}\n`; +}; + +/** + * Validate the format of the SSH private key + * + * Returns the SSH public key corresponding to the private key + * and the key algorithm categorization. + */ +export const validateSshPrivateKey = async (privateKey: string) => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-privkey-")); + const privateKeyFile = path.join(tempDir, "id_key"); + + try { + await fs.writeFile(privateKeyFile, privateKey, { + encoding: "utf8", + mode: 0o600 + }); + + // This will fail if the private key is malformed or unreadable + const { stdout: publicKey } = await execFileAsync("ssh-keygen", ["-y", "-f", privateKeyFile], { + timeout: EXEC_TIMEOUT_MS + }); + + const { stdout: fingerprint } = await execFileAsync("ssh-keygen", ["-lf", privateKeyFile]); + const keyAlgorithm = getKeyAlgorithmFromFingerprintOutput(fingerprint); + + if (!keyAlgorithm) { + throw new BadRequestError({ + message: "Failed to validate SSH private key format: The key algorithm is not supported." + }); + } + + return { + publicKey, + keyAlgorithm + }; + } catch (err) { + throw new BadRequestError({ + message: "Failed to validate SSH private key format: could not be parsed." + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +}; + +/** + * Validate that the provided public and private keys are valid and constitute + * a matching SSH key pair. + */ +export const validateExternalSshCaKeyPair = async (publicKey: string, privateKey: string) => { + await validateSshPublicKey(publicKey); + + const { publicKey: derivedPublicKey, keyAlgorithm } = await validateSshPrivateKey(privateKey); + + if (publicKey.trim() !== derivedPublicKey.trim()) { + throw new BadRequestError({ + message: + "Failed to validate matching SSH key pair: The provided public key does not match the public key derived from the private key." + }); + } + + return keyAlgorithm; +}; + /** * Create an SSH certificate for a user or host. */ @@ -331,17 +446,32 @@ export const createSshCert = async ({ clientPublicKey, keyId, principals, - requestedTtl, + requestedTtl, // in ms lib format certType }: TCreateSshCertDTO) => { - // validate if the requested [certType] is allowed under the template configuration - validateSshCertificateType(template, certType); + let ttl: number | undefined; - // validate if the requested [principals] are valid for the given [certType] under the template configuration - validateSshCertificatePrincipals(certType, template, principals); + if (!template && requestedTtl) { + const parsedTtl = Math.ceil(ms(requestedTtl) / 1000); + if (parsedTtl > 0) ttl = parsedTtl; + } - // validate if the requested TTL is valid under the template configuration - const ttl = validateSshCertificateTtl(template, requestedTtl); + if (template) { + // validate if the requested [certType] is allowed under the template configuration + validateSshCertificateType(template, certType); + + // validate if the requested [principals] are valid for the given [certType] under the template configuration + validateSshCertificatePrincipals(certType, template, principals); + + // validate if the requested TTL is valid under the template configuration + ttl = validateSshCertificateTtl(template, requestedTtl); + } + + if (!ttl) { + throw new BadRequestError({ + message: "Failed to create SSH certificate due to missing TTL" + }); + } validateSshCertificateKeyId(keyId); await validateSshPublicKey(clientPublicKey); @@ -388,3 +518,88 @@ export const createSshCert = async ({ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); } }; + +export const createSshCaHelper = async ({ + projectId, + friendlyName, + keyAlgorithm: requestedKeyAlgorithm, + keySource, + externalPk, + externalSk, + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + kmsService, + tx: outerTx +}: TCreateSshCaHelperDTO) => { + // Function to handle the actual creation logic + const processCreation = async (tx: Knex) => { + let publicKey: string; + let privateKey: string; + let keyAlgorithm: SshCertKeyAlgorithm = requestedKeyAlgorithm; + if (keySource === SshCaKeySource.INTERNAL) { + // generate SSH CA key pair internally + ({ publicKey, privateKey } = await createSshKeyPair(requestedKeyAlgorithm)); + } else { + // use external SSH CA key pair + if (!externalPk || !externalSk) { + throw new BadRequestError({ + message: "Public and private keys are required when key source is external" + }); + } + publicKey = externalPk; + privateKey = externalSk; + keyAlgorithm = await validateExternalSshCaKeyPair(publicKey, privateKey); + } + const ca = await sshCertificateAuthorityDAL.create( + { + projectId, + friendlyName, + status: SshCaStatus.ACTIVE, + keyAlgorithm, + keySource + }, + tx + ); + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey( + { + type: KmsDataKey.SecretManager, + projectId + }, + tx + ); + await sshCertificateAuthoritySecretDAL.create( + { + sshCaId: ca.id, + encryptedPrivateKey: secretManagerEncryptor({ plainText: Buffer.from(privateKey, "utf8") }).cipherTextBlob + }, + tx + ); + return { ...ca, publicKey }; + }; + + if (outerTx) { + return processCreation(outerTx); + } + + return sshCertificateAuthorityDAL.transaction(processCreation); +}; + +/** + * Convert an actor to a list of principals to be included in an SSH certificate. + * + * (dangtony98): This function is only supported for user actors at the moment and returns + * only the email of the associated user. In the future, we will consider other + * actor types and attributes such as group membership slugs and/or metadata to be + * included in the list of principals. + */ +export const convertActorToPrincipals = async ({ userDAL, actor, actorId }: TConvertActorToPrincipalsDTO) => { + if (actor !== ActorType.USER) { + throw new BadRequestError({ + message: "Failed to convert actor to principals due to unsupported actor type" + }); + } + + const user = await userDAL.findById(actorId); + + return [user.username]; +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts index 9ff76efbc..af66e83ca 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts @@ -5,5 +5,6 @@ export const sanitizedSshCa = SshCertificateAuthoritiesSchema.pick({ projectId: true, friendlyName: true, status: true, - keyAlgorithm: true + keyAlgorithm: true, + keySource: true }); diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts index d7ca511e4..312b7966b 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts @@ -13,7 +13,7 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { SshCertTemplateStatus } from "../ssh-certificate-template/ssh-certificate-template-types"; -import { createSshCert, createSshKeyPair, getSshPublicKey } from "./ssh-certificate-authority-fns"; +import { createSshCaHelper, createSshCert, createSshKeyPair, getSshPublicKey } from "./ssh-certificate-authority-fns"; import { SshCaStatus, TCreateSshCaDTO, @@ -59,7 +59,10 @@ export const sshCertificateAuthorityServiceFactory = ({ const createSshCa = async ({ projectId, friendlyName, - keyAlgorithm, + keyAlgorithm: requestedKeyAlgorithm, + publicKey: externalPk, + privateKey: externalSk, + keySource, actorId, actorAuthMethod, actor, @@ -79,33 +82,16 @@ export const sshCertificateAuthorityServiceFactory = ({ ProjectPermissionSub.SshCertificateAuthorities ); - const newCa = await sshCertificateAuthorityDAL.transaction(async (tx) => { - const ca = await sshCertificateAuthorityDAL.create( - { - projectId, - friendlyName, - status: SshCaStatus.ACTIVE, - keyAlgorithm - }, - tx - ); - - const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm); - - const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.SecretManager, - projectId - }); - - await sshCertificateAuthoritySecretDAL.create( - { - sshCaId: ca.id, - encryptedPrivateKey: secretManagerEncryptor({ plainText: Buffer.from(privateKey, "utf8") }).cipherTextBlob - }, - tx - ); - - return { ...ca, publicKey }; + const newCa = await createSshCaHelper({ + projectId, + friendlyName, + keyAlgorithm: requestedKeyAlgorithm, + keySource, + externalPk, + externalSk, + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + kmsService }); return newCa; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts index 3f202ebf0..d433bd5ad 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts @@ -1,12 +1,24 @@ +import { Knex } from "knex"; + import { TSshCertificateTemplates } from "@app/db/schemas"; +import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; +import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal"; +import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { TProjectPermission } from "@app/lib/types"; -import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TUserDALFactory } from "@app/services/user/user-dal"; export enum SshCaStatus { ACTIVE = "active", DISABLED = "disabled" } +export enum SshCaKeySource { + INTERNAL = "internal", + EXTERNAL = "external" +} + export enum SshCertType { USER = "user", HOST = "host" @@ -14,9 +26,25 @@ export enum SshCertType { export type TCreateSshCaDTO = { friendlyName: string; - keyAlgorithm: CertKeyAlgorithm; + keyAlgorithm: SshCertKeyAlgorithm; + publicKey?: string; + privateKey?: string; + keySource: SshCaKeySource; } & TProjectPermission; +export type TCreateSshCaHelperDTO = { + projectId: string; + friendlyName: string; + keyAlgorithm: SshCertKeyAlgorithm; + keySource: SshCaKeySource; + externalPk?: string; + externalSk?: string; + sshCertificateAuthorityDAL: Pick; + sshCertificateAuthoritySecretDAL: Pick; + kmsService: Pick; + tx?: Knex; +}; + export type TGetSshCaDTO = { caId: string; } & Omit; @@ -37,7 +65,7 @@ export type TDeleteSshCaDTO = { export type TIssueSshCredsDTO = { certificateTemplateId: string; - keyAlgorithm: CertKeyAlgorithm; + keyAlgorithm: SshCertKeyAlgorithm; certType: SshCertType; principals: string[]; ttl?: string; @@ -58,7 +86,7 @@ export type TGetSshCaCertificateTemplatesDTO = { } & Omit; export type TCreateSshCertDTO = { - template: TSshCertificateTemplates; + template?: TSshCertificateTemplates; caPrivateKey: string; clientPublicKey: string; keyId: string; @@ -66,3 +94,9 @@ export type TCreateSshCertDTO = { requestedTtl?: string; certType: SshCertType; }; + +export type TConvertActorToPrincipalsDTO = { + actor: ActorType; + actorId: string; + userDAL: Pick; +}; diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 8fef532f5..ac28e9ade 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -77,6 +77,8 @@ export const keyStoreFactory = (redisUrl: string) => { const incrementBy = async (key: string, value: number) => redis.incrby(key, value); + const setExpiry = async (key: string, expiryInSeconds: number) => redis.expire(key, expiryInSeconds); + const waitTillReady = async ({ key, waitingCb, @@ -103,6 +105,7 @@ export const keyStoreFactory = (redisUrl: string) => { return { setItem, getItem, + setExpiry, setItemWithExpiry, deleteItem, incrementBy, diff --git a/backend/src/keystore/memory.ts b/backend/src/keystore/memory.ts index 1fe78cf7e..10b28ffec 100644 --- a/backend/src/keystore/memory.ts +++ b/backend/src/keystore/memory.ts @@ -10,6 +10,7 @@ export const inMemoryKeyStore = (): TKeyStoreFactory => { store[key] = value; return "OK"; }, + setExpiry: async () => 0, setItemWithExpiry: async (key, value) => { store[key] = value; return "OK"; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index ea5c07789..7c0f47efe 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -66,6 +66,17 @@ export const IDENTITIES = { }, LIST: { orgId: "The ID of the organization to list identities." + }, + SEARCH: { + search: { + desc: "The filters to apply to the search.", + name: "The name of the identity to filter by.", + role: "The organizational role of the identity to filter by." + }, + offset: "The offset to start from. If you enter 10, it will start from the 10th identity.", + limit: "The number of identities to return.", + orderBy: "The column to order identities by.", + orderDirection: "The direction to order identities in." } } as const; @@ -508,6 +519,9 @@ export const PROJECTS = { LIST_SSH_CAS: { projectId: "The ID of the project to list SSH CAs for." }, + LIST_SSH_HOSTS: { + projectId: "The ID of the project to list SSH hosts for." + }, LIST_SSH_CERTIFICATES: { projectId: "The ID of the project to list SSH certificates for.", offset: "The offset to start from. If you enter 10, it will start from the 10th SSH certificate.", @@ -1242,7 +1256,11 @@ export const SSH_CERTIFICATE_AUTHORITIES = { CREATE: { projectId: "The ID of the project to create the SSH CA in.", friendlyName: "A friendly name for the SSH CA.", - keyAlgorithm: "The type of public key algorithm and size, in bits, of the key pair for the SSH CA." + keyAlgorithm: + "The type of public key algorithm and size, in bits, of the key pair for the SSH CA; required if keySource is internal.", + publicKey: "The public key for the SSH CA key pair; required if keySource is external.", + privateKey: "The private key for the SSH CA key pair; required if keySource is external.", + keySource: "The source of the SSH CA key pair. This can be one of internal or external." }, GET: { sshCaId: "The ID of the SSH CA to get." @@ -1316,6 +1334,62 @@ export const SSH_CERTIFICATE_TEMPLATES = { } }; +export const SSH_HOSTS = { + GET: { + sshHostId: "The ID of the SSH host to get." + }, + CREATE: { + projectId: "The ID of the project to create the SSH host in.", + hostname: "The hostname of the SSH host.", + userCertTtl: "The time to live for user certificates issued under this host.", + hostCertTtl: "The time to live for host certificates issued under this host.", + loginUser: "A login user on the remote machine (e.g. 'ec2-user', 'deploy', 'admin')", + allowedPrincipals: "A list of allowed principals that can log in as the login user.", + loginMappings: + "A list of login mappings for the SSH host. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users in the Infisical SSH project.", + userSshCaId: + "The ID of the SSH CA to use for user certificates. If not specified, the default user SSH CA will be used if it exists.", + hostSshCaId: + "The ID of the SSH CA to use for host certificates. If not specified, the default host SSH CA will be used if it exists." + }, + UPDATE: { + sshHostId: "The ID of the SSH host to update.", + hostname: "The hostname of the SSH host to update to.", + userCertTtl: "The time to live for user certificates issued under this host to update to.", + hostCertTtl: "The time to live for host certificates issued under this host to update to.", + loginUser: "A login user on the remote machine (e.g. 'ec2-user', 'deploy', 'admin')", + allowedPrincipals: "A list of allowed principals that can log in as the login user.", + loginMappings: + "A list of login mappings for the SSH host. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users in the Infisical SSH project." + }, + DELETE: { + sshHostId: "The ID of the SSH host to delete." + }, + ISSUE_SSH_CREDENTIALS: { + sshHostId: "The ID of the SSH host to issue the SSH credentials for.", + loginUser: "The login user to issue the SSH credentials for.", + keyAlgorithm: "The type of public key algorithm and size, in bits, of the key pair for the SSH host.", + serialNumber: "The serial number of the issued SSH certificate.", + signedKey: "The SSH certificate or signed SSH public key.", + privateKey: "The private key corresponding to the issued SSH certificate.", + publicKey: "The public key of the issued SSH certificate." + }, + ISSUE_HOST_CERT: { + sshHostId: "The ID of the SSH host to issue the SSH certificate for.", + publicKey: "The SSH public key to issue the SSH certificate for.", + serialNumber: "The serial number of the issued SSH certificate.", + signedKey: "The SSH certificate or signed SSH public key." + }, + GET_USER_CA_PUBLIC_KEY: { + sshHostId: "The ID of the SSH host to get the user SSH CA public key for.", + publicKey: "The public key of the user SSH CA linked to the SSH host." + }, + GET_HOST_CA_PUBLIC_KEY: { + sshHostId: "The ID of the SSH host to get the host SSH CA public key for.", + publicKey: "The public key of the host SSH CA linked to the SSH host." + } +}; + export const CERTIFICATE_AUTHORITIES = { CREATE: { projectSlug: "Slug of the project to create the CA in.", @@ -1598,7 +1672,8 @@ export const KMS = { projectId: "The ID of the project to create the key in.", name: "The name of the key to be created. Must be slug-friendly.", description: "An optional description of the key.", - encryptionAlgorithm: "The algorithm to use when performing cryptographic operations with the key." + encryptionAlgorithm: "The algorithm to use when performing cryptographic operations with the key.", + type: "The type of key to be created, either encrypt-decrypt or sign-verify, based on your intended use for the key." }, UPDATE_KEY: { keyId: "The ID of the key to be updated.", @@ -1631,6 +1706,28 @@ export const KMS = { DECRYPT: { keyId: "The ID of the key to decrypt the data with.", ciphertext: "The ciphertext to be decrypted (base64 encoded)." + }, + + LIST_SIGNING_ALGORITHMS: { + keyId: "The ID of the key to list the signing algorithms for. The key must be for signing and verifying." + }, + + GET_PUBLIC_KEY: { + keyId: "The ID of the key to get the public key for. The key must be for signing and verifying." + }, + + SIGN: { + keyId: "The ID of the key to sign the data with.", + data: "The data in string format to be signed (base64 encoded).", + isDigest: + "Whether the data is already digested or not. Please be aware that if you are passing a digest the algorithm used to create the digest must match the signing algorithm used to sign the digest.", + signingAlgorithm: "The algorithm to use when performing cryptographic operations with the key." + }, + VERIFY: { + keyId: "The ID of the key to verify the data with.", + data: "The data in string format to be verified (base64 encoded). For data larger than 4096 bytes you must first create a digest of the data and then pass the digest in the data parameter.", + signature: "The signature to be verified (base64 encoded).", + isDigest: "Whether the data is already digested or not." } }; @@ -1694,6 +1791,16 @@ export const AppConnections = { sslEnabled: "Whether or not to use SSL when connecting to the database.", sslRejectUnauthorized: "Whether or not to reject unauthorized SSL certificates.", sslCertificate: "The SSL certificate to use for connection." + }, + TERRAFORM_CLOUD: { + apiToken: "The API token to use to connect with Terraform Cloud." + }, + VERCEL: { + apiToken: "The API token used to authenticate with Vercel." + }, + CAMUNDA: { + clientId: "The client ID used to authenticate with Camunda.", + clientSecret: "The client secret used to authenticate with Camunda." } } }; @@ -1804,11 +1911,31 @@ export const SecretSyncs = { DATABRICKS: { scope: "The Databricks secret scope that secrets should be synced to." }, + CAMUNDA: { + scope: "The Camunda scope that secrets should be synced to.", + clusterUUID: "The UUID of the Camunda cluster that secrets should be synced to." + }, HUMANITEC: { app: "The ID of the Humanitec app to sync secrets to.", org: "The ID of the Humanitec org to sync secrets to.", env: "The ID of the Humanitec environment to sync secrets to.", scope: "The Humanitec scope that secrets should be synced to." + }, + TERRAFORM_CLOUD: { + org: "The ID of the Terraform Cloud org to sync secrets to.", + variableSetName: "The name of the Terraform Cloud Variable Set to sync secrets to.", + variableSetId: "The ID of the Terraform Cloud Variable Set to sync secrets to.", + workspaceName: "The name of the Terraform Cloud workspace to sync secrets to.", + workspaceId: "The ID of the Terraform Cloud workspace to sync secrets to.", + scope: "The Terraform Cloud scope that secrets should be synced to.", + category: "The Terraform Cloud category that secrets should be synced to." + }, + VERCEL: { + app: "The ID of the Vercel app to sync secrets to.", + appName: "The name of the Vercel app to sync secrets to.", + env: "The ID of the Vercel environment to sync secrets to.", + branch: "The branch to sync preview secrets to.", + teamId: "The ID of the Vercel team to sync secrets to." } } }; diff --git a/backend/src/lib/crypto/cache.ts b/backend/src/lib/crypto/cache.ts new file mode 100644 index 000000000..9f36d360b --- /dev/null +++ b/backend/src/lib/crypto/cache.ts @@ -0,0 +1,10 @@ +import crypto from "node:crypto"; + +export const generateCacheKeyFromData = (data: unknown) => + crypto + .createHash("md5") + .update(JSON.stringify(data)) + .digest("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); diff --git a/backend/src/lib/crypto/cipher/cipher.ts b/backend/src/lib/crypto/cipher/cipher.ts index 7bc16b470..718c8ad5e 100644 --- a/backend/src/lib/crypto/cipher/cipher.ts +++ b/backend/src/lib/crypto/cipher/cipher.ts @@ -1,6 +1,6 @@ import crypto from "crypto"; -import { SymmetricEncryption, TSymmetricEncryptionFns } from "./types"; +import { SymmetricKeyAlgorithm, TSymmetricEncryptionFns } from "./types"; const getIvLength = () => { return 12; @@ -10,7 +10,9 @@ const getTagLength = () => { return 16; }; -export const symmetricCipherService = (type: SymmetricEncryption): TSymmetricEncryptionFns => { +export const symmetricCipherService = ( + type: SymmetricKeyAlgorithm.AES_GCM_128 | SymmetricKeyAlgorithm.AES_GCM_256 +): TSymmetricEncryptionFns => { const IV_LENGTH = getIvLength(); const TAG_LENGTH = getTagLength(); diff --git a/backend/src/lib/crypto/cipher/index.ts b/backend/src/lib/crypto/cipher/index.ts index 41dbcf639..27373a009 100644 --- a/backend/src/lib/crypto/cipher/index.ts +++ b/backend/src/lib/crypto/cipher/index.ts @@ -1,2 +1,2 @@ export { symmetricCipherService } from "./cipher"; -export { SymmetricEncryption } from "./types"; +export { AllowedEncryptionKeyAlgorithms, SymmetricKeyAlgorithm } from "./types"; diff --git a/backend/src/lib/crypto/cipher/types.ts b/backend/src/lib/crypto/cipher/types.ts index f490d6a66..e2f63ce5e 100644 --- a/backend/src/lib/crypto/cipher/types.ts +++ b/backend/src/lib/crypto/cipher/types.ts @@ -1,7 +1,18 @@ -export enum SymmetricEncryption { +import { z } from "zod"; + +import { AsymmetricKeyAlgorithm } from "../sign/types"; + +// Supported symmetric encrypt/decrypt algorithms +export enum SymmetricKeyAlgorithm { AES_GCM_256 = "aes-256-gcm", AES_GCM_128 = "aes-128-gcm" } +export const SymmetricKeyAlgorithmEnum = z.enum(Object.values(SymmetricKeyAlgorithm) as [string, ...string[]]).options; + +export const AllowedEncryptionKeyAlgorithms = z.enum([ + ...Object.values(SymmetricKeyAlgorithm), + ...Object.values(AsymmetricKeyAlgorithm) +] as [string, ...string[]]).options; export type TSymmetricEncryptionFns = { encrypt: (text: Buffer, key: Buffer) => Buffer; diff --git a/backend/src/lib/crypto/sign/index.ts b/backend/src/lib/crypto/sign/index.ts new file mode 100644 index 000000000..5680cd27a --- /dev/null +++ b/backend/src/lib/crypto/sign/index.ts @@ -0,0 +1,2 @@ +export { signingService } from "./signing"; +export { AsymmetricKeyAlgorithm, SigningAlgorithm } from "./types"; diff --git a/backend/src/lib/crypto/sign/signing.ts b/backend/src/lib/crypto/sign/signing.ts new file mode 100644 index 000000000..7dd71b5f6 --- /dev/null +++ b/backend/src/lib/crypto/sign/signing.ts @@ -0,0 +1,539 @@ +import { execFile } from "child_process"; +import crypto from "crypto"; +import fs from "fs/promises"; +import path from "path"; +import { promisify } from "util"; + +import { BadRequestError } from "@app/lib/errors"; +import { cleanTemporaryDirectory, createTemporaryDirectory, writeToTemporaryFile } from "@app/lib/files"; +import { logger } from "@app/lib/logger"; + +import { AsymmetricKeyAlgorithm, SigningAlgorithm, TAsymmetricSignVerifyFns } from "./types"; + +const execFileAsync = promisify(execFile); + +interface SigningParams { + hashAlgorithm: SupportedHashAlgorithm; + padding?: number; + saltLength?: number; +} + +enum SupportedHashAlgorithm { + SHA256 = "sha256", + SHA384 = "sha384", + SHA512 = "sha512" +} + +const COMMAND_TIMEOUT = 15_000; + +const SHA256_DIGEST_LENGTH = 32; +const SHA384_DIGEST_LENGTH = 48; +const SHA512_DIGEST_LENGTH = 64; + +/** + * Service for cryptographic signing and verification operations using asymmetric keys + * + * @param algorithm The key algorithm itself. The signing algorithm is supplied in the individual sign/verify functions. + * @returns Object with sign and verify functions + */ +export const signingService = (algorithm: AsymmetricKeyAlgorithm): TAsymmetricSignVerifyFns => { + const $getSigningParams = (signingAlgorithm: SigningAlgorithm): SigningParams => { + switch (signingAlgorithm) { + // RSA PSS + case SigningAlgorithm.RSASSA_PSS_SHA_512: + return { + hashAlgorithm: SupportedHashAlgorithm.SHA512, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: SHA512_DIGEST_LENGTH + }; + case SigningAlgorithm.RSASSA_PSS_SHA_256: + return { + hashAlgorithm: SupportedHashAlgorithm.SHA256, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: SHA256_DIGEST_LENGTH + }; + case SigningAlgorithm.RSASSA_PSS_SHA_384: + return { + hashAlgorithm: SupportedHashAlgorithm.SHA384, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: SHA384_DIGEST_LENGTH + }; + + // RSA PKCS#1 v1.5 + case SigningAlgorithm.RSASSA_PKCS1_V1_5_SHA_512: + return { + hashAlgorithm: SupportedHashAlgorithm.SHA512, + padding: crypto.constants.RSA_PKCS1_PADDING + }; + case SigningAlgorithm.RSASSA_PKCS1_V1_5_SHA_384: + return { + hashAlgorithm: SupportedHashAlgorithm.SHA384, + padding: crypto.constants.RSA_PKCS1_PADDING + }; + case SigningAlgorithm.RSASSA_PKCS1_V1_5_SHA_256: + return { + hashAlgorithm: SupportedHashAlgorithm.SHA256, + padding: crypto.constants.RSA_PKCS1_PADDING + }; + + // ECDSA + case SigningAlgorithm.ECDSA_SHA_256: + return { hashAlgorithm: SupportedHashAlgorithm.SHA256 }; + case SigningAlgorithm.ECDSA_SHA_384: + return { hashAlgorithm: SupportedHashAlgorithm.SHA384 }; + case SigningAlgorithm.ECDSA_SHA_512: + return { hashAlgorithm: SupportedHashAlgorithm.SHA512 }; + + default: + throw new Error(`Unsupported signing algorithm: ${signingAlgorithm as string}`); + } + }; + + const $getEcCurveName = (keyAlgorithm: AsymmetricKeyAlgorithm): { full: string; short: string } => { + // We will support more in the future + switch (keyAlgorithm) { + case AsymmetricKeyAlgorithm.ECC_NIST_P256: + return { + full: "prime256v1", + short: "p256" + }; + default: + throw new Error(`Unsupported EC curve: ${keyAlgorithm}`); + } + }; + + const $validateAlgorithmWithKeyType = (signingAlgorithm: SigningAlgorithm) => { + const isRsaKey = algorithm.startsWith("RSA"); + const isEccKey = algorithm.startsWith("ECC"); + + const isRsaAlgorithm = signingAlgorithm.startsWith("RSASSA"); + const isEccAlgorithm = signingAlgorithm.startsWith("ECDSA"); + + if (isRsaKey && !isRsaAlgorithm) { + throw new BadRequestError({ message: `KMS RSA key cannot be used with ${signingAlgorithm}` }); + } + + if (isEccKey && !isEccAlgorithm) { + throw new BadRequestError({ message: `KMS ECC key cannot be used with ${signingAlgorithm}` }); + } + }; + + const $signRsaDigest = async (digest: Buffer, privateKey: Buffer, hashAlgorithm: SupportedHashAlgorithm) => { + const tempDir = await createTemporaryDirectory("kms-rsa-sign"); + const digestPath = path.join(tempDir, "digest.bin"); + const sigPath = path.join(tempDir, "signature.bin"); + const keyPath = path.join(tempDir, "key.pem"); + + try { + await writeToTemporaryFile(digestPath, digest); + await writeToTemporaryFile(keyPath, privateKey); + + const { stderr } = await execFileAsync( + "openssl", + [ + "pkeyutl", + "-sign", + "-in", + digestPath, + "-inkey", + keyPath, + "-pkeyopt", + `digest:${hashAlgorithm}`, + "-out", + sigPath + ], + { + maxBuffer: 10 * 1024 * 1024, + timeout: COMMAND_TIMEOUT + } + ); + + if (stderr) { + logger.error(stderr, "KMS: Failed to sign RSA digest"); + throw new BadRequestError({ + message: "Failed to sign RSA digest due to signing error" + }); + } + const signature = await fs.readFile(sigPath); + + if (!signature) { + throw new BadRequestError({ + message: + "No signature was created. Make sure you are using an appropriate signing algorithm that uses the same hashing algorithm as the one used to create the digest." + }); + } + + return signature; + } finally { + await cleanTemporaryDirectory(tempDir); + } + }; + + const $signEccDigest = async (digest: Buffer, privateKey: Buffer, hashAlgorithm: SupportedHashAlgorithm) => { + const tempDir = await createTemporaryDirectory("ecc-sign"); + const digestPath = path.join(tempDir, "digest.bin"); + const keyPath = path.join(tempDir, "key.pem"); + const sigPath = path.join(tempDir, "signature.bin"); + + try { + await writeToTemporaryFile(digestPath, digest); + await writeToTemporaryFile(keyPath, privateKey); + + const { stderr } = await execFileAsync( + "openssl", + [ + "pkeyutl", + "-sign", + "-in", + digestPath, + "-inkey", + keyPath, + "-pkeyopt", + `digest:${hashAlgorithm}`, + "-out", + sigPath + ], + { + maxBuffer: 10 * 1024 * 1024, + timeout: COMMAND_TIMEOUT + } + ); + + if (stderr) { + logger.error(stderr, "KMS: Failed to sign ECC digest"); + throw new BadRequestError({ + message: "Failed to sign ECC digest due to signing error" + }); + } + + const signature = await fs.readFile(sigPath); + + if (!signature) { + throw new BadRequestError({ + message: + "No signature was created. Make sure you are using an appropriate signing algorithm that uses the same hashing algorithm as the one used to create the digest." + }); + } + + return signature; + } finally { + await cleanTemporaryDirectory(tempDir); + } + }; + + const $verifyEccDigest = async ( + digest: Buffer, + signature: Buffer, + publicKey: Buffer, + hashAlgorithm: SupportedHashAlgorithm + ) => { + const tempDir = await createTemporaryDirectory("ecc-signature-verification"); + const publicKeyFile = path.join(tempDir, "public-key.pem"); + const sigFile = path.join(tempDir, "signature.sig"); + const digestFile = path.join(tempDir, "digest.bin"); + + try { + await writeToTemporaryFile(publicKeyFile, publicKey); + await writeToTemporaryFile(sigFile, signature); + await writeToTemporaryFile(digestFile, digest); + + await execFileAsync( + "openssl", + [ + "pkeyutl", + "-verify", + "-in", + digestFile, + "-inkey", + publicKeyFile, + "-pubin", // Important for EC public keys + "-sigfile", + sigFile, + "-pkeyopt", + `digest:${hashAlgorithm}` + ], + { timeout: COMMAND_TIMEOUT } + ); + + return true; + } catch (error) { + const err = error as { stderr: string }; + + if ( + !err?.stderr?.toLowerCase()?.includes("signature verification failure") && + !err?.stderr?.toLowerCase()?.includes("bad signature") + ) { + logger.error(error, "KMS: Failed to verify ECC signature"); + } + return false; + } finally { + await cleanTemporaryDirectory(tempDir); + } + }; + + const $verifyRsaDigest = async ( + digest: Buffer, + signature: Buffer, + publicKey: Buffer, + hashAlgorithm: SupportedHashAlgorithm + ) => { + const tempDir = await createTemporaryDirectory("kms-signature-verification"); + const publicKeyFile = path.join(tempDir, "public-key.pub"); + const signatureFile = path.join(tempDir, "signature.sig"); + const digestFile = path.join(tempDir, "digest.bin"); + + try { + await writeToTemporaryFile(publicKeyFile, publicKey); + await writeToTemporaryFile(signatureFile, signature); + await writeToTemporaryFile(digestFile, digest); + + await execFileAsync( + "openssl", + [ + "pkeyutl", + "-verify", + "-in", + digestFile, + "-inkey", + publicKeyFile, + "-pubin", + "-sigfile", + signatureFile, + "-pkeyopt", + `digest:${hashAlgorithm}` + ], + { timeout: COMMAND_TIMEOUT } + ); + + // it'll throw if the verification was not successful + return true; + } catch (error) { + const err = error as { stdout: string }; + + if (!err?.stdout?.toLowerCase()?.includes("signature verification failure")) { + logger.error(error, "KMS: Failed to verify signature"); + } + return false; + } finally { + await cleanTemporaryDirectory(tempDir); + } + }; + + const verifyDigestFunctionsMap: Record< + AsymmetricKeyAlgorithm, + (data: Buffer, signature: Buffer, publicKey: Buffer, hashAlgorithm: SupportedHashAlgorithm) => Promise + > = { + [AsymmetricKeyAlgorithm.ECC_NIST_P256]: $verifyEccDigest, + [AsymmetricKeyAlgorithm.RSA_4096]: $verifyRsaDigest + }; + + const signDigestFunctionsMap: Record< + AsymmetricKeyAlgorithm, + (data: Buffer, privateKey: Buffer, hashAlgorithm: SupportedHashAlgorithm) => Promise + > = { + [AsymmetricKeyAlgorithm.ECC_NIST_P256]: $signEccDigest, + [AsymmetricKeyAlgorithm.RSA_4096]: $signRsaDigest + }; + + const sign = async ( + data: Buffer, + privateKey: Buffer, + signingAlgorithm: SigningAlgorithm, + isDigest: boolean + ): Promise => { + $validateAlgorithmWithKeyType(signingAlgorithm); + + const { hashAlgorithm, padding, saltLength } = $getSigningParams(signingAlgorithm); + + if (isDigest) { + if (signingAlgorithm.startsWith("RSASSA_PSS")) { + throw new BadRequestError({ + message: "RSA PSS does not support digested input" + }); + } + + const signFunction = signDigestFunctionsMap[algorithm]; + + if (!signFunction) { + throw new BadRequestError({ + message: `Digested input is not supported for key algorithm ${algorithm}` + }); + } + + const signature = await signFunction(data, privateKey, hashAlgorithm); + return signature; + } + + const privateKeyObject = crypto.createPrivateKey({ + key: privateKey, + format: "pem", + type: "pkcs8" + }); + + // For RSA signatures + if (signingAlgorithm.startsWith("RSA")) { + const signer = crypto.createSign(hashAlgorithm); + signer.update(data); + + return signer.sign({ + key: privateKeyObject, + padding, + ...(signingAlgorithm.includes("PSS") ? { saltLength } : {}) + }); + } + if (signingAlgorithm.startsWith("ECDSA")) { + // For ECDSA signatures + const signer = crypto.createSign(hashAlgorithm); + signer.update(data); + return signer.sign({ + key: privateKeyObject, + dsaEncoding: "der" + }); + } + throw new BadRequestError({ + message: `Signing algorithm ${signingAlgorithm} not implemented` + }); + }; + + const verify = async ( + data: Buffer, + signature: Buffer, + publicKey: Buffer, + signingAlgorithm: SigningAlgorithm, + isDigest: boolean + ): Promise => { + try { + $validateAlgorithmWithKeyType(signingAlgorithm); + + const { hashAlgorithm, padding, saltLength } = $getSigningParams(signingAlgorithm); + + if (isDigest) { + if (signingAlgorithm.startsWith("RSASSA_PSS")) { + throw new BadRequestError({ + message: "RSA PSS does not support digested input" + }); + } + + const verifyFunction = verifyDigestFunctionsMap[algorithm]; + + if (!verifyFunction) { + throw new BadRequestError({ + message: `Digested input is not supported for key algorithm ${algorithm}` + }); + } + + const signatureValid = await verifyFunction(data, signature, publicKey, hashAlgorithm); + + return signatureValid; + } + + const publicKeyObject = crypto.createPublicKey({ + key: publicKey, + format: "der", + type: "spki" + }); + + // For RSA signatures + if (signingAlgorithm.startsWith("RSA")) { + const verifier = crypto.createVerify(hashAlgorithm); + verifier.update(data); + + return verifier.verify( + { + key: publicKeyObject, + padding, + ...(signingAlgorithm.includes("PSS") ? { saltLength } : {}) + }, + signature + ); + } + // For ECDSA signatures + if (signingAlgorithm.startsWith("ECDSA")) { + const verifier = crypto.createVerify(hashAlgorithm); + verifier.update(data); + return verifier.verify( + { + key: publicKeyObject, + dsaEncoding: "der" + }, + signature + ); + } + throw new BadRequestError({ + message: `Verification for algorithm ${signingAlgorithm} not implemented` + }); + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + logger.error(error, "KMS: Failed to verify signature"); + return false; + } + }; + + const generateAsymmetricPrivateKey = async () => { + const { privateKey } = await new Promise<{ privateKey: string }>((resolve, reject) => { + if (algorithm.startsWith("RSA")) { + crypto.generateKeyPair( + "rsa", + { + modulusLength: Number(algorithm.split("_")[1]), + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" } + }, + (err, _, pk) => { + if (err) { + reject(err); + } else { + resolve({ privateKey: pk }); + } + } + ); + } else { + const { full: namedCurve } = $getEcCurveName(algorithm); + + crypto.generateKeyPair( + "ec", + { + namedCurve, + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" } + }, + (err, _, pk) => { + if (err) { + reject(err); + } else { + resolve({ + privateKey: pk + }); + } + } + ); + } + }); + + return Buffer.from(privateKey); + }; + + const getPublicKeyFromPrivateKey = (privateKey: Buffer) => { + const privateKeyObj = crypto.createPrivateKey({ + key: privateKey, + format: "pem", + type: "pkcs8" + }); + + const publicKey = crypto.createPublicKey(privateKeyObj).export({ + type: "spki", + format: "der" + }); + + return publicKey; + }; + + return { + sign, + verify, + generateAsymmetricPrivateKey, + getPublicKeyFromPrivateKey + }; +}; diff --git a/backend/src/lib/crypto/sign/types.ts b/backend/src/lib/crypto/sign/types.ts new file mode 100644 index 000000000..aa81b4057 --- /dev/null +++ b/backend/src/lib/crypto/sign/types.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +export type TAsymmetricSignVerifyFns = { + sign: (data: Buffer, key: Buffer, signingAlgorithm: SigningAlgorithm, isDigest: boolean) => Promise; + verify: ( + data: Buffer, + signature: Buffer, + key: Buffer, + signingAlgorithm: SigningAlgorithm, + isDigest: boolean + ) => Promise; + generateAsymmetricPrivateKey: () => Promise; + getPublicKeyFromPrivateKey: (privateKey: Buffer) => Buffer; +}; + +// Supported asymmetric key types +export enum AsymmetricKeyAlgorithm { + RSA_4096 = "RSA_4096", + ECC_NIST_P256 = "ECC_NIST_P256" +} + +export const AsymmetricKeyAlgorithmEnum = z.enum( + Object.values(AsymmetricKeyAlgorithm) as [string, ...string[]] +).options; + +export enum SigningAlgorithm { + // RSA PSS algorithms + // These are NOT deterministic and include randomness. + // This means that the output signature is different each time for the same input. + RSASSA_PSS_SHA_512 = "RSASSA_PSS_SHA_512", + RSASSA_PSS_SHA_384 = "RSASSA_PSS_SHA_384", + RSASSA_PSS_SHA_256 = "RSASSA_PSS_SHA_256", + + // RSA PKCS#1 v1.5 algorithms + // These are deterministic and the output is the same each time for the same input. + RSASSA_PKCS1_V1_5_SHA_512 = "RSASSA_PKCS1_V1_5_SHA_512", + RSASSA_PKCS1_V1_5_SHA_384 = "RSASSA_PKCS1_V1_5_SHA_384", + RSASSA_PKCS1_V1_5_SHA_256 = "RSASSA_PKCS1_V1_5_SHA_256", + + // ECDSA algorithms + // None of these are deterministic and include randomness like RSA PSS. + ECDSA_SHA_512 = "ECDSA_SHA_512", + ECDSA_SHA_384 = "ECDSA_SHA_384", + ECDSA_SHA_256 = "ECDSA_SHA_256" +} diff --git a/backend/src/lib/files/files.ts b/backend/src/lib/files/files.ts new file mode 100644 index 000000000..063d71d09 --- /dev/null +++ b/backend/src/lib/files/files.ts @@ -0,0 +1,35 @@ +import crypto from "crypto"; +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import { logger } from "@app/lib/logger"; + +const baseDir = path.join(os.tmpdir(), "infisical"); +const randomPath = () => `${crypto.randomBytes(32).toString("hex")}`; + +export const createTemporaryDirectory = async (name: string) => { + const tempDirPath = path.join(baseDir, `${name}-${randomPath()}`); + await fs.mkdir(tempDirPath, { recursive: true }); + + return tempDirPath; +}; + +export const removeTemporaryBaseDirectory = async () => { + await fs.rm(baseDir, { force: true, recursive: true }).catch((err) => { + logger.error(err, `Failed to remove temporary base directory [path=${baseDir}]`); + }); +}; + +export const cleanTemporaryDirectory = async (dirPath: string) => { + await fs.rm(dirPath, { recursive: true, force: true }).catch((err) => { + logger.error(err, `Failed to cleanup temporary directory [path=${dirPath}]`); + }); +}; + +export const writeToTemporaryFile = async (tempDirPath: string, data: string | Buffer) => { + await fs.writeFile(tempDirPath, data, { mode: 0o600 }).catch((err) => { + logger.error(err, `Failed to write to temporary file [path=${tempDirPath}]`); + throw err; + }); +}; diff --git a/backend/src/lib/files/index.ts b/backend/src/lib/files/index.ts new file mode 100644 index 000000000..b2cba4b62 --- /dev/null +++ b/backend/src/lib/files/index.ts @@ -0,0 +1 @@ +export * from "./files"; diff --git a/backend/src/lib/search-resource/db.ts b/backend/src/lib/search-resource/db.ts new file mode 100644 index 000000000..fc450d9f9 --- /dev/null +++ b/backend/src/lib/search-resource/db.ts @@ -0,0 +1,141 @@ +import { Knex } from "knex"; + +import { SearchResourceOperators, TSearchResourceOperator } from "./search"; + +const buildKnexQuery = ( + query: Knex.QueryBuilder, + // when it's multiple table field means it's field1 or field2 + fields: string | string[], + operator: SearchResourceOperators, + value: unknown +) => { + switch (operator) { + case SearchResourceOperators.$eq: { + if (typeof value !== "string" && typeof value !== "number") + throw new Error("Invalid value type for $eq operator"); + + if (typeof fields === "string") { + return void query.where(fields, "=", value); + } + + return void query.where((qb) => { + return fields.forEach((el, index) => { + if (index === 0) { + return void qb.where(el, "=", value); + } + return void qb.orWhere(el, "=", value); + }); + }); + } + + case SearchResourceOperators.$neq: { + if (typeof value !== "string" && typeof value !== "number") + throw new Error("Invalid value type for $neq operator"); + + if (typeof fields === "string") { + return void query.where(fields, "<>", value); + } + + return void query.where((qb) => { + return fields.forEach((el, index) => { + if (index === 0) { + return void qb.where(el, "<>", value); + } + return void qb.orWhere(el, "<>", value); + }); + }); + } + case SearchResourceOperators.$in: { + if (!Array.isArray(value)) throw new Error("Invalid value type for $in operator"); + + if (typeof fields === "string") { + return void query.whereIn(fields, value); + } + + return void query.where((qb) => { + return fields.forEach((el, index) => { + if (index === 0) { + return void qb.whereIn(el, value); + } + return void qb.orWhereIn(el, value); + }); + }); + } + case SearchResourceOperators.$contains: { + if (typeof value !== "string") throw new Error("Invalid value type for $contains operator"); + + if (typeof fields === "string") { + return void query.whereILike(fields, `%${value}%`); + } + + return void query.where((qb) => { + return fields.forEach((el, index) => { + if (index === 0) { + return void qb.whereILike(el, `%${value}%`); + } + return void qb.orWhereILike(el, `%${value}%`); + }); + }); + } + default: + throw new Error(`Unsupported operator: ${String(operator)}`); + } +}; + +export const buildKnexFilterForSearchResource = ( + rootQuery: Knex.QueryBuilder, + searchFilter: T & { $or?: T[] }, + getAttributeField: (attr: K) => string | string[] | null +) => { + const { $or: orFilters = [] } = searchFilter; + (Object.keys(searchFilter) as K[]).forEach((key) => { + // akhilmhdh: yes, we could have split in top. This is done to satisfy ts type error + if (key === "$or") return; + + const dbField = getAttributeField(key); + if (!dbField) throw new Error(`DB field not found for ${String(key)}`); + + const dbValue = searchFilter[key]; + if (typeof dbValue === "string" || typeof dbValue === "number") { + buildKnexQuery(rootQuery, dbField, SearchResourceOperators.$eq, dbValue); + return; + } + + Object.keys(dbValue as Record).forEach((el) => { + buildKnexQuery( + rootQuery, + dbField, + el as SearchResourceOperators, + (dbValue as Record)[el as SearchResourceOperators] + ); + }); + }); + + if (orFilters.length) { + void rootQuery.andWhere((andQb) => { + return orFilters.forEach((orFilter) => { + return void andQb.orWhere((qb) => { + (Object.keys(orFilter) as K[]).forEach((key) => { + const dbField = getAttributeField(key); + if (!dbField) throw new Error(`DB field not found for ${String(key)}`); + + const dbValue = orFilter[key]; + if (typeof dbValue === "string" || typeof dbValue === "number") { + buildKnexQuery(qb, dbField, SearchResourceOperators.$eq, dbValue); + return; + } + + Object.keys(dbValue as Record).forEach((el) => { + buildKnexQuery( + qb, + dbField, + el as SearchResourceOperators, + (dbValue as Record)[el as SearchResourceOperators] + ); + }); + }); + }); + }); + }); + } +}; diff --git a/backend/src/lib/search-resource/search.ts b/backend/src/lib/search-resource/search.ts new file mode 100644 index 000000000..6431bf953 --- /dev/null +++ b/backend/src/lib/search-resource/search.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +export enum SearchResourceOperators { + $eq = "$eq", + $neq = "$neq", + $in = "$in", + $contains = "$contains" +} + +export const SearchResourceOperatorSchema = z.union([ + z.string(), + z.number(), + z + .object({ + [SearchResourceOperators.$eq]: z.string().optional(), + [SearchResourceOperators.$neq]: z.string().optional(), + [SearchResourceOperators.$in]: z.string().array().optional(), + [SearchResourceOperators.$contains]: z.string().array().optional() + }) + .partial() +]); + +export type TSearchResourceOperator = z.infer; + +export type TSearchResource = { + [k: string]: z.ZodOptional< + z.ZodUnion< + [ + z.ZodEffects, + z.ZodObject<{ + [SearchResourceOperators.$eq]?: z.ZodOptional>; + [SearchResourceOperators.$neq]?: z.ZodOptional>; + [SearchResourceOperators.$in]?: z.ZodOptional>>; + [SearchResourceOperators.$contains]?: z.ZodOptional>; + }> + ] + > + >; +}; + +export const buildSearchZodSchema = (schema: z.ZodObject) => { + return schema.extend({ $or: schema.array().max(5).optional() }).optional(); +}; diff --git a/backend/src/lib/types/index.ts b/backend/src/lib/types/index.ts index b8b272017..9f063172f 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -41,6 +41,18 @@ export type RequiredKeys = { [K in keyof T]-?: undefined extends T[K] ? never : K; }[keyof T]; +export type BufferKeysToString = { + [K in keyof T]: T[K] extends Buffer + ? string + : T[K] extends Buffer | null + ? string | null + : T[K] extends Buffer | undefined + ? string | undefined + : T[K] extends Buffer | null | undefined + ? string | null | undefined + : T[K]; +}; + export type PickRequired = Pick>; export type DiscriminativePick = T extends unknown ? Pick : never; diff --git a/backend/src/lib/validator/validate-string.ts b/backend/src/lib/validator/validate-string.ts index bc279fdbf..d2d033693 100644 --- a/backend/src/lib/validator/validate-string.ts +++ b/backend/src/lib/validator/validate-string.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export enum CharacterType { Alphabets = "alphabets", Numbers = "numbers", @@ -101,3 +103,10 @@ export const characterValidator = (allowedCharacters: CharacterType[]) => { return regex.test(input); }; }; + +export const zodValidateCharacters = (allowedCharacters: CharacterType[]) => { + const validator = characterValidator(allowedCharacters); + return (schema: z.ZodString, fieldName: string) => { + return schema.refine(validator, { message: `${fieldName} can only contain ${allowedCharacters.join(",")}` }); + }; +}; diff --git a/backend/src/main.ts b/backend/src/main.ts index d5c54991b..80d98abcf 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -9,6 +9,7 @@ import { runMigrations } from "./auto-start-migrations"; import { initAuditLogDbConnection, initDbConnection } from "./db"; import { keyStoreFactory } from "./keystore/keystore"; import { formatSmtpConfig, initEnvConfig } from "./lib/config/env"; +import { removeTemporaryBaseDirectory } from "./lib/files"; import { initLogger } from "./lib/logger"; import { queueServiceFactory } from "./queue"; import { main } from "./server/app"; @@ -21,6 +22,8 @@ const run = async () => { const logger = initLogger(); const envConfig = initEnvConfig(logger); + await removeTemporaryBaseDirectory(); + const db = initDbConnection({ dbConnectionUri: envConfig.DB_CONNECTION_URI, dbRootCert: envConfig.DB_ROOT_CERT, @@ -71,6 +74,7 @@ const run = async () => { process.on("SIGINT", async () => { await server.close(); await db.destroy(); + await removeTemporaryBaseDirectory(); hsmModule.finalize(); process.exit(0); }); @@ -79,6 +83,7 @@ const run = async () => { process.on("SIGTERM", async () => { await server.close(); await db.destroy(); + await removeTemporaryBaseDirectory(); hsmModule.finalize(); process.exit(0); }); diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index 26f556508..3f5c477ef 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -113,7 +113,7 @@ export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, key await server.register(fastifyErrHandler); // Rate limiters and security headers - if (appCfg.isProductionMode) { + if (appCfg.isProductionMode && appCfg.isCloud) { await server.register(ratelimiter, globalRateLimiterCfg()); } diff --git a/backend/src/server/config/rateLimiter.ts b/backend/src/server/config/rateLimiter.ts index 176d44183..681442d1b 100644 --- a/backend/src/server/config/rateLimiter.ts +++ b/backend/src/server/config/rateLimiter.ts @@ -93,3 +93,10 @@ export const userEngagementLimit: RateLimitOptions = { max: 5, keyGenerator: (req) => req.realIp }; + +export const publicSshCaLimit: RateLimitOptions = { + timeWindow: 60 * 1000, + hook: "preValidation", + max: 30, // conservative default + keyGenerator: (req) => req.realIp +}; diff --git a/backend/src/server/lib/schemas.ts b/backend/src/server/lib/schemas.ts index d09a2c40b..9f93eaea0 100644 --- a/backend/src/server/lib/schemas.ts +++ b/backend/src/server/lib/schemas.ts @@ -45,4 +45,6 @@ export const BaseSecretNameSchema = z.string().trim().min(1); export const SecretNameSchema = BaseSecretNameSchema.refine( (el) => !el.includes(" "), "Secret name cannot contain spaces." -).refine((el) => !el.includes(":"), "Secret name cannot contain colon."); +) + .refine((el) => !el.includes(":"), "Secret name cannot contain colon.") + .refine((el) => !el.includes("/"), "Secret name cannot contain forward slash."); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 21988e12d..743577d25 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -96,6 +96,10 @@ import { sshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/s import { sshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; import { sshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { sshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service"; +import { sshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal"; +import { sshHostLoginUserMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-user-mapping-dal"; +import { sshHostServiceFactory } from "@app/ee/services/ssh-host/ssh-host-service"; +import { sshHostLoginUserDALFactory } from "@app/ee/services/ssh-host/ssh-login-user-dal"; import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal"; import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { TKeyStoreFactory } from "@app/keystore/keystore"; @@ -184,6 +188,7 @@ import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-co import { projectDALFactory } from "@app/services/project/project-dal"; import { projectQueueFactory } from "@app/services/project/project-queue"; import { projectServiceFactory } from "@app/services/project/project-service"; +import { projectSshConfigDALFactory } from "@app/services/project/project-ssh-config-dal"; import { projectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { projectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { projectEnvDALFactory } from "@app/services/project-env/project-env-dal"; @@ -292,6 +297,7 @@ export const registerRoutes = async ( const apiKeyDAL = apiKeyDALFactory(db); const projectDAL = projectDALFactory(db); + const projectSshConfigDAL = projectSshConfigDALFactory(db); const projectMembershipDAL = projectMembershipDALFactory(db); const projectUserAdditionalPrivilegeDAL = projectUserAdditionalPrivilegeDALFactory(db); const projectUserMembershipRoleDAL = projectUserMembershipRoleDALFactory(db); @@ -309,7 +315,7 @@ export const registerRoutes = async ( const secretVersionTagDAL = secretVersionTagDALFactory(db); const secretBlindIndexDAL = secretBlindIndexDALFactory(db); - const secretV2BridgeDAL = secretV2BridgeDALFactory(db); + const secretV2BridgeDAL = secretV2BridgeDALFactory({ db, keyStore }); const secretVersionV2BridgeDAL = secretVersionV2BridgeDALFactory(db); const secretVersionTagV2BridgeDAL = secretVersionV2TagBridgeDALFactory(db); @@ -385,6 +391,9 @@ export const registerRoutes = async ( const sshCertificateAuthorityDAL = sshCertificateAuthorityDALFactory(db); const sshCertificateAuthoritySecretDAL = sshCertificateAuthoritySecretDALFactory(db); const sshCertificateTemplateDAL = sshCertificateTemplateDALFactory(db); + const sshHostDAL = sshHostDALFactory(db); + const sshHostLoginUserDAL = sshHostLoginUserDALFactory(db); + const sshHostLoginUserMappingDAL = sshHostLoginUserMappingDALFactory(db); const kmsDAL = kmskeyDALFactory(db); const internalKmsDAL = internalKmsDALFactory(db); @@ -796,6 +805,21 @@ export const registerRoutes = async ( permissionService }); + const sshHostService = sshHostServiceFactory({ + userDAL, + projectDAL, + projectSshConfigDAL, + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + sshCertificateDAL, + sshCertificateBodyDAL, + sshHostDAL, + sshHostLoginUserDAL, + sshHostLoginUserMappingDAL, + permissionService, + kmsService + }); + const certificateAuthorityService = certificateAuthorityServiceFactory({ certificateAuthorityDAL, certificateAuthorityCertDAL, @@ -938,6 +962,7 @@ export const registerRoutes = async ( const projectService = projectServiceFactory({ permissionService, projectDAL, + projectSshConfigDAL, secretDAL, secretV2BridgeDAL, queueService, @@ -959,8 +984,10 @@ export const registerRoutes = async ( pkiAlertDAL, pkiCollectionDAL, sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, sshCertificateDAL, sshCertificateTemplateDAL, + sshHostDAL, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, keyStore, @@ -1603,6 +1630,7 @@ export const registerRoutes = async ( certificate: certificateService, sshCertificateAuthority: sshCertificateAuthorityService, sshCertificateTemplate: sshCertificateTemplateService, + sshHost: sshHostService, certificateAuthority: certificateAuthorityService, certificateTemplate: certificateTemplateService, certificateAuthorityCrl: certificateAuthorityCrlService, diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 2cb5d6db9..ebc23a4bd 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -12,6 +12,10 @@ import { AzureKeyVaultConnectionListItemSchema, SanitizedAzureKeyVaultConnectionSchema } from "@app/services/app-connection/azure-key-vault"; +import { + CamundaConnectionListItemSchema, + SanitizedCamundaConnectionSchema +} from "@app/services/app-connection/camunda"; import { DatabricksConnectionListItemSchema, SanitizedDatabricksConnectionSchema @@ -27,6 +31,11 @@ import { PostgresConnectionListItemSchema, SanitizedPostgresConnectionSchema } from "@app/services/app-connection/postgres"; +import { + SanitizedTerraformCloudConnectionSchema, + TerraformCloudConnectionListItemSchema +} from "@app/services/app-connection/terraform-cloud"; +import { SanitizedVercelConnectionSchema, VercelConnectionListItemSchema } from "@app/services/app-connection/vercel"; import { AuthMode } from "@app/services/auth/auth-type"; // can't use discriminated due to multiple schemas for certain apps @@ -38,8 +47,11 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedAzureAppConfigurationConnectionSchema.options, ...SanitizedDatabricksConnectionSchema.options, ...SanitizedHumanitecConnectionSchema.options, + ...SanitizedTerraformCloudConnectionSchema.options, + ...SanitizedVercelConnectionSchema.options, ...SanitizedPostgresConnectionSchema.options, - ...SanitizedMsSqlConnectionSchema.options + ...SanitizedMsSqlConnectionSchema.options, + ...SanitizedCamundaConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -50,8 +62,11 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ AzureAppConfigurationConnectionListItemSchema, DatabricksConnectionListItemSchema, HumanitecConnectionListItemSchema, + TerraformCloudConnectionListItemSchema, + VercelConnectionListItemSchema, PostgresConnectionListItemSchema, - MsSqlConnectionListItemSchema + MsSqlConnectionListItemSchema, + CamundaConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/camunda-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/camunda-connection-router.ts new file mode 100644 index 000000000..7da0b7e5f --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/camunda-connection-router.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateCamundaConnectionSchema, + SanitizedCamundaConnectionSchema, + UpdateCamundaConnectionSchema +} from "@app/services/app-connection/camunda"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerCamundaConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.Camunda, + server, + sanitizedResponseSchema: SanitizedCamundaConnectionSchema, + createSchema: CreateCamundaConnectionSchema, + updateSchema: UpdateCamundaConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + + server.route({ + method: "GET", + url: `/:connectionId/clusters`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + clusters: z.object({ uuid: z.string(), name: z.string() }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const clusters = await server.services.appConnection.camunda.listClusters(connectionId, req.permission); + + return { clusters }; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 906ffaee9..b89eb0991 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -3,12 +3,15 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums import { registerAwsConnectionRouter } from "./aws-connection-router"; import { registerAzureAppConfigurationConnectionRouter } from "./azure-app-configuration-connection-router"; import { registerAzureKeyVaultConnectionRouter } from "./azure-key-vault-connection-router"; +import { registerCamundaConnectionRouter } from "./camunda-connection-router"; import { registerDatabricksConnectionRouter } from "./databricks-connection-router"; import { registerGcpConnectionRouter } from "./gcp-connection-router"; import { registerGitHubConnectionRouter } from "./github-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; +import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; +import { registerVercelConnectionRouter } from "./vercel-connection-router"; export * from "./app-connection-router"; @@ -21,6 +24,9 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.TerraformCloud, + server, + sanitizedResponseSchema: SanitizedTerraformCloudConnectionSchema, + createSchema: CreateTerraformCloudConnectionSchema, + updateSchema: UpdateTerraformCloudConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/organizations`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string(), + variableSets: z + .object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + global: z.boolean().optional() + }) + .array(), + workspaces: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const organizations: TTerraformCloudOrganization[] = + await server.services.appConnection.terraformCloud.listOrganizations(connectionId, req.permission); + + return organizations; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/vercel-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/vercel-connection-router.ts new file mode 100644 index 000000000..079870305 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/vercel-connection-router.ts @@ -0,0 +1,77 @@ +import z from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateVercelConnectionSchema, + SanitizedVercelConnectionSchema, + UpdateVercelConnectionSchema, + VercelOrgWithApps +} from "@app/services/app-connection/vercel"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerVercelConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.Vercel, + server, + sanitizedResponseSchema: SanitizedVercelConnectionSchema, + createSchema: CreateVercelConnectionSchema, + updateSchema: UpdateVercelConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/projects`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string(), + slug: z.string(), + apps: z + .object({ + id: z.string(), + name: z.string(), + envs: z + .object({ + id: z.string(), + slug: z.string(), + type: z.string(), + target: z.array(z.string()).optional(), + description: z.string().optional(), + createdAt: z.number().optional(), + updatedAt: z.number().optional() + }) + .array() + .optional(), + previewBranches: z.array(z.string()).optional() + }) + .array() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const projects: VercelOrgWithApps[] = await server.services.appConnection.vercel.listProjects( + connectionId, + req.permission + ); + + return projects; + } + }); +}; diff --git a/backend/src/server/routes/v1/cmek-router.ts b/backend/src/server/routes/v1/cmek-router.ts index 7aecaee37..64f47f980 100644 --- a/backend/src/server/routes/v1/cmek-router.ts +++ b/backend/src/server/routes/v1/cmek-router.ts @@ -4,13 +4,15 @@ import { InternalKmsSchema, KmsKeysSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { KMS } from "@app/lib/api-docs"; import { getBase64SizeInBytes, isBase64 } from "@app/lib/base64"; -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { AllowedEncryptionKeyAlgorithms, SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; +import { AsymmetricKeyAlgorithm, SigningAlgorithm } from "@app/lib/crypto/sign"; import { OrderByDirection } from "@app/lib/types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { CmekOrderBy } from "@app/services/cmek/cmek-types"; +import { CmekOrderBy, TCmekKeyEncryptionAlgorithm } from "@app/services/cmek/cmek-types"; +import { KmsKeyUsage } from "@app/services/kms/kms-types"; const keyNameSchema = slugSchema({ min: 1, max: 32, field: "Name" }); const keyDescriptionSchema = z.string().trim().max(500).optional(); @@ -45,16 +47,46 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { }, schema: { description: "Create KMS key", - body: z.object({ - projectId: z.string().describe(KMS.CREATE_KEY.projectId), - name: keyNameSchema.describe(KMS.CREATE_KEY.name), - description: keyDescriptionSchema.describe(KMS.CREATE_KEY.description), - encryptionAlgorithm: z - .nativeEnum(SymmetricEncryption) - .optional() - .default(SymmetricEncryption.AES_GCM_256) - .describe(KMS.CREATE_KEY.encryptionAlgorithm) // eventually will support others - }), + body: z + .object({ + projectId: z.string().describe(KMS.CREATE_KEY.projectId), + name: keyNameSchema.describe(KMS.CREATE_KEY.name), + description: keyDescriptionSchema.describe(KMS.CREATE_KEY.description), + keyUsage: z + .nativeEnum(KmsKeyUsage) + .optional() + .default(KmsKeyUsage.ENCRYPT_DECRYPT) + .describe(KMS.CREATE_KEY.type), + encryptionAlgorithm: z + .enum(AllowedEncryptionKeyAlgorithms) + .optional() + .default(SymmetricKeyAlgorithm.AES_GCM_256) + .describe(KMS.CREATE_KEY.encryptionAlgorithm) + }) + .superRefine((data, ctx) => { + if ( + data.keyUsage === KmsKeyUsage.ENCRYPT_DECRYPT && + !Object.values(SymmetricKeyAlgorithm).includes(data.encryptionAlgorithm as SymmetricKeyAlgorithm) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `encryptionAlgorithm must be a valid symmetric encryption algorithm. Valid options are: ${Object.values( + SymmetricKeyAlgorithm + ).join(", ")}` + }); + } + if ( + data.keyUsage === KmsKeyUsage.SIGN_VERIFY && + !Object.values(AsymmetricKeyAlgorithm).includes(data.encryptionAlgorithm as AsymmetricKeyAlgorithm) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `encryptionAlgorithm must be a valid asymmetric sign-verify algorithm. Valid options are: ${Object.values( + AsymmetricKeyAlgorithm + ).join(", ")}` + }); + } + }), response: { 200: z.object({ key: CmekSchema @@ -64,12 +96,19 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const { - body: { projectId, name, description, encryptionAlgorithm }, + body: { projectId, name, description, encryptionAlgorithm, keyUsage }, permission } = req; const cmek = await server.services.cmek.createCmek( - { orgId: permission.orgId, projectId, name, description, encryptionAlgorithm }, + { + orgId: permission.orgId, + projectId, + name, + description, + encryptionAlgorithm: encryptionAlgorithm as TCmekKeyEncryptionAlgorithm, + keyUsage + }, permission ); @@ -82,7 +121,7 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { keyId: cmek.id, name, description, - encryptionAlgorithm + encryptionAlgorithm: encryptionAlgorithm as TCmekKeyEncryptionAlgorithm } } }); @@ -126,7 +165,7 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: permission.orgId, + projectId: cmek.projectId!, event: { type: EventType.UPDATE_CMEK, metadata: { @@ -169,7 +208,7 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: permission.orgId, + projectId: cmek.projectId!, event: { type: EventType.DELETE_CMEK, metadata: { @@ -282,7 +321,7 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { - description: "Get KMS key by Name", + description: "Get KMS key by name", params: z.object({ keyName: slugSchema({ field: "Key name" }).describe(KMS.GET_KEY_BY_NAME.keyName) }), @@ -349,11 +388,11 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { permission } = req; - const ciphertext = await server.services.cmek.cmekEncrypt({ keyId, plaintext }, permission); + const { ciphertext, projectId } = await server.services.cmek.cmekEncrypt({ keyId, plaintext }, permission); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: permission.orgId, + projectId, event: { type: EventType.CMEK_ENCRYPT, metadata: { @@ -366,6 +405,198 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/keys/:keyId/public-key", + config: { + rateLimit: readLimit + }, + schema: { + description: + "Get the public key for a KMS key that is used for signing and verifying data. This endpoint is only available for asymmetric keys.", + params: z.object({ + keyId: z.string().uuid().describe(KMS.GET_PUBLIC_KEY.keyId) + }), + response: { + 200: z.object({ + publicKey: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + params: { keyId }, + permission + } = req; + + const { publicKey, projectId } = await server.services.cmek.getPublicKey({ keyId }, permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.CMEK_GET_PUBLIC_KEY, + metadata: { + keyId + } + } + }); + + return { publicKey }; + } + }); + + server.route({ + method: "GET", + url: "/keys/:keyId/signing-algorithms", + config: { + rateLimit: readLimit + }, + schema: { + description: "List all available signing algorithms for a KMS key", + params: z.object({ + keyId: z.string().uuid().describe(KMS.LIST_SIGNING_ALGORITHMS.keyId) + }), + response: { + 200: z.object({ + signingAlgorithms: z.array(z.nativeEnum(SigningAlgorithm)) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { keyId } = req.params; + + const { signingAlgorithms, projectId } = await server.services.cmek.listSigningAlgorithms( + { keyId }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.CMEK_LIST_SIGNING_ALGORITHMS, + metadata: { + keyId + } + } + }); + + return { signingAlgorithms }; + } + }); + + server.route({ + method: "POST", + url: "/keys/:keyId/sign", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Sign data with a KMS key.", + params: z.object({ + keyId: z.string().uuid().describe(KMS.SIGN.keyId) + }), + body: z.object({ + signingAlgorithm: z.nativeEnum(SigningAlgorithm), + isDigest: z.boolean().optional().default(false).describe(KMS.SIGN.isDigest), + data: base64Schema.describe(KMS.SIGN.data) + }), + response: { + 200: z.object({ + signature: z.string(), + keyId: z.string().uuid(), + signingAlgorithm: z.nativeEnum(SigningAlgorithm) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + params: { keyId: inputKeyId }, + body: { data, signingAlgorithm, isDigest }, + permission + } = req; + + const { projectId, ...result } = await server.services.cmek.cmekSign( + { keyId: inputKeyId, data, signingAlgorithm, isDigest }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.CMEK_SIGN, + metadata: { + keyId: inputKeyId, + signingAlgorithm, + signature: result.signature + } + } + }); + return result; + } + }); + + server.route({ + method: "POST", + url: "/keys/:keyId/verify", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Verify data signatures with a KMS key.", + params: z.object({ + keyId: z.string().uuid().describe(KMS.VERIFY.keyId) + }), + body: z.object({ + isDigest: z.boolean().optional().default(false).describe(KMS.VERIFY.isDigest), + data: base64Schema.describe(KMS.VERIFY.data), + signature: base64Schema.describe(KMS.VERIFY.signature), + signingAlgorithm: z.nativeEnum(SigningAlgorithm) + }), + response: { + 200: z.object({ + signatureValid: z.boolean(), + keyId: z.string().uuid(), + signingAlgorithm: z.nativeEnum(SigningAlgorithm) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + params: { keyId }, + body: { data, signature, signingAlgorithm, isDigest }, + permission + } = req; + + const { projectId, ...result } = await server.services.cmek.cmekVerify( + { keyId, data, signature, signingAlgorithm, isDigest }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.CMEK_VERIFY, + metadata: { + keyId, + signatureValid: result.signatureValid, + signingAlgorithm, + signature + } + } + }); + + return result; + } + }); + server.route({ method: "POST", url: "/keys/:keyId/decrypt", @@ -394,11 +625,11 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { permission } = req; - const plaintext = await server.services.cmek.cmekDecrypt({ keyId, ciphertext }, permission); + const { plaintext, projectId } = await server.services.cmek.cmekDecrypt({ keyId, ciphertext }, permission); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: permission.orgId, + projectId, event: { type: EventType.CMEK_DECRYPT, metadata: { diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index 344da3383..107a4b9ef 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -3,15 +3,26 @@ 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 { buildSearchZodSchema, SearchResourceOperators } from "@app/lib/search-resource/search"; +import { OrderByDirection } from "@app/lib/types"; +import { CharacterType, zodValidateCharacters } from "@app/lib/validator/validate-string"; 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 { OrgIdentityOrderBy } from "@app/services/identity/identity-types"; import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; import { SanitizedProjectSchema } from "../sanitizedSchemas"; +const searchResourceZodValidate = zodValidateCharacters([ + CharacterType.AlphaNumeric, + CharacterType.Spaces, + CharacterType.Underscore, + CharacterType.Hyphen +]); + export const registerIdentityRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", @@ -245,7 +256,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { method: "GET", url: "/", config: { - rateLimit: writeLimit + rateLimit: readLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { @@ -289,6 +300,103 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/search", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Search identities", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + orderBy: z + .nativeEnum(OrgIdentityOrderBy) + .default(OrgIdentityOrderBy.Name) + .describe(IDENTITIES.SEARCH.orderBy) + .optional(), + orderDirection: z + .nativeEnum(OrderByDirection) + .default(OrderByDirection.ASC) + .describe(IDENTITIES.SEARCH.orderDirection) + .optional(), + limit: z.number().max(100).default(50).describe(IDENTITIES.SEARCH.limit), + offset: z.number().default(0).describe(IDENTITIES.SEARCH.offset), + search: buildSearchZodSchema( + z + .object({ + name: z + .union([ + searchResourceZodValidate(z.string().max(255), "Name"), + z + .object({ + [SearchResourceOperators.$eq]: searchResourceZodValidate(z.string().max(255), "Name $eq"), + [SearchResourceOperators.$contains]: searchResourceZodValidate( + z.string().max(255), + "Name $contains" + ), + [SearchResourceOperators.$in]: searchResourceZodValidate(z.string().max(255), "Name $in").array() + }) + .partial() + ]) + .describe(IDENTITIES.SEARCH.search.name), + role: z + .union([ + searchResourceZodValidate(z.string().max(255), "Role"), + z + .object({ + [SearchResourceOperators.$eq]: searchResourceZodValidate(z.string().max(255), "Role $eq"), + [SearchResourceOperators.$in]: searchResourceZodValidate(z.string().max(255), "Role $in").array() + }) + .partial() + ]) + .describe(IDENTITIES.SEARCH.search.role) + }) + .describe(IDENTITIES.SEARCH.search.desc) + .partial() + ) + }), + response: { + 200: z.object({ + identities: IdentityOrgMembershipsSchema.extend({ + customRole: OrgRolesSchema.pick({ + id: true, + name: true, + slug: true, + permissions: true, + description: true + }).optional(), + identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + authMethods: z.array(z.string()) + }) + }).array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { identityMemberships, totalCount } = await server.services.identity.searchOrgIdentities({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + searchFilter: req.body.search, + orgId: req.permission.orgId, + limit: req.body.limit, + offset: req.body.offset, + orderBy: req.body.orderBy, + orderDirection: req.body.orderDirection + }); + + return { identities: identityMemberships, totalCount }; + } + }); + server.route({ method: "GET", url: "/:identityId/identity-memberships", diff --git a/backend/src/server/routes/v1/secret-sync-routers/camunda-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/camunda-sync-router.ts new file mode 100644 index 000000000..13a8680c8 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/camunda-sync-router.ts @@ -0,0 +1,13 @@ +import { CamundaSyncSchema, CreateCamundaSyncSchema, UpdateCamundaSyncSchema } from "@app/services/secret-sync/camunda"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerCamundaSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.Camunda, + server, + responseSchema: CamundaSyncSchema, + createSchema: CreateCamundaSyncSchema, + updateSchema: UpdateCamundaSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index c342f3b73..07567124d 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -4,10 +4,13 @@ import { registerAwsParameterStoreSyncRouter } from "./aws-parameter-store-sync- import { registerAwsSecretsManagerSyncRouter } from "./aws-secrets-manager-sync-router"; import { registerAzureAppConfigurationSyncRouter } from "./azure-app-configuration-sync-router"; import { registerAzureKeyVaultSyncRouter } from "./azure-key-vault-sync-router"; +import { registerCamundaSyncRouter } from "./camunda-sync-router"; import { registerDatabricksSyncRouter } from "./databricks-sync-router"; import { registerGcpSyncRouter } from "./gcp-sync-router"; import { registerGitHubSyncRouter } from "./github-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; +import { registerTerraformCloudSyncRouter } from "./terraform-cloud-sync-router"; +import { registerVercelSyncRouter } from "./vercel-sync-router"; export * from "./secret-sync-router"; @@ -19,5 +22,8 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/server/routes/v1/secret-sync-routers/terraform-cloud-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/terraform-cloud-sync-router.ts new file mode 100644 index 000000000..d52666593 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/terraform-cloud-sync-router.ts @@ -0,0 +1,17 @@ +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + CreateTerraformCloudSyncSchema, + TerraformCloudSyncSchema, + UpdateTerraformCloudSyncSchema +} from "@app/services/secret-sync/terraform-cloud"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerTerraformCloudSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.TerraformCloud, + server, + responseSchema: TerraformCloudSyncSchema, + createSchema: CreateTerraformCloudSyncSchema, + updateSchema: UpdateTerraformCloudSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/vercel-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/vercel-sync-router.ts new file mode 100644 index 000000000..e6e2f40c6 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/vercel-sync-router.ts @@ -0,0 +1,13 @@ +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { CreateVercelSyncSchema, UpdateVercelSyncSchema, VercelSyncSchema } from "@app/services/secret-sync/vercel"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerVercelSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.Vercel, + server, + responseSchema: VercelSyncSchema, + createSchema: CreateVercelSyncSchema, + updateSchema: UpdateVercelSyncSchema + }); diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index a4570389f..a222ab172 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -108,7 +108,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { const { email } = ghEmails.filter((gitHubEmail) => gitHubEmail.primary)[0]; const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ email, - firstName: profile.displayName, + firstName: profile.displayName || profile.username || "", lastName: "", authMethod: AuthMethod.GITHUB, callbackPort @@ -145,7 +145,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { const email = profile.emails[0].value; const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ email, - firstName: profile.displayName, + firstName: profile.displayName || profile.username || "", lastName: "", authMethod: AuthMethod.GITLAB, callbackPort diff --git a/backend/src/server/routes/v2/identity-project-router.ts b/backend/src/server/routes/v2/identity-project-router.ts index 18244068c..4205d9326 100644 --- a/backend/src/server/routes/v2/identity-project-router.ts +++ b/backend/src/server/routes/v2/identity-project-router.ts @@ -351,4 +351,56 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) return { identityMembership }; } }); + + server.route({ + method: "GET", + url: "/identity-memberships/:identityMembershipId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + params: z.object({ + identityMembershipId: z.string().trim() + }), + response: { + 200: z.object({ + identityMembership: z.object({ + id: z.string(), + identityId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + authMethods: z.array(z.string()) + }), + project: SanitizedProjectSchema.pick({ name: true, id: true }) + }) + }) + } + }, + handler: async (req) => { + const identityMembership = await server.services.identityProject.getProjectIdentityByMembershipId({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityMembershipId: req.params.identityMembershipId + }); + return { identityMembership }; + } + }); }; diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 84d2ee6cd..6e4a8170e 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -13,6 +13,7 @@ import { InfisicalProjectTemplate } from "@app/ee/services/project-template/proj import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-schema"; import { sanitizedSshCertificate } from "@app/ee/services/ssh-certificate/ssh-certificate-schema"; import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; +import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; import { PROJECTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; @@ -600,4 +601,38 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return { cas }; } }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-hosts", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_HOSTS.projectId) + }), + response: { + 200: z.object({ + hosts: z.array( + sanitizedSshHost.extend({ + loginMappings: z.array(loginMappingSchema) + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const hosts = await server.services.project.listProjectSshHosts({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId + }); + + return { hosts }; + } + }); }; diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index f5f921c4e..f5eb31598 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -6,8 +6,11 @@ export enum AppConnection { AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", Humanitec = "humanitec", + TerraformCloud = "terraform-cloud", + Vercel = "vercel", Postgres = "postgres", - MsSql = "mssql" + MsSql = "mssql", + Camunda = "camunda" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index b2d45e71f..2c54a1fbc 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -27,6 +27,7 @@ import { getAzureKeyVaultConnectionListItem, validateAzureKeyVaultConnectionCredentials } from "./azure-key-vault"; +import { CamundaConnectionMethod, getCamundaConnectionListItem, validateCamundaConnectionCredentials } from "./camunda"; import { DatabricksConnectionMethod, getDatabricksConnectionListItem, @@ -41,6 +42,13 @@ import { } from "./humanitec"; import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; +import { + getTerraformCloudConnectionListItem, + TerraformCloudConnectionMethod, + validateTerraformCloudConnectionCredentials +} from "./terraform-cloud"; +import { VercelConnectionMethod } from "./vercel"; +import { getVercelConnectionListItem, validateVercelConnectionCredentials } from "./vercel/vercel-connection-fns"; export const listAppConnectionOptions = () => { return [ @@ -51,8 +59,11 @@ export const listAppConnectionOptions = () => { getAzureAppConfigurationConnectionListItem(), getDatabricksConnectionListItem(), getHumanitecConnectionListItem(), + getTerraformCloudConnectionListItem(), + getVercelConnectionListItem(), getPostgresConnectionListItem(), - getMsSqlConnectionListItem() + getMsSqlConnectionListItem(), + getCamundaConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -108,7 +119,10 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record return "Service Account Impersonation"; case DatabricksConnectionMethod.ServicePrincipal: return "Service Principal"; + case CamundaConnectionMethod.ClientCredentials: + return "Client Credentials"; case HumanitecConnectionMethod.ApiToken: + case TerraformCloudConnectionMethod.ApiToken: + case VercelConnectionMethod.ApiToken: return "API Token"; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: @@ -175,5 +193,8 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.AzureAppConfiguration]: platformManagedCredentialsNotSupported, [AppConnection.Humanitec]: platformManagedCredentialsNotSupported, [AppConnection.Postgres]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, - [AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform + [AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, + [AppConnection.TerraformCloud]: platformManagedCredentialsNotSupported, + [AppConnection.Camunda]: platformManagedCredentialsNotSupported, + [AppConnection.Vercel]: platformManagedCredentialsNotSupported }; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index eb28070d5..8c77bfc58 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -8,6 +8,9 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.AzureAppConfiguration]: "Azure App Configuration", [AppConnection.Databricks]: "Databricks", [AppConnection.Humanitec]: "Humanitec", + [AppConnection.TerraformCloud]: "Terraform Cloud", + [AppConnection.Vercel]: "Vercel", [AppConnection.Postgres]: "PostgreSQL", - [AppConnection.MsSql]: "Microsoft SQL Server" + [AppConnection.MsSql]: "Microsoft SQL Server", + [AppConnection.Camunda]: "Camunda" }; diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 978f3bfd7..143cce3e3 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -31,6 +31,8 @@ import { ValidateAwsConnectionCredentialsSchema } from "./aws"; import { awsConnectionService } from "./aws/aws-connection-service"; import { ValidateAzureAppConfigurationConnectionCredentialsSchema } from "./azure-app-configuration"; import { ValidateAzureKeyVaultConnectionCredentialsSchema } from "./azure-key-vault"; +import { ValidateCamundaConnectionCredentialsSchema } from "./camunda"; +import { camundaConnectionService } from "./camunda/camunda-connection-service"; import { ValidateDatabricksConnectionCredentialsSchema } from "./databricks"; import { databricksConnectionService } from "./databricks/databricks-connection-service"; import { ValidateGcpConnectionCredentialsSchema } from "./gcp"; @@ -41,6 +43,10 @@ import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; import { humanitecConnectionService } from "./humanitec/humanitec-connection-service"; import { ValidateMsSqlConnectionCredentialsSchema } from "./mssql"; import { ValidatePostgresConnectionCredentialsSchema } from "./postgres"; +import { ValidateTerraformCloudConnectionCredentialsSchema } from "./terraform-cloud"; +import { terraformCloudConnectionService } from "./terraform-cloud/terraform-cloud-connection-service"; +import { ValidateVercelConnectionCredentialsSchema } from "./vercel"; +import { vercelConnectionService } from "./vercel/vercel-connection-service"; export type TAppConnectionServiceFactoryDep = { appConnectionDAL: TAppConnectionDALFactory; @@ -58,8 +64,11 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record>>; @@ -76,8 +97,11 @@ export type TAppConnectionInput = { id: string } & ( | TAzureAppConfigurationConnectionInput | TDatabricksConnectionInput | THumanitecConnectionInput + | TTerraformCloudConnectionInput + | TVercelConnectionInput | TPostgresConnectionInput | TMsSqlConnectionInput + | TCamundaConnectionInput ); export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput; @@ -99,7 +123,10 @@ export type TAppConnectionConfig = | TAzureAppConfigurationConnectionConfig | TDatabricksConnectionConfig | THumanitecConnectionConfig - | TSqlConnectionConfig; + | TTerraformCloudConnectionConfig + | TVercelConnectionConfig + | TSqlConnectionConfig + | TCamundaConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -110,7 +137,10 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateDatabricksConnectionCredentialsSchema | TValidateHumanitecConnectionCredentialsSchema | TValidatePostgresConnectionCredentialsSchema - | TValidateMsSqlConnectionCredentialsSchema; + | TValidateMsSqlConnectionCredentialsSchema + | TValidateCamundaConnectionCredentialsSchema + | TValidateTerraformCloudConnectionCredentialsSchema + | TValidateVercelConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; diff --git a/backend/src/services/app-connection/camunda/camunda-connection-enums.ts b/backend/src/services/app-connection/camunda/camunda-connection-enums.ts new file mode 100644 index 000000000..ea1ea0aaf --- /dev/null +++ b/backend/src/services/app-connection/camunda/camunda-connection-enums.ts @@ -0,0 +1,3 @@ +export enum CamundaConnectionMethod { + ClientCredentials = "client-credentials" +} diff --git a/backend/src/services/app-connection/camunda/camunda-connection-fns.ts b/backend/src/services/app-connection/camunda/camunda-connection-fns.ts new file mode 100644 index 000000000..90d9744b8 --- /dev/null +++ b/backend/src/services/app-connection/camunda/camunda-connection-fns.ts @@ -0,0 +1,88 @@ +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { encryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +import { TAppConnectionDALFactory } from "../app-connection-dal"; +import { CamundaConnectionMethod } from "./camunda-connection-enums"; +import { TAuthorizeCamundaConnection, TCamundaConnection, TCamundaConnectionConfig } from "./camunda-connection-types"; + +export const getCamundaConnectionListItem = () => { + return { + name: "Camunda" as const, + app: AppConnection.Camunda as const, + methods: Object.values(CamundaConnectionMethod) as [CamundaConnectionMethod.ClientCredentials] + }; +}; + +const authorizeCamundaConnection = async ({ + clientId, + clientSecret +}: Pick) => { + const { data } = await request.post( + IntegrationUrls.CAMUNDA_TOKEN_URL, + { + grant_type: "client_credentials", + client_id: clientId, + client_secret: clientSecret, + audience: "api.cloud.camunda.io" + }, + { + headers: { + "Content-Type": "application/json" + } + } + ); + + return { accessToken: data.access_token, expiresAt: data.expires_in * 1000 + Date.now() }; +}; + +export const getCamundaConnectionAccessToken = async ( + { id, orgId, credentials }: TCamundaConnection, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const { clientSecret, clientId, accessToken, expiresAt } = credentials; + + // get new token if less than 30 seconds from expiry + if (Date.now() < expiresAt - 30_000) { + return accessToken; + } + + const authData = await authorizeCamundaConnection({ clientId, clientSecret }); + + const updatedCredentials: TCamundaConnection["credentials"] = { + ...credentials, + ...authData + }; + + const encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: updatedCredentials, + orgId, + kmsService + }); + + await appConnectionDAL.updateById(id, { encryptedCredentials }); + + return authData.accessToken; +}; + +export const validateCamundaConnectionCredentials = async (appConnection: TCamundaConnectionConfig) => { + const { credentials } = appConnection; + + try { + const { accessToken, expiresAt } = await authorizeCamundaConnection(appConnection.credentials); + + return { + ...credentials, + accessToken, + expiresAt + }; + } catch (e: unknown) { + throw new BadRequestError({ + message: `Unable to validate connection: verify credentials` + }); + } +}; diff --git a/backend/src/services/app-connection/camunda/camunda-connection-schema.ts b/backend/src/services/app-connection/camunda/camunda-connection-schema.ts new file mode 100644 index 000000000..fa769c650 --- /dev/null +++ b/backend/src/services/app-connection/camunda/camunda-connection-schema.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { CamundaConnectionMethod } from "./camunda-connection-enums"; + +const BaseCamundaConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Camunda) }); + +export const CamundaConnectionClientCredentialsInputCredentialsSchema = z.object({ + clientId: z.string().trim().min(1, "Client ID required").describe(AppConnections.CREDENTIALS.CAMUNDA.clientId), + clientSecret: z + .string() + .trim() + .min(1, "Client Secret required") + .describe(AppConnections.CREDENTIALS.CAMUNDA.clientSecret) +}); + +export const CamundaConnectionClientCredentialsOutputCredentialsSchema = z + .object({ + accessToken: z.string(), + expiresAt: z.number() + }) + .merge(CamundaConnectionClientCredentialsInputCredentialsSchema); + +export const CamundaConnectionSchema = z.intersection( + BaseCamundaConnectionSchema, + z.discriminatedUnion("method", [ + z.object({ + method: z.literal(CamundaConnectionMethod.ClientCredentials), + credentials: CamundaConnectionClientCredentialsOutputCredentialsSchema + }) + ]) +); + +export const SanitizedCamundaConnectionSchema = z.discriminatedUnion("method", [ + BaseCamundaConnectionSchema.extend({ + method: z.literal(CamundaConnectionMethod.ClientCredentials), + credentials: CamundaConnectionClientCredentialsOutputCredentialsSchema.pick({ + clientId: true + }) + }) +]); + +export const ValidateCamundaConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(CamundaConnectionMethod.ClientCredentials) + .describe(AppConnections.CREATE(AppConnection.Camunda).method), + credentials: CamundaConnectionClientCredentialsInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Camunda).credentials + ) + }) +]); + +export const CreateCamundaConnectionSchema = ValidateCamundaConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Camunda) +); + +export const UpdateCamundaConnectionSchema = z + .object({ + credentials: CamundaConnectionClientCredentialsInputCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Camunda).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Camunda)); + +export const CamundaConnectionListItemSchema = z.object({ + name: z.literal("Camunda"), + app: z.literal(AppConnection.Camunda), + methods: z.nativeEnum(CamundaConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/camunda/camunda-connection-service.ts b/backend/src/services/app-connection/camunda/camunda-connection-service.ts new file mode 100644 index 000000000..28b3882fa --- /dev/null +++ b/backend/src/services/app-connection/camunda/camunda-connection-service.ts @@ -0,0 +1,50 @@ +import { request } from "@app/lib/config/request"; +import { OrgServiceActor } from "@app/lib/types"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +import { getCamundaConnectionAccessToken } from "./camunda-connection-fns"; +import { TCamundaConnection, TCamundaListClustersResponse } from "./camunda-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +const listCamundaClusters = async ( + appConnection: TCamundaConnection, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const accessToken = await getCamundaConnectionAccessToken(appConnection, appConnectionDAL, kmsService); + + const { data } = await request.get(`${IntegrationUrls.CAMUNDA_API_URL}/clusters`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + }); + + return data ?? []; +}; + +export const camundaConnectionService = ( + getAppConnection: TGetAppConnectionFunc, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const listClusters = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Camunda, connectionId, actor); + + const clusters = await listCamundaClusters(appConnection, appConnectionDAL, kmsService); + + return clusters; + }; + + return { + listClusters + }; +}; diff --git a/backend/src/services/app-connection/camunda/camunda-connection-types.ts b/backend/src/services/app-connection/camunda/camunda-connection-types.ts new file mode 100644 index 000000000..d59a8c7ce --- /dev/null +++ b/backend/src/services/app-connection/camunda/camunda-connection-types.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { + CamundaConnectionSchema, + CreateCamundaConnectionSchema, + ValidateCamundaConnectionCredentialsSchema +} from "./camunda-connection-schema"; + +export type TCamundaConnection = z.infer; + +export type TCamundaConnectionInput = z.infer & { + app: AppConnection.Camunda; +}; + +export type TValidateCamundaConnectionCredentialsSchema = typeof ValidateCamundaConnectionCredentialsSchema; + +export type TCamundaConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type TAuthorizeCamundaConnection = { + access_token: string; + scope: string; + token_type: string; + expires_in: number; +}; + +export type TCamundaListClustersResponse = { uuid: string; name: string }[]; diff --git a/backend/src/services/app-connection/camunda/index.ts b/backend/src/services/app-connection/camunda/index.ts new file mode 100644 index 000000000..445871724 --- /dev/null +++ b/backend/src/services/app-connection/camunda/index.ts @@ -0,0 +1,4 @@ +export * from "./camunda-connection-enums"; +export * from "./camunda-connection-fns"; +export * from "./camunda-connection-schema"; +export * from "./camunda-connection-types"; diff --git a/backend/src/services/app-connection/terraform-cloud/index.ts b/backend/src/services/app-connection/terraform-cloud/index.ts new file mode 100644 index 000000000..dd7493443 --- /dev/null +++ b/backend/src/services/app-connection/terraform-cloud/index.ts @@ -0,0 +1,4 @@ +export * from "./terraform-cloud-connection-enums"; +export * from "./terraform-cloud-connection-fns"; +export * from "./terraform-cloud-connection-schemas"; +export * from "./terraform-cloud-connection-types"; diff --git a/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-enums.ts b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-enums.ts new file mode 100644 index 000000000..7f6696ef2 --- /dev/null +++ b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-enums.ts @@ -0,0 +1,3 @@ +export enum TerraformCloudConnectionMethod { + ApiToken = "api-token" +} diff --git a/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-fns.ts b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-fns.ts new file mode 100644 index 000000000..017766bae --- /dev/null +++ b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-fns.ts @@ -0,0 +1,135 @@ +import { AxiosError, AxiosResponse } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { TerraformCloudConnectionMethod } from "./terraform-cloud-connection-enums"; +import { + TTerraformCloudConnection, + TTerraformCloudConnectionConfig, + TTerraformCloudOrganization, + TTerraformCloudVariableSet, + TTerraformCloudWorkspace +} from "./terraform-cloud-connection-types"; + +export const getTerraformCloudConnectionListItem = () => { + return { + name: "Terraform Cloud" as const, + app: AppConnection.TerraformCloud as const, + methods: Object.values(TerraformCloudConnectionMethod) as [TerraformCloudConnectionMethod.ApiToken] + }; +}; + +export const validateTerraformCloudConnectionCredentials = async (config: TTerraformCloudConnectionConfig) => { + const { credentials: inputCredentials } = config; + + let response: AxiosResponse<{ data: TTerraformCloudOrganization[] }> | null = null; + + try { + response = await request.get<{ data: TTerraformCloudOrganization[] }>( + `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations`, + { + headers: { + Authorization: `Bearer ${inputCredentials.apiToken}`, + "Content-Type": "application/vnd.api+json" + } + } + ); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection - verify credentials" + }); + } + + if (!response?.data) { + throw new InternalServerError({ + message: "Failed to get organizations: Response was empty" + }); + } + + return inputCredentials; +}; + +export const listOrganizations = async ( + appConnection: TTerraformCloudConnection +): Promise => { + const { + credentials: { apiToken } + } = appConnection; + + const headers = { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/vnd.api+json" + }; + + const fetchAllPages = async (url: string): Promise => { + let results: T[] = []; + let nextUrl: string | null = url; + + while (nextUrl) { + // eslint-disable-next-line no-await-in-loop + const res: AxiosResponse<{ data: T[]; links?: { next?: string } }> = await request.get(nextUrl, { headers }); + results = results.concat(res.data.data); + nextUrl = res.data.links?.next || null; + } + + return results; + }; + + const orgEntities = await fetchAllPages<{ id: string; attributes: { name: string } }>( + `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations` + ); + + const orgsWithVariableSetsAndWorkspaces: TTerraformCloudOrganization[] = []; + + const variableSetPromises = orgEntities.map((org) => + fetchAllPages<{ id: string; attributes: { name: string; description?: string; global?: boolean } }>( + `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations/${org.id}/varsets` + ).catch(() => []) + ); + + const workspacePromises = orgEntities.map((org) => + fetchAllPages<{ id: string; attributes: { name: string } }>( + `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations/${org.id}/workspaces` + ).catch(() => []) + ); + + const [variableSetResults, workspaceResults] = await Promise.all([ + Promise.all(variableSetPromises), + Promise.all(workspacePromises) + ]); + + for (let i = 0; i < orgEntities.length; i += 1) { + const org = orgEntities[i]; + const variableSetsData = variableSetResults[i]; + const workspacesData = workspaceResults[i]; + + const variableSets: TTerraformCloudVariableSet[] = variableSetsData.map((varSet) => ({ + id: varSet.id, + name: varSet.attributes.name, + description: varSet.attributes.description, + global: varSet.attributes.global + })); + + const workspaces: TTerraformCloudWorkspace[] = workspacesData.map((workspace) => ({ + id: workspace.id, + name: workspace.attributes.name + })); + + orgsWithVariableSetsAndWorkspaces.push({ + id: org.id, + name: org.attributes.name, + variableSets, + workspaces + }); + } + + return orgsWithVariableSetsAndWorkspaces; +}; diff --git a/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-schemas.ts b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-schemas.ts new file mode 100644 index 000000000..0d408ba4f --- /dev/null +++ b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-schemas.ts @@ -0,0 +1,60 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { TerraformCloudConnectionMethod } from "./terraform-cloud-connection-enums"; + +export const TerraformCloudConnectionAccessTokenCredentialsSchema = z.object({ + apiToken: z.string().trim().min(1, "API Token required").describe(AppConnections.CREDENTIALS.TERRAFORM_CLOUD.apiToken) +}); + +const BaseTerraformCloudConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.TerraformCloud) +}); + +export const TerraformCloudConnectionSchema = BaseTerraformCloudConnectionSchema.extend({ + method: z.literal(TerraformCloudConnectionMethod.ApiToken), + credentials: TerraformCloudConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedTerraformCloudConnectionSchema = z.discriminatedUnion("method", [ + BaseTerraformCloudConnectionSchema.extend({ + method: z.literal(TerraformCloudConnectionMethod.ApiToken), + credentials: TerraformCloudConnectionAccessTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateTerraformCloudConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(TerraformCloudConnectionMethod.ApiToken) + .describe(AppConnections?.CREATE(AppConnection.TerraformCloud).method), + credentials: TerraformCloudConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.TerraformCloud).credentials + ) + }) +]); + +export const CreateTerraformCloudConnectionSchema = ValidateTerraformCloudConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.TerraformCloud) +); + +export const UpdateTerraformCloudConnectionSchema = z + .object({ + credentials: TerraformCloudConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.TerraformCloud).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.TerraformCloud)); + +export const TerraformCloudConnectionListItemSchema = z.object({ + name: z.literal("Terraform Cloud"), + app: z.literal(AppConnection.TerraformCloud), + methods: z.nativeEnum(TerraformCloudConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-service.ts b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-service.ts new file mode 100644 index 000000000..56d56492b --- /dev/null +++ b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-service.ts @@ -0,0 +1,29 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listOrganizations as getTerraformCloudOrganizations } from "./terraform-cloud-connection-fns"; +import { TTerraformCloudConnection } from "./terraform-cloud-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const terraformCloudConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listOrganizations = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.TerraformCloud, connectionId, actor); + try { + const organizations = await getTerraformCloudOrganizations(appConnection); + return organizations; + } catch (error) { + logger.error(error, "Failed to establish connection with Terraform Cloud"); + return []; + } + }; + + return { + listOrganizations + }; +}; diff --git a/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-types.ts b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-types.ts new file mode 100644 index 000000000..cabcbb146 --- /dev/null +++ b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-types.ts @@ -0,0 +1,45 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateTerraformCloudConnectionSchema, + TerraformCloudConnectionSchema, + ValidateTerraformCloudConnectionCredentialsSchema +} from "./terraform-cloud-connection-schemas"; + +export type TTerraformCloudConnection = z.infer; + +export type TTerraformCloudConnectionInput = z.infer & { + app: AppConnection.TerraformCloud; +}; + +export type TValidateTerraformCloudConnectionCredentialsSchema = + typeof ValidateTerraformCloudConnectionCredentialsSchema; + +export type TTerraformCloudConnectionConfig = DiscriminativePick< + TTerraformCloudConnectionInput, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TTerraformCloudVariableSet = { + id: string; + name: string; + description?: string; + global?: boolean; +}; + +export type TTerraformCloudWorkspace = { + id: string; + name: string; +}; + +export type TTerraformCloudOrganization = { + id: string; + name: string; + variableSets: TTerraformCloudVariableSet[]; + workspaces: TTerraformCloudWorkspace[]; +}; diff --git a/backend/src/services/app-connection/vercel/index.ts b/backend/src/services/app-connection/vercel/index.ts new file mode 100644 index 000000000..82d8493f8 --- /dev/null +++ b/backend/src/services/app-connection/vercel/index.ts @@ -0,0 +1,4 @@ +export * from "./vercel-connection-enums"; +export * from "./vercel-connection-fns"; +export * from "./vercel-connection-schemas"; +export * from "./vercel-connection-types"; diff --git a/backend/src/services/app-connection/vercel/vercel-connection-enums.ts b/backend/src/services/app-connection/vercel/vercel-connection-enums.ts new file mode 100644 index 000000000..1bff0eb57 --- /dev/null +++ b/backend/src/services/app-connection/vercel/vercel-connection-enums.ts @@ -0,0 +1,3 @@ +export enum VercelConnectionMethod { + ApiToken = "api-token" +} diff --git a/backend/src/services/app-connection/vercel/vercel-connection-fns.ts b/backend/src/services/app-connection/vercel/vercel-connection-fns.ts new file mode 100644 index 000000000..43de8641d --- /dev/null +++ b/backend/src/services/app-connection/vercel/vercel-connection-fns.ts @@ -0,0 +1,273 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError, AxiosResponse } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { TVercelBranches } from "@app/services/integration-auth/integration-auth-types"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { VercelConnectionMethod } from "./vercel-connection-enums"; +import { + TVercelConnection, + TVercelConnectionConfig, + VercelApp, + VercelEnvironment, + VercelOrgWithApps +} from "./vercel-connection-types"; + +export const getVercelConnectionListItem = () => { + return { + name: "Vercel" as const, + app: AppConnection.Vercel as const, + methods: Object.values(VercelConnectionMethod) as [VercelConnectionMethod.ApiToken] + }; +}; + +export const validateVercelConnectionCredentials = async (config: TVercelConnectionConfig) => { + const { credentials: inputCredentials } = config; + + let response: AxiosResponse | null = null; + + try { + response = await request.get(`${IntegrationUrls.VERCEL_API_URL}/v9/projects`, { + headers: { + Authorization: `Bearer ${inputCredentials.apiToken}` + } + }); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection - verify credentials" + }); + } + + if (!response?.data) { + throw new InternalServerError({ + message: "Failed to get organizations: Response was empty" + }); + } + + return inputCredentials; +}; + +interface ApiResponse { + pagination?: { + count: number; + next: number; + }; + data: T[]; + [key: string]: unknown; +} + +async function fetchAllPages( + apiUrl: string, + apiToken: string, + initialParams: Record = {}, + dataPath?: string +): Promise { + const allItems: T[] = []; + let hasMoreItems = true; + let params: Record = { ...initialParams, limit: 100 }; + + while (hasMoreItems) { + try { + const response = await request.get>(apiUrl, { + params, + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + }); + + if (!response?.data) { + throw new InternalServerError({ + message: `Failed to fetch data from ${apiUrl}: Response was empty or malformed` + }); + } + + let itemsData: T[]; + + if (dataPath && dataPath in response.data) { + itemsData = response.data[dataPath] as T[]; + } else { + itemsData = response.data.data; + } + + if (!Array.isArray(itemsData)) { + throw new InternalServerError({ + message: `Failed to fetch data from ${apiUrl}: Expected array but got ${typeof itemsData}` + }); + } + + allItems.push(...itemsData); + + if (response.data.pagination?.next) { + params = { ...params, since: response.data.pagination.next }; + } else { + hasMoreItems = false; + } + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to fetch data from ${apiUrl}: ${error.message || "Unknown error"}` + }); + } + throw error; + } + } + + return allItems; +} + +async function fetchOrgProjects(orgId: string, apiToken: string): Promise { + return fetchAllPages( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects`, + apiToken, + { teamId: orgId }, + "projects" + ); +} + +async function fetchProjectEnvironments( + projectId: string, + teamId: string, + apiToken: string +): Promise { + try { + return await fetchAllPages( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${projectId}/custom-environments?teamId=${teamId}`, + apiToken, + {}, + "environments" + ); + } catch (error) { + return []; + } +} + +async function fetchPreviewBranches(projectId: string, apiToken: string): Promise { + try { + const { data } = await request.get( + `${IntegrationUrls.VERCEL_API_URL}/v1/integrations/git-branches`, + { + params: { + projectId + }, + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + return data.filter((b) => b.ref !== "main").map((b) => b.ref); + } catch (error) { + return []; + } +} + +type VercelTeam = { + id: string; + name: string; + slug: string; +}; + +type VercelUserResponse = { + user: { + id: string; + name: string; + username: string; + }; +}; + +export const listProjects = async (appConnection: TVercelConnection): Promise => { + const { credentials } = appConnection; + const { apiToken } = credentials; + + const orgs = await fetchAllPages(`${IntegrationUrls.VERCEL_API_URL}/v2/teams`, apiToken, {}, "teams"); + + const personalAccountResponse = await request.get(`${IntegrationUrls.VERCEL_API_URL}/v2/user`, { + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + }); + + if (personalAccountResponse?.data?.user) { + const { user } = personalAccountResponse.data; + orgs.push({ + id: user.id, + name: user.name || "Personal Account", + slug: user.username || "personal" + }); + } + + const orgsWithApps: VercelOrgWithApps[] = []; + + const orgPromises = orgs.map(async (org) => { + try { + const projects = await fetchOrgProjects(org.id, apiToken); + + const enhancedProjectsPromises = projects.map(async (project) => { + try { + const [environments, previewBranches] = await Promise.all([ + fetchProjectEnvironments(project.name, org.id, apiToken), + fetchPreviewBranches(project.id, apiToken) + ]); + + return { + name: project.name, + id: project.id, + envs: environments, + previewBranches + }; + } catch (error) { + return { + name: project.name, + id: project.id, + envs: [], + previewBranches: [] + }; + } + }); + + const enhancedProjects = await Promise.all(enhancedProjectsPromises); + + return { + ...org, + apps: enhancedProjects + }; + } catch (error) { + return null; + } + }); + + const results = await Promise.all(orgPromises); + + results.forEach((result) => { + if (result !== null) { + orgsWithApps.push(result); + } + }); + + return orgsWithApps; +}; + +export const getProjectEnvironmentVariables = (project: VercelApp): Record => { + const envVars: Record = {}; + + if (!project.envs) return envVars; + + project.envs.forEach((env) => { + if (env.slug && env.type !== "gitBranch") { + const { id, slug } = env; + envVars[id] = slug; + } + }); + + return envVars; +}; diff --git a/backend/src/services/app-connection/vercel/vercel-connection-schemas.ts b/backend/src/services/app-connection/vercel/vercel-connection-schemas.ts new file mode 100644 index 000000000..60baa4f5c --- /dev/null +++ b/backend/src/services/app-connection/vercel/vercel-connection-schemas.ts @@ -0,0 +1,58 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { VercelConnectionMethod } from "./vercel-connection-enums"; + +export const VercelConnectionAccessTokenCredentialsSchema = z.object({ + apiToken: z.string().trim().min(1, "API Token required").describe(AppConnections.CREDENTIALS.VERCEL.apiToken) +}); + +const BaseVercelConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.Vercel) +}); + +export const VercelConnectionSchema = BaseVercelConnectionSchema.extend({ + method: z.literal(VercelConnectionMethod.ApiToken), + credentials: VercelConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedVercelConnectionSchema = z.discriminatedUnion("method", [ + BaseVercelConnectionSchema.extend({ + method: z.literal(VercelConnectionMethod.ApiToken), + credentials: VercelConnectionAccessTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateVercelConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(VercelConnectionMethod.ApiToken).describe(AppConnections.CREATE(AppConnection.Vercel).method), + credentials: VercelConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Vercel).credentials + ) + }) +]); + +export const CreateVercelConnectionSchema = ValidateVercelConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Vercel) +); + +export const UpdateVercelConnectionSchema = z + .object({ + credentials: VercelConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Vercel).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Vercel)); + +export const VercelConnectionListItemSchema = z.object({ + name: z.literal("Vercel"), + app: z.literal(AppConnection.Vercel), + methods: z.nativeEnum(VercelConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/vercel/vercel-connection-service.ts b/backend/src/services/app-connection/vercel/vercel-connection-service.ts new file mode 100644 index 000000000..68e5215e9 --- /dev/null +++ b/backend/src/services/app-connection/vercel/vercel-connection-service.ts @@ -0,0 +1,29 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listProjects as getVercelProjects } from "./vercel-connection-fns"; +import { TVercelConnection } from "./vercel-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const vercelConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listProjects = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Vercel, connectionId, actor); + try { + const projects = await getVercelProjects(appConnection); + return projects; + } catch (error) { + logger.error(error, "Failed to establish connection with Vercel"); + return []; + } + }; + + return { + listProjects + }; +}; diff --git a/backend/src/services/app-connection/vercel/vercel-connection-types.ts b/backend/src/services/app-connection/vercel/vercel-connection-types.ts new file mode 100644 index 000000000..4ab69d1df --- /dev/null +++ b/backend/src/services/app-connection/vercel/vercel-connection-types.ts @@ -0,0 +1,73 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateVercelConnectionSchema, + ValidateVercelConnectionCredentialsSchema, + VercelConnectionSchema +} from "./vercel-connection-schemas"; + +export type TVercelConnection = z.infer; + +export type TVercelConnectionInput = z.infer & { + app: AppConnection.Vercel; +}; + +export type TValidateVercelConnectionCredentialsSchema = typeof ValidateVercelConnectionCredentialsSchema; + +export type TVercelConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type VercelTeam = { + id: string; + name: string; + slug: string; +}; + +export type VercelEnvironment = { + id: string; + slug: string; + type: string; + target?: string[]; + gitBranch?: string; + createdAt?: number; + updatedAt?: number; +}; + +export type VercelAppMeta = { + githubCommitRef?: string; + githubCommitSha?: string; + githubCommitMessage?: string; + githubCommitAuthorName?: string; +}; + +export type VercelDeployment = { + id: string; + name: string; + url: string; + created: number; + meta?: VercelAppMeta; + target?: "production" | "preview" | "development"; +}; + +export type VercelApp = { + name: string; + id: string; + envs?: VercelEnvironment[]; + previewBranches?: string[]; +}; + +export type VercelOrgWithApps = VercelTeam & { + apps: VercelApp[]; +}; + +export type VercelUserResponse = { + user: { + id: string; + name: string; + username: string; + }; +}; diff --git a/backend/src/services/cmek/cmek-service.ts b/backend/src/services/cmek/cmek-service.ts index 5e74a5bac..b968a8951 100644 --- a/backend/src/services/cmek/cmek-service.ts +++ b/backend/src/services/cmek/cmek-service.ts @@ -3,12 +3,18 @@ import { ForbiddenError } from "@casl/ability"; import { ActionProjectType, ProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionCmekActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { SigningAlgorithm } from "@app/lib/crypto/sign"; import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; import { TCmekDecryptDTO, TCmekEncryptDTO, + TCmekGetPublicKeyDTO, + TCmekKeyEncryptionAlgorithm, + TCmekListSigningAlgorithmsDTO, + TCmekSignDTO, + TCmekVerifyDTO, TCreateCmekDTO, TListCmeksByProjectIdDTO, TUpdabteCmekByIdDTO @@ -16,6 +22,7 @@ import { import { TKmsKeyDALFactory } from "@app/services/kms/kms-key-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsKeyUsage } from "../kms/kms-types"; import { TProjectDALFactory } from "../project/project-dal"; type TCmekServiceFactoryDep = { @@ -221,7 +228,151 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService, proj const { cipherTextBlob } = await encrypt({ plainText: Buffer.from(plaintext, "base64") }); - return cipherTextBlob.toString("base64"); + return { + ciphertext: cipherTextBlob.toString("base64"), + projectId: key.projectId + }; + }; + + const listSigningAlgorithms = async ({ keyId }: TCmekListSigningAlgorithmsDTO, actor: OrgServiceActor) => { + const key = await kmsDAL.findCmekById(keyId); + + if (!key) throw new NotFoundError({ message: `Key with ID "${keyId}" not found` }); + if (!key.projectId || key.isReserved) throw new BadRequestError({ message: "Key is not customer managed" }); + if (key.isDisabled) throw new BadRequestError({ message: "Key is disabled" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: key.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.KMS + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); + + if (key.keyUsage !== KmsKeyUsage.SIGN_VERIFY) { + throw new BadRequestError({ message: `Key with ID '${keyId}' is not intended for signing` }); + } + + const encryptionAlgorithm = key.encryptionAlgorithm as TCmekKeyEncryptionAlgorithm; + + const algos = [ + { + keyAlgorithm: "rsa", + signingAlgorithms: Object.values(SigningAlgorithm).filter((algorithm) => + algorithm.toLowerCase().startsWith("rsa") + ) + }, + { + keyAlgorithm: "ecc", + signingAlgorithms: Object.values(SigningAlgorithm).filter((algorithm) => + algorithm.toLowerCase().startsWith("ecdsa") + ) + } + ]; + + const selectedAlgorithm = algos.find((algo) => encryptionAlgorithm.toLowerCase().startsWith(algo.keyAlgorithm)); + + if (!selectedAlgorithm) { + throw new BadRequestError({ message: `Unsupported encryption algorithm: ${encryptionAlgorithm}` }); + } + + return { signingAlgorithms: selectedAlgorithm.signingAlgorithms, projectId: key.projectId }; + }; + + const getPublicKey = async ({ keyId }: TCmekGetPublicKeyDTO, actor: OrgServiceActor) => { + const key = await kmsDAL.findCmekById(keyId); + + if (!key) throw new NotFoundError({ message: `Key with ID "${keyId}" not found` }); + if (!key.projectId || key.isReserved) throw new BadRequestError({ message: "Key is not customer managed" }); + if (key.isDisabled) throw new BadRequestError({ message: "Key is disabled" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: key.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.KMS + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); + + const publicKey = await kmsService.getPublicKey({ kmsId: keyId }); + return { publicKey: publicKey.toString("base64"), projectId: key.projectId }; + }; + + const cmekSign = async ({ keyId, data, signingAlgorithm, isDigest }: TCmekSignDTO, actor: OrgServiceActor) => { + const key = await kmsDAL.findCmekById(keyId); + + if (!key) throw new NotFoundError({ message: `Key with ID "${keyId}" not found` }); + + if (!key.projectId || key.isReserved) throw new BadRequestError({ message: "Key is not customer managed" }); + + if (key.isDisabled) throw new BadRequestError({ message: "Key is disabled" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: key.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.KMS + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Sign, ProjectPermissionSub.Cmek); + + const sign = await kmsService.signWithKmsKey({ kmsId: keyId }); + + const { signature, algorithm } = await sign({ data: Buffer.from(data, "base64"), signingAlgorithm, isDigest }); + + return { + signature: signature.toString("base64"), + keyId: key.id, + projectId: key.projectId, + signingAlgorithm: algorithm + }; + }; + + const cmekVerify = async ( + { keyId, data, signature, signingAlgorithm, isDigest }: TCmekVerifyDTO, + actor: OrgServiceActor + ) => { + const key = await kmsDAL.findCmekById(keyId); + + if (!key) throw new NotFoundError({ message: `Key with ID "${keyId}" not found` }); + + if (!key.projectId || key.isReserved) throw new BadRequestError({ message: "Key is not customer managed" }); + + if (key.isDisabled) throw new BadRequestError({ message: "Key is disabled" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: key.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.KMS + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Verify, ProjectPermissionSub.Cmek); + + const verify = await kmsService.verifyWithKmsKey({ kmsId: keyId, signingAlgorithm }); + + const { signatureValid, algorithm } = await verify({ + isDigest, + data: Buffer.from(data, "base64"), + signature: Buffer.from(signature, "base64") + }); + + return { + signatureValid, + keyId: key.id, + projectId: key.projectId, + signingAlgorithm: algorithm + }; }; const cmekDecrypt = async ({ keyId, ciphertext }: TCmekDecryptDTO, actor: OrgServiceActor) => { @@ -248,7 +399,10 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService, proj const plaintextBlob = await decrypt({ cipherTextBlob: Buffer.from(ciphertext, "base64") }); - return plaintextBlob.toString("base64"); + return { + plaintext: plaintextBlob.toString("base64"), + projectId: key.projectId + }; }; return { @@ -259,6 +413,10 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService, proj cmekEncrypt, cmekDecrypt, findCmekById, - findCmekByName + findCmekByName, + cmekSign, + cmekVerify, + listSigningAlgorithms, + getPublicKey }; }; diff --git a/backend/src/services/cmek/cmek-types.ts b/backend/src/services/cmek/cmek-types.ts index b99ff1d6e..0421bce0e 100644 --- a/backend/src/services/cmek/cmek-types.ts +++ b/backend/src/services/cmek/cmek-types.ts @@ -1,12 +1,18 @@ -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; +import { AsymmetricKeyAlgorithm, SigningAlgorithm } from "@app/lib/crypto/sign"; import { OrderByDirection } from "@app/lib/types"; +import { KmsKeyUsage } from "../kms/kms-types"; + +export type TCmekKeyEncryptionAlgorithm = SymmetricKeyAlgorithm | AsymmetricKeyAlgorithm; + export type TCreateCmekDTO = { orgId: string; projectId: string; name: string; description?: string; - encryptionAlgorithm: SymmetricEncryption; + encryptionAlgorithm: TCmekKeyEncryptionAlgorithm; + keyUsage: KmsKeyUsage; }; export type TUpdabteCmekByIdDTO = { @@ -38,3 +44,26 @@ export type TCmekDecryptDTO = { export enum CmekOrderBy { Name = "name" } + +export type TCmekListSigningAlgorithmsDTO = { + keyId: string; +}; + +export type TCmekGetPublicKeyDTO = { + keyId: string; +}; + +export type TCmekSignDTO = { + keyId: string; + data: string; + signingAlgorithm: SigningAlgorithm; + isDigest: boolean; +}; + +export type TCmekVerifyDTO = { + keyId: string; + data: string; + signature: string; + signingAlgorithm: SigningAlgorithm; + isDigest: boolean; +}; diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index ba63f7afd..14df0cd4a 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -21,6 +21,7 @@ import { TCreateProjectIdentityDTO, TDeleteProjectIdentityDTO, TGetProjectIdentityByIdentityIdDTO, + TGetProjectIdentityByMembershipIdDTO, TListProjectIdentityDTO, TUpdateProjectIdentityDTO } from "./identity-project-types"; @@ -370,11 +371,48 @@ export const identityProjectServiceFactory = ({ return identityMembership; }; + const getProjectIdentityByMembershipId = async ({ + identityMembershipId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TGetProjectIdentityByMembershipIdDTO) => { + const membership = await identityProjectDAL.findOne({ id: identityMembershipId }); + + if (!membership) { + throw new NotFoundError({ + message: `Project membership with ID '${identityMembershipId}' not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: membership.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.Any + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId: membership.identityId }) + ); + + const [identityMembership] = await identityProjectDAL.findByProjectId(membership.projectId, { + identityId: membership.identityId + }); + + return identityMembership; + }; + return { createProjectIdentity, updateProjectIdentity, deleteProjectIdentity, listProjectIdentities, - getProjectIdentityByIdentityId + getProjectIdentityByIdentityId, + getProjectIdentityByMembershipId }; }; diff --git a/backend/src/services/identity-project/identity-project-types.ts b/backend/src/services/identity-project/identity-project-types.ts index 607fd4823..bc85ca398 100644 --- a/backend/src/services/identity-project/identity-project-types.ts +++ b/backend/src/services/identity-project/identity-project-types.ts @@ -52,6 +52,10 @@ export type TGetProjectIdentityByIdentityIdDTO = { identityId: string; } & TProjectPermission; +export type TGetProjectIdentityByMembershipIdDTO = { + identityMembershipId: string; +} & Omit; + export enum ProjectIdentityOrderBy { Name = "name" } diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index 92a6795d0..dbae59bbe 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -14,10 +14,15 @@ import { TIdentityUniversalAuths, TOrgRoles } from "@app/db/schemas"; -import { DatabaseError } from "@app/lib/errors"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { buildKnexFilterForSearchResource } from "@app/lib/search-resource/db"; import { OrderByDirection } from "@app/lib/types"; -import { OrgIdentityOrderBy, TListOrgIdentitiesByOrgIdDTO } from "@app/services/identity/identity-types"; +import { + OrgIdentityOrderBy, + TListOrgIdentitiesByOrgIdDTO, + TSearchOrgIdentitiesByOrgIdDAL +} from "@app/services/identity/identity-types"; import { buildAuthMethods } from "./identity-fns"; @@ -195,7 +200,6 @@ export const identityOrgDALFactory = (db: TDbClient) => { "paginatedIdentity.identityId", `${TableName.IdentityJwtAuth}.identityId` ) - .select( db.ref("id").withSchema("paginatedIdentity"), db.ref("role").withSchema("paginatedIdentity"), @@ -309,6 +313,214 @@ export const identityOrgDALFactory = (db: TDbClient) => { } }; + const searchIdentities = async ( + { + limit, + offset = 0, + orderBy = OrgIdentityOrderBy.Name, + orderDirection = OrderByDirection.ASC, + searchFilter, + orgId + }: TSearchOrgIdentitiesByOrgIdDAL, + tx?: Knex + ) => { + try { + const searchQuery = (tx || db.replicaNode())(TableName.IdentityOrgMembership) + .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityOrgMembership}.identityId`) + .where(`${TableName.IdentityOrgMembership}.orgId`, orgId) + .leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`) + .orderBy(`${TableName.Identity}.${orderBy}`, orderDirection) + .select(`${TableName.IdentityOrgMembership}.id`) + .select<{ id: string; total_count: string }>( + db.raw( + `count(${TableName.IdentityOrgMembership}."identityId") OVER(PARTITION BY ${TableName.IdentityOrgMembership}."orgId") as total_count` + ) + ) + .as("searchedIdentities"); + + if (searchFilter) { + buildKnexFilterForSearchResource(searchQuery, searchFilter, (attr) => { + switch (attr) { + case "role": + return [`${TableName.OrgRoles}.slug`, `${TableName.IdentityOrgMembership}.role`]; + case "name": + return `${TableName.Identity}.name`; + default: + throw new BadRequestError({ message: `Invalid ${String(attr)} provided` }); + } + }); + } + + if (limit) { + void searchQuery.offset(offset).limit(limit); + } + + type TSubquery = Awaited; + const query = (tx || db.replicaNode())(TableName.IdentityOrgMembership) + .where(`${TableName.IdentityOrgMembership}.orgId`, orgId) + .join(searchQuery, `${TableName.IdentityOrgMembership}.id`, "searchedIdentities.id") + .join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`) + .leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.IdentityOrgMembership}.identityId`, `${TableName.IdentityMetadata}.identityId`) + .andOn(`${TableName.IdentityOrgMembership}.orgId`, `${TableName.IdentityMetadata}.orgId`); + }) + .leftJoin( + TableName.IdentityUniversalAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityUniversalAuth}.identityId` + ) + .leftJoin( + TableName.IdentityGcpAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityGcpAuth}.identityId` + ) + .leftJoin( + TableName.IdentityAwsAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityAwsAuth}.identityId` + ) + .leftJoin( + TableName.IdentityKubernetesAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityKubernetesAuth}.identityId` + ) + .leftJoin( + TableName.IdentityOidcAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityOidcAuth}.identityId` + ) + .leftJoin( + TableName.IdentityAzureAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityAzureAuth}.identityId` + ) + .leftJoin( + TableName.IdentityTokenAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityTokenAuth}.identityId` + ) + .leftJoin( + TableName.IdentityJwtAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityJwtAuth}.identityId` + ) + .select( + db.ref("id").withSchema(TableName.IdentityOrgMembership), + db.ref("total_count").withSchema("searchedIdentities"), + db.ref("role").withSchema(TableName.IdentityOrgMembership), + db.ref("roleId").withSchema(TableName.IdentityOrgMembership), + db.ref("orgId").withSchema(TableName.IdentityOrgMembership), + db.ref("createdAt").withSchema(TableName.IdentityOrgMembership), + db.ref("updatedAt").withSchema(TableName.IdentityOrgMembership), + db.ref("identityId").withSchema(TableName.IdentityOrgMembership).as("identityId"), + db.ref("name").withSchema(TableName.Identity).as("identityName"), + + db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), + db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), + db.ref("id").as("awsId").withSchema(TableName.IdentityAwsAuth), + db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), + db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), + db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), + db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), + db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth) + ) + // cr stands for custom role + .select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) + .select(db.ref("name").as("crName").withSchema(TableName.OrgRoles)) + .select(db.ref("slug").as("crSlug").withSchema(TableName.OrgRoles)) + .select(db.ref("description").as("crDescription").withSchema(TableName.OrgRoles)) + .select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles)) + .select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles)) + .select( + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue") + ); + + if (orderBy === OrgIdentityOrderBy.Name) { + void query.orderBy("identityName", orderDirection); + } + + const docs = await query; + const formattedDocs = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: ({ + crId, + crDescription, + crSlug, + crPermission, + crName, + identityId, + identityName, + role, + roleId, + total_count, + id, + uaId, + awsId, + gcpId, + jwtId, + kubernetesId, + oidcId, + azureId, + tokenId, + createdAt, + updatedAt + }) => ({ + role, + roleId, + identityId, + id, + total_count: total_count as string, + orgId, + createdAt, + updatedAt, + customRole: roleId + ? { + id: crId, + name: crName, + slug: crSlug, + permissions: crPermission, + description: crDescription + } + : undefined, + identity: { + id: identityId, + name: identityName, + authMethods: buildAuthMethods({ + uaId, + awsId, + gcpId, + kubernetesId, + oidcId, + azureId, + tokenId, + jwtId + }) + } + }), + childrenMapper: [ + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return { docs: formattedDocs, totalCount: Number(formattedDocs?.[0]?.total_count ?? 0) }; + } catch (error) { + throw new DatabaseError({ error, name: "FindByOrgId" }); + } + }; + const countAllOrgIdentities = async ( { search, ...filter }: Partial & Pick, tx?: Knex @@ -331,5 +543,5 @@ export const identityOrgDALFactory = (db: TDbClient) => { } }; - return { ...identityOrgOrm, find, findOne, countAllOrgIdentities }; + return { ...identityOrgOrm, find, findOne, countAllOrgIdentities, searchIdentities }; }; diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index f9185ba9d..6f72b3c6e 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -21,6 +21,7 @@ import { TGetIdentityByIdDTO, TListOrgIdentitiesByOrgIdDTO, TListProjectIdentitiesByIdentityIdDTO, + TSearchOrgIdentitiesByOrgIdDTO, TUpdateIdentityDTO } from "./identity-types"; @@ -288,6 +289,33 @@ export const identityServiceFactory = ({ return { identityMemberships, totalCount }; }; + const searchOrgIdentities = async ({ + orgId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + limit, + offset, + orderBy, + orderDirection, + searchFilter = {} + }: TSearchOrgIdentitiesByOrgIdDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + + const { totalCount, docs } = await identityOrgMembershipDAL.searchIdentities({ + orgId, + limit, + offset, + orderBy, + orderDirection, + searchFilter + }); + + return { identityMemberships: docs, totalCount }; + }; + const listProjectIdentitiesByIdentityId = async ({ identityId, actor, @@ -317,6 +345,7 @@ export const identityServiceFactory = ({ deleteIdentity, listOrgIdentities, getIdentityById, + searchOrgIdentities, listProjectIdentitiesByIdentityId }; }; diff --git a/backend/src/services/identity/identity-types.ts b/backend/src/services/identity/identity-types.ts index 0eca6b7ee..363d42a88 100644 --- a/backend/src/services/identity/identity-types.ts +++ b/backend/src/services/identity/identity-types.ts @@ -1,4 +1,5 @@ import { IPType } from "@app/lib/ip"; +import { TSearchResourceOperator } from "@app/lib/search-resource/search"; import { OrderByDirection, TOrgPermission } from "@app/lib/types"; export type TCreateIdentityDTO = { @@ -46,3 +47,17 @@ export enum OrgIdentityOrderBy { Name = "name" // Role = "role" } + +export type TSearchOrgIdentitiesByOrgIdDAL = { + limit?: number; + offset?: number; + orderBy?: OrgIdentityOrderBy; + orderDirection?: OrderByDirection; + orgId: string; + searchFilter?: Partial<{ + name: Omit; + role: Omit; + }>; +}; + +export type TSearchOrgIdentitiesByOrgIdDTO = TSearchOrgIdentitiesByOrgIdDAL & TOrgPermission; diff --git a/backend/src/services/integration-auth/integration-delete-secret.ts b/backend/src/services/integration-auth/integration-delete-secret.ts index 4b07245ac..18406f29a 100644 --- a/backend/src/services/integration-auth/integration-delete-secret.ts +++ b/backend/src/services/integration-auth/integration-delete-secret.ts @@ -50,7 +50,7 @@ const getIntegrationSecretsV2 = async ( } // process secrets in current folder - const secrets = await secretV2BridgeDAL.findByFolderId(dto.folderId); + const secrets = await secretV2BridgeDAL.findByFolderId({ folderId: dto.folderId, projectId: dto.projectId }); secrets.forEach((secret) => { const secretKey = secret.key; @@ -63,6 +63,7 @@ const getIntegrationSecretsV2 = async ( // if no imports then return secrets in the current folder if (!secretImports.length) return content; const importedSecrets = await fnSecretsV2FromImports({ + projectId: dto.projectId, decryptor: dto.decryptor, folderDAL, secretDAL: secretV2BridgeDAL, diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index d6c450751..7bd33d86b 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -63,6 +63,7 @@ export enum IntegrationUrls { GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token", GITLAB_TOKEN_URL = "https://gitlab.com/oauth/token", BITBUCKET_TOKEN_URL = "https://bitbucket.org/site/oauth2/access_token", + CAMUNDA_TOKEN_URL = "https://login.cloud.camunda.io/oauth/token", // integration apps endpoints GCP_API_URL = "https://cloudresourcemanager.googleapis.com", @@ -94,6 +95,7 @@ export enum IntegrationUrls { HASURA_CLOUD_API_URL = "https://data.pro.hasura.io/v1/graphql", AZURE_DEVOPS_API_URL = "https://dev.azure.com", HUMANITEC_API_URL = "https://api.humanitec.io", + CAMUNDA_API_URL = "https://api.cloud.camunda.io", GCP_SECRET_MANAGER_SERVICE_NAME = "secretmanager.googleapis.com", GCP_SECRET_MANAGER_URL = `https://${GCP_SECRET_MANAGER_SERVICE_NAME}`, diff --git a/backend/src/services/kms/kms-fns.ts b/backend/src/services/kms/kms-fns.ts index 06395272b..8c7aa13dd 100644 --- a/backend/src/services/kms/kms-fns.ts +++ b/backend/src/services/kms/kms-fns.ts @@ -1,13 +1,55 @@ -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; +import { AsymmetricKeyAlgorithm } from "@app/lib/crypto/sign"; +import { BadRequestError } from "@app/lib/errors"; + +import { KmsKeyUsage } from "./kms-types"; export const KMS_ROOT_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"; -export const getByteLengthForAlgorithm = (encryptionAlgorithm: SymmetricEncryption) => { +export const getByteLengthForSymmetricEncryptionAlgorithm = (encryptionAlgorithm: SymmetricKeyAlgorithm) => { switch (encryptionAlgorithm) { - case SymmetricEncryption.AES_GCM_128: + case SymmetricKeyAlgorithm.AES_GCM_128: return 16; - case SymmetricEncryption.AES_GCM_256: + case SymmetricKeyAlgorithm.AES_GCM_256: default: return 32; } }; + +export const verifyKeyTypeAndAlgorithm = ( + keyUsage: KmsKeyUsage, + algorithm: SymmetricKeyAlgorithm | AsymmetricKeyAlgorithm, + extra?: { + forceType?: KmsKeyUsage; + } +) => { + if (extra?.forceType && keyUsage !== extra.forceType) { + throw new BadRequestError({ + message: `Unsupported key type, expected ${extra.forceType} but got ${keyUsage}` + }); + } + + if (keyUsage === KmsKeyUsage.ENCRYPT_DECRYPT) { + if (!Object.values(SymmetricKeyAlgorithm).includes(algorithm as SymmetricKeyAlgorithm)) { + throw new BadRequestError({ + message: `Unsupported encryption algorithm for encrypt/decrypt key: ${algorithm as string}` + }); + } + + return true; + } + + if (keyUsage === KmsKeyUsage.SIGN_VERIFY) { + if (!Object.values(AsymmetricKeyAlgorithm).includes(algorithm as AsymmetricKeyAlgorithm)) { + throw new BadRequestError({ + message: `Unsupported sign/verify algorithm for sign/verify key: ${algorithm as string}` + }); + } + + return true; + } + + throw new BadRequestError({ + message: `Unsupported key type: ${keyUsage as string}` + }); +}; diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index cfd64a89a..754c9be76 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -15,12 +15,17 @@ import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { KeyStorePrefixes, PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { TEnvConfig } from "@app/lib/config/env"; import { randomSecureBytes } from "@app/lib/crypto"; -import { symmetricCipherService, SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { symmetricCipherService, SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; import { generateHash } from "@app/lib/crypto/encryption"; +import { AsymmetricKeyAlgorithm, signingService } from "@app/lib/crypto/sign"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; -import { getByteLengthForAlgorithm, KMS_ROOT_CONFIG_UUID } from "@app/services/kms/kms-fns"; +import { + getByteLengthForSymmetricEncryptionAlgorithm, + KMS_ROOT_CONFIG_UUID, + verifyKeyTypeAndAlgorithm +} from "@app/services/kms/kms-fns"; import { TOrgDALFactory } from "../org/org-dal"; import { TProjectDALFactory } from "../project/project-dal"; @@ -29,6 +34,7 @@ import { TKmsKeyDALFactory } from "./kms-key-dal"; import { TKmsRootConfigDALFactory } from "./kms-root-config-dal"; import { KmsDataKey, + KmsKeyUsage, KmsType, RootKeyEncryptionStrategy, TDecryptWithKeyDTO, @@ -38,8 +44,11 @@ import { TEncryptWithKmsDTO, TGenerateKMSDTO, TGetKeyMaterialDTO, + TGetPublicKeyDTO, TImportKeyMaterialDTO, - TUpdateProjectSecretManagerKmsKeyDTO + TSignWithKmsDTO, + TUpdateProjectSecretManagerKmsKeyDTO, + TVerifyWithKmsDTO } from "./kms-types"; type TKmsServiceFactoryDep = { @@ -83,19 +92,42 @@ export const kmsServiceFactory = ({ tx, name, projectId, - encryptionAlgorithm = SymmetricEncryption.AES_GCM_256, + encryptionAlgorithm = SymmetricKeyAlgorithm.AES_GCM_256, + keyUsage = KmsKeyUsage.ENCRYPT_DECRYPT, description }: TGenerateKMSDTO) => { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + // daniel: ensure that the key type (sign/encrypt) and the encryption algorithm are compatible. + verifyKeyTypeAndAlgorithm(keyUsage, encryptionAlgorithm); - const kmsKeyMaterial = randomSecureBytes(getByteLengthForAlgorithm(encryptionAlgorithm)); + let kmsKeyMaterial: Buffer | null = null; + if (keyUsage === KmsKeyUsage.ENCRYPT_DECRYPT) { + kmsKeyMaterial = randomSecureBytes( + getByteLengthForSymmetricEncryptionAlgorithm(encryptionAlgorithm as SymmetricKeyAlgorithm) + ); + } else if (keyUsage === KmsKeyUsage.SIGN_VERIFY) { + const { generateAsymmetricPrivateKey, getPublicKeyFromPrivateKey } = signingService( + encryptionAlgorithm as AsymmetricKeyAlgorithm + ); + kmsKeyMaterial = await generateAsymmetricPrivateKey(); + // daniel: safety check to ensure we're able to extract the public key from the private key before we proceed to key creation + getPublicKeyFromPrivateKey(kmsKeyMaterial); + } + + if (!kmsKeyMaterial) { + throw new BadRequestError({ + message: `Invalid KMS key type. No key material was created for key usage '${keyUsage}' using algorithm '${encryptionAlgorithm}'` + }); + } + + const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); const encryptedKeyMaterial = cipher.encrypt(kmsKeyMaterial, ROOT_ENCRYPTION_KEY); const sanitizedName = name ? slugify(name) : slugify(alphaNumericNanoId(8).toLowerCase()); const dbQuery = async (db: Knex) => { const kmsDoc = await kmsDAL.create( { name: sanitizedName, + keyUsage, orgId, isReserved, projectId, @@ -115,6 +147,7 @@ export const kmsServiceFactory = ({ ); return kmsDoc; }; + if (tx) return dbQuery(tx); const doc = await kmsDAL.transaction(async (tx2) => dbQuery(tx2)); return doc; @@ -134,7 +167,7 @@ export const kmsServiceFactory = ({ */ const encryptWithInputKey = async ({ key }: Omit) => { // akhilmhdh: as more encryption are added do a check here on kmsDoc.encryptionAlgorithm - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); return ({ plainText }: Pick) => { const encryptedPlainTextBlob = cipher.encrypt(plainText, key); // Buffer#1 encrypted text + Buffer#2 version number @@ -149,7 +182,7 @@ export const kmsServiceFactory = ({ * This can be even later exposed directly as api for encryption as function */ const decryptWithInputKey = async ({ key }: Omit) => { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); return ({ cipherTextBlob: versionedCipherTextBlob }: Pick) => { const cipherTextBlob = versionedCipherTextBlob.subarray(0, -KMS_VERSION_BLOB_LENGTH); @@ -227,7 +260,7 @@ export const kmsServiceFactory = ({ }; const encryptWithRootKey = () => { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); return (plainTextBuffer: Buffer) => { const encryptedBuffer = cipher.encrypt(plainTextBuffer, ROOT_ENCRYPTION_KEY); @@ -236,7 +269,7 @@ export const kmsServiceFactory = ({ }; const decryptWithRootKey = () => { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); return (cipherTextBuffer: Buffer) => { return cipher.decrypt(cipherTextBuffer, ROOT_ENCRYPTION_KEY); @@ -255,6 +288,11 @@ export const kmsServiceFactory = ({ throw new NotFoundError({ message: `KMS with ID '${kmsId}' not found` }); } + const encryptionAlgorithm = kmsDoc.internalKms?.encryptionAlgorithm as SymmetricKeyAlgorithm; + verifyKeyTypeAndAlgorithm(kmsDoc.keyUsage as KmsKeyUsage, encryptionAlgorithm, { + forceType: KmsKeyUsage.ENCRYPT_DECRYPT + }); + if (kmsDoc.externalKms) { let externalKms: TExternalKmsProviderFns; @@ -316,8 +354,8 @@ export const kmsServiceFactory = ({ } // internal KMS - const keyCipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); - const dataCipher = symmetricCipherService(kmsDoc.internalKms?.encryptionAlgorithm as SymmetricEncryption); + const keyCipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); + const dataCipher = symmetricCipherService(encryptionAlgorithm); const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); return ({ cipherTextBlob: versionedCipherTextBlob }: Pick) => { @@ -345,19 +383,22 @@ export const kmsServiceFactory = ({ }); } - const keyCipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const keyCipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); return kmsKey; }; const importKeyMaterial = async ( - { key, algorithm, name, isReserved, projectId, orgId }: TImportKeyMaterialDTO, + { key, algorithm, name, isReserved, projectId, orgId, keyUsage }: TImportKeyMaterialDTO, tx?: Knex ) => { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + // daniel: currently we only support imports for encrypt/decrypt keys + verifyKeyTypeAndAlgorithm(keyUsage, algorithm, { forceType: KmsKeyUsage.ENCRYPT_DECRYPT }); - const expectedByteLength = getByteLengthForAlgorithm(algorithm); + const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); + + const expectedByteLength = getByteLengthForSymmetricEncryptionAlgorithm(algorithm as SymmetricKeyAlgorithm); if (key.byteLength !== expectedByteLength) { throw new BadRequestError({ message: `Invalid key length for ${algorithm}. Expected ${expectedByteLength} bytes but got ${key.byteLength} bytes` @@ -370,6 +411,7 @@ export const kmsServiceFactory = ({ const kmsDoc = await kmsDAL.create( { name: sanitizedName, + keyUsage: KmsKeyUsage.ENCRYPT_DECRYPT, orgId, isReserved, projectId @@ -393,12 +435,85 @@ export const kmsServiceFactory = ({ return doc; }; + const getPublicKey = async ({ kmsId }: TGetPublicKeyDTO) => { + const kmsDoc = await kmsDAL.findByIdWithAssociatedKms(kmsId); + if (!kmsDoc) { + throw new NotFoundError({ message: `KMS with ID '${kmsId}' not found` }); + } + + const encryptionAlgorithm = kmsDoc.internalKms?.encryptionAlgorithm as AsymmetricKeyAlgorithm; + + verifyKeyTypeAndAlgorithm(kmsDoc.keyUsage as KmsKeyUsage, encryptionAlgorithm, { + forceType: KmsKeyUsage.SIGN_VERIFY + }); + + const keyCipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); + const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); + + return signingService(encryptionAlgorithm).getPublicKeyFromPrivateKey(kmsKey); + }; + + const signWithKmsKey = async ({ kmsId }: Pick) => { + const kmsDoc = await kmsDAL.findByIdWithAssociatedKms(kmsId); + if (!kmsDoc) { + throw new NotFoundError({ message: `KMS with ID '${kmsId}' not found` }); + } + + const encryptionAlgorithm = kmsDoc.internalKms?.encryptionAlgorithm as AsymmetricKeyAlgorithm; + verifyKeyTypeAndAlgorithm(kmsDoc.keyUsage as KmsKeyUsage, encryptionAlgorithm, { + forceType: KmsKeyUsage.SIGN_VERIFY + }); + + const keyCipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); + const { sign } = signingService(encryptionAlgorithm); + return async ({ + data, + signingAlgorithm, + isDigest + }: Pick) => { + const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); + const signature = await sign(data, kmsKey, signingAlgorithm, isDigest); + + return Promise.resolve({ signature, algorithm: signingAlgorithm }); + }; + }; + + const verifyWithKmsKey = async ({ + kmsId, + signingAlgorithm + }: Pick) => { + const kmsDoc = await kmsDAL.findByIdWithAssociatedKms(kmsId); + if (!kmsDoc) { + throw new NotFoundError({ message: `KMS with ID '${kmsId}' not found` }); + } + + const encryptionAlgorithm = kmsDoc.internalKms?.encryptionAlgorithm as AsymmetricKeyAlgorithm; + verifyKeyTypeAndAlgorithm(kmsDoc.keyUsage as KmsKeyUsage, encryptionAlgorithm, { + forceType: KmsKeyUsage.SIGN_VERIFY + }); + + const keyCipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); + const { verify, getPublicKeyFromPrivateKey } = signingService(encryptionAlgorithm); + return async ({ data, signature, isDigest }: Pick) => { + const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); + + const publicKey = getPublicKeyFromPrivateKey(kmsKey); + const signatureValid = await verify(data, signature, publicKey, signingAlgorithm, isDigest); + return Promise.resolve({ signatureValid, algorithm: signingAlgorithm }); + }; + }; + const encryptWithKmsKey = async ({ kmsId }: Omit, tx?: Knex) => { const kmsDoc = await kmsDAL.findByIdWithAssociatedKms(kmsId, tx); if (!kmsDoc) { throw new NotFoundError({ message: `KMS with ID '${kmsId}' not found` }); } + const encryptionAlgorithm = kmsDoc.internalKms?.encryptionAlgorithm as SymmetricKeyAlgorithm; + verifyKeyTypeAndAlgorithm(kmsDoc.keyUsage as KmsKeyUsage, encryptionAlgorithm, { + forceType: KmsKeyUsage.ENCRYPT_DECRYPT + }); + if (kmsDoc.externalKms) { let externalKms: TExternalKmsProviderFns; if (!kmsDoc.orgKms.id || !kmsDoc.orgKms.encryptedDataKey) { @@ -454,8 +569,8 @@ export const kmsServiceFactory = ({ } // internal KMS - const keyCipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); - const dataCipher = symmetricCipherService(kmsDoc.internalKms?.encryptionAlgorithm as SymmetricEncryption); + const keyCipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); + const dataCipher = symmetricCipherService(encryptionAlgorithm); return ({ plainText }: Pick) => { const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); const encryptedPlainTextBlob = dataCipher.encrypt(plainText, kmsKey); @@ -729,7 +844,7 @@ export const kmsServiceFactory = ({ // case 2: root key is encrypted with software encryption if (kmsRootConfig.encryptionStrategy === RootKeyEncryptionStrategy.Software) { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); const encryptionKeyBuffer = $getBasicEncryptionKey(); return cipher.decrypt(kmsRootConfig.encryptedRootKey, encryptionKeyBuffer); @@ -749,7 +864,7 @@ export const kmsServiceFactory = ({ } if (strategy === RootKeyEncryptionStrategy.Software) { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); const encryptionKeyBuffer = $getBasicEncryptionKey(); return cipher.encrypt(plainKeyBuffer, encryptionKeyBuffer); @@ -765,7 +880,7 @@ export const kmsServiceFactory = ({ const createCipherPairWithDataKey = async (encryptionContext: TEncryptWithKmsDataKeyDTO, trx?: Knex) => { const dataKey = await $getDataKey(encryptionContext, trx); - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); return { encryptor: ({ plainText }: Pick) => { @@ -966,6 +1081,7 @@ export const kmsServiceFactory = ({ const decryptedRootKey = await $decryptRootKey(kmsRootConfig); logger.info("KMS: Loading ROOT Key into Memory."); + ROOT_ENCRYPTION_KEY = decryptedRootKey; }; @@ -1014,6 +1130,9 @@ export const kmsServiceFactory = ({ getKmsById, createCipherPairWithDataKey, getKeyMaterial, - importKeyMaterial + importKeyMaterial, + signWithKmsKey, + verifyWithKmsKey, + getPublicKey }; }; diff --git a/backend/src/services/kms/kms-types.ts b/backend/src/services/kms/kms-types.ts index 8be0b29fc..ca2401bb6 100644 --- a/backend/src/services/kms/kms-types.ts +++ b/backend/src/services/kms/kms-types.ts @@ -1,6 +1,7 @@ import { Knex } from "knex"; -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; +import { AsymmetricKeyAlgorithm, SigningAlgorithm } from "@app/lib/crypto/sign/types"; export enum KmsDataKey { Organization, @@ -13,6 +14,11 @@ export enum KmsType { Internal = "internal" } +export enum KmsKeyUsage { + ENCRYPT_DECRYPT = "encrypt-decrypt", + SIGN_VERIFY = "sign-verify" +} + export type TEncryptWithKmsDataKeyDTO = | { type: KmsDataKey.Organization; orgId: string } | { type: KmsDataKey.SecretManager; projectId: string }; @@ -25,7 +31,8 @@ export type TEncryptWithKmsDataKeyDTO = export type TGenerateKMSDTO = { orgId: string; projectId?: string; - encryptionAlgorithm?: SymmetricEncryption; + encryptionAlgorithm?: SymmetricKeyAlgorithm | AsymmetricKeyAlgorithm; + keyUsage?: KmsKeyUsage; isReserved?: boolean; name?: string; description?: string; @@ -37,6 +44,25 @@ export type TEncryptWithKmsDTO = { plainText: Buffer; }; +export type TGetPublicKeyDTO = { + kmsId: string; +}; + +export type TSignWithKmsDTO = { + kmsId: string; + data: Buffer; + signingAlgorithm: SigningAlgorithm; + isDigest: boolean; +}; + +export type TVerifyWithKmsDTO = { + kmsId: string; + data: Buffer; + signature: Buffer; + signingAlgorithm: SigningAlgorithm; + isDigest: boolean; +}; + export type TEncryptionWithKeyDTO = { key: Buffer; plainText: Buffer; @@ -67,9 +93,10 @@ export type TGetKeyMaterialDTO = { export type TImportKeyMaterialDTO = { key: Buffer; - algorithm: SymmetricEncryption; + algorithm: SymmetricKeyAlgorithm; name?: string; isReserved: boolean; projectId: string; orgId: string; + keyUsage: KmsKeyUsage; }; diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts index 3dfe11d2c..fc2fb9319 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -141,6 +141,7 @@ export const projectRoleServiceFactory = ({ validateHandlebarTemplate("Project Role Update", JSON.stringify(data.permissions || []), { allowedExpressions: (val) => val.includes("identity.") }); + const updatedRole = await projectRoleDAL.updateById(projectRole.id, { ...data, permissions: data.permissions ? data.permissions : undefined diff --git a/backend/src/services/project/project-fns.ts b/backend/src/services/project/project-fns.ts index 92d0dfc39..08652e348 100644 --- a/backend/src/services/project/project-fns.ts +++ b/backend/src/services/project/project-fns.ts @@ -1,12 +1,15 @@ import crypto from "crypto"; import { ProjectVersion, TProjects } from "@app/db/schemas"; +import { createSshCaHelper } from "@app/ee/services/ssh/ssh-certificate-authority-fns"; +import { SshCaKeySource } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; import { NotFoundError } from "@app/lib/errors"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; -import { AddUserToWsDTO } from "./project-types"; +import { AddUserToWsDTO, TBootstrapSshProjectDTO } from "./project-types"; export const assignWorkspaceKeysToMembers = ({ members, decryptKey, userPrivateKey }: AddUserToWsDTO) => { const plaintextProjectKey = decryptAsymmetric({ @@ -102,3 +105,48 @@ export const getProjectKmsCertificateKeyId = async ({ return keyId; }; + +/** + * Bootstraps an SSH project. + * - Creates a user and host SSH CA + * - Creates a project SSH config with the user and host SSH CA as defaults + */ +export const bootstrapSshProject = async ({ + projectId, + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + kmsService, + projectSshConfigDAL, + tx +}: TBootstrapSshProjectDTO) => { + const userSshCa = await createSshCaHelper({ + projectId, + friendlyName: "User CA", + keyAlgorithm: SshCertKeyAlgorithm.ED25519, + keySource: SshCaKeySource.INTERNAL, + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + kmsService, + tx + }); + + const hostSshCa = await createSshCaHelper({ + projectId, + friendlyName: "Host CA", + keyAlgorithm: SshCertKeyAlgorithm.ED25519, + keySource: SshCaKeySource.INTERNAL, + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + kmsService, + tx + }); + + await projectSshConfigDAL.create( + { + projectId, + defaultHostSshCaId: hostSshCa.id, + defaultUserSshCaId: userSshCa.id + }, + tx + ); +}; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 58e3f9b54..1f45734b3 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; import { @@ -15,13 +15,16 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionSecretActions, + ProjectPermissionSshHostActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types"; import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; +import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal"; import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; +import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; @@ -61,8 +64,9 @@ import { TSlackIntegrationDALFactory } from "../slack/slack-integration-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TProjectDALFactory } from "./project-dal"; -import { assignWorkspaceKeysToMembers, createProjectKey } from "./project-fns"; +import { assignWorkspaceKeysToMembers, bootstrapSshProject, createProjectKey } from "./project-fns"; import { TProjectQueueFactory } from "./project-queue"; +import { TProjectSshConfigDALFactory } from "./project-ssh-config-dal"; import { TCreateProjectDTO, TDeleteProjectDTO, @@ -77,6 +81,7 @@ import { TListProjectSshCasDTO, TListProjectSshCertificatesDTO, TListProjectSshCertificateTemplatesDTO, + TListProjectSshHostsDTO, TLoadProjectKmsBackupDTO, TProjectAccessRequestDTO, TSearchProjectsDTO, @@ -97,8 +102,8 @@ export const DEFAULT_PROJECT_ENVS = [ ]; type TProjectServiceFactoryDep = { - // TODO: Pick projectDAL: TProjectDALFactory; + projectSshConfigDAL: Pick; projectQueue: TProjectQueueFactory; userDAL: TUserDALFactory; projectBotService: Pick; @@ -123,9 +128,11 @@ type TProjectServiceFactoryDep = { certificateTemplateDAL: Pick; pkiAlertDAL: Pick; pkiCollectionDAL: Pick; - sshCertificateAuthorityDAL: Pick; + sshCertificateAuthorityDAL: Pick; + sshCertificateAuthoritySecretDAL: Pick; sshCertificateDAL: Pick; sshCertificateTemplateDAL: Pick; + sshHostDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; licenseService: Pick; @@ -144,6 +151,7 @@ type TProjectServiceFactoryDep = { | "getKmsById" | "getProjectSecretManagerKmsKeyId" | "deleteInternalKms" + | "createCipherPairWithDataKey" >; projectTemplateService: TProjectTemplateServiceFactory; }; @@ -152,6 +160,7 @@ export type TProjectServiceFactory = ReturnType; export const projectServiceFactory = ({ projectDAL, + projectSshConfigDAL, secretDAL, secretV2BridgeDAL, projectQueue, @@ -177,8 +186,10 @@ export const projectServiceFactory = ({ pkiCollectionDAL, pkiAlertDAL, sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, sshCertificateDAL, sshCertificateTemplateDAL, + sshHostDAL, keyStore, kmsService, projectBotDAL, @@ -266,6 +277,17 @@ export const projectServiceFactory = ({ tx ); + if (type === ProjectType.SSH) { + await bootstrapSshProject({ + projectId: project.id, + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + kmsService, + projectSshConfigDAL, + tx + }); + } + // set ghost user as admin of project const projectMembership = await projectMembershipDAL.create( { @@ -1046,6 +1068,48 @@ export const projectServiceFactory = ({ return cas; }; + /** + * Return list of SSH hosts for project + */ + const listProjectSshHosts = async ({ + actorId, + actorOrgId, + actorAuthMethod, + actor, + projectId + }: TListProjectSshHostsDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + const allowedHosts = []; + + // (dangtony98): room to optimize + const hosts = await sshHostDAL.findSshHostsWithLoginMappings(projectId); + + for (const host of hosts) { + try { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSshHostActions.Read, + subject(ProjectPermissionSub.SshHosts, { + hostname: host.hostname + }) + ); + + allowedHosts.push(host); + } catch { + // intentionally ignore projects where user lacks access + } + } + + return allowedHosts; + }; + /** * Return list of SSH certificates for project */ @@ -1443,6 +1507,7 @@ export const projectServiceFactory = ({ listProjectPkiCollections, listProjectCertificateTemplates, listProjectSshCas, + listProjectSshHosts, listProjectSshCertificates, listProjectSshCertificateTemplates, updateVersionLimit, diff --git a/backend/src/services/project/project-ssh-config-dal.ts b/backend/src/services/project/project-ssh-config-dal.ts new file mode 100644 index 000000000..5085bd438 --- /dev/null +++ b/backend/src/services/project/project-ssh-config-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TProjectSshConfigDALFactory = ReturnType; + +export const projectSshConfigDALFactory = (db: TDbClient) => { + const projectSshConfigOrm = ormify(db, TableName.ProjectSshConfig); + + return projectSshConfigOrm; +}; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 30519005d..4195f3dfc 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -1,6 +1,10 @@ import { Knex } from "knex"; -import { ProjectType, SortDirection, TProjectKeys } from "@app/db/schemas"; +import { ProjectType, TProjectKeys, SortDirection } from "@app/db/schemas"; +import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; +import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectSshConfigDALFactory } from "@app/services/project/project-ssh-config-dal"; import { OrgServiceActor, TProjectPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; @@ -143,6 +147,7 @@ export type TGetProjectKmsKey = TProjectPermission; export type TListProjectCertificateTemplatesDTO = TProjectPermission; export type TListProjectSshCasDTO = TProjectPermission; +export type TListProjectSshHostsDTO = TProjectPermission; export type TListProjectSshCertificateTemplatesDTO = TProjectPermission; export type TListProjectSshCertificatesDTO = { offset: number; @@ -159,6 +164,15 @@ export type TUpdateProjectSlackConfig = { secretRequestChannels: string; } & TProjectPermission; +export type TBootstrapSshProjectDTO = { + projectId: string; + sshCertificateAuthorityDAL: Pick; + sshCertificateAuthoritySecretDAL: Pick; + projectSshConfigDAL: Pick; + kmsService: Pick; + tx?: Knex; +}; + export enum SearchProjectSortBy { NAME = "name" } diff --git a/backend/src/services/secret-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index e5a450441..2056d5a2c 100644 --- a/backend/src/services/secret-import/secret-import-fns.ts +++ b/backend/src/services/secret-import/secret-import-fns.ts @@ -159,7 +159,8 @@ export const fnSecretsV2FromImports = async ({ decryptor, expandSecretReferences, hasSecretAccess, - viewSecretValue + viewSecretValue, + projectId }: { secretImports: (Omit & { importEnv: { id: string; slug: string; name: string }; @@ -176,6 +177,7 @@ export const fnSecretsV2FromImports = async ({ environment: string; }) => Promise; hasSecretAccess: (environment: string, secretPath: string, secretName: string, secretTagSlugs: string[]) => boolean; + projectId: string; }) => { const cyclicDetector = new Set(); const stack: { @@ -216,7 +218,8 @@ export const fnSecretsV2FromImports = async ({ type: SecretType.Shared }, { - sort: [["id", "asc"]] + sort: [["id", "asc"]], + useCache: { projectId } } ); const importedSecretsGroupByFolderId = groupBy(importedSecrets, (i) => i.folderId); diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index 154b4a77f..c40d6b22b 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -44,7 +44,7 @@ type TSecretImportServiceFactoryDep = { secretImportDAL: TSecretImportDALFactory; folderDAL: TSecretFolderDALFactory; secretDAL: Pick; - secretV2BridgeDAL: Pick; + secretV2BridgeDAL: Pick; projectBotService: Pick; projectDAL: Pick; projectEnvDAL: TProjectEnvDALFactory; @@ -185,6 +185,7 @@ export const secretImportServiceFactory = ({ }); } + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); return { ...secImport, importEnv }; }; @@ -282,6 +283,8 @@ export const secretImportServiceFactory = ({ ); return doc; }); + + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); return { ...updatedSecImport, importEnv: importedEnv }; }; @@ -356,6 +359,7 @@ export const secretImportServiceFactory = ({ actorId }); + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); return secImport; }; @@ -694,6 +698,7 @@ export const secretImportServiceFactory = ({ projectId }); const importedSecrets = await fnSecretsV2FromImports({ + projectId, secretImports, folderDAL, viewSecretValue: true, diff --git a/backend/src/services/secret-sync/camunda/camunda-sync-constants.ts b/backend/src/services/secret-sync/camunda/camunda-sync-constants.ts new file mode 100644 index 000000000..7a2bad8f7 --- /dev/null +++ b/backend/src/services/secret-sync/camunda/camunda-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const CAMUNDA_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Camunda", + destination: SecretSync.Camunda, + connection: AppConnection.Camunda, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/camunda/camunda-sync-fns.ts b/backend/src/services/secret-sync/camunda/camunda-sync-fns.ts new file mode 100644 index 000000000..3a52a4939 --- /dev/null +++ b/backend/src/services/secret-sync/camunda/camunda-sync-fns.ts @@ -0,0 +1,173 @@ +import { request } from "@app/lib/config/request"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { getCamundaConnectionAccessToken } from "@app/services/app-connection/camunda"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { + TCamundaCreateSecret, + TCamundaDeleteSecret, + TCamundaListSecrets, + TCamundaListSecretsResponse, + TCamundaPutSecret, + TCamundaSyncWithCredentials +} from "@app/services/secret-sync/camunda/camunda-sync-types"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; + +import { TSecretMap } from "../secret-sync-types"; + +type TCamundaSecretSyncFactoryDeps = { + appConnectionDAL: Pick; + kmsService: Pick; +}; + +const getCamundaSecrets = async ({ accessToken, clusterUUID }: TCamundaListSecrets) => { + const { data } = await request.get( + `${IntegrationUrls.CAMUNDA_API_URL}/clusters/${clusterUUID}/secrets`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + return data; +}; + +const createCamundaSecret = async ({ accessToken, clusterUUID, key, value }: TCamundaCreateSecret) => + request.post( + `${IntegrationUrls.CAMUNDA_API_URL}/clusters/${clusterUUID}/secrets`, + { + secretName: key, + secretValue: value + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + +const deleteCamundaSecret = async ({ accessToken, clusterUUID, key }: TCamundaDeleteSecret) => + request.delete(`${IntegrationUrls.CAMUNDA_API_URL}/clusters/${clusterUUID}/secrets/${key}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + }); + +const updateCamundaSecret = async ({ accessToken, clusterUUID, key, value }: TCamundaPutSecret) => + request.put( + `${IntegrationUrls.CAMUNDA_API_URL}/clusters/${clusterUUID}/secrets/${key}`, + { + secretValue: value + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + +export const camundaSyncFactory = ({ kmsService, appConnectionDAL }: TCamundaSecretSyncFactoryDeps) => { + const syncSecrets = async (secretSync: TCamundaSyncWithCredentials, secretMap: TSecretMap) => { + const { + destinationConfig: { clusterUUID }, + connection + } = secretSync; + + const accessToken = await getCamundaConnectionAccessToken(connection, appConnectionDAL, kmsService); + const camundaSecrets = await getCamundaSecrets({ accessToken, clusterUUID }); + + for await (const entry of Object.entries(secretMap)) { + const [key, { value }] = entry; + + if (!value) { + // eslint-disable-next-line no-continue + continue; + } + + try { + if (camundaSecrets[key] === undefined) { + await createCamundaSecret({ + key, + value, + clusterUUID, + accessToken + }); + } else if (camundaSecrets[key] !== value) { + await updateCamundaSecret({ + key, + value, + clusterUUID, + accessToken + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + + if (secretSync.syncOptions.disableSecretDeletion) return; + + for await (const secret of Object.keys(camundaSecrets)) { + if (!(secret in secretMap) || !secretMap[secret].value) { + try { + await deleteCamundaSecret({ + key: secret, + clusterUUID, + accessToken + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: secret + }); + } + } + } + }; + + const removeSecrets = async (secretSync: TCamundaSyncWithCredentials, secretMap: TSecretMap) => { + const { + destinationConfig: { clusterUUID }, + connection + } = secretSync; + + const accessToken = await getCamundaConnectionAccessToken(connection, appConnectionDAL, kmsService); + const camundaSecrets = await getCamundaSecrets({ accessToken, clusterUUID }); + + for await (const secret of Object.keys(camundaSecrets)) { + if (!(secret in secretMap)) { + await deleteCamundaSecret({ + key: secret, + clusterUUID, + accessToken + }); + } + } + }; + + const getSecrets = async (secretSync: TCamundaSyncWithCredentials) => { + const { + destinationConfig: { clusterUUID }, + connection + } = secretSync; + + const accessToken = await getCamundaConnectionAccessToken(connection, appConnectionDAL, kmsService); + const camundaSecrets = await getCamundaSecrets({ accessToken, clusterUUID }); + + return Object.fromEntries(Object.entries(camundaSecrets).map(([key, value]) => [key, { value }])); + }; + + return { + syncSecrets, + removeSecrets, + getSecrets + }; +}; diff --git a/backend/src/services/secret-sync/camunda/camunda-sync-schemas.ts b/backend/src/services/secret-sync/camunda/camunda-sync-schemas.ts new file mode 100644 index 000000000..726b5dfac --- /dev/null +++ b/backend/src/services/secret-sync/camunda/camunda-sync-schemas.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const CamundaSyncDestinationConfigSchema = z.object({ + scope: z.string().trim().min(1, "Camunda scope required").describe(SecretSyncs.DESTINATION_CONFIG.CAMUNDA.scope), + clusterUUID: z + .string() + .min(1, "Camunda cluster UUID is required") + .describe(SecretSyncs.DESTINATION_CONFIG.CAMUNDA.clusterUUID) +}); + +const CamundaSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const CamundaSyncSchema = BaseSecretSyncSchema(SecretSync.Camunda, CamundaSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Camunda), + destinationConfig: CamundaSyncDestinationConfigSchema +}); + +export const CreateCamundaSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Camunda, + CamundaSyncOptionsConfig +).extend({ + destinationConfig: CamundaSyncDestinationConfigSchema +}); + +export const UpdateCamundaSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Camunda, + CamundaSyncOptionsConfig +).extend({ + destinationConfig: CamundaSyncDestinationConfigSchema.optional() +}); + +export const CamundaSyncListItemSchema = z.object({ + name: z.literal("Camunda"), + connection: z.literal(AppConnection.Camunda), + destination: z.literal(SecretSync.Camunda), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/camunda/camunda-sync-types.ts b/backend/src/services/secret-sync/camunda/camunda-sync-types.ts new file mode 100644 index 000000000..49eb3262e --- /dev/null +++ b/backend/src/services/secret-sync/camunda/camunda-sync-types.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +import { TCamundaConnection } from "@app/services/app-connection/camunda"; + +import { CamundaSyncListItemSchema, CamundaSyncSchema, CreateCamundaSyncSchema } from "./camunda-sync-schemas"; + +export type TCamundaSync = z.infer; + +export type TCamundaSyncInput = z.infer; + +export type TCamundaSyncListItem = z.infer; + +export type TCamundaSyncWithCredentials = TCamundaSync & { + connection: TCamundaConnection; +}; + +export type TCamundaListSecretsResponse = { [key: string]: string }; + +type TBaseCamundaSecretRequest = { + accessToken: string; + clusterUUID: string; +}; + +export type TCamundaListSecrets = TBaseCamundaSecretRequest; + +export type TCamundaCreateSecret = { + key: string; + value?: string; +} & TBaseCamundaSecretRequest; + +export type TCamundaPutSecret = { + key: string; + value?: string; +} & TBaseCamundaSecretRequest; + +export type TCamundaDeleteSecret = { + key: string; +} & TBaseCamundaSecretRequest; diff --git a/backend/src/services/secret-sync/camunda/index.ts b/backend/src/services/secret-sync/camunda/index.ts new file mode 100644 index 000000000..c81d82c99 --- /dev/null +++ b/backend/src/services/secret-sync/camunda/index.ts @@ -0,0 +1,4 @@ +export * from "./camunda-sync-constants"; +export * from "./camunda-sync-fns"; +export * from "./camunda-sync-schemas"; +export * from "./camunda-sync-types"; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 006d033f5..9349e2197 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -6,7 +6,10 @@ export enum SecretSync { AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", Databricks = "databricks", - Humanitec = "humanitec" + Humanitec = "humanitec", + TerraformCloud = "terraform-cloud", + Camunda = "camunda", + Vercel = "vercel" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 6c8a6d4df..a3efa4bc1 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -22,10 +22,13 @@ import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; import { AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION, azureAppConfigurationSyncFactory } from "./azure-app-configuration"; import { AZURE_KEY_VAULT_SYNC_LIST_OPTION, azureKeyVaultSyncFactory } from "./azure-key-vault"; +import { CAMUNDA_SYNC_LIST_OPTION, camundaSyncFactory } from "./camunda"; import { GCP_SYNC_LIST_OPTION } from "./gcp"; import { GcpSyncFns } from "./gcp/gcp-sync-fns"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; +import { TERRAFORM_CLOUD_SYNC_LIST_OPTION, TerraformCloudSyncFns } from "./terraform-cloud"; +import { VERCEL_SYNC_LIST_OPTION, VercelSyncFns } from "./vercel"; const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.AWSParameterStore]: AWS_PARAMETER_STORE_SYNC_LIST_OPTION, @@ -35,7 +38,10 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.AzureKeyVault]: AZURE_KEY_VAULT_SYNC_LIST_OPTION, [SecretSync.AzureAppConfiguration]: AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION, [SecretSync.Databricks]: DATABRICKS_SYNC_LIST_OPTION, - [SecretSync.Humanitec]: HUMANITEC_SYNC_LIST_OPTION + [SecretSync.Humanitec]: HUMANITEC_SYNC_LIST_OPTION, + [SecretSync.TerraformCloud]: TERRAFORM_CLOUD_SYNC_LIST_OPTION, + [SecretSync.Camunda]: CAMUNDA_SYNC_LIST_OPTION, + [SecretSync.Vercel]: VERCEL_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -121,6 +127,15 @@ export const SecretSyncFns = { }).syncSecrets(secretSync, secretMap); case SecretSync.Humanitec: return HumanitecSyncFns.syncSecrets(secretSync, secretMap); + case SecretSync.TerraformCloud: + return TerraformCloudSyncFns.syncSecrets(secretSync, secretMap); + case SecretSync.Camunda: + return camundaSyncFactory({ + appConnectionDAL, + kmsService + }).syncSecrets(secretSync, secretMap); + case SecretSync.Vercel: + return VercelSyncFns.syncSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -165,6 +180,18 @@ export const SecretSyncFns = { case SecretSync.Humanitec: secretMap = await HumanitecSyncFns.getSecrets(secretSync); break; + case SecretSync.TerraformCloud: + secretMap = await TerraformCloudSyncFns.getSecrets(secretSync); + break; + case SecretSync.Camunda: + secretMap = await camundaSyncFactory({ + appConnectionDAL, + kmsService + }).getSecrets(secretSync); + break; + case SecretSync.Vercel: + secretMap = await VercelSyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -207,6 +234,15 @@ export const SecretSyncFns = { }).removeSecrets(secretSync, secretMap); case SecretSync.Humanitec: return HumanitecSyncFns.removeSecrets(secretSync, secretMap); + case SecretSync.TerraformCloud: + return TerraformCloudSyncFns.removeSecrets(secretSync, secretMap); + case SecretSync.Camunda: + return camundaSyncFactory({ + appConnectionDAL, + kmsService + }).removeSecrets(secretSync, secretMap); + case SecretSync.Vercel: + return VercelSyncFns.removeSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index cd4125e1b..661815814 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -9,7 +9,10 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.AzureKeyVault]: "Azure Key Vault", [SecretSync.AzureAppConfiguration]: "Azure App Configuration", [SecretSync.Databricks]: "Databricks", - [SecretSync.Humanitec]: "Humanitec" + [SecretSync.Humanitec]: "Humanitec", + [SecretSync.TerraformCloud]: "Terraform Cloud", + [SecretSync.Camunda]: "Camunda", + [SecretSync.Vercel]: "Vercel" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -20,5 +23,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.AzureKeyVault]: AppConnection.AzureKeyVault, [SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration, [SecretSync.Databricks]: AppConnection.Databricks, - [SecretSync.Humanitec]: AppConnection.Humanitec + [SecretSync.Humanitec]: AppConnection.Humanitec, + [SecretSync.TerraformCloud]: AppConnection.TerraformCloud, + [SecretSync.Camunda]: AppConnection.Camunda, + [SecretSync.Vercel]: AppConnection.Vercel }; diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index 8afcf6416..17257b8e2 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -213,7 +213,7 @@ export const secretSyncQueueFactory = ({ canExpandValue: () => true }); - const secrets = await secretV2BridgeDAL.findByFolderId(folderId); + const secrets = await secretV2BridgeDAL.findByFolderId({ folderId, projectId }); await Promise.allSettled( secrets.map(async (secret) => { @@ -243,6 +243,7 @@ export const secretSyncQueueFactory = ({ if (secretImports.length) { const importedSecrets = await fnSecretsV2FromImports({ + projectId, decryptor: decryptSecretValue, folderDAL, secretDAL: secretV2BridgeDAL, diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index bd28e1ee7..d3207918c 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -9,6 +9,12 @@ import { TAwsSecretsManagerSyncListItem, TAwsSecretsManagerSyncWithCredentials } from "@app/services/secret-sync/aws-secrets-manager"; +import { + TCamundaSync, + TCamundaSyncInput, + TCamundaSyncListItem, + TCamundaSyncWithCredentials +} from "@app/services/secret-sync/camunda"; import { TDatabricksSync, TDatabricksSyncInput, @@ -49,6 +55,13 @@ import { THumanitecSyncListItem, THumanitecSyncWithCredentials } from "./humanitec"; +import { + TTerraformCloudSync, + TTerraformCloudSyncInput, + TTerraformCloudSyncListItem, + TTerraformCloudSyncWithCredentials +} from "./terraform-cloud"; +import { TVercelSync, TVercelSyncInput, TVercelSyncListItem, TVercelSyncWithCredentials } from "./vercel"; export type TSecretSync = | TAwsParameterStoreSync @@ -58,7 +71,10 @@ export type TSecretSync = | TAzureKeyVaultSync | TAzureAppConfigurationSync | TDatabricksSync - | THumanitecSync; + | THumanitecSync + | TTerraformCloudSync + | TCamundaSync + | TVercelSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -68,7 +84,10 @@ export type TSecretSyncWithCredentials = | TAzureKeyVaultSyncWithCredentials | TAzureAppConfigurationSyncWithCredentials | TDatabricksSyncWithCredentials - | THumanitecSyncWithCredentials; + | THumanitecSyncWithCredentials + | TTerraformCloudSyncWithCredentials + | TCamundaSyncWithCredentials + | TVercelSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -78,7 +97,10 @@ export type TSecretSyncInput = | TAzureKeyVaultSyncInput | TAzureAppConfigurationSyncInput | TDatabricksSyncInput - | THumanitecSyncInput; + | THumanitecSyncInput + | TTerraformCloudSyncInput + | TCamundaSyncInput + | TVercelSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -88,7 +110,10 @@ export type TSecretSyncListItem = | TAzureKeyVaultSyncListItem | TAzureAppConfigurationSyncListItem | TDatabricksSyncListItem - | THumanitecSyncListItem; + | THumanitecSyncListItem + | TTerraformCloudSyncListItem + | TCamundaSyncListItem + | TVercelSyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/backend/src/services/secret-sync/terraform-cloud/index.ts b/backend/src/services/secret-sync/terraform-cloud/index.ts new file mode 100644 index 000000000..2df19747d --- /dev/null +++ b/backend/src/services/secret-sync/terraform-cloud/index.ts @@ -0,0 +1,5 @@ +export * from "./terraform-cloud-sync-constants"; +export * from "./terraform-cloud-sync-enums"; +export * from "./terraform-cloud-sync-fns"; +export * from "./terraform-cloud-sync-schemas"; +export * from "./terraform-cloud-sync-types"; diff --git a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-constants.ts b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-constants.ts new file mode 100644 index 000000000..edca7d304 --- /dev/null +++ b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const TERRAFORM_CLOUD_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Terraform Cloud", + destination: SecretSync.TerraformCloud, + connection: AppConnection.TerraformCloud, + canImportSecrets: false +}; diff --git a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-enums.ts b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-enums.ts new file mode 100644 index 000000000..cfd1daf2c --- /dev/null +++ b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-enums.ts @@ -0,0 +1,9 @@ +export enum TerraformCloudSyncScope { + VariableSet = "variable-set", + Workspace = "workspace" +} + +export enum TerraformCloudSyncCategory { + Environment = "env", + Terraform = "terraform" +} diff --git a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts new file mode 100644 index 000000000..4cfd7ec05 --- /dev/null +++ b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts @@ -0,0 +1,253 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosResponse } from "axios"; + +import { request } from "@app/lib/config/request"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; +import { TerraformCloudSyncScope } from "./terraform-cloud-sync-enums"; +import { + TerraformCloudApiResponse, + TerraformCloudApiVariable, + TerraformCloudVariable, + TTerraformCloudSyncWithCredentials +} from "./terraform-cloud-sync-types"; + +const getTerraformCloudVariables = async ( + secretSync: TTerraformCloudSyncWithCredentials +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + let url: string; + let source: TerraformCloudVariable["source"]; + + if (destinationConfig.scope === TerraformCloudSyncScope.VariableSet) { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/varsets/${destinationConfig.variableSetId}/relationships/vars`; + source = "varset"; + } else { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${destinationConfig.workspaceId}/vars`; + source = "workspace"; + } + + const headers = { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/vnd.api+json" + }; + + const fetchAllPages = async (): Promise => { + let results: TerraformCloudApiVariable[] = []; + let nextUrl: string | null = url; + + while (nextUrl) { + const res: AxiosResponse> = await request.get< + TerraformCloudApiResponse + >(nextUrl, { + headers + }); + + if (res.data?.data) { + results = results.concat(res.data.data); + } + + nextUrl = res.data?.links?.next ?? null; + } + + return results; + }; + + const allVariableData = await fetchAllPages(); + + const variables: TerraformCloudVariable[] = allVariableData.map((variable) => ({ + id: variable.id, + key: variable.attributes.key, + value: variable.attributes.value || "", + sensitive: variable.attributes.sensitive, + description: variable.attributes.description || "", + category: variable.attributes.category, + source + })); + + return variables; +}; + +const deleteVariable = async ( + secretSync: TTerraformCloudSyncWithCredentials, + variable: TerraformCloudVariable +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + try { + let url; + + if (destinationConfig.scope === TerraformCloudSyncScope.VariableSet) { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/varsets/${destinationConfig.variableSetId}/relationships/vars/${variable.id}`; + } else { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${destinationConfig.workspaceId}/vars/${variable.id}`; + } + + await request.delete(url, { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/vnd.api+json" + } + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: variable.key + }); + } +}; + +const createVariable = async ( + secretSync: TTerraformCloudSyncWithCredentials, + secretMap: TSecretMap, + key: string +): Promise => { + try { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + let url; + + if (destinationConfig.scope === TerraformCloudSyncScope.VariableSet) { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/varsets/${destinationConfig.variableSetId}/relationships/vars`; + } else { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${destinationConfig.workspaceId}/vars`; + } + + await request.post( + url, + { + data: { + type: "vars", + attributes: { + key, + value: secretMap[key].value, + description: secretMap[key].comment || "", + category: secretSync.destinationConfig.category, + sensitive: true + } + } + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/vnd.api+json" + } + } + ); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } +}; + +const updateVariable = async ( + secretSync: TTerraformCloudSyncWithCredentials, + secretMap: TSecretMap, + variable: TerraformCloudVariable +): Promise => { + try { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + let url; + + if (destinationConfig.scope === TerraformCloudSyncScope.VariableSet) { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/varsets/${destinationConfig.variableSetId}/relationships/vars/${variable.id}`; + } else { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${destinationConfig.workspaceId}/vars/${variable.id}`; + } + + await request.patch( + url, + { + data: { + type: "vars", + id: variable.id, + attributes: { + value: secretMap[variable.key].value, + description: secretMap[variable.key].comment || "", + category: secretSync.destinationConfig.category + } + } + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/vnd.api+json" + } + } + ); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: variable.key + }); + } +}; + +export const TerraformCloudSyncFns = { + syncSecrets: async (secretSync: TTerraformCloudSyncWithCredentials, secretMap: TSecretMap): Promise => { + const terraformCloudVariables = await getTerraformCloudVariables(secretSync); + const terraformCloudVariablesMap = new Map( + terraformCloudVariables.map((v) => [v.key, v]) + ); + + const secretKeys = Object.keys(secretMap); + for (const key of secretKeys) { + const existingVariable = terraformCloudVariablesMap.get(key); + + if (!existingVariable) { + await createVariable(secretSync, secretMap, key); + } else { + await updateVariable(secretSync, secretMap, existingVariable); + } + } + + if (secretSync.syncOptions.disableSecretDeletion) return; + + for (const terraformCloudVariable of terraformCloudVariables) { + if (!Object.prototype.hasOwnProperty.call(secretMap, terraformCloudVariable.key)) { + await deleteVariable(secretSync, terraformCloudVariable); + } + } + }, + + getSecrets: async (secretSync: TTerraformCloudSyncWithCredentials): Promise => { + throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`); + }, + + removeSecrets: async (secretSync: TTerraformCloudSyncWithCredentials, secretMap: TSecretMap): Promise => { + const terraformCloudVariables = await getTerraformCloudVariables(secretSync); + + for (const variable of terraformCloudVariables) { + if (Object.prototype.hasOwnProperty.call(secretMap, variable.key)) { + await deleteVariable(secretSync, variable); + } + } + } +}; diff --git a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-schemas.ts b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-schemas.ts new file mode 100644 index 000000000..359d7f4c5 --- /dev/null +++ b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-schemas.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { + TerraformCloudSyncCategory, + TerraformCloudSyncScope +} from "@app/services/secret-sync/terraform-cloud/terraform-cloud-sync-enums"; + +const TerraformCloudSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ + z.object({ + scope: z + .literal(TerraformCloudSyncScope.VariableSet) + .describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.scope), + org: z.string().min(1, "Org ID is required").describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.org), + variableSetName: z + .string() + .min(1, "Variable set name is required") + .describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.variableSetName), + variableSetId: z + .string() + .min(1, "Variable set ID is required") + .describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.variableSetId), + category: z.nativeEnum(TerraformCloudSyncCategory).describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.category) + }), + z.object({ + scope: z.literal(TerraformCloudSyncScope.Workspace).describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.scope), + org: z.string().min(1, "Org ID is required").describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.org), + workspaceName: z + .string() + .min(1, "Workspace name is required") + .describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.workspaceName), + workspaceId: z + .string() + .min(1, "Workspace ID is required") + .describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.workspaceId), + category: z.nativeEnum(TerraformCloudSyncCategory).describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.category) + }) +]); + +const TerraformCloudSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; + +export const TerraformCloudSyncSchema = BaseSecretSyncSchema( + SecretSync.TerraformCloud, + TerraformCloudSyncOptionsConfig +).extend({ + destination: z.literal(SecretSync.TerraformCloud), + destinationConfig: TerraformCloudSyncDestinationConfigSchema +}); + +export const CreateTerraformCloudSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.TerraformCloud, + TerraformCloudSyncOptionsConfig +).extend({ + destinationConfig: TerraformCloudSyncDestinationConfigSchema +}); + +export const UpdateTerraformCloudSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.TerraformCloud, + TerraformCloudSyncOptionsConfig +).extend({ + destinationConfig: TerraformCloudSyncDestinationConfigSchema.optional() +}); + +export const TerraformCloudSyncListItemSchema = z.object({ + name: z.literal("Terraform Cloud"), + connection: z.literal(AppConnection.TerraformCloud), + destination: z.literal(SecretSync.TerraformCloud), + canImportSecrets: z.literal(false) +}); diff --git a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-types.ts b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-types.ts new file mode 100644 index 000000000..f68db0d51 --- /dev/null +++ b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-types.ts @@ -0,0 +1,77 @@ +import z from "zod"; + +import { TTerraformCloudConnection } from "@app/services/app-connection/terraform-cloud"; + +import { + CreateTerraformCloudSyncSchema, + TerraformCloudSyncListItemSchema, + TerraformCloudSyncSchema +} from "./terraform-cloud-sync-schemas"; + +export type TTerraformCloudSyncListItem = z.infer; + +export type TTerraformCloudSync = z.infer; + +export type TTerraformCloudSyncInput = z.infer; + +export type TTerraformCloudSyncWithCredentials = TTerraformCloudSync & { + connection: TTerraformCloudConnection; +}; + +export type TerraformCloudApiVariable = { + id: string; + type: string; + attributes: { + key: string; + value: string | null; + sensitive: boolean; + category: "terraform" | "env"; + hcl: boolean; + description: string | null; + }; + relationships: { + workspace?: { + data: { + id: string; + type: string; + }; + }; + project?: { + data: { + id: string; + type: string; + }; + }; + }; +}; + +export type TerraformCloudVariable = { + id: string; + key: string; + value: string; + sensitive: boolean; + description: string; + category: "terraform" | "env"; + source: "varset" | "workspace"; +}; + +export type TerraformCloudApiResponse = { + data: T; + included?: unknown[]; + links?: { + self?: string; + first?: string; + prev?: string; + next?: string; + last?: string; + }; + meta?: { + pagination?: { + current_page: number; + prev_page: number | null; + next_page: number | null; + total_pages: number; + total_count: number; + }; + }; +}; diff --git a/backend/src/services/secret-sync/vercel/index.ts b/backend/src/services/secret-sync/vercel/index.ts new file mode 100644 index 000000000..b8379b5d9 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/index.ts @@ -0,0 +1,5 @@ +export * from "./vercel-sync-constants"; +export * from "./vercel-sync-enums"; +export * from "./vercel-sync-fns"; +export * from "./vercel-sync-schemas"; +export * from "./vercel-sync-types"; diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-constants.ts b/backend/src/services/secret-sync/vercel/vercel-sync-constants.ts new file mode 100644 index 000000000..60b3eb00a --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const VERCEL_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Vercel", + destination: SecretSync.Vercel, + connection: AppConnection.Vercel, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-enums.ts b/backend/src/services/secret-sync/vercel/vercel-sync-enums.ts new file mode 100644 index 000000000..36c46985b --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-enums.ts @@ -0,0 +1,12 @@ +export enum VercelSyncScope { + Application = "application", + Environment = "environment" +} + +export const VercelEnvironmentType = { + Development: "development", + Preview: "preview", + Production: "production" +} as const; + +export type VercelEnvironment = (typeof VercelEnvironmentType)[keyof typeof VercelEnvironmentType]; diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts b/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts new file mode 100644 index 000000000..713971283 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts @@ -0,0 +1,313 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { request } from "@app/lib/config/request"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { VercelEnvironmentType } from "./vercel-sync-enums"; +import { DefaultVercelEnvType, TVercelSyncWithCredentials, VercelApiSecret } from "./vercel-sync-types"; + +function isVercelDefaultEnvType(value: string): value is DefaultVercelEnvType { + return Object.values(VercelEnvironmentType).map(String).includes(value); +} + +const MAX_RETRIES = 5; + +const sleep = async () => + new Promise((resolve) => { + setTimeout(resolve, 60000); + }); + +const getVercelSecretsWithRetries = async ( + secretSync: TVercelSyncWithCredentials, + attempt = 0 +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + const params: { [key: string]: string } = { + decrypt: "true", + ...(destinationConfig.branch ? { gitBranch: destinationConfig.branch } : {}) + }; + try { + const { data } = await request.get<{ envs: VercelApiSecret[] }>( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env?teamId=${destinationConfig.teamId}`, + { + params, + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + return data.envs; + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await getVercelSecretsWithRetries(secretSync, attempt + 1); + } + throw error; + } +}; + +const getDecryptedVercelSecret = async ( + secretSync: TVercelSyncWithCredentials, + secret: VercelApiSecret, + attempt = 0 +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + const params: { [key: string]: string } = { + decrypt: "true", + ...(destinationConfig.branch ? { gitBranch: destinationConfig.branch } : {}) + }; + + try { + const { data: decryptedSecret } = await request.get( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${secret.id}?teamId=${destinationConfig.teamId}`, + { + params, + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + return decryptedSecret as VercelApiSecret; + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await getDecryptedVercelSecret(secretSync, secret, attempt + 1); + } + throw error; + } +}; + +const getVercelSecrets = async (secretSync: TVercelSyncWithCredentials): Promise => { + const { destinationConfig } = secretSync; + + const secrets = await getVercelSecretsWithRetries(secretSync); + + const filteredSecrets = secrets.filter((secret) => { + if (!isVercelDefaultEnvType(destinationConfig.env)) { + if (secret.customEnvironmentIds?.includes(destinationConfig.env)) { + return true; + } + return false; + } + if (secret.target.includes(destinationConfig.env)) { + // If it's preview environment with a branch specified + if ( + destinationConfig.env === VercelEnvironmentType.Preview && + destinationConfig.branch && + secret.gitBranch && + secret.gitBranch !== destinationConfig.branch + ) { + return false; + } + return true; + } + return false; + }); + + // For secrets of type "encrypted", we need to get their decrypted value + const secretsWithValues = await Promise.all( + filteredSecrets.map(async (secret) => { + if (secret.type === "encrypted") { + const decryptedSecret = await getDecryptedVercelSecret(secretSync, secret); + return decryptedSecret; + } + return secret; + }) + ); + + return secretsWithValues; +}; + +const deleteSecret = async ( + secretSync: TVercelSyncWithCredentials, + vercelSecret: VercelApiSecret, + attempt = 0 +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + try { + await request.delete( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${vercelSecret.id}?teamId=${destinationConfig.teamId}`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await deleteSecret(secretSync, vercelSecret, attempt + 1); + } + throw new SecretSyncError({ + error, + secretKey: vercelSecret.key + }); + } +}; + +const createSecret = async ( + secretSync: TVercelSyncWithCredentials, + secretMap: TSecretMap, + key: string, + attempt = 0 +): Promise => { + try { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + await request.post( + `${IntegrationUrls.VERCEL_API_URL}/v10/projects/${destinationConfig.app}/env?teamId=${destinationConfig.teamId}`, + { + key, + value: secretMap[key].value, + type: "encrypted", + target: isVercelDefaultEnvType(destinationConfig.env) ? [destinationConfig.env] : [], + customEnvironmentIds: !isVercelDefaultEnvType(destinationConfig.env) ? [destinationConfig.env] : [], + ...(destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch + ? { gitBranch: destinationConfig.branch } + : {}) + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await createSecret(secretSync, secretMap, key, attempt + 1); + } + throw new SecretSyncError({ + error, + secretKey: key + }); + } +}; + +const updateSecret = async ( + secretSync: TVercelSyncWithCredentials, + secretMap: TSecretMap, + vercelSecret: VercelApiSecret, + attempt = 0 +): Promise => { + try { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + let target = [...vercelSecret.target]; + if (isVercelDefaultEnvType(destinationConfig.env) && !vercelSecret.target.includes(destinationConfig.env)) { + target = [...target, destinationConfig.env]; + } + let customEnvironmentIds = [...(vercelSecret.customEnvironmentIds || [])]; + if ( + !isVercelDefaultEnvType(destinationConfig.env) && + !vercelSecret.customEnvironmentIds?.includes(destinationConfig.env) + ) { + customEnvironmentIds = [...customEnvironmentIds, destinationConfig.env]; + } + + await request.patch( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${vercelSecret.id}?teamId=${destinationConfig.teamId}`, + { + ...(vercelSecret.type !== "sensitive" && { key: vercelSecret.key }), + value: secretMap[vercelSecret.key].value, + type: vercelSecret.type, + target, + customEnvironmentIds, + ...(destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch + ? { gitBranch: destinationConfig.branch } + : {}) + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await updateSecret(secretSync, secretMap, vercelSecret, attempt + 1); + } + throw new SecretSyncError({ + error, + secretKey: vercelSecret.key + }); + } +}; + +export const VercelSyncFns = { + syncSecrets: async (secretSync: TVercelSyncWithCredentials, secretMap: TSecretMap) => { + const vercelSecrets = await getVercelSecrets(secretSync); + const vercelSecretsMap = new Map(vercelSecrets.map((s) => [s.key, s])); + + // Create or update secrets + for await (const key of Object.keys(secretMap)) { + const existingSecret = vercelSecretsMap.get(key); + + if (!existingSecret) { + await createSecret(secretSync, secretMap, key); + } else if (existingSecret.value !== secretMap[key].value) { + await updateSecret(secretSync, secretMap, existingSecret); + } + } + + // Delete secrets if disableSecretDeletion is not set + if (secretSync.syncOptions.disableSecretDeletion) return; + + for await (const vercelSecret of vercelSecrets) { + if (!secretMap[vercelSecret.key]) { + await deleteSecret(secretSync, vercelSecret); + } + } + }, + + getSecrets: async (secretSync: TVercelSyncWithCredentials): Promise => { + const vercelSecrets = await getVercelSecrets(secretSync); + return Object.fromEntries(vercelSecrets.map((s) => [s.key, { value: s.value ?? "" }])); + }, + + removeSecrets: async (secretSync: TVercelSyncWithCredentials, secretMap: TSecretMap) => { + const vercelSecrets = await getVercelSecrets(secretSync); + + for await (const vercelSecret of vercelSecrets) { + if (vercelSecret.key in secretMap) { + await deleteSecret(secretSync, vercelSecret); + } + } + } +}; diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-schemas.ts b/backend/src/services/secret-sync/vercel/vercel-sync-schemas.ts new file mode 100644 index 000000000..84d7a6da4 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-schemas.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +import { VercelEnvironmentType } from "./vercel-sync-enums"; + +const VercelSyncDestinationConfigSchema = z.object({ + app: z.string().min(1, "App ID is required").describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.app), + appName: z.string().min(1, "App Name is required").describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.appName), + env: z.nativeEnum(VercelEnvironmentType).or(z.string()).describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.env), + branch: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.branch), + teamId: z.string().describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.teamId) +}); + +const VercelSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const VercelSyncSchema = BaseSecretSyncSchema(SecretSync.Vercel, VercelSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Vercel), + destinationConfig: VercelSyncDestinationConfigSchema +}); + +export const CreateVercelSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Vercel, + VercelSyncOptionsConfig +).extend({ + destinationConfig: VercelSyncDestinationConfigSchema +}); + +export const UpdateVercelSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Vercel, + VercelSyncOptionsConfig +).extend({ + destinationConfig: VercelSyncDestinationConfigSchema.optional() +}); + +export const VercelSyncListItemSchema = z.object({ + name: z.literal("Vercel"), + connection: z.literal(AppConnection.Vercel), + destination: z.literal(SecretSync.Vercel), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-types.ts b/backend/src/services/secret-sync/vercel/vercel-sync-types.ts new file mode 100644 index 000000000..d6d2b6433 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-types.ts @@ -0,0 +1,40 @@ +import z from "zod"; + +import { TVercelConnection } from "@app/services/app-connection/vercel"; + +import { VercelEnvironmentType } from "./vercel-sync-enums"; +import { CreateVercelSyncSchema, VercelSyncListItemSchema, VercelSyncSchema } from "./vercel-sync-schemas"; + +export type TVercelSyncListItem = z.infer; + +export type TVercelSync = z.infer; + +export type TVercelSyncInput = z.infer; + +export type TVercelSyncWithCredentials = TVercelSync & { + connection: TVercelConnection; +}; + +export type VercelSecret = { + description: string; + is_secret: boolean; + key: string; + source: "app" | "env"; + value: string; +}; + +export interface VercelApiSecret { + id: string; + key: string; + value: string; + type: string; + target: string[]; + customEnvironmentIds?: string[]; + gitBranch?: string; + createdAt?: number; + updatedAt?: number; + configurationId?: string; + system?: boolean; +} + +export type DefaultVercelEnvType = (typeof VercelEnvironmentType)[keyof typeof VercelEnvironmentType]; 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 9bed01637..a9c909899 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 @@ -2,7 +2,10 @@ import { Knex } from "knex"; import { validate as uuidValidate } from "uuid"; import { TDbClient } from "@app/db"; -import { SecretsV2Schema, SecretType, TableName, TSecretsV2, TSecretsV2Update } from "@app/db/schemas"; +import { ProjectType, SecretsV2Schema, SecretType, TableName, TSecretsV2, TSecretsV2Update } from "@app/db/schemas"; +import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { getConfig } from "@app/lib/config/env"; +import { generateCacheKeyFromData } from "@app/lib/crypto/cache"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { buildFindFilter, @@ -12,15 +15,67 @@ import { TFindFilter, TFindOpt } from "@app/lib/knex"; -import { OrderByDirection } from "@app/lib/types"; +import { BufferKeysToString, OrderByDirection } from "@app/lib/types"; import { SecretsOrderBy } from "@app/services/secret/secret-types"; -import { TFindSecretsByFolderIdsFilter } from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; +import type { TFindSecretsByFolderIdsFilter } from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; + +export const SecretDalCacheKeys = { + get productKey() { + const { INFISICAL_PLATFORM_VERSION } = getConfig(); + return `${ProjectType.SecretManager}:${INFISICAL_PLATFORM_VERSION || 0}`; + }, + getSecretDalVersion: (projectId: string) => { + return `${SecretDalCacheKeys.productKey}:${projectId}:${TableName.SecretV2}-dal-version`; + }, + findByFolderIds: ( + projectId: string, + version: number, + { useCache, tx, ...cacheKey }: Parameters[0] + ) => { + return `${SecretDalCacheKeys.productKey}:${projectId}:${ + TableName.SecretV2 + }-dal:v${version}:find-by-folder-ids:${generateCacheKeyFromData(cacheKey)}`; + }, + findByFolderId: ( + projectId: string, + version: number, + { useCache, tx, ...cacheKey }: Parameters[0] + ) => { + return `${SecretDalCacheKeys.productKey}:${projectId}:${ + TableName.SecretV2 + }-dal:v${version}:find-by-folder-id:${generateCacheKeyFromData(cacheKey)}`; + }, + find: (projectId: string, version: number, ...args: Parameters) => { + const [filter, opts] = args; + delete opts?.tx; + delete opts?.useCache; + return `${SecretDalCacheKeys.productKey}:${projectId}:${ + TableName.SecretV2 + }-dal:v${version}:find:${generateCacheKeyFromData({ + filter, + opts + })}`; + } +}; export type TSecretV2BridgeDALFactory = ReturnType; +interface TSecretV2DalArg { + db: TDbClient; + keyStore: TKeyStoreFactory; +} -export const secretV2BridgeDALFactory = (db: TDbClient) => { +const SECRET_DAL_TTL = 5 * 60; +const SECRET_DAL_VERSION_TTL = 15 * 60; +const MAX_SECRET_CACHE_BYTES = 25 * 1024 * 1024; +export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { const secretOrm = ormify(db, TableName.SecretV2); + const invalidateSecretCacheByProjectId = async (projectId: string) => { + const secretDalVersionKey = SecretDalCacheKeys.getSecretDalVersion(projectId); + await keyStore.incrementBy(secretDalVersionKey, 1); + await keyStore.setExpiry(secretDalVersionKey, SECRET_DAL_VERSION_TTL); + }; + const findOne = async (filter: Partial, tx?: Knex) => { try { const docs = await (tx || db)(TableName.SecretV2) @@ -73,8 +128,35 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { } }; - const find = async (filter: TFindFilter, { offset, limit, sort, tx }: TFindOpt = {}) => { + const find = async ( + filter: TFindFilter, + opts: TFindOpt & { useCache?: { projectId: string } } = {} + ) => { + const { offset, limit, sort, tx, useCache } = opts; try { + let secretDalVersion = 0; + if (useCache) { + const cachedSecretDalVersion = await keyStore.getItem( + SecretDalCacheKeys.getSecretDalVersion(useCache.projectId) + ); + secretDalVersion = Number(cachedSecretDalVersion || 0); + const cacheKey = SecretDalCacheKeys.find(useCache.projectId, secretDalVersion, filter, opts); + const cachedSecrets = await keyStore.getItem(cacheKey); + if (cachedSecrets) { + await keyStore.setExpiry(cacheKey, SECRET_DAL_TTL); + + const unsanitizedSecrets = JSON.parse(cachedSecrets) as BufferKeysToString<(typeof data)[number]>[]; + const sanitizedSecrets = unsanitizedSecrets.map((el) => { + const encryptedValue = el.encryptedValue ? Buffer.from(el.encryptedValue, "base64") : null; + const encryptedComment = el.encryptedComment ? Buffer.from(el.encryptedComment, "base64") : null; + const createdAt = new Date(el.createdAt); + const updatedAt = new Date(el.updatedAt); + return { ...el, encryptedComment, encryptedValue, createdAt, updatedAt }; + }); + return sanitizedSecrets; + } + } + const query = (tx || db)(TableName.SecretV2) // eslint-disable-next-line @typescript-eslint/no-misused-promises .where(buildFindFilter(filter)) @@ -142,6 +224,23 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { } ] }); + + if (useCache) { + const cachedSecrets = data.map((el) => { + const encryptedValue = el.encryptedValue ? el.encryptedValue.toString("base64") : null; + const encryptedComment = el.encryptedComment ? el.encryptedComment.toString("base64") : null; + return { ...el, encryptedValue, encryptedComment }; + }); + const cache = JSON.stringify(cachedSecrets); + if (Buffer.byteLength(cache, "utf8") < MAX_SECRET_CACHE_BYTES) { + await keyStore.setItemWithExpiry( + SecretDalCacheKeys.find(useCache.projectId, secretDalVersion, filter, opts), + SECRET_DAL_TTL, + cache + ); + } + } + return data; } catch (error) { throw new DatabaseError({ error, name: `${TableName.SecretV2}: Find` }); @@ -246,14 +345,43 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { } }; - const findByFolderId = async (folderId: string, userId?: string, tx?: Knex) => { + const findByFolderId = async (dto: { + folderId: string; + userId?: string; + tx?: Knex; + projectId: string; + useCache?: boolean; + }) => { try { - // check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo) + const { folderId, tx, projectId } = dto; + let { userId } = dto; + // check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo if (userId && !uuidValidate(userId)) { // eslint-disable-next-line userId = undefined; } + const cachedSecretDalVersion = await keyStore.getItem(SecretDalCacheKeys.getSecretDalVersion(projectId)); + const secretDalVersion = Number(cachedSecretDalVersion || 0); + + if (dto.useCache) { + const cacheKey = SecretDalCacheKeys.findByFolderId(projectId, secretDalVersion, dto); + const cachedSecrets = await keyStore.getItem(cacheKey); + if (cachedSecrets) { + await keyStore.setExpiry(cacheKey, SECRET_DAL_TTL); + + const unsanitizedSecrets = JSON.parse(cachedSecrets) as BufferKeysToString<(typeof data)[number]>[]; + const sanitizedSecrets = unsanitizedSecrets.map((el) => { + const encryptedValue = el.encryptedValue ? Buffer.from(el.encryptedValue, "base64") : null; + const encryptedComment = el.encryptedComment ? Buffer.from(el.encryptedComment, "base64") : null; + const createdAt = new Date(el.createdAt); + const updatedAt = new Date(el.updatedAt); + return { ...el, encryptedComment, encryptedValue, createdAt, updatedAt }; + }); + return sanitizedSecrets; + } + } + const secs = await (tx || db.replicaNode())(TableName.SecretV2) .where({ folderId }) .where((bd) => { @@ -309,6 +437,22 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { } ] }); + if (dto.useCache) { + const newCachedSecrets = data.map((el) => { + const encryptedValue = el.encryptedValue ? el.encryptedValue.toString("base64") : null; + const encryptedComment = el.encryptedComment ? el.encryptedComment.toString("base64") : null; + return { ...el, encryptedValue, encryptedComment }; + }); + const cache = JSON.stringify(newCachedSecrets); + + if (Buffer.byteLength(cache, "utf8") < MAX_SECRET_CACHE_BYTES) { + await keyStore.setItemWithExpiry( + SecretDalCacheKeys.findByFolderId(projectId, secretDalVersion, dto), + SECRET_DAL_TTL, + cache + ); + } + } return data; } catch (error) { throw new DatabaseError({ error, name: "get all secret" }); @@ -394,12 +538,16 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { } }; - const findByFolderIds = async ( - folderIds: string[], - userId?: string, - tx?: Knex, - filters?: TFindSecretsByFolderIdsFilter - ) => { + const findByFolderIds = async (dto: { + folderIds: string[]; + userId?: string; + tx?: Knex; + projectId: string; + filters?: TFindSecretsByFolderIdsFilter; + useCache?: boolean; + }) => { + const { folderIds, tx, filters, useCache, projectId } = dto; + let { userId } = dto; try { // check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo) if (userId && !uuidValidate(userId)) { @@ -407,6 +555,26 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { userId = undefined; } + const cachedSecretDalVersion = await keyStore.getItem(SecretDalCacheKeys.getSecretDalVersion(projectId)); + const secretDalVersion = Number(cachedSecretDalVersion || 0); + if (useCache) { + const cacheKey = SecretDalCacheKeys.findByFolderIds(projectId, secretDalVersion, dto); + const cachedSecrets = await keyStore.getItem(cacheKey); + if (cachedSecrets) { + await keyStore.setExpiry(cacheKey, SECRET_DAL_TTL); + + const unsanitizedSecrets = JSON.parse(cachedSecrets) as BufferKeysToString<(typeof data)[number]>[]; + const sanitizedSecrets = unsanitizedSecrets.map((el) => { + const encryptedValue = el.encryptedValue ? Buffer.from(el.encryptedValue, "base64") : null; + const encryptedComment = el.encryptedComment ? Buffer.from(el.encryptedComment, "base64") : null; + const createdAt = new Date(el.createdAt); + const updatedAt = new Date(el.updatedAt); + return { ...el, encryptedComment, encryptedValue, createdAt, updatedAt }; + }); + return sanitizedSecrets; + } + } + const query = (tx || db.replicaNode())(TableName.SecretV2) .whereIn(`${TableName.SecretV2}.folderId`, folderIds) .where((bd) => { @@ -532,6 +700,22 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { } ] }); + if (useCache) { + const cachedSecrets = data.map((el) => { + const encryptedValue = el.encryptedValue ? el.encryptedValue.toString("base64") : null; + const encryptedComment = el.encryptedComment ? el.encryptedComment.toString("base64") : null; + return { ...el, encryptedValue, encryptedComment }; + }); + const cache = JSON.stringify(cachedSecrets); + + if (Buffer.byteLength(cache, "utf8") < MAX_SECRET_CACHE_BYTES) { + await keyStore.setItemWithExpiry( + SecretDalCacheKeys.findByFolderIds(projectId, secretDalVersion, dto), + SECRET_DAL_TTL, + cache + ); + } + } return data; } catch (error) { @@ -724,6 +908,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { findAllProjectSecretValues, countByFolderIds, findOne, - find + find, + invalidateSecretCacheByProjectId }; }; 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 4ab021510..3b35ab41a 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 @@ -501,7 +501,7 @@ export const expandSecretReferencesFactory = ({ const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) return { value: "", tags: [] }; - const secrets = await secretDAL.findByFolderId(folder.id); + const secrets = await secretDAL.findByFolderId({ folderId: folder.id, projectId, useCache: true }); const decryptedSecret = secrets.reduce>((prev, secret) => { // eslint-disable-next-line no-param-reassign 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 509144e21..596ebb5a1 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 @@ -331,6 +331,7 @@ export const secretV2BridgeServiceFactory = ({ return createdSecret; }); + await secretDAL.invalidateSecretCacheByProjectId(projectId); if (inputSecret.type === SecretType.Shared) { await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ @@ -539,6 +540,7 @@ export const secretV2BridgeServiceFactory = ({ projectId }); + await secretDAL.invalidateSecretCacheByProjectId(projectId); if (inputSecret.type === SecretType.Shared) { await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ @@ -647,6 +649,7 @@ export const secretV2BridgeServiceFactory = ({ }) ); + await secretDAL.invalidateSecretCacheByProjectId(projectId); if (inputSecret.type === SecretType.Shared) { await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ @@ -796,12 +799,14 @@ export const secretV2BridgeServiceFactory = ({ ) => { const groupedFolderMappings = groupBy(folderMappings, (folderMapping) => folderMapping.folderId); - const secrets = await secretDAL.findByFolderIds( - folderMappings.map((folderMapping) => folderMapping.folderId), + const secrets = await secretDAL.findByFolderIds({ + projectId, + folderIds: folderMappings.map((folderMapping) => folderMapping.folderId), userId, - undefined, - filters - ); + tx: undefined, + filters, + useCache: true + }); const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, @@ -952,12 +957,14 @@ export const secretV2BridgeServiceFactory = ({ const groupedPaths = groupBy(paths, (p) => p.folderId); - const secrets = await secretDAL.findByFolderIds( - paths.map((p) => p.folderId), - actorId, - undefined, - params - ); + const secrets = await secretDAL.findByFolderIds({ + projectId, + folderIds: paths.map((p) => p.folderId), + userId: actorId, + tx: undefined, + filters: params, + useCache: true + }); const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, @@ -1087,6 +1094,7 @@ export const secretV2BridgeServiceFactory = ({ const secretImports = await secretImportDAL.findByFolderIds(paths.map((p) => p.folderId)); const allowedImports = secretImports.filter(({ isReplication }) => !isReplication); const importedSecrets = await fnSecretsV2FromImports({ + projectId, viewSecretValue, secretImports: allowedImports, secretDAL, @@ -1304,6 +1312,7 @@ export const secretV2BridgeServiceFactory = ({ if (!secret && includeImports) { const secretImports = await secretImportDAL.find({ folderId, isReplication: false }); const importedSecrets = await fnSecretsV2FromImports({ + projectId, secretImports, viewSecretValue, secretDAL, @@ -1543,7 +1552,7 @@ export const secretV2BridgeServiceFactory = ({ tx }) ); - + await secretDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ actor, @@ -1883,6 +1892,7 @@ export const secretV2BridgeServiceFactory = ({ } }); + await secretDAL.invalidateSecretCacheByProjectId(projectId); await Promise.allSettled(folders.map((el) => (el?.id ? snapshotService.performSnapshot(el.id) : undefined))); await Promise.allSettled( folders.map((el) => @@ -2014,6 +2024,7 @@ export const secretV2BridgeServiceFactory = ({ }) ); + await secretDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ actor, @@ -2537,6 +2548,9 @@ export const secretV2BridgeServiceFactory = ({ } }); + if (isDestinationUpdated || isSourceUpdated) { + await secretDAL.invalidateSecretCacheByProjectId(projectId); + } if (isDestinationUpdated) { await snapshotService.performSnapshot(destinationFolder.id); await secretQueueService.syncSecrets({ @@ -2715,7 +2729,7 @@ export const secretV2BridgeServiceFactory = ({ generatePaths(folderMap).map(({ folderId, path }) => [folderId, path === "/" ? path : path.substring(1)]) ); - const secrets = await secretDAL.findByFolderIds(folders.map((f) => f.id)); + const secrets = await secretDAL.findByFolderIds({ folderIds: folders.map((f) => f.id), projectId, useCache: true }); const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, 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 7b4ea1ee1..11149c605 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 @@ -279,6 +279,13 @@ export type TUpdateManySecretsFnFactory = { folderDAL: TSecretFolderDALFactory; }; +export type TFindByFolderIdDALDTO = { + folderId: string; + userId?: string; + tx?: Knex; + projectId: string; +}; + export type TUpdateManySecretsFn = { projectId: string; environment: string; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 60eec9cb1..0d36250fb 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -367,7 +367,7 @@ export const secretQueueFactory = ({ canExpandValue: () => true }); // process secrets in current folder - const secrets = await secretV2BridgeDAL.findByFolderId(dto.folderId); + const secrets = await secretV2BridgeDAL.findByFolderId({ folderId: dto.folderId, projectId: dto.projectId }); await Promise.allSettled( secrets.map(async (secret) => { @@ -397,6 +397,7 @@ export const secretQueueFactory = ({ // if no imports then return secrets in the current folder if (!secretImports.length) return content; const importedSecrets = await fnSecretsV2FromImports({ + projectId: dto.projectId, decryptor: dto.decryptor, folderDAL, secretDAL: secretV2BridgeDAL, diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts index f92f96a24..6c84c0e76 100644 --- a/backend/src/services/slack/slack-fns.ts +++ b/backend/src/services/slack/slack-fns.ts @@ -87,7 +87,12 @@ View the complete details <${appCfg.SITE_URL}/secret-manager/${payload.projectId The following permissions are requested: ${payload.permissions.join(", ")} -View the request and approve or deny it <${payload.approvalUrl}|here>.`; +View the request and approve or deny it <${payload.approvalUrl}|here>.${ + payload.note + ? ` +User Note: ${payload.note}` + : "" + }`; const payloadBlocks = [ { diff --git a/backend/src/services/slack/slack-types.ts b/backend/src/services/slack/slack-types.ts index a92ba4e8b..3e8354adf 100644 --- a/backend/src/services/slack/slack-types.ts +++ b/backend/src/services/slack/slack-types.ts @@ -76,5 +76,6 @@ export type TSlackNotification = projectName: string; permissions: string[]; approvalUrl: string; + note?: string; }; }; diff --git a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars index ef11957a7..6813c1200 100644 --- a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars +++ b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars @@ -40,6 +40,9 @@ {{/each}}

+ {{#if note}} +

User Note: "{{note}}"

+ {{/if}}

View the request and approve or deny it diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index 45510899a..ab90a71d4 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -18,6 +18,8 @@ export enum PostHogEventTypes { SecretRequestDeleted = "Secret Request Deleted", SignSshKey = "Sign SSH Key", IssueSshCreds = "Issue SSH Credentials", + IssueSshHostUserCert = "Issue SSH Host User Certificate", + IssueSshHostHostCert = "Issue SSH Host Host Certificate", SignCert = "Sign PKI Certificate", IssueCert = "Issue PKI Certificate" } @@ -161,6 +163,26 @@ export type TIssueSshCredsEvent = { }; }; +export type TIssueSshHostUserCertEvent = { + event: PostHogEventTypes.IssueSshHostUserCert; + properties: { + sshHostId: string; + hostname: string; + principals: string[]; + userAgent?: string; + }; +}; + +export type TIssueSshHostHostCertEvent = { + event: PostHogEventTypes.IssueSshHostHostCert; + properties: { + sshHostId: string; + hostname: string; + principals: string[]; + userAgent?: string; + }; +}; + export type TSignCertificateEvent = { event: PostHogEventTypes.SignCert; properties: { @@ -195,6 +217,8 @@ export type TPostHogEvent = { distinctId: string } & ( | TSecretRequestDeletedEvent | TSignSshKeyEvent | TIssueSshCredsEvent + | TIssueSshHostUserCertEvent + | TIssueSshHostHostCertEvent | TSignCertificateEvent | TIssueCertificateEvent ); diff --git a/cli/go.mod b/cli/go.mod index 5f3992e17..c713417e2 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -12,7 +12,7 @@ require ( github.com/fatih/semgroup v1.2.0 github.com/gitleaks/go-gitdiff v0.8.0 github.com/h2non/filetype v1.1.3 - github.com/infisical/go-sdk v0.5.1 + github.com/infisical/go-sdk v0.5.8 github.com/infisical/infisical-kmip v0.3.5 github.com/mattn/go-isatty v0.0.20 github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a diff --git a/cli/go.sum b/cli/go.sum index da221fd6f..68bce9cd3 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -277,8 +277,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/infisical/go-sdk v0.5.1 h1:bl0D4A6CmvfL8RwEQTcZh39nsxC6q3HSs76/4J8grWY= -github.com/infisical/go-sdk v0.5.1/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= +github.com/infisical/go-sdk v0.5.8 h1:bCetYLp7HWt8DnU9KPh1n8n3z5pjmunkGDB4bA3lEFs= +github.com/infisical/go-sdk v0.5.8/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= github.com/infisical/infisical-kmip v0.3.5 h1:QM3s0e18B+mYv3a9HQNjNAlbwZJBzXq5BAJM2scIeiE= github.com/infisical/infisical-kmip v0.3.5/go.mod h1:bO1M4YtKyutNg1bREPmlyZspC5duSR7hyQ3lPmLzrIs= github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= diff --git a/cli/packages/cmd/ssh.go b/cli/packages/cmd/ssh.go index a3d10fc91..5b2bb37bb 100644 --- a/cli/packages/cmd/ssh.go +++ b/cli/packages/cmd/ssh.go @@ -8,6 +8,7 @@ import ( "fmt" "net" "os" + "os/exec" "path/filepath" "strings" "time" @@ -17,6 +18,7 @@ import ( "github.com/Infisical/infisical-merge/packages/util" infisicalSdk "github.com/infisical/go-sdk" infisicalSdkUtil "github.com/infisical/go-sdk/packages/util" + "github.com/manifoldco/promptui" "github.com/spf13/cobra" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" @@ -48,6 +50,18 @@ var sshSignKeyCmd = &cobra.Command{ Run: signKey, } +var sshConnectCmd = &cobra.Command{ + Use: "connect", + Short: "Connect to an SSH host using issued credentials", + Run: sshConnect, +} + +var sshAddHostCmd = &cobra.Command{ + Use: "add-host", + Short: "Register a new SSH host with Infisical", + Run: sshAddHost, +} + var algoToFileName = map[infisicalSdkUtil.CertKeyAlgorithm]string{ infisicalSdkUtil.RSA2048: "id_rsa_2048", infisicalSdkUtil.RSA4096: "id_rsa_4096", @@ -240,7 +254,7 @@ func issueCredentials(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse addToAgent flag") } - if outFilePath == "" && addToAgent == false { + if outFilePath == "" && !addToAgent { util.PrintErrorMessageAndExit("You must provide either --outFilePath or --addToAgent flag to use this command") } @@ -595,6 +609,380 @@ func signKey(cmd *cobra.Command, args []string) { fmt.Println("Successfully wrote SSH certificate to:", signedKeyPath) } +func sshConnect(cmd *cobra.Command, args []string) { + util.RequireLogin() + util.RequireLocalWorkspaceFile() + + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) + if err != nil { + util.HandleError(err, "Unable to authenticate") + } + + if loggedInUserDetails.LoginExpired { + util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + } + + infisicalToken := loggedInUserDetails.UserCredentials.JTWToken + + writeHostCaToFile, err := cmd.Flags().GetBool("writeHostCaToFile") + if err != nil { + util.HandleError(err, "Unable to parse --writeHostCaToFile flag") + } + + customHeaders, err := util.GetInfisicalCustomHeadersMap() + if err != nil { + util.HandleError(err, "Unable to get custom headers") + } + + infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ + SiteUrl: config.INFISICAL_URL, + UserAgent: api.USER_AGENT, + AutoTokenRefresh: false, + CustomHeaders: customHeaders, + }) + infisicalClient.Auth().SetAccessToken(infisicalToken) + + // Fetch SSH Hosts + hosts, err := infisicalClient.Ssh().GetSshHosts(infisicalSdk.GetSshHostsOptions{}) + if err != nil { + util.HandleError(err, "Failed to fetch SSH hosts") + } + if len(hosts) == 0 { + util.PrintErrorMessageAndExit("You do not have access to any SSH hosts") + } + + // Prompt to select host + hostNames := make([]string, len(hosts)) + for i, h := range hosts { + hostNames[i] = h.Hostname + } + + hostPrompt := promptui.Select{ + Label: "Select an SSH Host", + Items: hostNames, + Size: 10, + } + hostIdx, _, err := hostPrompt.Run() + if err != nil { + util.HandleError(err, "Prompt failed") + } + selectedHost := hosts[hostIdx] + + // Prompt to select login user + if len(selectedHost.LoginMappings) == 0 { + util.PrintErrorMessageAndExit("No login users available for selected host") + } + + loginUsers := make([]string, len(selectedHost.LoginMappings)) + for i, m := range selectedHost.LoginMappings { + loginUsers[i] = m.LoginUser + } + + loginPrompt := promptui.Select{ + Label: "Select Login User", + Items: loginUsers, + Size: 5, + } + loginIdx, _, err := loginPrompt.Run() + if err != nil { + util.HandleError(err, "Prompt failed") + } + selectedLoginUser := selectedHost.LoginMappings[loginIdx].LoginUser + + // Issue SSH creds for host + creds, err := infisicalClient.Ssh().IssueSshHostUserCert(selectedHost.ID, infisicalSdk.IssueSshHostUserCertOptions{ + LoginUser: selectedLoginUser, + }) + if err != nil { + util.HandleError(err, "Failed to issue SSH credentials") + } + + // Write Host CA public key to known_hosts if enabled + if writeHostCaToFile { + hostCaPublicKey, err := infisicalClient.Ssh().GetSshHostHostCaPublicKey(selectedHost.ID) + if err != nil { + util.HandleError(err, "Failed to fetch Host CA public key") + } + + // Build @cert-authority line + caLine := fmt.Sprintf("@cert-authority %s %s\n", selectedHost.Hostname, strings.TrimSpace(hostCaPublicKey)) + + // Determine known_hosts path + sshDir := filepath.Join(os.Getenv("HOME"), ".ssh") + knownHostsPath := filepath.Join(sshDir, "known_hosts") + + // Ensure ~/.ssh exists + if _, err := os.Stat(sshDir); os.IsNotExist(err) { + if err := os.MkdirAll(sshDir, 0700); err != nil { + util.HandleError(err, "Failed to create ~/.ssh directory") + } + } + + // Check if CA line already exists + knownHostsBytes, _ := os.ReadFile(knownHostsPath) + if !strings.Contains(string(knownHostsBytes), caLine) { + f, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + util.HandleError(err, "Failed to open known_hosts file") + } + defer f.Close() + + if _, err := f.WriteString(caLine); err != nil { + util.HandleError(err, "Failed to write Host CA to known_hosts") + } + + fmt.Printf("📁 Wrote Host CA entry to %s\n", knownHostsPath) + } + } + + // Load credentials into SSH agent + err = addCredentialsToAgent(creds.PrivateKey, creds.SignedKey) + if err != nil { + util.HandleError(err, "Failed to add credentials to SSH agent") + } + fmt.Println("✔ SSH credentials successfully added to agent") + + // Connect to host using system ssh and agent + target := fmt.Sprintf("%s@%s", selectedLoginUser, selectedHost.Hostname) + fmt.Printf("Connecting to %s...\n", target) + + sshCmd := exec.Command("ssh", target) + sshCmd.Stdin = os.Stdin + sshCmd.Stdout = os.Stdout + sshCmd.Stderr = os.Stderr + + err = sshCmd.Run() + if err != nil { + util.HandleError(err, "SSH connection failed") + } +} + +func sshAddHost(cmd *cobra.Command, args []string) { + + token, err := util.GetInfisicalToken(cmd) + if err != nil { + util.HandleError(err, "Unable to parse token") + } + + var infisicalToken string + if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) { + infisicalToken = token.Token + } else { + util.RequireLogin() + util.RequireLocalWorkspaceFile() + + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) + if err != nil { + util.HandleError(err, "Unable to authenticate") + } + if loggedInUserDetails.LoginExpired { + util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login]") + } + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken + } + + projectId, err := cmd.Flags().GetString("projectId") + if err != nil { + util.HandleError(err, "Unable to parse --projectId flag") + } + if projectId == "" { + util.PrintErrorMessageAndExit("You must provide --projectId") + } + + hostname, err := cmd.Flags().GetString("hostname") + if err != nil { + util.HandleError(err, "Unable to parse --hostname flag") + } + if hostname == "" { + util.PrintErrorMessageAndExit("You must provide --hostname") + } + + writeUserCaToFile, err := cmd.Flags().GetBool("writeUserCaToFile") + if err != nil { + util.HandleError(err, "Unable to parse --writeUserCaToFile flag") + } + + userCaOutFilePath, err := cmd.Flags().GetString("userCaOutFilePath") + if err != nil { + util.HandleError(err, "Unable to parse --userCaOutFilePath flag") + } + + writeHostCertToFile, err := cmd.Flags().GetBool("writeHostCertToFile") + if err != nil { + util.HandleError(err, "Unable to parse --writeHostCertToFile flag") + } + + configureSshd, err := cmd.Flags().GetBool("configureSshd") + if err != nil { + util.HandleError(err, "Unable to parse --configureSshd flag") + } + + forceOverwrite, err := cmd.Flags().GetBool("force") + if err != nil { + util.HandleError(err, "Unable to parse --force flag") + } + + if configureSshd && (!writeUserCaToFile || !writeHostCertToFile) { + util.PrintErrorMessageAndExit("--configureSshd requires both --writeUserCaToFile and --writeHostCertToFile to also be set") + } + + // Pre-check for file overwrites before proceeding + if writeUserCaToFile { + if strings.HasPrefix(userCaOutFilePath, "~") { + homeDir, err := os.UserHomeDir() + if err != nil { + util.HandleError(err, "Unable to resolve ~ in userCaOutFilePath") + } + userCaOutFilePath = strings.Replace(userCaOutFilePath, "~", homeDir, 1) + } + if _, err := os.Stat(userCaOutFilePath); err == nil && !forceOverwrite { + util.PrintErrorMessageAndExit("File already exists at " + userCaOutFilePath + ". Use --force to overwrite.") + } + } + + keyTypes := []string{"ed25519", "ecdsa", "rsa"} + var hostKeyPath, certOutPath, hostPrivateKeyPath string + if writeHostCertToFile { + for _, keyType := range keyTypes { + pub := fmt.Sprintf("/etc/ssh/ssh_host_%s_key.pub", keyType) + cert := fmt.Sprintf("/etc/ssh/ssh_host_%s_key-cert.pub", keyType) + priv := fmt.Sprintf("/etc/ssh/ssh_host_%s_key", keyType) + + if _, err := os.Stat(pub); err == nil { + hostKeyPath = pub + certOutPath = cert + hostPrivateKeyPath = priv + break + } + } + + if hostKeyPath == "" { + util.PrintErrorMessageAndExit("No supported SSH host public key found at /etc/ssh") + } + + if _, err := os.Stat(certOutPath); err == nil && !forceOverwrite { + util.PrintErrorMessageAndExit("File already exists at " + certOutPath + ". Use --force to overwrite.") + } + } + + if configureSshd { + sshdConfig := "/etc/ssh/sshd_config" + existing, err := os.ReadFile(sshdConfig) + if err != nil { + util.HandleError(err, "Failed to read sshd_config") + } + configLines := []string{ + "TrustedUserCAKeys " + userCaOutFilePath, + "HostKey " + hostPrivateKeyPath, + "HostCertificate " + certOutPath, + } + for _, line := range configLines { + for _, existingLine := range strings.Split(string(existing), "\n") { + trimmed := strings.TrimSpace(existingLine) + if trimmed == line && !strings.HasPrefix(trimmed, "#") && !forceOverwrite { + util.PrintErrorMessageAndExit("sshd_config already contains: " + line + ". Use --force to overwrite.") + } + } + } + } + + customHeaders, err := util.GetInfisicalCustomHeadersMap() + if err != nil { + util.HandleError(err, "Unable to get custom headers") + } + + client := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ + SiteUrl: config.INFISICAL_URL, + UserAgent: api.USER_AGENT, + AutoTokenRefresh: false, + CustomHeaders: customHeaders, + }) + client.Auth().SetAccessToken(infisicalToken) + + host, err := client.Ssh().AddSshHost(infisicalSdk.AddSshHostOptions{ + ProjectID: projectId, + Hostname: hostname, + }) + if err != nil { + util.HandleError(err, "Failed to register SSH host") + } + + fmt.Println("✅ Successfully registered host:", host.Hostname) + + if writeUserCaToFile { + publicKey, err := client.Ssh().GetSshHostUserCaPublicKey(host.ID) + if err != nil { + util.HandleError(err, "Failed to fetch associated User CA public key") + } + + if err := writeToFile(userCaOutFilePath, publicKey, 0644); err != nil { + util.HandleError(err, "Failed to write User CA public key to file") + } + + fmt.Println("📁 Wrote User CA public key to:", userCaOutFilePath) + } + + if writeHostCertToFile { + pubKeyBytes, err := os.ReadFile(hostKeyPath) + if err != nil { + util.HandleError(err, "Failed to read SSH host public key") + } + res, err := client.Ssh().IssueSshHostHostCert(host.ID, infisicalSdk.IssueSshHostHostCertOptions{ + PublicKey: string(pubKeyBytes), + }) + if err != nil { + util.HandleError(err, "Failed to issue SSH host certificate") + } + if err := writeToFile(certOutPath, res.SignedKey, 0644); err != nil { + util.HandleError(err, "Failed to write SSH host certificate to file") + } + fmt.Println("📁 Wrote host certificate to:", certOutPath) + } + + if configureSshd { + sshdConfig := "/etc/ssh/sshd_config" + contentBytes, err := os.ReadFile(sshdConfig) + if err != nil { + util.HandleError(err, "Failed to read sshd_config") + } + lines := strings.Split(string(contentBytes), "\n") + + configMap := map[string]string{ + "TrustedUserCAKeys": userCaOutFilePath, + "HostKey": hostPrivateKeyPath, + "HostCertificate": certOutPath, + } + + seenKeys := map[string]bool{} + for i, line := range lines { + trimmed := strings.TrimSpace(line) + for key, value := range configMap { + if strings.HasPrefix(trimmed, key+" ") { + seenKeys[key] = true + if strings.HasPrefix(trimmed, "#") || forceOverwrite { + lines[i] = fmt.Sprintf("%s %s", key, value) + } else { + util.PrintErrorMessageAndExit("sshd_config already contains: " + trimmed + ". Use --force to overwrite.") + } + } + } + } + + // Append missing lines + for key, value := range configMap { + if !seenKeys[key] { + lines = append(lines, fmt.Sprintf("%s %s", key, value)) + } + } + + // Write back to file + if err := os.WriteFile(sshdConfig, []byte(strings.Join(lines, "\n")), 0644); err != nil { + util.HandleError(err, "Failed to update sshd_config") + } + fmt.Println("📄 Updated sshd_config entries") + } +} + func init() { sshSignKeyCmd.Flags().String("token", "", "Issue SSH certificate using machine identity access token") sshSignKeyCmd.Flags().String("certificateTemplateId", "", "The ID of the SSH certificate template to issue the SSH certificate for") @@ -617,5 +1005,20 @@ func init() { sshIssueCredentialsCmd.Flags().String("outFilePath", "", "The path to write the SSH credentials to such as ~/.ssh, ./some_folder, ./some_folder/id_rsa-cert.pub. If not provided, the credentials will be saved to the current working directory") sshIssueCredentialsCmd.Flags().Bool("addToAgent", false, "Whether to add issued SSH credentials to the SSH agent") sshCmd.AddCommand(sshIssueCredentialsCmd) + + sshConnectCmd.Flags().Bool("writeHostCaToFile", true, "Write Host CA public key to ~/.ssh/known_hosts as a separate entry if doesn't already exist") + sshCmd.AddCommand(sshConnectCmd) + + sshAddHostCmd.Flags().String("token", "", "Use a machine identity access token") + sshAddHostCmd.Flags().String("projectId", "", "Project ID the host belongs to (required)") + sshAddHostCmd.Flags().String("hostname", "", "Hostname of the SSH host (required)") + sshAddHostCmd.Flags().Bool("writeUserCaToFile", false, "Write User CA public key to /etc/ssh/infisical_user_ca.pub") + sshAddHostCmd.Flags().String("userCaOutFilePath", "/etc/ssh/infisical_user_ca.pub", "Custom file path to write the User CA public key") + sshAddHostCmd.Flags().Bool("writeHostCertToFile", false, "Write SSH host certificate to /etc/ssh/ssh_host__key-cert.pub") + sshAddHostCmd.Flags().Bool("configureSshd", false, "Update TrustedUserCAKeys, HostKey, and HostCertificate in the sshd_config file") + sshAddHostCmd.Flags().Bool("force", false, "Force overwrite of existing certificate files as part of writeUserCaToFile and writeHostCertToFile") + + sshCmd.AddCommand(sshAddHostCmd) + rootCmd.AddCommand(sshCmd) } diff --git a/docs/api-reference/endpoints/app-connections/camunda/available.mdx b/docs/api-reference/endpoints/app-connections/camunda/available.mdx new file mode 100644 index 000000000..7df54478b --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/camunda/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/camunda/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/camunda/create.mdx b/docs/api-reference/endpoints/app-connections/camunda/create.mdx new file mode 100644 index 000000000..54896202d --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/camunda/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/camunda" +--- diff --git a/docs/api-reference/endpoints/app-connections/camunda/delete.mdx b/docs/api-reference/endpoints/app-connections/camunda/delete.mdx new file mode 100644 index 000000000..a8e6be6b6 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/camunda/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/camunda/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/camunda/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/camunda/get-by-id.mdx new file mode 100644 index 000000000..a8ede9255 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/camunda/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/camunda/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/camunda/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/camunda/get-by-name.mdx new file mode 100644 index 000000000..ae1cced4e --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/camunda/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/camunda/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/camunda/list.mdx b/docs/api-reference/endpoints/app-connections/camunda/list.mdx new file mode 100644 index 000000000..e98e535dd --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/camunda/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/camunda" +--- diff --git a/docs/api-reference/endpoints/app-connections/camunda/update.mdx b/docs/api-reference/endpoints/app-connections/camunda/update.mdx new file mode 100644 index 000000000..0db91e9d9 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/camunda/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/camunda/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/terraform-cloud/available.mdx b/docs/api-reference/endpoints/app-connections/terraform-cloud/available.mdx new file mode 100644 index 000000000..fb2dbdeaf --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/terraform-cloud/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/terraform-cloud/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/terraform-cloud/create.mdx b/docs/api-reference/endpoints/app-connections/terraform-cloud/create.mdx new file mode 100644 index 000000000..ad7d4a5d1 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/terraform-cloud/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/terraform-cloud" +--- + + + Check out the configuration docs for [Terraform Cloud Connections](/integrations/app-connections/terraform-cloud) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/terraform-cloud/delete.mdx b/docs/api-reference/endpoints/app-connections/terraform-cloud/delete.mdx new file mode 100644 index 000000000..daa558f5a --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/terraform-cloud/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/terraform-cloud/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/terraform-cloud/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/terraform-cloud/get-by-id.mdx new file mode 100644 index 000000000..587cc8f11 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/terraform-cloud/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/terraform-cloud/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/terraform-cloud/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/terraform-cloud/get-by-name.mdx new file mode 100644 index 000000000..722381605 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/terraform-cloud/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/terraform-cloud/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/terraform-cloud/list.mdx b/docs/api-reference/endpoints/app-connections/terraform-cloud/list.mdx new file mode 100644 index 000000000..831846155 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/terraform-cloud/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/terraform-cloud" +--- diff --git a/docs/api-reference/endpoints/app-connections/terraform-cloud/update.mdx b/docs/api-reference/endpoints/app-connections/terraform-cloud/update.mdx new file mode 100644 index 000000000..b8f526a88 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/terraform-cloud/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/terraform-cloud/{connectionId}" +--- + + + Check out the configuration docs for [Terraform Cloud Connections](/integrations/app-connections/terraform-cloud) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/vercel/available.mdx b/docs/api-reference/endpoints/app-connections/vercel/available.mdx new file mode 100644 index 000000000..16859bded --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/vercel/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/vercel/create.mdx b/docs/api-reference/endpoints/app-connections/vercel/create.mdx new file mode 100644 index 000000000..63998ac32 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/vercel" +--- + + + Check out the configuration docs for [Vercel Connections](/integrations/app-connections/vercel) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/vercel/delete.mdx b/docs/api-reference/endpoints/app-connections/vercel/delete.mdx new file mode 100644 index 000000000..4e5b12eff --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/vercel/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/vercel/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/vercel/get-by-id.mdx new file mode 100644 index 000000000..fdeb715a8 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/vercel/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/vercel/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/vercel/get-by-name.mdx new file mode 100644 index 000000000..258ed67c7 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/vercel/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/vercel/list.mdx b/docs/api-reference/endpoints/app-connections/vercel/list.mdx new file mode 100644 index 000000000..5412d35bb --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/vercel" +--- diff --git a/docs/api-reference/endpoints/app-connections/vercel/update.mdx b/docs/api-reference/endpoints/app-connections/vercel/update.mdx new file mode 100644 index 000000000..d0e2f4ae2 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/vercel/{connectionId}" +--- + + + Check out the configuration docs for [Vercel Connections](/integrations/app-connections/vercel) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/identities/search.mdx b/docs/api-reference/endpoints/identities/search.mdx new file mode 100644 index 000000000..93906a33b --- /dev/null +++ b/docs/api-reference/endpoints/identities/search.mdx @@ -0,0 +1,4 @@ +--- +title: "Search" +openapi: "POST /api/v1/identities/search" +--- diff --git a/docs/api-reference/endpoints/kms/keys/decrypt.mdx b/docs/api-reference/endpoints/kms/encryption/decrypt.mdx similarity index 100% rename from docs/api-reference/endpoints/kms/keys/decrypt.mdx rename to docs/api-reference/endpoints/kms/encryption/decrypt.mdx diff --git a/docs/api-reference/endpoints/kms/keys/encrypt.mdx b/docs/api-reference/endpoints/kms/encryption/encrypt.mdx similarity index 100% rename from docs/api-reference/endpoints/kms/keys/encrypt.mdx rename to docs/api-reference/endpoints/kms/encryption/encrypt.mdx diff --git a/docs/api-reference/endpoints/kms/signing/public-key.mdx b/docs/api-reference/endpoints/kms/signing/public-key.mdx new file mode 100644 index 000000000..4c8e1fda5 --- /dev/null +++ b/docs/api-reference/endpoints/kms/signing/public-key.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve Public Key" +openapi: "GET /api/v1/kms/keys/{keyId}/public-key" +--- diff --git a/docs/api-reference/endpoints/kms/signing/sign.mdx b/docs/api-reference/endpoints/kms/signing/sign.mdx new file mode 100644 index 000000000..ebeca5924 --- /dev/null +++ b/docs/api-reference/endpoints/kms/signing/sign.mdx @@ -0,0 +1,4 @@ +--- +title: "Sign Data" +openapi: "POST /api/v1/kms/keys/{keyId}/sign" +--- diff --git a/docs/api-reference/endpoints/kms/signing/signing-algorithms.mdx b/docs/api-reference/endpoints/kms/signing/signing-algorithms.mdx new file mode 100644 index 000000000..0a09ef9e0 --- /dev/null +++ b/docs/api-reference/endpoints/kms/signing/signing-algorithms.mdx @@ -0,0 +1,4 @@ +--- +title: "List Signing Algorithms" +openapi: "GET /api/v1/kms/keys/{keyId}/signing-algorithms" +--- diff --git a/docs/api-reference/endpoints/kms/signing/verify.mdx b/docs/api-reference/endpoints/kms/signing/verify.mdx new file mode 100644 index 000000000..a76270fc3 --- /dev/null +++ b/docs/api-reference/endpoints/kms/signing/verify.mdx @@ -0,0 +1,4 @@ +--- +title: "Verify Signature" +openapi: "POST /api/v1/kms/keys/{keyId}/verify" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/camunda/create.mdx b/docs/api-reference/endpoints/secret-syncs/camunda/create.mdx new file mode 100644 index 000000000..d1a6b4354 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/camunda/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/camunda" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/camunda/delete.mdx b/docs/api-reference/endpoints/secret-syncs/camunda/delete.mdx new file mode 100644 index 000000000..8ac85b1e9 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/camunda/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/camunda/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/camunda/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/camunda/get-by-id.mdx new file mode 100644 index 000000000..2579281fe --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/camunda/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/camunda/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/camunda/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/camunda/get-by-name.mdx new file mode 100644 index 000000000..876808543 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/camunda/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/camunda/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/camunda/list.mdx b/docs/api-reference/endpoints/secret-syncs/camunda/list.mdx new file mode 100644 index 000000000..54040e359 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/camunda/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/camunda" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/camunda/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/camunda/remove-secrets.mdx new file mode 100644 index 000000000..5757238f4 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/camunda/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/camunda/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/camunda/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/camunda/sync-secrets.mdx new file mode 100644 index 000000000..24a28909a --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/camunda/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/camunda/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/camunda/update.mdx b/docs/api-reference/endpoints/secret-syncs/camunda/update.mdx new file mode 100644 index 000000000..bc10cb500 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/camunda/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/camunda/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/terraform-cloud/create.mdx b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/create.mdx new file mode 100644 index 000000000..491889e16 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/terraform-cloud" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/terraform-cloud/delete.mdx b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/delete.mdx new file mode 100644 index 000000000..dfd3206f5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/terraform-cloud/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/terraform-cloud/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/get-by-id.mdx new file mode 100644 index 000000000..c25888a53 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/terraform-cloud/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/terraform-cloud/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/get-by-name.mdx new file mode 100644 index 000000000..5a1645866 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/terraform-cloud/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/terraform-cloud/list.mdx b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/list.mdx new file mode 100644 index 000000000..0993c76ef --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/terraform-cloud" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/terraform-cloud/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/remove-secrets.mdx new file mode 100644 index 000000000..6f00362e3 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/terraform-cloud/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/terraform-cloud/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/sync-secrets.mdx new file mode 100644 index 000000000..c71b68e48 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/terraform-cloud/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/terraform-cloud/update.mdx b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/update.mdx new file mode 100644 index 000000000..759fcfc71 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/terraform-cloud/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/terraform-cloud/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/create.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/create.mdx new file mode 100644 index 000000000..e14d6dddd --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/vercel" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/delete.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/delete.mdx new file mode 100644 index 000000000..746e7ffe5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/vercel/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/get-by-id.mdx new file mode 100644 index 000000000..9a4efd1e6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/vercel/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/get-by-name.mdx new file mode 100644 index 000000000..3f71a6b3b --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/vercel/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/import-secrets.mdx new file mode 100644 index 000000000..807eb2850 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/vercel/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/list.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/list.mdx new file mode 100644 index 000000000..905470d0d --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/vercel" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/remove-secrets.mdx new file mode 100644 index 000000000..49c76ef99 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/vercel/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/sync-secrets.mdx new file mode 100644 index 000000000..2b3bc8324 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/vercel/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/update.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/update.mdx new file mode 100644 index 000000000..75be8dd89 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/vercel/{syncId}" +--- diff --git a/docs/changelog/overview.mdx b/docs/changelog/overview.mdx index a20f52171..822a8b24a 100644 --- a/docs/changelog/overview.mdx +++ b/docs/changelog/overview.mdx @@ -4,6 +4,34 @@ title: "Changelog" The changelog below reflects new product developments and updates on a monthly basis. +## March 2025 + +- Released [Infisical Gateway](https://infisical.com/docs/documentation/platform/gateways/overview) for secure access to private resources without needing direct inbound connections to private networks. +- Enhanced [Terraform](https://infisical.com/docs/integrations/frameworks/terraform#terraform) capabilities with token authentication, ability to import existing Infisical secrets as resources, and support for project templates. +- Self-hosted improvements: Usage and billing visibility for enabled features, ability to delete users, and support for multiple super admins. +- UI and UX updates: Improved secret import interface on the overview page, password reset without backup PDF. +- CLI enhancements: Various improvements including multiline secret support and ability to pass headers. +- Kubernetes operator updates: Auto-reloading for DaemonSets and StatefulSets (previously only Deployments), added support for ConfigMaps. +- Implemented powerful [Access Control](https://infisical.com/docs/documentation/platform/access-controls/overview#access-controls) updates including \"**Grant Privileges**\" feature for designating specific users for policy management, **Access Tree** visualization for simulating permissions, and ability to restrict scope of secret sharing within organizations. +- Released new **Secret Requests** feature under Secret Share, added support for reminders with webhook triggers and implementing password policies for dynamic secrets. +- Enhanced secret version history to show who made changes. +- New integrations and syncs: **Crossplane** provider, **Humanitec** secret sync, **Airflow** system integration +- Performed significant performance optimizations including a 50% reduction in database usage and optimized client secret handling for universal auth. +- Enhanced security features with ability to add custom instance banners (useful for regulated industries), short-lived tokens for Kubernetes auth, and OIDC claim passing from machine identity login to permissions. +- [Golang SDK](https://infisical.com/docs/sdks/languages/go#infisical-go-sdk): New API added for enhanced functionality +- Added capability to programmatically configure an Infisical instance from start to finish without UI interaction. + +## February 2025 + +- Released [KMIP integration](https://infisical.com/docs/documentation/platform/kms/kmip) with PKI structure, auth model integration with machine identities, complete set of client operations, and client certificate authentication flow. +- Added new [AWS App Connection](https://infisical.com/docs/integrations/app-connections/aws) and [Secret Sync](https://infisical.com/docs/integrations/secret-syncs/aws-secrets-manager) functionality for enhanced AWS integration. +- Released new [Azure Key Vault App Connection](https://infisical.com/docs/integrations/app-connections/azure-key-vault) and [Secret Sync](https://infisical.com/docs/integrations/secret-syncs/azure-key-vault), plus Terraform provider support. +- Introduced more comprehensive logging with detailed records for secret sharing and metadata in audit logs. +- Introduced new [permission types](https://infisical.com/docs/internals/permissions/project-permissions#subject-secrets): \"View Value\" vs \"Describe Value\" for more granular access control over secrets. +- Updated encryption logic with unified approach for all platform data, ensuring consistency across the system. +- Added support for [OIDC group mapping](https://infisical.com/docs/documentation/platform/sso/general-oidc) to automatically map groups to Infisical for role-based access control. +- Added [Terraform Cloud support for OIDC](https://infisical.com/docs/documentation/platform/identities/oidc-auth/terraform-cloud#terraform-cloud). + ## January 2025 - Released new integration architecture with decoupled authentication, replacing native integrations with [App Connections](https://infisical.com/docs/integrations/app-connections/overview) and [Secret Syncs](https://infisical.com/docs/integrations/secret-syncs/overview). Initial support for AWS Parameter Store, GitHub, and GCP Secret Manager with improved API and Terraform integration capabilities. @@ -15,7 +43,6 @@ The changelog below reflects new product developments and updates on a monthly b - Implemented secret Access Visibility allowing users to view all entities with access to specific secrets in the secret side panel. - Added secret filtering by metadata and SSH assigned certificates (Version 1). - ## December 2024 - Added [GCP KMS](https://infisical.com/docs/documentation/platform/kms/overview) integration support. - Added support for [K8s CSI integration](https://infisical.com/docs/integrations/platforms/kubernetes-csi) and ability to point K8s operator to specific secret versions. diff --git a/docs/documentation/platform/ssh-old.mdx b/docs/documentation/platform/ssh-old.mdx new file mode 100644 index 000000000..9e9e8aac4 --- /dev/null +++ b/docs/documentation/platform/ssh-old.mdx @@ -0,0 +1,363 @@ +--- +title: "Infisical SSH" +sidebarTitle: "Infisical SSH" +description: "Learn how to generate SSH credentials to provide secure and centralized SSH access control for your infrastructure." +--- + +## Concept + +Infisical can be used to issue SSH credentials to clients to provide short-lived, secure SSH access to infrastructure; +this improves on many limitations of traditional SSH key-based authentication via mitigation of private key compromise, static key management, +unauthorized access, and SSH key sprawl. + +The following concepts are useful to know when working with Infisical SSH: + +- SSH Certificate Authority (CA): A trusted authority that issues SSH certificates. +- Certificate Template: A set of policies bound to an SSH CA for certificates issued under that template; a CA can possess multiple templates, each with different policies for a different purpose (e.g. for admin versus developer access). +- SSH Certificate: A short-lived, credential issued by the SSH CA granting time-bound access to infrastructure. + +

+ +```mermaid +graph TD + A[SSH CA] + A --> B[Certificate Template A] + A --> C[Certificate Template N] + B --> D[SSH Certificate A] + C --> E[SSH Certificate N] + +``` + +
+ +When using Infisical SSH to provision client access to a remote host, an operator must create an SSH CA in Infisical; a certificate template under it, +specifying policies such as allowed users that can be requested under that template by a client; and configure the host to trust certificates issued by the Infisical SSH CA. + +When a client needs access to a host, they authenticate with Infisical and request an SSH certificate (and optionally key pair) +to be used to access the host for a time-bound session as part of the SSH operation. + +## Client Workflow + +The following sequence diagram illustrates the client workflow for accessing a remote host using an SSH certificate (and optionally key pair) +supplied by Infisical. + +```mermaid +sequenceDiagram + participant Client as Client + participant Infisical as Infisical (SSH CA) + participant Host as Remote Host + + Note over Client,Client: Step 1: Client Authentication with Infisical + Client->>Infisical: Send credential(s) to authenticate with Infisical + + Infisical-->>Client: Return access token + + Note over Client,Infisical: Step 2: SSH Certificate Request + Client->>Infisical: Make authenticated request for SSH certificate via either /api/v1/ssh/issue or /api/v1/ssh/sign + + Infisical-->>Client: Return signed SSH certificate (and optionally key pair) + + Note over Client,Client: Step 3: SSH Operation + Client->>Host: SSH into Host using the SSH certificate + + Host-->>Client: Grant access to the host +``` + +At a high-level, Infisical issues a signed SSH certificate to a client that can be used to access a remote host. + +To be more specific: + +1. The client authenticates with Infisical; this can be done using a user or machine identity [authentication method](/documentation/platform/identities/machine-identities) or a user [authentication method](/documentation/platform/identities/user-identities). +2. The client makes an authenticated request for an SSH certificate via either the `/api/v1/ssh/issue` or `/api/v1/ssh/sign` endpoints. Note that if the client wishes to use an existing SSH key pair, it can use the `/api/v1/ssh/sign` endpoint; otherwise, it can use the `/api/v1/ssh/issue` endpoint to have Infisical issue a new SSH key pair along with the certificate. +3. The client uses the issued SSH certificate (and potentially SSH key pair) to temporarily access the host. + + + Note that the workflow above requires an operator to perform additional + configuration on the remote host to trust SSH certificates issued by + Infisical. + + +## Guide to Configuring Infisical SSH + +In the following steps, we explore how to configure Infisical SSH to start issuing SSH certificates to clients as well as a remote host to trust these certificates +as part of the SSH operation. + + + + 1.1. Start by creating an SSH project in the SSH tab of your organization. + + ![ssh project create](/images/platform/ssh/ssh-project.png) + + 1.2. Next, create an SSH CA in the **Certificate Authorities** tab of the + project; this CA will be used for client key signing. + + ![ssh create client ca](/images/platform/ssh/ssh-client-create-ca-1.png) + + ![ssh create client ca popup](/images/platform/ssh/ssh-client-create-ca-2.png) + + Here's some guidance on each field: + + - Friendly Name: A friendly name for the CA; this is only for display. + - Key Source: Whether the CA's key pair should be generated internally or supplied from an external source. Select **Internal**. + - Key Algorithm: The type of public key algorithm and size, in bits, of the key pair for the CA. Supported key algorithms are `RSA 2048`, `RSA 4096`, `ECDSA P-256`, and `ECDSA P-384` with the default being `RSA 2048`. + + + + + 2.1. Next, create a certificate template in the **Certificate Templates** section of the newly-created CA. + + A certificate template is a set of policies for certificates issued under that template; each template is bound to a specific CA. + + With certificate templates, you can specify, for example, that certificates issued under a template are only allowed for users with a specific username like `ec2-user` or perhaps that the max TTL requested cannot exceed 1 hour. + + ![ssh client create template](/images/platform/ssh/ssh-client-create-template-1.png) + + ![ssh client create template popup](/images/platform/ssh/ssh-client-create-template-2.png) + + Here's some guidance on each field: + + - SSH Template Name: A name for the certificate template; this must be a valid slug. + - Allowed Users: A comma-separated list of valid usernames (e.g. `ec2-user`) on the remote host for which a client can request a certificate for. If you wish to allow a client to request a certificate for any username, set this to `*`; alternatively, if left blank, the template will not allow issuance of certificates under any username. + - Allowed Hosts: A comma-separated list of valid hostnames/domains on the remote host for which a client can request a certificate for. Each item in the list can be either a wildcard hostname (e.g. `*.acme.com`), a specific hostname (e.g. `example.com`), an IPv4 address (e.g. `192.168.1.1`), or an IPv6 address. If left empty, the template will not allow any hostnames; if set to `*`, the template will allow any hostname. + - Default TTL: The default Time-to-Live (TTL) for certificates issued under this template when a client does not explicitly specify a TTL in the certificate request. We recommend setting a shorter **Default TTL** for client certificates such as `30m`. + - Max TTL: The maximum TTL for certificates issued under this template. + - Allow User Certificates: Whether or not to allow issuance of user certificates; this should be set to `true`. + - Allow Host Certificates: Whether or not to allow issuance of host certificates; this is not relevant for this step. + - Allow Custom Key IDs: Whether or not to allow clients to specify a custom key ID to be included on the certificate as part of the certificate request. + + 2.2. Finally, add the user(s) you wish to be able to request an SSH certificate to the SSH project through the **Access Control** tab. + + + + + 3.1. Begin by downloading the client CA's public key from the CA's details section. + + ![ssh ca public key](/images/platform/ssh/ssh-client-ca-public-key.png) + + + The CA's public key can also be retrieved programmatically via API by making a `GET` request to the endpoint [here](/api-reference/endpoints/ssh/ca/public-key). + + + 3.2. Next, create a file containing this public key in the SSH folder of the remote host; we'll call the file `ca.pub`. + + This would result in the file at the path `/etc/ssh/ca.pub`. + + 3.3. Next, add the following lines to the `/etc/ssh/sshd_config` file on the remote host. + + ```bash + TrustedUserCAKeys /etc/ssh/ca.pub + + PubkeyAcceptedKeyTypes=+ssh-rsa,ssh-rsa-cert-v01@openssh.com + ``` + + 3.4. Finally, reload the SSH daemon on the remote host to apply the changes. + + ```bash + sudo systemctl reload sshd + ``` + + At this point, the remote host is configured to trust SSH certificates issued by the Infisical SSH CA. + + + + +## Guide to Using Infisical SSH to Access a Host + +In the following steps, we show how to obtain an SSH certificate and use it for a client to access a host via CLI: + + + The subsequent guide assumes the following prerequisites: + +- SSH Agent is running: The `ssh-agent` must be actively running on the host machine. +- OpenSSH is installed: The system should have OpenSSH installed; this includes + both the `ssh` client and `ssh-agent`. +- `SSH_AUTH_SOCK` environment variable + is set; the `SSH_AUTH_SOCK` variable should point to the UNIX socket that + `ssh-agent` uses for communication. + + + + + + +```bash +infisical login +``` + + + + Run the `infisical ssh issue-credentials` command, specifying the `--addToAgent` flag to automatically load the SSH certificate into the SSH agent. + ```bash + infisical ssh issue-credentials --certificateTemplateId= --principals= --addToAgent + ``` + + Here's some guidance on each flag: + + - `certificateTemplateId`: The ID of the certificate template to use for issuing the SSH certificate. + - `principals`: The comma-delimited username(s) or hostname(s) to include in the SSH certificate. + + For fuller documentation on commands and flags supported by the Infisical CLI for SSH, refer to the docs [here](/cli/commands/ssh). + + + + Finally, SSH into the desired host; the SSH operation will be performed using the SSH certificate loaded into the SSH agent. + + ```bash + ssh username@hostname + ``` + + + + + + Note that the above workflow can be executed via API or other client methods + such as SDK. + + +## Guide to Configuring Host Key Signing + +In the following steps, we show how to configure host key signing for clients to verify the identity of a remote host before attempting the SSH operation; this is recommended to reduce the probability of a client accessing a malicious machine. + + +This guide expects that the remote host already has an existing SSH key pair (typically found in the `/etc/ssh/` folder at `/etc/ssh/ssh_host__key` and `.pub`). + +If the remote host does not have an existing SSH key pair, you can generate a new key pair using the `ssh-keygen` command: `ssh-keygen -t rsa -b 4096 -f /etc/ssh/ssh_host_rsa_key -N ''`. This will generate: + +- A private key: `/etc/ssh/ssh_host_rsa_key`. +- A public key: `/etc/ssh/ssh_host_rsa_key.pub`. + + + + + + 1.1. In the same SSH project, create another SSH CA in the **Certificate Authorities** tab; this CA will be used for host key signing. + + ![ssh create host ca](/images/platform/ssh/ssh-host-create-ca-1.png) + + ![ssh create host ca popup](/images/platform/ssh/ssh-host-create-ca-2.png) + + Here's some guidance on each field: + + - Friendly Name: A friendly name for the CA; this is only for display. + - Key Source: Whether the CA's key pair should be generated internally or supplied from an external source. Select **External**. + - Public Key: The public key for the CA (i.e. the host's SSH public key). + - Private Key: The private key for the CA (i.e. the host's SSH private key). + + + + + 2.1. Next, create a certificate template in the **Certificate Templates** section of the newly-created CA. + + ![ssh host create template](/images/platform/ssh/ssh-host-create-template-1.png) + + ![ssh host create template popup](/images/platform/ssh/ssh-host-create-template-2.png) + + Here's some guidance on each field: + + - SSH Template Name: A name for the certificate template; this must be a valid slug. + - Allowed Users: A comma-separated list of valid usernames (e.g. `ec2-user`) on the remote host for which a client can request a certificate for. If you wish to allow a client to request a certificate for any username, set this to `*`; alternatively, if left blank, the template will not allow issuance of certificates under any username. + - Allowed Hosts: A comma-separated list of valid hostnames/domains on the remote host for which a client can request a certificate for. Each item in the list can be either a wildcard hostname (e.g. `*.acme.com`), a specific hostname (e.g. `example.com`), an IPv4 address (e.g. `192.168.1.1`), or an IPv6 address. If left empty, the template will not allow any hostnames; if set to `*`, the template will allow any hostname. + - Default TTL: The default Time-to-Live (TTL) for certificates issued under this template when a client does not explicitly specify a TTL in the certificate request. We recommend setting a longer **Default TTL** for host certificates such as `2y`. + - Max TTL: The maximum TTL for certificates issued under this template. + - Allow User Certificates: Whether or not to allow issuance of user certificates; this is not relevant for this step. + - Allow Host Certificates: Whether or not to allow issuance of host certificates; this should be set to `true`. + - Allow Custom Key IDs: Whether or not to allow clients to specify a custom key ID to be included on the certificate as part of the certificate request. + + + + + 3.1. Obtain an SSH certificate for the host by requesting one from the **Certificates** tab. + + ![ssh host issue certificate 1](/images/platform/ssh/ssh-host-issue-cert-1.png) + + ![ssh host issue certificate 2](/images/platform/ssh/ssh-host-issue-cert-2.png) + + + You should select **Sign SSH Key** under the **Operation** field. + + Then input your host's SSH public key under the **SSH Public Key** field and hostname under the **Principal(s)** field; the host's public key should be in the `/etc/ssh` folder of the host as used in step 1. + + + ![ssh host issue certificate 3](/images/platform/ssh/ssh-host-issue-cert-3.png) + + 3.2. Create a file containing the certificate in the SSH folder of the remote host; we'll call it `ssh_host_key-cert.pub`. + + 3.3. Set permissions on the certificate to be `0640`: + + ```bash + sudo chmod 0640 /etc/ssh/ssh_host_key-cert.pub + ``` + + 3.4. Next, add the following lines to the `/etc/ssh/sshd_config` file on the remote host. + + ```bash + HostKey /etc/ssh/ssh_host_rsa_key + HostCertificate /etc/ssh/ssh_host_key-cert.pub + ``` + + + You should adjust the `HostKey` directive to match the path to the host's SSH private key as used in step 1. + + + 3.5. Finally, reload the SSH daemon on the remote host to apply the changes. + + ```bash + sudo systemctl reload sshd + ``` + + + + 4.1. Begin by downloading the host CA's public key from the CA's details section. + + ![ssh host ca public key](/images/platform/ssh/ssh-host-ca-public-key.png) + + + The CA's public key can also be retrieved programmatically via API by making a `GET` request to the endpoint [here](/api-reference/endpoints/ssh/ca/public-key). + + + 4.2. Next, add the resulting public key to the `known_hosts` file on the client machine (e.g. at the path `~/.ssh/known_hosts`). + + ```bash + @cert-authority *.example.com ssh-rsa ... + ``` + + + + Finally, SSH into the desired host as usual; the SSH operation will now also include client-side host verification. + + ```bash + ssh username@hostname + ``` + + + + +## FAQ + + + + After configuring Infisical SSH, you can add the `-vvv` flag as part of the + SSH operation to see verbose output from the SSH client. + + ```bash + ssh -vvv username@hostname + ``` + + You should see output from the SSH client that includes the following if both client key signing and host key signing are working: + + Host certificate was verified and trusted: + + ```bash + debug1: Host 'example.com' is known and matches the ECDSA-CERT host certificate. + debug1: Found CA key in /Users/user/.ssh/known_hosts:1 + ``` + + You authenticated with your user certificate: + + ```bash + debug1: Offering public key: Added via Infisical CLI RSA-CERT SHA256:... + debug1: Server accepts key: Added via Infisical CLI RSA-CERT SHA256:... + ``` + + + diff --git a/docs/documentation/platform/ssh.mdx b/docs/documentation/platform/ssh.mdx index 16faf1267..ae1e43df5 100644 --- a/docs/documentation/platform/ssh.mdx +++ b/docs/documentation/platform/ssh.mdx @@ -1,210 +1,179 @@ --- title: "Infisical SSH" sidebarTitle: "Infisical SSH" -description: "Learn how to generate SSH credentials to provide secure and centralized SSH access control for your infrastructure." +description: "Learn how to securely provision user SSH access to your infrastructure using SSH certificates." --- ## Concept -Infisical can be used to issue SSH credentials to clients to provide short-lived, secure SSH access to infrastructure; -this improves on many limitations of traditional SSH key-based authentication via mitigation of private key compromise, static key management, +Infisical SSH can be configured to provide users on your team short-lived, secure SSH access to infrastructure. Under the hood, it uses SSH certificates +and improves upon traditional SSH key-based authentication by mitigating private key compromise, static key management, unauthorized access, and SSH key sprawl. -The following concepts are useful to know when working with Infisical SSH: +The following entities and concepts are important to understand when using Infisical SSH: -- SSH Certificate Authority (CA): A trusted authority that issues SSH certificates. -- Certificate Template: A set of policies bound to a SSH CA for certificates issued under that template; a CA can possess multiple templates, each with different policies for a different purpose (e.g. for admin versus developer access). -- SSH Certificate: A short-lived, credential issued by the SSH CA granting time-bound access to infrastructure. +- Administrator: An individual on your team who is responsible for configuring Infisical SSH. +- Users: Other individuals on your team that need access to the remote host. +- Host: A remote machine (e.g. EC2 instance, GCP VM, Azure VM, on-prem Linux server, Raspberry Pi, VMware VM, etc.) that users need SSH access to that is registered with Infisical SSH. -
+## Workflow -```mermaid -graph TD - A[SSH CA] - A --> B[Certificate Template A] - A --> C[Certificate Template N] - B --> D[SSH Certificate A] - C --> E[SSH Certificate N] +The typical workflow for using Infisical SSH consists of the following steps: -``` +1. The administrator registers a remote host with Infisical using the Infisical CLI via the `infisical ssh add-host` command. +2. The administrator configures Infisical SSH to grant users access to the remote host. +3. User(s) access the remote host using the Infisical CLI via the `infisical ssh connect` command. -
+## Admin Guide for Configuring Infisical SSH -When using Infisical SSH to provision client access to a remote host, an operator must create a SSH CA in Infisical; a certificate template under it, -specifying policies such as allowed users that can be requested under that template by a client; and configure the host to trust certificates issued by the Infisical SSH CA. - -When a client needs access to a host, they authenticate with Infisical and request a SSH certificate (and optionally key pair) -to be used to access the host for a time-bound session as part of the SSH operation. - -## Client Workflow - -The following sequence diagram illustrates the client workflow for accessing a remote host using an SSH certificate (and optionally key pair) -supplied by Infisical. - -```mermaid -sequenceDiagram - participant Client as Client - participant Infisical as Infisical (SSH CA) - participant Host as Remote Host - - Note over Client,Client: Step 1: Client Authentication with Infisical - Client->>Infisical: Send credential(s) to authenticate with Infisical - - Infisical-->>Client: Return access token - - Note over Client,Infisical: Step 2: SSH Certificate Request - Client->>Infisical: Make authenticated request for SSH certificate via either /api/v1/ssh/issue or /api/v1/ssh/sign - - Infisical-->>Client: Return signed SSH certificate (and optionally key pair) - - Note over Client,Client: Step 3: SSH Operation - Client->>Host: SSH into Host using the SSH certificate - - Host-->>Client: Grant access to the host -``` - -At a high-level, Infisical issues a signed SSH certificate to a client that can be used to access a remote host. - -To be more specific: - -1. The client authenticates with Infisical; this can be done using a machine identity [authentication method](/documentation/platform/identities/machine-identities) or a user [authentication method](/documentation/platform/identities/user-identities). -2. The client makes an authenticated request for an SSH certificate via either the `/api/v1/ssh/issue` or `/api/v1/ssh/sign` endpoints. Note that if the client wishes to use an existing SSH key pair, it can use the `/api/v1/ssh/sign` endpoint; otherwise, it can use the `/api/v1/ssh/issue` endpoint to have Infisical issue a new SSH key pair in conjunction with the certificate. -3. The client uses the issued SSH certificate (and potentially SSH key pair) to temporarily access the host. - - - Note that the workflow above requires an operator to perform additional - configuration on the remote host to trust SSH certificates issued by - Infisical. - - -## Guide to Configuring Infisical SSH - -In the following steps, we explore how to configure Infisical SSH to start issuing SSH certificates to clients as well as a remote host to trust these certificates -as part of the SSH operation. +In the following steps, we explore how to configure Infisical SSH to control and streamline your team's SSH access to infrastructure. As part of this guide, +we will register a remote host with Infisical through a [machine identity](/documentation/platform/identities/machine-identities) and configure Infisical to grant user(s) access to the remote host. - - 1.1. Start by creating a SSH project in the SSH tab of your organization. + + 1.1. Start by creating a new Infisical SSH project in Infisical. - ![ssh project create](/images/platform/ssh/ssh-project.png) + ![ssh project create](/images/platform/ssh/v2/ssh-create-project.png) - 1.2. Next, create a CA in the **Certificate Authorities** tab of the - project. + 1.2. Create a custom role in the project under Access Control > Project Roles to grant the machine identity that we will create in step 2 the ability to **Create** and **Issue Host Certificates** on the **SSH Host** resource; this will enable the linked machine identity to bootstrap a remote host with Infisical + and establish the necessary configuration on it. - ![ssh create ca](/images/platform/ssh/ssh-create-ca-1.png) + ![ssh custom role bootstrap 1](/images/platform/ssh/v2/ssh-add-bootstrap-role-1.png) - ![ssh create ca popup](/images/platform/ssh/ssh-create-ca-2.png) - - Here's some guidance on each field: + ![ssh custom role bootstrap 2](/images/platform/ssh/v2/ssh-add-bootstrap-role-2.png) + + + 2.1. Follow the instructions [here](/documentation/platform/identities/universal-auth) to configure a [machine identity](/documentation/platform/identities/machine-identities) in Infisical with Universal Auth. - - Friendly Name: A friendly name for the CA; this is only for display. - - Key Algorithm: The type of public key algorithm and size, in bits, of the key pair for the CA. Supported key algorithms are `RSA 2048`, `RSA 4096`, `ECDSA P-256`, and `ECDSA P-384` with the default being `RSA 2048`. + By the end of this step, you should have a **Client ID** and **Client Secret** on hand as part of the Universal Auth configuration for the identity to authenticate with Infisical + as part of registering a remote host in step 3. - 1.3. Next, create a certificate template in the **Certificate Templates** section of the newly-created CA. + + You may use other authentication methods as suitable (e.g. [AWS Auth](/documentation/platform/identities/aws-auth), [Azure Auth](/documentation/platform/identities/azure-auth), [GCP Auth](/documentation/platform/identities/gcp-auth), etc.) as part of the machine identity configuration but, to keep this example simple, we will be using Universal Auth. + - A certificate template is a set of policies for certificates issued under that template; each template is bound to a specific CA. + 2.2. Add the machine identity to the Infisical SSH project you created in the previous step and assign it the custom role you created in step 1.2. - With certificate templates, you can specify, for example, that certificates issued under a template are only allowed for users with a specific username like `ec2-user` or perhaps that the max TTL requested cannot exceed 1 year. - - ![ssh create template](/images/platform/ssh/ssh-create-template-1.png) - - ![ssh create template popup](/images/platform/ssh/ssh-create-template-2.png) - - Here's some guidance on each field: - - - SSH Template Name: A name for the certificate template; this must be a valid slug. - - Allowed Users: A comma-separated list of valid usernames (e.g. `ec2-user`) on the remote host for which a client can request a certificate for. If you wish to allow a client to request a certificate for any username, set this to `*`; alternatively, if left blank, the template will not allow issuance of certificates under any username. - - Allowed Hosts: A comma-separated list of valid hostnames/domains on the remote host for which a client can request a certificate for. Each item in the list can be either a wildcard hostname (e.g. `*.acme.com`), a specific hostname (e.g. `example.com`), an IPv4 address (e.g. `192.168.1.1`), or an IPv6 address. If left empty, the template will not allow any hostnames; if set to `*`, the template will allow any hostname. - - Default TTL: The default Time-to-Live (TTL) for certificates issued under this template when a client does not explicitly specify a TTL in the certificate request. - - Max TTL: The maximum TTL for certificates issued under this template. - - Allow User Certificates: Whether or not to allow issuance of user certificates. - - Allow Host Certificates: Whether or not to allow issuance of host certificates. - - Allow Custom Key IDs: Whether or not to allow clients to specify a custom key ID to be included on the certificate as part of the certificate request. - - 1.4. Finally, add the user(s) you wish to be able to request a SSH certificate to the SSH project through the **Access Control** tab. + ![ssh add identity to project](/images/platform/ssh/v2/ssh-add-identity-to-project.png) - - - 2.1. Begin by downloading the CA's public key from the CA's details section. + + 3.1. Follow the instructions [here](/cli/overview) to install the Infisical CLI onto the remote host. - ![ssh ca public key](/images/platform/ssh/ssh-ca-public-key.png) - - - The CA's public key can also be retrieved programmatically via API by making a `GET` request to the `/ssh/ca//public-key` endpoint. - - - 2.2. Next, create a file containing this public key in the SSH folder of the remote host; we'll call the file `ca.pub`. + 3.2. Run the commands below to register the remote host with Infisical. - This would result in the file at the path `/etc/ssh/ca.pub`. - - 2.3. Next, add the following lines to the `/etc/ssh/sshd_config` file on the remote host. + Use the **Client ID** and **Client Secret** from the machine identity you created in step 2.1 as part of the `infisical login` command + to obtain an access token and save it as an environment variable. ```bash - TrustedUserCAKeys /etc/ssh/ca.pub - - PubkeyAcceptedKeyTypes=+ssh-rsa,ssh-rsa-cert-v01@openssh.com + export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --silent --plain) ``` - 2.4. Finally, reload the SSH daemon on the remote host to apply the changes. + Next, use the `infisical ssh add-host` command to register the remote host with Infisical. As part of this command, input the ID of the Infisical SSH project you created in step 1 for the `--projectId` flag and the hostname of the remote host for the `--hostname` flag. + + ```bash + sudo infisical ssh add-host --projectId= --hostname= --token="$INFISICAL_TOKEN" --writeUserCaToFile --writeHostCertToFile --configureSshd + ``` + + + Note that if you're self-hosting Infisical, you can use the `--domain` flag on the `infisical login` command to specify the domain of your Infisical instance. + + For more information on the `infisical ssh add-host` command, please refer to the Infisical CLI [documentation](/cli/overview). + + + If successful, you should see output similar to the following: + + ```bash + ✅ Successfully registered host: + 📁 Wrote User CA public key to: /etc/ssh/infisical_user_ca.pub + 📁 Wrote host certificate to: /etc/ssh/ssh_host_ed25519_key-cert.pub + 📄 Updated sshd_config entries + ``` + + Finally, use the following command to reload the SSH daemon on the remote host to apply the changes: ```bash sudo systemctl reload sshd ``` - At this point, the remote host is configured to trust SSH certificates issued by the Infisical SSH CA. + + The command may differ depending on the host. For older versions of Ubuntu/Debian/CentOS, you may need to use `sudo service ssh reload` instead; + for Alpine or minimal systems, `/etc/init.d/sshd reload`. + + + Back in Infisical, you should now see the remote host you just registered in the Infisical SSH project you created in step 1 under the **Hosts** tab. + + ![ssh hosts](/images/platform/ssh/v2/ssh-added-hosts.png) + + + + 4.1. Add the user(s) you wish to grant access to the remote host to the Infisical SSH project under Access Control > Users. + + ![ssh hosts](/images/platform/ssh/v2/ssh-add-user.png) + + 4.2. On the registered host in the **Hosts** tab, click **Edit SSH Host** and add a login mapping for the user(s) you added in step 4.1. + + The login mapping dictates what user(s) will be allowed access to the remote host and under a specific login user; in the allowed principals, + you should select user(s) part of the Infisical SSH project that will be allowed to login to the remote host as the login user. + + For instance, if you add a mapping with the login user `ec2-user` to some users John and Alice in Infisical, then they will be allowed to login to the remote host as `ec2-user` which is a system user that + exists on the remote host. + + ![ssh host mappings](/images/platform/ssh/v2/ssh-host-login-mappings.png) + + + Note that you should configure authorized principals files for each login user you add to the remote host. + -## Guide to Using Infisical SSH to Access a Host +## User Guide for SSHing to a Host -We show how to obtain a SSH certificate and use it for a client to access a host via CLI: - - - The subsequent guide assumes the following prerequisites: - -- SSH Agent is running: The `ssh-agent` must be actively running on the host machine. -- OpenSSH is installed: The system should have OpenSSH installed; this includes - both the `ssh` client and `ssh-agent`. -- `SSH_AUTH_SOCK` environment variable - is set; the `SSH_AUTH_SOCK` variable should point to the UNIX socket that - `ssh-agent` uses for communication. - - +Once Infisical SSH is configured by an administrator, users can SSH to the remote host using the Infisical CLI. - + + Follow the instructions [here](/cli/overview) to install the Infisical CLI onto your local machine. + + + Run the `infisical login` command to authenticate with Infisical. + + ```bash + infisical login + ``` + + + Run the `infisical ssh connect` command to connect to a remote host. -```bash -infisical login -``` + ```bash + infisical ssh connect + ``` - - - Run the `infisical ssh issue-credentials` command, specifying the `--addToAgent` flag to automatically load the SSH certificate into the SSH agent. - ```bash - infisical ssh issue-credentials --certificateTemplateId= --principals= --addToAgent - ``` + You'll be prompted to select an SSH Host from a list of accessible hosts; this is based on project membership and login mappings configured on hosts by + the administrator. - Here's some guidance on each flag: + ```bash + Use the arrow keys to navigate: ↓ ↑ → ← + ? Select an SSH Host: + ▸ ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com + ``` - - `certificateTemplateId`: The ID of the certificate template to use for issuing the SSH certificate. - - `principals`: The comma-delimited username(s) or hostname(s) to include in the SSH certificate. - - For fuller documentation on commands and flags supported by the Infisical CLI for SSH, refer to the docs [here](/cli/commands/ssh). - - - - Finally, SSH into the desired host; the SSH operation will be performed using the SSH certificate loaded into the SSH agent. + After selecting a host, you'll be prompted to select a login user from a list of allowed login users: + + ```bash + ? Select Login User: + ▸ ec2-user + ``` + + If successful, you should be able to SSH to the remote host. + + ```bash + ✔ ec2-54-199-104-116.ap-northeast-1.compute.amazonaws.com + ✔ ec2-user + ✔ SSH credentials successfully added to agent + Connecting to ec2-user@ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com... + ``` + - ```bash - ssh username@hostname - ``` - - - - Note that the above workflow can be executed via API or other client methods - such as SDK. - \ No newline at end of file diff --git a/docs/images/app-connections/camunda/camunda-app-connection-created.png b/docs/images/app-connections/camunda/camunda-app-connection-created.png new file mode 100644 index 000000000..7af738e89 Binary files /dev/null and b/docs/images/app-connections/camunda/camunda-app-connection-created.png differ diff --git a/docs/images/app-connections/camunda/camunda-app-connection-form.png b/docs/images/app-connections/camunda/camunda-app-connection-form.png new file mode 100644 index 000000000..d8b26f0b5 Binary files /dev/null and b/docs/images/app-connections/camunda/camunda-app-connection-form.png differ diff --git a/docs/images/app-connections/camunda/camunda-app-connection-select.png b/docs/images/app-connections/camunda/camunda-app-connection-select.png new file mode 100644 index 000000000..33c45bd84 Binary files /dev/null and b/docs/images/app-connections/camunda/camunda-app-connection-select.png differ diff --git a/docs/images/app-connections/camunda/camunda-client-credentials.png b/docs/images/app-connections/camunda/camunda-client-credentials.png new file mode 100644 index 000000000..522f7f74c Binary files /dev/null and b/docs/images/app-connections/camunda/camunda-client-credentials.png differ diff --git a/docs/images/app-connections/camunda/camunda-console.png b/docs/images/app-connections/camunda/camunda-console.png new file mode 100644 index 000000000..ca96adbd2 Binary files /dev/null and b/docs/images/app-connections/camunda/camunda-console.png differ diff --git a/docs/images/app-connections/camunda/camunda-create-client-1.png b/docs/images/app-connections/camunda/camunda-create-client-1.png new file mode 100644 index 000000000..5ef589806 Binary files /dev/null and b/docs/images/app-connections/camunda/camunda-create-client-1.png differ diff --git a/docs/images/app-connections/camunda/camunda-create-client-2.png b/docs/images/app-connections/camunda/camunda-create-client-2.png new file mode 100644 index 000000000..9b8575c76 Binary files /dev/null and b/docs/images/app-connections/camunda/camunda-create-client-2.png differ diff --git a/docs/images/app-connections/camunda/camunda-organization-page.png b/docs/images/app-connections/camunda/camunda-organization-page.png new file mode 100644 index 000000000..fdc71a378 Binary files /dev/null and b/docs/images/app-connections/camunda/camunda-organization-page.png differ diff --git a/docs/images/app-connections/terraform-cloud/terraform-cloud-account-settings.png b/docs/images/app-connections/terraform-cloud/terraform-cloud-account-settings.png new file mode 100644 index 000000000..f80df4229 Binary files /dev/null and b/docs/images/app-connections/terraform-cloud/terraform-cloud-account-settings.png differ diff --git a/docs/images/app-connections/terraform-cloud/terraform-cloud-app-connection-created.png b/docs/images/app-connections/terraform-cloud/terraform-cloud-app-connection-created.png new file mode 100644 index 000000000..f8904957a Binary files /dev/null and b/docs/images/app-connections/terraform-cloud/terraform-cloud-app-connection-created.png differ diff --git a/docs/images/app-connections/terraform-cloud/terraform-cloud-app-connection-modal.png b/docs/images/app-connections/terraform-cloud/terraform-cloud-app-connection-modal.png new file mode 100644 index 000000000..e8f0b9524 Binary files /dev/null and b/docs/images/app-connections/terraform-cloud/terraform-cloud-app-connection-modal.png differ diff --git a/docs/images/app-connections/terraform-cloud/terraform-cloud-app-connection-option.png b/docs/images/app-connections/terraform-cloud/terraform-cloud-app-connection-option.png new file mode 100644 index 000000000..369067a31 Binary files /dev/null and b/docs/images/app-connections/terraform-cloud/terraform-cloud-app-connection-option.png differ diff --git a/docs/images/app-connections/terraform-cloud/terraform-cloud-copy-api-token.png b/docs/images/app-connections/terraform-cloud/terraform-cloud-copy-api-token.png new file mode 100644 index 000000000..348a4fc53 Binary files /dev/null and b/docs/images/app-connections/terraform-cloud/terraform-cloud-copy-api-token.png differ diff --git a/docs/images/app-connections/terraform-cloud/terraform-cloud-create-api-token.png b/docs/images/app-connections/terraform-cloud/terraform-cloud-create-api-token.png new file mode 100644 index 000000000..3637145b6 Binary files /dev/null and b/docs/images/app-connections/terraform-cloud/terraform-cloud-create-api-token.png differ diff --git a/docs/images/app-connections/terraform-cloud/terraform-cloud-tokens-tab.png b/docs/images/app-connections/terraform-cloud/terraform-cloud-tokens-tab.png new file mode 100644 index 000000000..3293ccdac Binary files /dev/null and b/docs/images/app-connections/terraform-cloud/terraform-cloud-tokens-tab.png differ diff --git a/docs/images/app-connections/vercel/vercel-app-connection-created.png b/docs/images/app-connections/vercel/vercel-app-connection-created.png new file mode 100644 index 000000000..8fb371440 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-app-connection-created.png differ diff --git a/docs/images/app-connections/vercel/vercel-app-connection-modal.png b/docs/images/app-connections/vercel/vercel-app-connection-modal.png new file mode 100644 index 000000000..6b789713d Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-app-connection-modal.png differ diff --git a/docs/images/app-connections/vercel/vercel-app-connection-option.png b/docs/images/app-connections/vercel/vercel-app-connection-option.png new file mode 100644 index 000000000..b4308a1a2 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-app-connection-option.png differ diff --git a/docs/images/app-connections/vercel/vercel-copy-token.png b/docs/images/app-connections/vercel/vercel-copy-token.png new file mode 100644 index 000000000..d6491c02e Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-copy-token.png differ diff --git a/docs/images/app-connections/vercel/vercel-create-token.png b/docs/images/app-connections/vercel/vercel-create-token.png new file mode 100644 index 000000000..c507f0d14 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-create-token.png differ diff --git a/docs/images/app-connections/vercel/vercel-main-page.png b/docs/images/app-connections/vercel/vercel-main-page.png new file mode 100644 index 000000000..e1a28248e Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-main-page.png differ diff --git a/docs/images/app-connections/vercel/vercel-settings-page.png b/docs/images/app-connections/vercel/vercel-settings-page.png new file mode 100644 index 000000000..86e67d6e1 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-settings-page.png differ diff --git a/docs/images/app-connections/vercel/vercel-token-created.png b/docs/images/app-connections/vercel/vercel-token-created.png new file mode 100644 index 000000000..5e57a4bd8 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-token-created.png differ diff --git a/docs/images/platform/ssh/ssh-client-ca-public-key.png b/docs/images/platform/ssh/ssh-client-ca-public-key.png new file mode 100644 index 000000000..058b844fb Binary files /dev/null and b/docs/images/platform/ssh/ssh-client-ca-public-key.png differ diff --git a/docs/images/platform/ssh/ssh-client-create-ca-1.png b/docs/images/platform/ssh/ssh-client-create-ca-1.png new file mode 100644 index 000000000..30658944f Binary files /dev/null and b/docs/images/platform/ssh/ssh-client-create-ca-1.png differ diff --git a/docs/images/platform/ssh/ssh-client-create-ca-2.png b/docs/images/platform/ssh/ssh-client-create-ca-2.png new file mode 100644 index 000000000..3a0bf155c Binary files /dev/null and b/docs/images/platform/ssh/ssh-client-create-ca-2.png differ diff --git a/docs/images/platform/ssh/ssh-client-create-template-1.png b/docs/images/platform/ssh/ssh-client-create-template-1.png new file mode 100644 index 000000000..0c0ba97c8 Binary files /dev/null and b/docs/images/platform/ssh/ssh-client-create-template-1.png differ diff --git a/docs/images/platform/ssh/ssh-client-create-template-2.png b/docs/images/platform/ssh/ssh-client-create-template-2.png new file mode 100644 index 000000000..8c64ec62b Binary files /dev/null and b/docs/images/platform/ssh/ssh-client-create-template-2.png differ diff --git a/docs/images/platform/ssh/ssh-host-ca-public-key.png b/docs/images/platform/ssh/ssh-host-ca-public-key.png new file mode 100644 index 000000000..f77034896 Binary files /dev/null and b/docs/images/platform/ssh/ssh-host-ca-public-key.png differ diff --git a/docs/images/platform/ssh/ssh-host-create-ca-1.png b/docs/images/platform/ssh/ssh-host-create-ca-1.png new file mode 100644 index 000000000..f064dec35 Binary files /dev/null and b/docs/images/platform/ssh/ssh-host-create-ca-1.png differ diff --git a/docs/images/platform/ssh/ssh-host-create-ca-2.png b/docs/images/platform/ssh/ssh-host-create-ca-2.png new file mode 100644 index 000000000..75b0fe76c Binary files /dev/null and b/docs/images/platform/ssh/ssh-host-create-ca-2.png differ diff --git a/docs/images/platform/ssh/ssh-host-create-template-1.png b/docs/images/platform/ssh/ssh-host-create-template-1.png new file mode 100644 index 000000000..e8ec9ec20 Binary files /dev/null and b/docs/images/platform/ssh/ssh-host-create-template-1.png differ diff --git a/docs/images/platform/ssh/ssh-host-create-template-2.png b/docs/images/platform/ssh/ssh-host-create-template-2.png new file mode 100644 index 000000000..95b41be9b Binary files /dev/null and b/docs/images/platform/ssh/ssh-host-create-template-2.png differ diff --git a/docs/images/platform/ssh/ssh-host-issue-cert-1.png b/docs/images/platform/ssh/ssh-host-issue-cert-1.png new file mode 100644 index 000000000..c4483eb83 Binary files /dev/null and b/docs/images/platform/ssh/ssh-host-issue-cert-1.png differ diff --git a/docs/images/platform/ssh/ssh-host-issue-cert-2.png b/docs/images/platform/ssh/ssh-host-issue-cert-2.png new file mode 100644 index 000000000..ec5f677bc Binary files /dev/null and b/docs/images/platform/ssh/ssh-host-issue-cert-2.png differ diff --git a/docs/images/platform/ssh/ssh-host-issue-cert-3.png b/docs/images/platform/ssh/ssh-host-issue-cert-3.png new file mode 100644 index 000000000..41af0c9f3 Binary files /dev/null and b/docs/images/platform/ssh/ssh-host-issue-cert-3.png differ diff --git a/docs/images/platform/ssh/ssh-project.png b/docs/images/platform/ssh/ssh-project.png index 0b57f9245..9b28f04ab 100644 Binary files a/docs/images/platform/ssh/ssh-project.png and b/docs/images/platform/ssh/ssh-project.png differ diff --git a/docs/images/platform/ssh/v2/ssh-add-bootstrap-role-1.png b/docs/images/platform/ssh/v2/ssh-add-bootstrap-role-1.png new file mode 100644 index 000000000..8acc1efe9 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-add-bootstrap-role-1.png differ diff --git a/docs/images/platform/ssh/v2/ssh-add-bootstrap-role-2.png b/docs/images/platform/ssh/v2/ssh-add-bootstrap-role-2.png new file mode 100644 index 000000000..2ad9804d4 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-add-bootstrap-role-2.png differ diff --git a/docs/images/platform/ssh/v2/ssh-add-identity-to-project.png b/docs/images/platform/ssh/v2/ssh-add-identity-to-project.png new file mode 100644 index 000000000..83bd3c984 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-add-identity-to-project.png differ diff --git a/docs/images/platform/ssh/v2/ssh-add-user.png b/docs/images/platform/ssh/v2/ssh-add-user.png new file mode 100644 index 000000000..363a2a898 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-add-user.png differ diff --git a/docs/images/platform/ssh/v2/ssh-added-hosts.png b/docs/images/platform/ssh/v2/ssh-added-hosts.png new file mode 100644 index 000000000..20c7f9861 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-added-hosts.png differ diff --git a/docs/images/platform/ssh/v2/ssh-create-project.png b/docs/images/platform/ssh/v2/ssh-create-project.png new file mode 100644 index 000000000..792d49612 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-create-project.png differ diff --git a/docs/images/platform/ssh/v2/ssh-host-login-mappings.png b/docs/images/platform/ssh/v2/ssh-host-login-mappings.png new file mode 100644 index 000000000..cdc192274 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-host-login-mappings.png differ diff --git a/docs/images/secret-syncs/camunda/camunda-created.png b/docs/images/secret-syncs/camunda/camunda-created.png new file mode 100644 index 000000000..10778748d Binary files /dev/null and b/docs/images/secret-syncs/camunda/camunda-created.png differ diff --git a/docs/images/secret-syncs/camunda/camunda-destination.png b/docs/images/secret-syncs/camunda/camunda-destination.png new file mode 100644 index 000000000..4dffbe1e6 Binary files /dev/null and b/docs/images/secret-syncs/camunda/camunda-destination.png differ diff --git a/docs/images/secret-syncs/camunda/camunda-details.png b/docs/images/secret-syncs/camunda/camunda-details.png new file mode 100644 index 000000000..06402a67f Binary files /dev/null and b/docs/images/secret-syncs/camunda/camunda-details.png differ diff --git a/docs/images/secret-syncs/camunda/camunda-options.png b/docs/images/secret-syncs/camunda/camunda-options.png new file mode 100644 index 000000000..b38153833 Binary files /dev/null and b/docs/images/secret-syncs/camunda/camunda-options.png differ diff --git a/docs/images/secret-syncs/camunda/camunda-review.png b/docs/images/secret-syncs/camunda/camunda-review.png new file mode 100644 index 000000000..6283b38b0 Binary files /dev/null and b/docs/images/secret-syncs/camunda/camunda-review.png differ diff --git a/docs/images/secret-syncs/camunda/camunda-source.png b/docs/images/secret-syncs/camunda/camunda-source.png new file mode 100644 index 000000000..5b7332ebd Binary files /dev/null and b/docs/images/secret-syncs/camunda/camunda-source.png differ diff --git a/docs/images/secret-syncs/camunda/select-camunda-option.png b/docs/images/secret-syncs/camunda/select-camunda-option.png new file mode 100644 index 000000000..e9e138e90 Binary files /dev/null and b/docs/images/secret-syncs/camunda/select-camunda-option.png differ diff --git a/docs/images/secret-syncs/terraform-cloud/terraform-cloud-created.png b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-created.png new file mode 100644 index 000000000..d6a89609e Binary files /dev/null and b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-created.png differ diff --git a/docs/images/secret-syncs/terraform-cloud/terraform-cloud-destination.png b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-destination.png new file mode 100644 index 000000000..bb2e2f095 Binary files /dev/null and b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-destination.png differ diff --git a/docs/images/secret-syncs/terraform-cloud/terraform-cloud-details.png b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-details.png new file mode 100644 index 000000000..b87c51494 Binary files /dev/null and b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-details.png differ diff --git a/docs/images/secret-syncs/terraform-cloud/terraform-cloud-option.png b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-option.png new file mode 100644 index 000000000..7670bf2c1 Binary files /dev/null and b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-option.png differ diff --git a/docs/images/secret-syncs/terraform-cloud/terraform-cloud-options.png b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-options.png new file mode 100644 index 000000000..def9cf1c0 Binary files /dev/null and b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-options.png differ diff --git a/docs/images/secret-syncs/terraform-cloud/terraform-cloud-review.png b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-review.png new file mode 100644 index 000000000..f7b245771 Binary files /dev/null and b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-review.png differ diff --git a/docs/images/secret-syncs/terraform-cloud/terraform-cloud-source.png b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-source.png new file mode 100644 index 000000000..7a7250f88 Binary files /dev/null and b/docs/images/secret-syncs/terraform-cloud/terraform-cloud-source.png differ diff --git a/docs/images/secret-syncs/vercel/select-vercel-option.png b/docs/images/secret-syncs/vercel/select-vercel-option.png new file mode 100644 index 000000000..b63d33cc7 Binary files /dev/null and b/docs/images/secret-syncs/vercel/select-vercel-option.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-created.png b/docs/images/secret-syncs/vercel/vercel-created.png new file mode 100644 index 000000000..fe955b00f Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-created.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-destination.png b/docs/images/secret-syncs/vercel/vercel-destination.png new file mode 100644 index 000000000..4d0f73d35 Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-destination.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-details.png b/docs/images/secret-syncs/vercel/vercel-details.png new file mode 100644 index 000000000..4421c426c Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-details.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-options.png b/docs/images/secret-syncs/vercel/vercel-options.png new file mode 100644 index 000000000..1d38e7ae6 Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-options.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-review.png b/docs/images/secret-syncs/vercel/vercel-review.png new file mode 100644 index 000000000..7a921f4ec Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-review.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-source.png b/docs/images/secret-syncs/vercel/vercel-source.png new file mode 100644 index 000000000..efb753aad Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-source.png differ diff --git a/docs/integrations/app-connections/camunda.mdx b/docs/integrations/app-connections/camunda.mdx new file mode 100644 index 000000000..68084cea3 --- /dev/null +++ b/docs/integrations/app-connections/camunda.mdx @@ -0,0 +1,77 @@ +--- +title: "Camunda Connection" +description: "Learn how to configure a Camunda Connection for Infisical." +--- + +Infisical supports connecting to Camunda APIs using [client credentials](https://docs.camunda.io/docs/apis-tools/administration-api/authentication/#client-credentials-and-scopes). + +## Configure Client Credentials for Infisical + + + + In your Camunda Cloud Console, navigate to the **Organization** tab in the top navigation menu. + ![Organization Management](/images/app-connections/camunda/camunda-console.png) + + + From the Organization Management tabs, click on **Administration API** to manage your API credentials and click the **Create client credentials** button. + ![Create Client Credentials](/images/app-connections/camunda/camunda-organization-page.png) + + + Enter a recognizable name for your client, such as "my-infisical-client". The name can contain letters, dashes, underscores, and digits. + + + In the "Create new client credentials" modal, select the following permissions required for secret syncs: + + - **Cluster**: Enable read access (Get) + - **Connector secrets**: Enable all operations (Get, Create, Update, Delete) + + These specific permissions are required for Infisical to properly sync and manage your Camunda secrets. + ![Set Permissions](/images/app-connections/camunda/camunda-create-client-1.png) + ![Set Permissions 2](/images/app-connections/camunda/camunda-create-client-2.png) + + + Click the **Create** button to generate your client credentials. + + + After creation, you'll be shown your client credentials. For the Infisical connection, you'll need: + + - **Client ID** (`CAMUNDA_CONSOLE_CLIENT_ID`) + - **Client Secret** (`CAMUNDA_CONSOLE_CLIENT_SECRET`) + + **IMPORTANT**: Make sure to securely save the Client Secret, as it will not be shown again after you close this dialog. + + You can download these credentials or copy them to use in the next section. + ![Client Credentials](/images/app-connections/camunda/camunda-client-credentials.png) + + + + +## Setup Camunda Connection in Infisical + + + + Navigate to the **App Connections** tab on the **Organization Settings** + page. ![App Connections + Tab](/images/app-connections/general/add-connection.png) + + + Select the **Camunda Connection** option from the connection options modal. + ![Select Camunda + Connection](/images/app-connections/camunda/camunda-app-connection-select.png) + + + Select the **Client Credentials** method and enter the Camunda client + credentials you created: + + - **Client ID**: Your `CAMUNDA_CONSOLE_CLIENT_ID` value + - **Client Secret**: Your `CAMUNDA_CONSOLE_CLIENT_SECRET` value + + Infisical will automatically configure the connection using these credentials to access the Camunda API. Click **Connect to Camunda** to establish the connection. ![Connect to Camunda](/images/app-connections/camunda/camunda-app-connection-form.png) + + + + Your **Camunda Connection** is now available for use in your Infisical + projects. ![Camunda Connection + Created](/images/app-connections/camunda/camunda-app-connection-created.png) + + diff --git a/docs/integrations/app-connections/terraform-cloud.mdx b/docs/integrations/app-connections/terraform-cloud.mdx new file mode 100644 index 000000000..02deb22cc --- /dev/null +++ b/docs/integrations/app-connections/terraform-cloud.mdx @@ -0,0 +1,83 @@ +--- +title: "Terraform Cloud Connection" +description: "Learn how to configure a Terraform Cloud Connection for Infisical." +--- + +Infisical supports connecting to Terraform Cloud using a service user. + +## Setup Terraform Cloud Connection in Infisical + + + + Navigate to the Terraform Cloud **Account Settings** tab. + ![Terraform Cloud Account Settings](/images/app-connections/terraform-cloud/terraform-cloud-account-settings.png) + + + Move to the **Tokens** tab. + ![Terraform Cloud Tokens Tab](/images/app-connections/terraform-cloud/terraform-cloud-tokens-tab.png) + + + Create the API token to be used by Infisical. + + If you configure an expiry date for your API token you will need to manually rotate to a new token prior to expiration to avoid integration downtime. + + ![Terraform Cloud Create API Token](/images/app-connections/terraform-cloud/terraform-cloud-create-api-token.png) + + + The API token will be displayed after creating it. Save the token in a secure location for later use in the following steps. + ![Terraform Cloud Copy API Token](/images/app-connections/terraform-cloud/terraform-cloud-copy-api-token.png) + + + + + 1. Navigate to the **App Connections** tab on the **Organization Settings** page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + 2. Select the **Terraform Cloud Connection** option from the connection options modal. + ![Select Terraform Cloud Connection](/images/app-connections/terraform-cloud/terraform-cloud-app-connection-option.png) + 3. Fill out the Terraform Cloud Connection modal, here you will need to provide the API Token generated in the previous step. + ![Terraform Cloud Connection Modal](/images/app-connections/terraform-cloud/terraform-cloud-app-connection-modal.png) + 4. Your **Terraform Cloud Connection** is now available for use. + ![Terraform Cloud Connection Created](/images/app-connections/terraform-cloud/terraform-cloud-app-connection-created.png) + + + To create an Terraform Cloud Connection, make an API request to the [Create Terraform Cloud + Connection](/api-reference/endpoints/app-connections/terraform-cloud/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/terraform-cloud \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-terraform-cloud-connection", + "method": "api-token", + "credentials": { + "apiToken": "...", + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-terraform-cloud-connection", + "version": 123, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "app": "terraform-cloud", + "method": "api-token", + "credentials": { + "apiToken": "..." + } + } + } + ``` + + + + diff --git a/docs/integrations/app-connections/vercel.mdx b/docs/integrations/app-connections/vercel.mdx new file mode 100644 index 000000000..8ef4a5647 --- /dev/null +++ b/docs/integrations/app-connections/vercel.mdx @@ -0,0 +1,97 @@ +--- +title: "Vercel Connection" +description: "Learn how to configure a Vercel Connection for Infisical." +--- + +Infisical supports connecting to Vercel using an API Token to securely sync your secrets to Vercel. + +## Setup Vercel Connection in Infisical + + + + Navigate to the Vercel **Account Settings** page by clicking on your profile icon in the top-right corner. + ![Vercel API Tokens Tab](/images/app-connections/vercel/vercel-main-page.png) + + + Select the **API Tokens** tab from the left sidebar navigation menu. + ![Vercel API Tokens Tab](/images/app-connections/vercel/vercel-settings-page.png) + + + Click the **Create** button and provide a name for your token (e.g., "Infisical Integration"). + Choose appropriate scope permissions based on your requirements. + + If you configure an expiry date for your API token, you will need to manually rotate to a new token prior to expiration to avoid integration downtime. Consider setting a calendar reminder for this task. + + ![Vercel Create API Token](/images/app-connections/vercel/vercel-create-token.png) + + + After creation, a modal with the API token will be displayed. Copy this token immediately and store it securely, as you won't be able to view it again after closing this dialog. + ![Vercel Copy API Token](/images/app-connections/vercel/vercel-copy-token.png) + + + You should now see your newly created token in the list of API tokens on the Vercel dashboard. + ![Vercel Connection Created](/images/app-connections/vercel/vercel-token-created.png) + + + + + 1. Navigate to App Connections + + In your Infisical dashboard, go to **Organization Settings** and select the **App Connections** tab. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + 2. Add Connection + + Click the **+ Add Connection** button and select the **Vercel Connection** option from the available integrations. + ![Select Vercel Connection](/images/app-connections/vercel/vercel-app-connection-option.png) + 3. Fill the Vercel Connection Modal + + Complete the Vercel Connection form by entering: + - A descriptive name for the connection + - The API Token you generated in steps 3-4 + - An optional description for future reference + ![Vercel Connection Modal](/images/app-connections/vercel/vercel-app-connection-modal.png) + 4. Connection Created + + After clicking Create, your **Vercel Connection** is established and ready to use with your Infisical projects. + ![Vercel Connection Created](/images/app-connections/vercel/vercel-app-connection-created.png) + + + To create a Vercel Connection, make an API request to the [Create Vercel + Connection](/api-reference/endpoints/app-connections/vercel/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/vercel \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-vercel-connection", + "method": "api-token", + "credentials": { + "apiToken": "...", + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-vercel-connection", + "version": 123, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2025-04-01T05:31:56Z", + "updatedAt": "2025-04-01T05:31:56Z", + "app": "vercel", + "method": "api-token", + "credentials": {} + } + } + ``` + + + + \ No newline at end of file diff --git a/docs/integrations/secret-syncs/camunda.mdx b/docs/integrations/secret-syncs/camunda.mdx new file mode 100644 index 000000000..5ed2cd9ae --- /dev/null +++ b/docs/integrations/secret-syncs/camunda.mdx @@ -0,0 +1,139 @@ +--- +title: "Camunda Sync" +description: "Learn how to configure a Camunda Sync for Infisical." +--- + +**Prerequisites:** + + - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) + - Create a [Camunda Connection](/integrations/app-connections/camunda) + + + + 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + 2. Select the **Camunda** option. + ![Select Camunda](/images/secret-syncs/camunda/select-camunda-option.png) + + 3. Configure the **Source** from where secrets should be retrieved, then click **Next**. + ![Configure Source](/images/secret-syncs/camunda/camunda-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + 4. Configure the **Destination** to where secrets should be deployed, then click **Next**. + ![Configure Destination](/images/secret-syncs/camunda/camunda-destination.png) + + - **Camunda Connection**: The Camunda Connection to authenticate with. + - **Cluster**: The Camunda cluster to sync connector secrets to. + + 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + ![Configure Options](/images/secret-syncs/camunda/camunda-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Camunda when keys conflict. + - **Import Secrets (Prioritize Camunda)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Camunda over Infisical when keys conflict. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + 6. Configure the **Details** of your Camunda Sync, then click **Next**. + ![Configure Details](/images/secret-syncs/camunda/camunda-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your Camunda Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/secret-syncs/camunda/camunda-review.png) + + 8. If enabled, your Camunda Sync will begin syncing your secrets to the destination endpoint. + ![Sync Secrets](/images/secret-syncs/camunda/camunda-created.png) + + + + To create an **Camunda Sync**, make an API request to the [Create Camunda Sync](/api-reference/endpoints/secret-syncs/camunda/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/camunda \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-camunda-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "destinationConfig": { + "scope": "cluster", + "clusterUUID": "cc4c8dae-dce9-4f4c-9882-132b2bd65fa5" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-camunda-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "camunda", + "name": "my-camunda-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "camunda", + "destinationConfig": { + "scope": "cluster", + "clusterUUID": "cc4c8dae-dce9-4f4c-9882-132b2bd65fa5" + } + } + } + ``` + + + diff --git a/docs/integrations/secret-syncs/terraform-cloud.mdx b/docs/integrations/secret-syncs/terraform-cloud.mdx new file mode 100644 index 000000000..80a087d2b --- /dev/null +++ b/docs/integrations/secret-syncs/terraform-cloud.mdx @@ -0,0 +1,161 @@ +--- +title: "Terraform Cloud Sync" +description: "Learn how to configure a Terraform Cloud Sync for Infisical." +--- + +**Prerequisites:** + + - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) + - Create a [Terraform Cloud Connection](/integrations/app-connections/terraform-cloud) + + + + 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + 2. Select the **Terraform Cloud** option. + ![Select Terraform Cloud](/images/secret-syncs/terraform-cloud/terraform-cloud-option.png) + + 3. Configure the **Source** from where secrets should be retrieved, then click **Next**. + ![Configure Source](/images/secret-syncs/terraform-cloud/terraform-cloud-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + 4. Configure the **Destination** to where secrets should be deployed, then click **Next**. + ![Configure Destination](/images/secret-syncs/terraform-cloud/terraform-cloud-destination.png) + + - **Terraform Cloud Connection**: The Terraform Cloud Connection to authenticate with. + - **Organization**: The Terraform Cloud organization to deploy secrets to. + - **Category**: The Terraform Cloud variable category to use on secrets syncs. Choose from: + - **Environment**: Sync secrets as environment variables. + - **Terraform**: Sync secrets as Terraform variables. + - **Scope**: The Terraform Cloud secret scope to sync secrets to. + - **Variable Set**: Sync secrets to a specific variable set. + - **Workspace**: Sync secrets to a specific workspace. +

+ The remaining fields are determined by the selected **Scope**: + + + - **Variable Set**: The variable set to deploy secrets to. + + + - **Workspace**: The workspace to deploy secrets to. + + + + 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + ![Configure Options](/images/secret-syncs/terraform-cloud/terraform-cloud-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + + Terraform Cloud does not support importing secrets. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + 6. Configure the **Details** of your Terraform Cloud Sync, then click **Next**. + ![Configure Details](/images/secret-syncs/terraform-cloud/terraform-cloud-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your Terraform Cloud Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/secret-syncs/terraform-cloud/terraform-cloud-review.png) + + 8. If enabled, your Terraform Cloud Sync will begin syncing your secrets to the destination endpoint. + ![Sync Secrets](/images/secret-syncs/terraform-cloud/terraform-cloud-created.png) + + + + To create an **Terraform Cloud Sync**, make an API request to the [Create Terraform Cloud Sync](/api-reference/endpoints/secret-syncs/terraform-cloud/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/terraform-cloud \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-terraform-cloud-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "destinationConfig": { + "scope": "variable-set", + "variableSetId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "variableSetName": "my-variable-set", + "org": "my-organization-id", + "category": "env" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-terraform-cloud-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "terraform-cloud", + "name": "my-terraform-cloud-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "terraform-cloud", + "destinationConfig": { + "scope": "workspace", + "workspaceId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "workspaceName": "my-workspace", + "org": "my-organization-id", + "category": "terraform" + } + } + } + ``` + + diff --git a/docs/integrations/secret-syncs/vercel.mdx b/docs/integrations/secret-syncs/vercel.mdx new file mode 100644 index 000000000..593874dee --- /dev/null +++ b/docs/integrations/secret-syncs/vercel.mdx @@ -0,0 +1,148 @@ +--- +title: "Vercel Sync" +description: "Learn how to configure a Vercel Sync for Infisical." +--- + +**Prerequisites:** + + - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) + - Create a [Vercel Connection](/integrations/app-connections/vercel) + + + + 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + 2. Select the **Vercel** option. + ![Select Vercel](/images/secret-syncs/vercel/select-vercel-option.png) + + 3. Configure the **Source** from where secrets should be retrieved, then click **Next**. + ![Configure Source](/images/secret-syncs/vercel/vercel-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + 4. Configure the **Destination** to where secrets should be deployed, then click **Next**. + ![Configure Destination](/images/secret-syncs/vercel/vercel-destination.png) + + - **Vercel Connection**: The Vercel Connection to authenticate with. + - **Vercel App**: The application to deploy secrets to. + - **Vercel App Environment**: The environment to deploy secrets to. + - **Vercel Preview Branch (Optional)**: Specify a branch for preview deployments if needed. + + After configuring these parameters, click the **Next** button to continue to the Sync Options step. + + 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + ![Configure Options](/images/secret-syncs/vercel/vercel-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Vercel when keys conflict. + - **Import Secrets (Prioritize Vercel)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Vercel over Infisical when keys conflict. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + 6. Configure the **Details** of your Vercel Sync, then click **Next**. + ![Configure Details](/images/secret-syncs/vercel/vercel-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your Vercel Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/secret-syncs/vercel/vercel-review.png) + + 8. If enabled, your Vercel Sync will begin syncing your secrets to the destination endpoint. + ![Sync Secrets](/images/secret-syncs/vercel/vercel-created.png) + + + + To create an **Vercel Sync**, make an API request to the [Create Vercel Sync](/api-reference/endpoints/secret-syncs/vercel/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/vercel \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-vercel-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "destinationConfig": { + "app": "prj_bz7zgHvQETPvJWc5tmIr0tGRH9kE", + "env": "preview", + "branch": "test", + "appName": "nextjs-boilerplate", + "teamId": "team_0d444b5088888dd257" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-vercel-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "vercel", + "name": "my-vercel-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "vercel", + "destinationConfig": { + "app": "prj_bz7zgHvQETPvJWc5tmIr0tGRH9kE", + "env": "preview", + "branch": "test", + "appName": "nextjs-boilerplate", + "teamId": "team_0d444b5088888dd257" + } + } + } + ``` + + diff --git a/docs/mint.json b/docs/mint.json index f94a05e18..f3c186535 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -417,12 +417,15 @@ "integrations/app-connections/aws", "integrations/app-connections/azure-app-configuration", "integrations/app-connections/azure-key-vault", + "integrations/app-connections/camunda", "integrations/app-connections/databricks", "integrations/app-connections/gcp", "integrations/app-connections/github", "integrations/app-connections/humanitec", "integrations/app-connections/mssql", - "integrations/app-connections/postgres" + "integrations/app-connections/postgres", + "integrations/app-connections/terraform-cloud", + "integrations/app-connections/vercel" ] } ] @@ -438,10 +441,13 @@ "integrations/secret-syncs/aws-secrets-manager", "integrations/secret-syncs/azure-app-configuration", "integrations/secret-syncs/azure-key-vault", + "integrations/secret-syncs/camunda", "integrations/secret-syncs/databricks", "integrations/secret-syncs/gcp-secret-manager", "integrations/secret-syncs/github", - "integrations/secret-syncs/humanitec" + "integrations/secret-syncs/humanitec", + "integrations/secret-syncs/terraform-cloud", + "integrations/secret-syncs/vercel" ] } ] @@ -582,7 +588,8 @@ "api-reference/endpoints/identities/update", "api-reference/endpoints/identities/delete", "api-reference/endpoints/identities/get-by-id", - "api-reference/endpoints/identities/list" + "api-reference/endpoints/identities/list", + "api-reference/endpoints/identities/search" ] }, { @@ -911,6 +918,18 @@ "api-reference/endpoints/app-connections/azure-key-vault/delete" ] }, + { + "group": "Camunda", + "pages": [ + "api-reference/endpoints/app-connections/camunda/list", + "api-reference/endpoints/app-connections/camunda/available", + "api-reference/endpoints/app-connections/camunda/get-by-id", + "api-reference/endpoints/app-connections/camunda/get-by-name", + "api-reference/endpoints/app-connections/camunda/create", + "api-reference/endpoints/app-connections/camunda/update", + "api-reference/endpoints/app-connections/camunda/delete" + ] + }, { "group": "Databricks", "pages": [ @@ -982,6 +1001,30 @@ "api-reference/endpoints/app-connections/postgres/update", "api-reference/endpoints/app-connections/postgres/delete" ] + }, + { + "group": "Terraform Cloud", + "pages": [ + "api-reference/endpoints/app-connections/terraform-cloud/list", + "api-reference/endpoints/app-connections/terraform-cloud/available", + "api-reference/endpoints/app-connections/terraform-cloud/get-by-id", + "api-reference/endpoints/app-connections/terraform-cloud/get-by-name", + "api-reference/endpoints/app-connections/terraform-cloud/create", + "api-reference/endpoints/app-connections/terraform-cloud/update", + "api-reference/endpoints/app-connections/terraform-cloud/delete" + ] + }, + { + "group": "Vercel", + "pages": [ + "api-reference/endpoints/app-connections/vercel/list", + "api-reference/endpoints/app-connections/vercel/available", + "api-reference/endpoints/app-connections/vercel/get-by-id", + "api-reference/endpoints/app-connections/vercel/get-by-name", + "api-reference/endpoints/app-connections/vercel/create", + "api-reference/endpoints/app-connections/vercel/update", + "api-reference/endpoints/app-connections/vercel/delete" + ] } ] }, @@ -1046,6 +1089,19 @@ "api-reference/endpoints/secret-syncs/azure-key-vault/remove-secrets" ] }, + { + "group": "Camunda", + "pages": [ + "api-reference/endpoints/secret-syncs/camunda/list", + "api-reference/endpoints/secret-syncs/camunda/get-by-id", + "api-reference/endpoints/secret-syncs/camunda/get-by-name", + "api-reference/endpoints/secret-syncs/camunda/create", + "api-reference/endpoints/secret-syncs/camunda/update", + "api-reference/endpoints/secret-syncs/camunda/delete", + "api-reference/endpoints/secret-syncs/camunda/sync-secrets", + "api-reference/endpoints/secret-syncs/camunda/remove-secrets" + ] + }, { "group": "Databricks", "pages": [ @@ -1098,6 +1154,33 @@ "api-reference/endpoints/secret-syncs/humanitec/sync-secrets", "api-reference/endpoints/secret-syncs/humanitec/remove-secrets" ] + }, + { + "group": "Terraform Cloud", + "pages": [ + "api-reference/endpoints/secret-syncs/terraform-cloud/list", + "api-reference/endpoints/secret-syncs/terraform-cloud/get-by-id", + "api-reference/endpoints/secret-syncs/terraform-cloud/get-by-name", + "api-reference/endpoints/secret-syncs/terraform-cloud/create", + "api-reference/endpoints/secret-syncs/terraform-cloud/update", + "api-reference/endpoints/secret-syncs/terraform-cloud/delete", + "api-reference/endpoints/secret-syncs/terraform-cloud/sync-secrets", + "api-reference/endpoints/secret-syncs/terraform-cloud/remove-secrets" + ] + }, + { + "group": "Vercel", + "pages": [ + "api-reference/endpoints/secret-syncs/vercel/list", + "api-reference/endpoints/secret-syncs/vercel/get-by-id", + "api-reference/endpoints/secret-syncs/vercel/get-by-name", + "api-reference/endpoints/secret-syncs/vercel/create", + "api-reference/endpoints/secret-syncs/vercel/update", + "api-reference/endpoints/secret-syncs/vercel/delete", + "api-reference/endpoints/secret-syncs/vercel/sync-secrets", + "api-reference/endpoints/secret-syncs/vercel/remove-secrets", + "api-reference/endpoints/secret-syncs/vercel/import-secrets" + ] } ] }, @@ -1236,9 +1319,23 @@ "api-reference/endpoints/kms/keys/get-by-name", "api-reference/endpoints/kms/keys/create", "api-reference/endpoints/kms/keys/update", - "api-reference/endpoints/kms/keys/delete", - "api-reference/endpoints/kms/keys/encrypt", - "api-reference/endpoints/kms/keys/decrypt" + "api-reference/endpoints/kms/keys/delete" + ] + }, + { + "group": "Encryption", + "pages": [ + "api-reference/endpoints/kms/encryption/encrypt", + "api-reference/endpoints/kms/encryption/decrypt" + ] + }, + { + "group": "Signing", + "pages": [ + "api-reference/endpoints/kms/signing/sign", + "api-reference/endpoints/kms/signing/verify", + "api-reference/endpoints/kms/signing/public-key", + "api-reference/endpoints/kms/signing/signing-algorithms" ] } ] diff --git a/docs/sdks/languages/go.mdx b/docs/sdks/languages/go.mdx index 47bc9a9ea..e04d11c6d 100644 --- a/docs/sdks/languages/go.mdx +++ b/docs/sdks/languages/go.mdx @@ -373,6 +373,9 @@ secret, err := client.Secrets().Retrieve(infisical.RetrieveSecretOptions{ The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". + + The version of the secret to retrieve. + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1fa72b81a..e7f57e85c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "@hcaptcha/react-hcaptcha": "^1.11.0", "@headlessui/react": "^1.7.19", "@hookform/resolvers": "^3.9.1", + "@lexical/react": "^0.29.0", "@lottiefiles/dotlottie-react": "^0.12.0", "@octokit/rest": "^21.0.2", "@peculiar/x509": "^1.12.3", @@ -66,6 +67,7 @@ "jspdf": "^2.5.2", "jsrp": "^0.2.4", "jwt-decode": "^4.0.0", + "lexical": "^0.29.0", "ms": "^2.1.3", "nprogress": "^0.2.0", "picomatch": "^4.0.2", @@ -127,7 +129,7 @@ "tailwindcss": "^3.4.16", "typescript": "~5.6.2", "typescript-eslint": "^8.15.0", - "vite": "^5.4.11", + "vite": "^5.4.18", "vite-plugin-node-polyfills": "^0.22.0", "vite-plugin-top-level-await": "^1.4.4", "vite-plugin-wasm": "^3.3.0", @@ -1570,6 +1572,260 @@ } } }, + "node_modules/@lexical/clipboard": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.29.0.tgz", + "integrity": "sha512-llxZosYCwH13p2GfPfhAinukdvAZYxWuwf5md107X80hsE8TQJj25unjqTwRKQ+w/wD+hpmBMziU8+K/WTitWQ==", + "license": "MIT", + "dependencies": { + "@lexical/html": "0.29.0", + "@lexical/list": "0.29.0", + "@lexical/selection": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/code": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.29.0.tgz", + "integrity": "sha512-yKGzoKpyIO39Xf7OKLPpoCE5V8mTDCM3l3CDHZR3X1gM/VZQzf4jAiO3b06y9YkQ2fM8kqwchYu87wGvs8/iIQ==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.29.0", + "lexical": "0.29.0", + "prismjs": "^1.30.0" + } + }, + "node_modules/@lexical/devtools-core": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.29.0.tgz", + "integrity": "sha512-uUq0m9ql/7mthp7Ho1vnG7Id6imQ5kD5mxUhX2lmgHretS+yAHGsGsGiPIVHdPWeVmUb2n4IVDJ+cJbUsUjQJw==", + "license": "MIT", + "dependencies": { + "@lexical/html": "0.29.0", + "@lexical/link": "0.29.0", + "@lexical/mark": "0.29.0", + "@lexical/table": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/dragon": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.29.0.tgz", + "integrity": "sha512-Zaky2jd/Pp1blAZqPeGNdyhxnVL4lwVjbWPxhfS1gbW4Q5CBQ3aD3B0T4ljiKfmRNJm004LJ9q7KjhlRbREvZA==", + "license": "MIT", + "dependencies": { + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/hashtag": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.29.0.tgz", + "integrity": "sha512-fa7s0Yi2RKz/GvgT5XU9fborx6VPU3VtvvEPaIXgyd6zXZRiOhD9rGypwB3oj4fMK1ndx2dX0m7SwhMJo48D8w==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/history": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.29.0.tgz", + "integrity": "sha512-OrCwZycp/yaq63mw511NutkwAB+W6WSchG1xTxlLh6nbc8jnbvKhCf4CGbnrvlhD7hTuzxJ8FI9/2M/2zv/mNQ==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/html": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.29.0.tgz", + "integrity": "sha512-+jV6ijppOpxpUGeXkGssXJbsAmFALfeLrgbM0xuZbxZ7RgYZ+5Atn00WjSno7+JV5EOuRkYmCNtS1tiHtXMY1g==", + "license": "MIT", + "dependencies": { + "@lexical/selection": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/link": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.29.0.tgz", + "integrity": "sha512-wGbKRF0x/6ZQHuCfr8m8qD1J0R1kFmWINBG2A1hUXPDf7UY5qm/nS2oKNDGpjiDMGwkVZ7n7WfzeBGO+KRe/Lg==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/list": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.29.0.tgz", + "integrity": "sha512-sWiof+i2ff8rL7KxJ3dxHLwyJfX423e1EVLmAdQEOPhyZJiNbeLTSNhNGsZ8FjFoBwvTTEDwuQZm3iT3hliKOg==", + "license": "MIT", + "dependencies": { + "@lexical/selection": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/mark": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.29.0.tgz", + "integrity": "sha512-UB3x6pyUdpZHRqF4tiajLnC1+Umvt7x8Rkkdi29aNNvzIWniVwGkBOlmvFus7x+4dOV1D1fydwiP4m38nGgLDw==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/markdown": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.29.0.tgz", + "integrity": "sha512-4Od8WoDoviv9DxJZVgrIORTIAzyoGOpztbGbIBXguGmwvy7NnHQDh9fZYIYRrdI1Awp1VVGdJ3ku/7KTgSOoRw==", + "license": "MIT", + "dependencies": { + "@lexical/code": "0.29.0", + "@lexical/link": "0.29.0", + "@lexical/list": "0.29.0", + "@lexical/rich-text": "0.29.0", + "@lexical/text": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/offset": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.29.0.tgz", + "integrity": "sha512-VyD2Ff3rBJpo++Fxvi3MNYmDELa+9nA0EgXqGRNb3MvRehRjHbaDbymtLMMHIwvbkF5lnra+ubStcTRQmoQxXw==", + "license": "MIT", + "dependencies": { + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/overflow": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.29.0.tgz", + "integrity": "sha512-IzH3M652Ej2gB2sK65N3yTgyiQAa3I3tqKbSnBRiXu/+isxHoCy/qRr9/kL63uy7zhGvgV+EYsoffQCawIFt8Q==", + "license": "MIT", + "dependencies": { + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/plain-text": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.29.0.tgz", + "integrity": "sha512-F5C3meDb2HmO0NmKJBVRkjmX9PNln6O1jXU/APJuSFBdvfcIWSY58ncHR4zy2M5LF1Q5PQMWyIay9p+SqOtY5A==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.29.0", + "@lexical/selection": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/react": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.29.0.tgz", + "integrity": "sha512-YMlnljW/jxmwSzsRv5UPatfOoMZXqxFmRIEltTUIQfrOFdqn+ssUtCpjE6xRD1oxD6KpSIekakzLs+y/8+7CuQ==", + "license": "MIT", + "dependencies": { + "@lexical/devtools-core": "0.29.0", + "@lexical/dragon": "0.29.0", + "@lexical/hashtag": "0.29.0", + "@lexical/history": "0.29.0", + "@lexical/link": "0.29.0", + "@lexical/list": "0.29.0", + "@lexical/mark": "0.29.0", + "@lexical/markdown": "0.29.0", + "@lexical/overflow": "0.29.0", + "@lexical/plain-text": "0.29.0", + "@lexical/rich-text": "0.29.0", + "@lexical/table": "0.29.0", + "@lexical/text": "0.29.0", + "@lexical/utils": "0.29.0", + "@lexical/yjs": "0.29.0", + "lexical": "0.29.0", + "react-error-boundary": "^3.1.4" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/rich-text": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.29.0.tgz", + "integrity": "sha512-fSKgXGxJUOWo7dwSTUYFVBNNk4pPN8norsZfdmKM1kGDS1/GKuVzlzHLKZ7rQb8RLD5a43p4ifEL+28P+q0Qqg==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.29.0", + "@lexical/selection": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/selection": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.29.0.tgz", + "integrity": "sha512-lX9CRrXgKte65cozTHFXwUJ2fvZD92OEtos+YU+U40GJjf3NdheGeKDxDfOpF4AXrYRSszY7E0CzmIvuEs0p4A==", + "license": "MIT", + "dependencies": { + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/table": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.29.0.tgz", + "integrity": "sha512-Jdj32kBDeJh/0dGaZB14JggnEIS956/cN7grnLr7cmhhVzDicvLMBENSXQVEJAQVcSIU4G9EvxC7GJZ9VgqDnA==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/text": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.29.0.tgz", + "integrity": "sha512-QnNGr6ickTLk76o3PdxJjPwt//dpuh8idVfR73WdCIoAwkhiEPUxxTZERoMsudXj6O/lJ+/HhI61wVjLckYr3A==", + "license": "MIT", + "dependencies": { + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/utils": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.29.0.tgz", + "integrity": "sha512-y2hhWQDjcXdplsAaQMuZx6ht9u1I4BV5NynA+WKoQ3h8vKxzeDnpCxVOK/zxU1R5dhM/nilnFu7uhvrSeEn+TQ==", + "license": "MIT", + "dependencies": { + "@lexical/list": "0.29.0", + "@lexical/selection": "0.29.0", + "@lexical/table": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/yjs": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.29.0.tgz", + "integrity": "sha512-6IXWWlGkVJEzWP/+LcuKYJ9jmcFp8k7TT/jmz4V5gBD9Ut3swOGsIA/sQCtB9y7jad10csaDVmFdFzGNWKVH9A==", + "license": "MIT", + "dependencies": { + "@lexical/offset": "0.29.0", + "@lexical/selection": "0.29.0", + "lexical": "0.29.0" + }, + "peerDependencies": { + "yjs": ">=13.5.22" + } + }, "node_modules/@lottiefiles/dotlottie-react": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.12.0.tgz", @@ -8871,6 +9127,17 @@ "node": ">=10" } }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/iterator.prototype": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.4.tgz", @@ -9100,6 +9367,34 @@ "node": ">= 0.8.0" } }, + "node_modules/lexical": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.29.0.tgz", + "integrity": "sha512-eoBHUEn0LmExKeK6x2cFKU0FPaMk2Bc5HgiCzTiv5ymKtwWw7LeKcxaNPmLxRRdQpcWV1IMKjayAbw7Lt/Gu7w==", + "license": "MIT" + }, + "node_modules/lib0": { + "version": "0.2.102", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.102.tgz", + "integrity": "sha512-g70kydI0I1sZU0ChO8mBbhw0oUW/8U0GHzygpvEIx8k+jgOpqnTSb/E+70toYVqHxBhrERD21TwD5QcZJQ40ZQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -10857,6 +11152,15 @@ } } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -11142,6 +11446,22 @@ "react": "^18.3.1" } }, + "node_modules/react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -13587,9 +13907,9 @@ } }, "node_modules/vite": { - "version": "5.4.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", - "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", + "version": "5.4.18", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.18.tgz", + "integrity": "sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==", "dev": true, "license": "MIT", "dependencies": { @@ -14131,6 +14451,24 @@ "node": ">=8" } }, + "node_modules/yjs": { + "version": "13.6.24", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.24.tgz", + "integrity": "sha512-xn/pYLTZa3uD1uDG8lpxfLRo5SR/rp0frdASOl2a71aYNvUXdWcLtVL91s2y7j+Q8ppmjZ9H3jsGVgoFMbT2VA==", + "license": "MIT", + "peer": true, + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 95ad59c7d..6225b78f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "@hcaptcha/react-hcaptcha": "^1.11.0", "@headlessui/react": "^1.7.19", "@hookform/resolvers": "^3.9.1", + "@lexical/react": "^0.29.0", "@lottiefiles/dotlottie-react": "^0.12.0", "@octokit/rest": "^21.0.2", "@peculiar/x509": "^1.12.3", @@ -70,6 +71,7 @@ "jspdf": "^2.5.2", "jsrp": "^0.2.4", "jwt-decode": "^4.0.0", + "lexical": "^0.29.0", "ms": "^2.1.3", "nprogress": "^0.2.0", "picomatch": "^4.0.2", @@ -131,7 +133,7 @@ "tailwindcss": "^3.4.16", "typescript": "~5.6.2", "typescript-eslint": "^8.15.0", - "vite": "^5.4.11", + "vite": "^5.4.18", "vite-plugin-node-polyfills": "^0.22.0", "vite-plugin-top-level-await": "^1.4.4", "vite-plugin-wasm": "^3.3.0", diff --git a/frontend/public/images/integrations/Camunda.png b/frontend/public/images/integrations/Camunda.png new file mode 100644 index 000000000..a3bb215b3 Binary files /dev/null and b/frontend/public/images/integrations/Camunda.png differ diff --git a/frontend/public/lotties/certificate-authority.json b/frontend/public/lotties/certificate-authority.json new file mode 100644 index 000000000..44e1488a0 --- /dev/null +++ b/frontend/public/lotties/certificate-authority.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":180,"w":430,"h":430,"nm":"wired-outline-1945-court","ddd":0,"assets":[{"id":"comp_1","nm":"hover-pinch","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"outline 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215.377,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250.377,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[43.109,0],[-43.109,0]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[43.109,0],[2.391,0]],"c":false}]},{"t":180,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[43.109,0],[-43.109,0]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[42.99,0],[-43.164,0]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-117.51,0],[-94.411,0]],"c":false}]},{"t":180,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[42.99,0],[-43.164,0]],"c":false}]}],"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[43.109,240.179],[42.99,240.179],[26.99,204.803],[27.097,204.803]],"c":true}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[43.109,240.179],[-117.51,240.179],[-117.46,205.303],[27.097,205.303]],"c":true}]},{"t":180,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[43.109,240.179],[42.99,240.179],[26.99,204.803],[27.097,204.803]],"c":true}]}],"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[15.1,26.687],[15.1,59.803],[14.994,59.803],[14.994,26.687]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[15.1,26.187],[15.1,59.303],[-117.423,59.303],[-117.423,26.187]],"c":false}]},{"t":180,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[15.1,26.687],[15.1,59.803],[14.994,59.803],[14.994,26.687]],"c":false}]}],"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[-117.01,-79.306],[43.109,0],[37.648,26.687],[37.533,26.687],[42.99,0]],"c":true}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[-117.01,-79.306],[43.109,0],[37.648,26.187],[-117.493,26.187],[-117.51,0]],"c":true}]},{"t":180,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[-117.01,-79.306],[43.109,0],[37.648,26.687],[37.533,26.687],[42.99,0]],"c":true}]}],"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[367.01,169.94],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":6,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":180,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"outline 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215.377,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250.377,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[43.109,0],[-43.109,0]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-116.885,0],[-140.433,0]],"c":false}]},{"t":180,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[43.109,0],[-43.109,0]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-277.129,0],[-190.911,0]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-277.129,0],[-238.911,0]],"c":false}]},{"t":180,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-277.129,0],[-190.911,0]],"c":false}]}],"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[43.109,240.179],[-277.129,240.179],[-261.117,205.303],[27.097,205.303]],"c":true}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-115.891,239.963],[-277.129,240.179],[-261.117,205.303],[-116.403,205.105]],"c":true}]},{"t":180,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[43.109,240.179],[-277.129,240.179],[-261.117,205.303],[27.097,205.303]],"c":true}]}],"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[15.1,26.187],[15.1,59.303],[-249.113,59.303],[-249.113,26.187]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-116.907,26.187],[-116.907,59.303],[-249.113,59.303],[-249.113,26.187]],"c":false}]},{"t":180,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[15.1,26.187],[15.1,59.303],[-249.113,59.303],[-249.113,26.187]],"c":false}]}],"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[-117.01,-79.306],[43.109,0],[37.648,26.187],[-271.669,26.187],[-277.129,0]],"c":true}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[-117.01,-79.306],[-116.885,0],[-116.889,26.187],[-271.669,26.187],[-277.129,0]],"c":true}]},{"t":180,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[-117.01,-79.306],[43.109,0],[37.648,26.187],[-271.669,26.187],[-277.129,0]],"c":true}]}],"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[367.01,169.94],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":6,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,-14.739],[-14.739,0],[0,14.739],[14.739,0]],"o":[[0,14.739],[14.739,0],[0,-14.739],[-14.739,0]],"v":[[-26.687,0],[0,26.687],[26.687,0],[0,-26.687]],"c":true}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,-10.29],[-10.29,0],[0,10.29],[10.29,0]],"o":[[0,10.29],[10.29,0],[0,-10.29],[-10.29,0]],"v":[[-18.631,0],[0,18.631],[18.631,0],[0,-18.631]],"c":true}]},{"t":180,"s":[{"i":[[0,-14.739],[-14.739,0],[0,14.739],[14.739,0]],"o":[[0,14.739],[14.739,0],[0,-14.739],[-14.739,0]],"v":[[-26.687,0],[0,26.687],[26.687,0],[0,-26.687]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[250,147.631],"to":[-8.667,0],"ti":[0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[198,147.631],"to":[0,0],"ti":[-8.667,0]},{"t":180,"s":[250,147.631]}],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":1,"k":[{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]},"t":90,"s":[30]},{"t":180,"s":[0]}],"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"outline 5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[173.268,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"outline 12","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[173.268,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"outline 4","tt":2,"tp":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[215,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[144.253,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[215,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-67.425,-132.286],[-78.275,-106.857],[-121.26,-106.857],[-132.11,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-132.11,12.714],[-121.26,-12.714],[-78.275,-12.714],[-67.425,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-78.275,-12.714],[-121.26,-12.714],[-121.26,-106.857],[-78.275,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":6,"ty":0,"nm":"mask-1","td":1,"refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":844,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"outline","tt":2,"tp":6,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":0.4},"o":{"x":0.333,"y":0.333},"t":0,"s":[115.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":0.4},"o":{"x":0.6,"y":0.6},"t":90,"s":[115.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[115.239,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[150.239,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-167.186,-132.286],[-178.036,-106.857],[-221.02,-106.857],[-231.87,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-231.87,12.714],[-221.02,-12.714],[-178.036,-12.714],[-167.186,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-178.036,-12.714],[-221.02,-12.714],[-221.02,-106.857],[-178.036,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":8,"ty":0,"nm":"mask-line-1","td":1,"refId":"comp_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":844,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":0,"nm":"Columns-2","tt":2,"tp":8,"refId":"comp_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":844,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":0,"nm":"mask-line-2","td":1,"refId":"comp_6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":844,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":0,"nm":"columns-3","tt":2,"tp":10,"refId":"comp_7","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":844,"st":0,"bm":0}]},{"id":"comp_2","nm":"mask-1","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"outline 14","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[173.268,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"outline 13","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[215,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[144.253,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[215,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-67.425,-132.286],[-78.275,-106.857],[-121.26,-106.857],[-132.11,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-132.11,12.714],[-121.26,-12.714],[-78.275,-12.714],[-67.425,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-78.275,-12.714],[-121.26,-12.714],[-121.26,-106.857],[-78.275,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0}]},{"id":"comp_3","nm":"mask-line-1","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"outline 16","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[173.268,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"outline 15","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[215,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[144.253,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[215,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-67.425,-132.286],[-78.275,-106.857],[-121.26,-106.857],[-132.11,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-132.11,12.714],[-121.26,-12.714],[-78.275,-12.714],[-67.425,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-78.275,-12.714],[-121.26,-12.714],[-121.26,-106.857],[-78.275,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"outline 14","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":0.4},"o":{"x":0.333,"y":0.333},"t":0,"s":[115.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":0.4},"o":{"x":0.6,"y":0.6},"t":90,"s":[115.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[115.239,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[150.239,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-167.186,-132.286],[-178.036,-106.857],[-221.02,-106.857],[-231.87,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-231.87,12.714],[-221.02,-12.714],[-178.036,-12.714],[-167.186,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-178.036,-12.714],[-221.02,-12.714],[-221.02,-106.857],[-178.036,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0}]},{"id":"comp_4","nm":"Columns-2","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"outline 8","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[244.268,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"outline 13","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[244.268,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.032258063555,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"outline 7","tt":2,"tp":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[215,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[215.253,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[215,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-67.425,-132.286],[-78.275,-106.857],[-121.26,-106.857],[-132.11,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-132.11,12.714],[-121.26,-12.714],[-78.275,-12.714],[-67.425,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-78.275,-12.714],[-121.26,-12.714],[-121.26,-106.857],[-78.275,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"mask-3","td":1,"refId":"comp_5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":844,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"outline 6","tt":2,"tp":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[115.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[186.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[115.239,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[150.239,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-167.186,-132.286],[-178.036,-106.857],[-221.02,-106.857],[-231.87,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-231.87,12.714],[-221.02,-12.714],[-178.036,-12.714],[-167.186,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-178.036,-12.714],[-221.02,-12.714],[-221.02,-106.857],[-178.036,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0}]},{"id":"comp_5","nm":"mask-3","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"outline 15","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[244.268,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.032258063555,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"outline 14","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[215,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[215.253,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[215,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-67.425,-132.286],[-78.275,-106.857],[-121.26,-106.857],[-132.11,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-132.11,12.714],[-121.26,-12.714],[-78.275,-12.714],[-67.425,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-78.275,-12.714],[-121.26,-12.714],[-121.26,-106.857],[-78.275,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.032258063555,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0}]},{"id":"comp_6","nm":"mask-line-2","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"outline 19","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[173.268,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"outline 18","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[215,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[144.253,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[215,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-67.425,-132.286],[-78.275,-106.857],[-121.26,-106.857],[-132.11,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-132.11,12.714],[-121.26,-12.714],[-78.275,-12.714],[-67.425,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-78.275,-12.714],[-121.26,-12.714],[-121.26,-106.857],[-78.275,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"outline 17","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":0.4},"o":{"x":0.333,"y":0.333},"t":0,"s":[115.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":0.4},"o":{"x":0.6,"y":0.6},"t":90,"s":[115.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[115.239,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[150.239,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-167.186,-132.286],[-178.036,-106.857],[-221.02,-106.857],[-231.87,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-231.87,12.714],[-221.02,-12.714],[-178.036,-12.714],[-167.186,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-178.036,-12.714],[-221.02,-12.714],[-221.02,-106.857],[-178.036,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"outline 16","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[244.268,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.032258063555,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"outline 15","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[215,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[215.253,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[215,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-67.425,-132.286],[-78.275,-106.857],[-121.26,-106.857],[-132.11,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-132.11,12.714],[-121.26,-12.714],[-78.275,-12.714],[-67.425,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-78.275,-12.714],[-121.26,-12.714],[-121.26,-106.857],[-78.275,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.032258063555,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"outline 14","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[115.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[186.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[115.239,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[150.239,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-167.186,-132.286],[-178.036,-106.857],[-221.02,-106.857],[-231.87,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-231.87,12.714],[-221.02,-12.714],[-178.036,-12.714],[-167.186,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-178.036,-12.714],[-221.02,-12.714],[-221.02,-106.857],[-178.036,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.032258063555,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0}]},{"id":"comp_7","nm":"columns-3","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"outline 11","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[313.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"mask","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[313.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"outline 10","tt":2,"tp":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[215,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[284.753,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[215,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-67.425,-132.286],[-78.275,-106.857],[-121.26,-106.857],[-132.11,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-132.11,12.714],[-121.26,-12.714],[-78.275,-12.714],[-67.425,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-78.275,-12.714],[-121.26,-12.714],[-121.26,-106.857],[-78.275,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"mask","td":1,"refId":"comp_8","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":844,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"outline 9","tt":2,"tp":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[115.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[255.739,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[115.239,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[150.239,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-167.186,-132.286],[-178.036,-106.857],[-221.02,-106.857],[-231.87,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-231.87,12.714],[-221.02,-12.714],[-178.036,-12.714],[-167.186,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-178.036,-12.714],[-221.02,-12.714],[-221.02,-106.857],[-178.036,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0}]},{"id":"comp_8","nm":"mask","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"mask 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[313.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"outline 14","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[215,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[284.753,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[215,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-67.425,-132.286],[-78.275,-106.857],[-121.26,-106.857],[-132.11,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-132.11,12.714],[-121.26,-12.714],[-78.275,-12.714],[-67.425,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-78.275,-12.714],[-121.26,-12.714],[-121.26,-106.857],[-78.275,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"stroke","np":3,"mn":"Pseudo/@@eNFtiauHQXSOqu227cRFCQ","ix":1,"en":1,"ef":[{"ty":7,"nm":"Menu","mn":"Pseudo/@@eNFtiauHQXSOqu227cRFCQ-0001","ix":1,"v":{"a":0,"k":3,"ix":1}}]},{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":2,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[1,1,1],"ix":1}}]},{"ty":5,"nm":"secondary","np":3,"mn":"ADBE Color Control","ix":3,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[1,1,1],"ix":1}}]}],"ip":0,"op":281,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"hover-pinch","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":190,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-pinch","dr":180}],"props":{}} \ No newline at end of file diff --git a/frontend/public/lotties/certificate.json b/frontend/public/lotties/certificate.json new file mode 100644 index 000000000..d634f9446 --- /dev/null +++ b/frontend/public/lotties/certificate.json @@ -0,0 +1 @@ +{"v":"5.8.1","fr":60,"ip":0,"op":89,"w":430,"h":430,"nm":"966-privacy-policy-outline","ddd":0,"assets":[{"id":"comp_1","nm":"Content-28","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"outline 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[17]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":25,"s":[-15]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":38,"s":[4]},{"t":50,"s":[0]}],"ix":10},"p":{"a":0,"k":[215,214.76,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.161,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[{"i":[[0,0],[0,0],[0.398,-0.317],[0,0],[0,0],[0.118,0.495],[0,0],[-0.308,0.344]],"o":[[0,0],[-0.118,0.495],[0,0],[0,0],[-0.398,-0.317],[0,0],[0,0],[0.308,0.344]],"v":[[2,-1.535],[1.71,0.268],[0.912,1.521],[0,2.236],[-0.912,1.521],[-1.71,0.268],[-2,-1.535],[0,-2.236]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":19,"s":[{"i":[[0,0],[0,0],[7.971,-6.344],[0,0],[0,0],[2.369,9.908],[0,0],[-6.168,6.878]],"o":[[0,0],[-2.369,9.908],[0,0],[0,0],[-7.971,-6.344],[0,0],[0,0],[6.169,6.878]],"v":[[40.037,-30.733],[34.222,5.361],[18.265,30.444],[0,44.76],[-18.265,30.444],[-34.222,5.361],[-40.037,-30.733],[0,-44.76]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":32,"s":[{"i":[[0,0],[0,0],[6.19,-4.927],[0,0],[0,0],[1.84,7.694],[0,0],[-4.79,5.341]],"o":[[0,0],[-1.84,7.694],[0,0],[0,0],[-6.19,-4.927],[0,0],[0,0],[4.79,5.341]],"v":[[31.092,-23.867],[26.577,4.163],[14.185,23.642],[0,34.76],[-14.185,23.642],[-26.577,4.163],[-31.092,-23.867],[0,-34.76]],"c":true}]},{"t":44,"s":[{"i":[[0,0],[0,0],[7.128,-5.674],[0,0],[0,0],[2.119,8.861],[0,0],[-5.516,6.151]],"o":[[0,0],[-2.119,8.861],[0,0],[0,0],[-7.128,-5.674],[0,0],[0,0],[5.517,6.151]],"v":[[35.806,-27.485],[30.606,4.795],[16.335,27.226],[0,40.03],[-16.335,27.226],[-30.606,4.795],[-35.806,-27.485],[0,-40.03]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('966-privacy-policy-outline').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"outline","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,321.746,0],"ix":2,"l":2},"a":{"a":0,"k":[135,291.746,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-40.03,0],[40.03,0]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"t":13,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('966-privacy-policy-outline').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[94.97,318.433],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-26.687,0],[26.687,0]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.21],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":17,"s":[0]},{"t":46,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('966-privacy-policy-outline').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[188.373,318.433],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":4,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-80.06,0],[80.06,0]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.21],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":36,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('966-privacy-policy-outline').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[135,265.06],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":4,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"bm":0}]},{"id":"comp_3","nm":"hover-swipe","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Page-corner","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.001,249.76,0],"ix":2,"l":2},"a":{"a":0,"k":[250.001,249.76,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.22,"y":1},"o":{"x":0.333,"y":0},"t":42,"s":[{"i":[[0,0],[-49.694,-50.431],[0,0]],"o":[[0,0],[50.313,51.06],[0,0]],"v":[[-53.373,-53.373],[-0.373,-0.627],[53.373,53.373]],"c":false}]},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-53.373,-53.373],[-53.373,53.373],[53.373,53.373]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('966-privacy-policy-outline').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[330.06,116.567],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Page","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.243],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.326],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":20,"s":[9]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":47,"s":[-7]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":70,"s":[5]},{"t":89,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.243,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[317.001,368.76,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.326,"y":1},"o":{"x":0.333,"y":0},"t":20,"s":[351.001,381.76,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":42,"s":[291.751,356.51,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":65,"s":[321.001,369.26,0],"to":[0,0,0],"ti":[0,0,0]},{"t":80,"s":[317.001,368.76,0]}],"ix":2,"l":2},"a":{"a":0,"k":[352.001,403.76,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-53.373,-53.373],[-53.373,53.373],[53.373,53.373]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":20,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-53.373,-53.373],[-53.373,53.373],[53.373,53.373]],"c":false}]},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-213.237,-53.373],[-213.237,319.57],[53.373,319.57]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('966-privacy-policy-outline').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[330.06,116.567],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":1,"k":[{"t":20,"s":[100],"h":1},{"t":38,"s":[0],"h":1}],"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[26.69,-186.57],[-133.43,-186.57],[-133.43,186.57],[133.43,186.57],[133.43,-79.82]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('966-privacy-policy-outline').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[250,249.76],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"mask","parent":2,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[249.001,249.76,0],"ix":2,"l":2},"a":{"a":0,"k":[250.001,249.76,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[26.69,-186.57],[26.75,-186.57],[26.75,-79.76],[133.43,-79.76],[133.43,-79.82]],"c":true}]},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[26.69,-186.57],[-133.43,-186.57],[-133.43,186.57],[133.43,186.57],[133.43,-79.82]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[250,249.76],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":51,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Content-28","parent":2,"tt":2,"refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":51,"st":-50,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"Content-28","parent":2,"refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":39,"op":883,"st":39,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"stroke","np":3,"mn":"Pseudo/@@C7/bkxIlQrGojTEoYN8oxw","ix":1,"en":1,"ef":[{"ty":7,"nm":"Menu","mn":"Pseudo/@@C7/bkxIlQrGojTEoYN8oxw-0001","ix":1,"v":{"a":0,"k":3,"ix":1}}]},{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":2,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[1,1,1],"ix":1}}]},{"ty":5,"nm":"secondary","np":3,"mn":"ADBE Color Control","ix":3,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[1,1,1],"ix":1}}]}],"ip":0,"op":375,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"hover-swipe","refId":"comp_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":99,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-swipe","dr":89}]} \ No newline at end of file diff --git a/frontend/public/lotties/server.json b/frontend/public/lotties/server.json new file mode 100644 index 000000000..537e6bfe0 --- /dev/null +++ b/frontend/public/lotties/server.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":60,"w":430,"h":430,"nm":"wired-outline-57-server","ddd":0,"assets":[{"id":"comp_1","nm":"hover-pinch","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Rectangle","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,285.471,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,-11.046],[0,0],[-11.046,0],[0,0],[0,11.046],[0,0],[11.046,0]],"o":[[-11.046,0],[0,0],[0,11.046],[0,0],[11.046,0],[0,0],[0,-11.046],[0,0]],"v":[[-165,-45.685],[-185,-25.685],[-185,25.685],[-165,45.685],[165,45.685],[185,25.685],[185,-25.685],[165,-45.685]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-57-server').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-57-server').layer('control').effect('stroke')('Menu'));"},"lc":1,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1800,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Vector 2","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":0,"s":[-25.74,-14.872,0],"to":[0,0,0],"ti":[0,0,0]},{"t":30,"s":[-25.74,-14.872,0]}],"ix":2,"l":2},"a":{"a":0,"k":[106.014,-14.875,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[106.014,-14.873],[286.263,-14.779]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.578],"y":[1]},"o":{"x":[0.182],"y":[0]},"t":0,"s":[100]},{"i":{"x":[0.703],"y":[1]},"o":{"x":[0.344],"y":[0]},"t":9,"s":[37]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":30,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":44,"s":[44]},{"t":56,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-57-server').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":24,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-57-server').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"d":[{"n":"d","nm":"dash","v":{"a":0,"k":0,"ix":1}},{"n":"g","nm":"gap","v":{"a":0,"k":30,"ix":2}},{"n":"o","nm":"offset","v":{"a":0,"k":0,"ix":7}}],"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[106.014,-14.873],[286.263,-14.779]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.573],"y":[1]},"o":{"x":[0.187],"y":[0]},"t":0,"s":[100]},{"i":{"x":[0.704],"y":[1]},"o":{"x":[0.337],"y":[0]},"t":17,"s":[6]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":34,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":48,"s":[60]},{"t":60,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-57-server').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":24,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-57-server').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"d":[{"n":"d","nm":"dash","v":{"a":0,"k":0,"ix":1}},{"n":"g","nm":"gap","v":{"a":0,"k":30,"ix":2}},{"n":"o","nm":"offset","v":{"a":0,"k":0,"ix":7}}],"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"tr","p":{"a":0,"k":[0,30],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1800,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Vector","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-131.754,0.002,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,9.625],[9.595,0.102],[0.064,0],[0.117,-9.683],[-9.683,0],[-0.059,0.001]],"o":[[0,-9.619],[-0.064,-0.001],[-9.567,0],[0,9.683],[0.059,0],[9.602,-0.094]],"v":[[17.5,0],[0.192,-17.499],[0,-17.5],[-17.5,0],[0,17.5],[0.176,17.499]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":15,"s":[{"i":[[0,9.625],[9.595,0.102],[0.064,0],[0.117,-9.683],[-9.683,0],[-0.059,0.001]],"o":[[0,-9.619],[-0.064,-0.001],[-9.567,0],[0,9.683],[0.059,0],[9.602,-0.094]],"v":[[72.75,-0.017],[55.442,-17.516],[0,-17.5],[-17.5,0],[0,17.5],[55.426,17.482]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":30,"s":[{"i":[[0,9.625],[9.595,0.102],[0.064,0],[0.117,-9.683],[-9.683,0],[-0.059,0.001]],"o":[[0,-9.619],[-0.064,-0.001],[-9.567,0],[0,9.683],[0.059,0],[9.602,-0.094]],"v":[[72.875,-0.027],[55.567,-17.526],[55.375,-17.527],[37.875,-0.027],[55.375,17.473],[55.551,17.473]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":45,"s":[{"i":[[0,9.625],[9.595,0.102],[0.064,0],[0.117,-9.683],[-9.683,0],[-0.059,0.001]],"o":[[0,-9.619],[-0.064,-0.001],[-9.567,0],[0,9.683],[0.059,0],[9.602,-0.094]],"v":[[72.75,-0.017],[55.442,-17.516],[0,-17.5],[-17.5,0],[0,17.5],[55.426,17.482]],"c":true}]},{"t":60,"s":[{"i":[[0,9.625],[9.595,0.102],[0.064,0],[0.117,-9.683],[-9.683,0],[-0.059,0.001]],"o":[[0,-9.619],[-0.064,-0.001],[-9.567,0],[0,9.683],[0.059,0],[9.602,-0.094]],"v":[[17.5,0],[0.192,-17.499],[0,-17.5],[-17.5,0],[0,17.5],[0.176,17.499]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-57-server').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-57-server').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1800,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215.001,176.112,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[8.478,0],[0,0],[2.827,-7.987],[0,0]],"o":[[0,0],[-2.823,-7.994],[0,0],[-8.473,0],[0,0],[0,0]],"v":[[183.952,77.268],[134.083,-63.929],[115.224,-77.268],[-115.115,-77.268],[-133.969,-63.942],[-183.952,77.268]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-57-server').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-57-server').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1800,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"stroke","np":3,"mn":"Pseudo/@@NH5Ou6jMSumHdvYySdCPdw","ix":1,"en":1,"ef":[{"ty":7,"nm":"Menu","mn":"Pseudo/@@NH5Ou6jMSumHdvYySdCPdw-0001","ix":1,"v":{"a":0,"k":3,"ix":1}}]},{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":2,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[1,1,1],"ix":1}}]},{"ty":5,"nm":"secondary","np":3,"mn":"ADBE Color Control","ix":3,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[1,1,1],"ix":1}}]}],"ip":0,"op":131,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"hover-pinch","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":70,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-pinch","dr":60}],"props":{}} \ No newline at end of file diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/CamundaSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/CamundaSyncFields.tsx new file mode 100644 index 000000000..820699951 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/CamundaSyncFields.tsx @@ -0,0 +1,80 @@ +import { useEffect } from "react"; +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2"; +import { useCamundaConnectionListClusters } from "@app/hooks/api/appConnections/camunda"; +import { TCamundaCluster } from "@app/hooks/api/appConnections/camunda/types"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { CamundaSyncScope } from "@app/hooks/api/secretSyncs/types/camunda-sync"; + +import { TSecretSyncForm } from "../schemas"; + +export const CamundaSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Camunda } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + + const { data: clusters, isPending } = useCamundaConnectionListClusters(connectionId, { + enabled: Boolean(connectionId) + }); + + useEffect(() => { + setValue("destinationConfig.scope", CamundaSyncScope.Cluster); + }, []); + + return ( + <> + { + setValue("destinationConfig.clusterUUID", ""); + }} + /> + ( + +

+ Don't see the cluster you're looking for?{" "} + +
+ + } + > + cluster.uuid === value) ?? null} + onChange={(option) => { + onChange((option as SingleValue)?.uuid ?? null); + setValue( + "destinationConfig.clusterName", + (option as SingleValue)?.name ?? "" + ); + }} + options={clusters} + placeholder="Select a cluster..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.uuid} + /> + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 973f8bf17..854c841dd 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -7,10 +7,13 @@ import { AwsParameterStoreSyncFields } from "./AwsParameterStoreSyncFields"; import { AwsSecretsManagerSyncFields } from "./AwsSecretsManagerSyncFields"; import { AzureAppConfigurationSyncFields } from "./AzureAppConfigurationSyncFields"; import { AzureKeyVaultSyncFields } from "./AzureKeyVaultSyncFields"; +import { CamundaSyncFields } from "./CamundaSyncFields"; import { DatabricksSyncFields } from "./DatabricksSyncFields"; import { GcpSyncFields } from "./GcpSyncFields"; import { GitHubSyncFields } from "./GitHubSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; +import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields"; +import { VercelSyncFields } from "./VercelSyncFields"; export const SecretSyncDestinationFields = () => { const { watch } = useFormContext(); @@ -34,6 +37,12 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Humanitec: return ; + case SecretSync.TerraformCloud: + return ; + case SecretSync.Camunda: + return ; + case SecretSync.Vercel: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TerraformCloudSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TerraformCloudSyncFields.tsx new file mode 100644 index 000000000..70394cc76 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TerraformCloudSyncFields.tsx @@ -0,0 +1,244 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2"; +import { + TERRAFORM_CLOUD_SYNC_SCOPES, + TerraformCloudSyncCategory, + TerraformCloudSyncScope, + TTerraformCloudConnectionOrganization, + TTerraformCloudConnectionVariableSet, + TTerraformCloudConnectionWorkspace, + useTerraformCloudConnectionListOrganizations +} from "@app/hooks/api/appConnections/terraform-cloud"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const TerraformCloudSyncFields = () => { + const { control, watch, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.TerraformCloud } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const currentOrg = watch("destinationConfig.org"); + const currentScope = watch("destinationConfig.scope"); + + const { data: organizations = [], isPending: isOrganizationsPending } = + useTerraformCloudConnectionListOrganizations(connectionId, { + enabled: Boolean(connectionId) + }); + + const selectedOrg = organizations?.find((org) => org.id === currentOrg); + const variableSets = selectedOrg?.variableSets || []; + const workspaces = selectedOrg?.workspaces || []; + + return ( + <> + { + setValue("destinationConfig.org", ""); + setValue("destinationConfig.variableSetId", ""); + setValue("destinationConfig.workspaceId", ""); + setValue("destinationConfig.variableSetName", ""); + setValue("destinationConfig.workspaceName", ""); + }} + /> + ( + + org.id === value) ?? null) : null} + onChange={(option) => { + onChange( + (option as SingleValue)?.id ?? null + ); + setValue("destinationConfig.variableSetId", ""); + setValue("destinationConfig.workspaceId", ""); + setValue("destinationConfig.variableSetName", ""); + setValue("destinationConfig.workspaceName", ""); + }} + options={organizations} + placeholder="Select an organization..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + ( + +
    +
  • +

    + + Environment variables configure Terraform's behavior (e.g., + credentials). + +

    +
  • +
  • +

    + + Terraform variables are used as input values in your configuration. + +

    +
  • +
+ + } + > + +
+ )} + /> + ( + +

+ Specify how Infisical should manage secrets from Terraform Cloud. The following + options are available: +

+
    + {Object.values(TERRAFORM_CLOUD_SYNC_SCOPES).map(({ name, description }) => { + return ( +
  • +

    + {name}: {description} +

    +
  • + ); + })} +
+ + } + > + +
+ )} + /> + {currentScope === TerraformCloudSyncScope.VariableSet && ( + ( + + variableSet.id === value) ?? null} + onChange={(option) => { + const selectedOption = + option as SingleValue; + onChange(selectedOption?.id ?? null); + + if (selectedOption) { + setValue("destinationConfig.variableSetName", selectedOption.name); + } else { + setValue("destinationConfig.variableSetName", ""); + } + }} + options={variableSets} + placeholder="Select a variable set..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + )} + {currentScope === TerraformCloudSyncScope.Workspace && ( + ( + + workspace.id === value) ?? null} + onChange={(option) => { + const selectedOption = option as SingleValue; + onChange(selectedOption?.id ?? null); + + if (selectedOption) { + setValue("destinationConfig.workspaceName", selectedOption.name); + } else { + setValue("destinationConfig.workspaceName", ""); + } + }} + options={workspaces} + placeholder="Select a workspace..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + )} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/VercelSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/VercelSyncFields.tsx new file mode 100644 index 000000000..5c328079d --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/VercelSyncFields.tsx @@ -0,0 +1,195 @@ +import { useMemo } from "react"; +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2"; +import { + TVercelConnectionApp, + useVercelConnectionListOrganizations +} from "@app/hooks/api/appConnections/vercel"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +const vercelEnvironments = [ + { name: "Development", slug: "development" }, + { name: "Preview", slug: "preview" }, + { name: "Production", slug: "production" } +]; + +export const VercelSyncFields = () => { + const { control, watch, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Vercel } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const currentApp = watch("destinationConfig.app"); + const currentEnv = watch("destinationConfig.env"); + + const { data: projects, isLoading: isProjectsLoading } = useVercelConnectionListOrganizations( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + const selectedProject = projects + ?.find((project) => project.apps.some((app) => app.id === currentApp)) + ?.apps.find((app) => app.id === currentApp); + + const allApps = + projects?.flatMap((project) => + project.apps.map((app) => ({ ...app, project: project.name, projectId: project.id })) + ) || []; + + const environmentOptions = useMemo(() => { + return vercelEnvironments + .map((env) => ({ + key: env.slug, + type: env.slug, + name: env.name + })) + .concat( + selectedProject?.envs?.map((env) => ({ + key: env.id, + type: env.type, + name: env.slug + })) || [] + ); + }, [currentApp]); + + const previewBranchOptions = + selectedProject?.previewBranches?.map((branch) => ({ + id: branch, + name: branch + })) || []; + + const isPreviewEnvironment = currentEnv === "preview"; + + return ( + <> + { + setValue("destinationConfig.app", ""); + setValue("destinationConfig.appName", ""); + setValue("destinationConfig.env", "production"); + setValue("destinationConfig.branch", ""); + }} + /> + + ( + +
+ Don't see the project you're looking for?{" "} + +
+ + } + > + app.id === value) ?? null} + onChange={(option) => { + const appId = (option as SingleValue)?.id ?? null; + onChange(appId); + setValue("destinationConfig.branch", ""); + setValue( + "destinationConfig.teamId", + (option as SingleValue)?.projectId || "" + ); + setValue( + "destinationConfig.appName", + (option as SingleValue)?.name || "" + ); + }} + options={allApps} + placeholder="Select a project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + groupBy="project" + /> +
+ )} + /> + + ( + + env.key === value)?.key, + type: environmentOptions.find((env) => env.key === value)?.type, + name: environmentOptions.find((env) => env.key === value)?.name + } + : null + } + onChange={(option) => { + const envKey = (option as any)?.key ?? null; + onChange(envKey); + + setValue("destinationConfig.branch", ""); + }} + options={environmentOptions} + placeholder="Select an environment..." + getOptionLabel={(option) => option.name || option.key || ""} + getOptionValue={(option) => option.key || ""} + /> + + )} + /> + + {isPreviewEnvironment && ( + ( + + branch.id === value) ?? null} + onChange={(option) => onChange((option as SingleValue<{ id: string }>)?.id || "")} + options={previewBranchOptions} + placeholder="Select a branch..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option?.id || ""} + isClearable + /> + + )} + /> + )} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index b14bce809..5e8ff01d1 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -39,6 +39,9 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.AzureAppConfiguration: case SecretSync.Databricks: case SecretSync.Humanitec: + case SecretSync.TerraformCloud: + case SecretSync.Camunda: + case SecretSync.Vercel: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/CamundaSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/CamundaSyncReviewFields.tsx new file mode 100644 index 000000000..483aa67fb --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/CamundaSyncReviewFields.tsx @@ -0,0 +1,20 @@ +import { useFormContext } from "react-hook-form"; + +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const CamundaSyncReviewFields = () => { + const { watch } = useFormContext(); + const scope = watch("destinationConfig.scope"); + const clusterName = watch("destinationConfig.clusterName"); + const clusterUUID = watch("destinationConfig.clusterUUID"); + return ( + <> + {scope} + + {clusterName} (id:{clusterUUID}) + + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 5816635f6..4e9e89427 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -17,10 +17,13 @@ import { } from "./AwsSecretsManagerSyncReviewFields"; import { AzureAppConfigurationSyncReviewFields } from "./AzureAppConfigurationSyncReviewFields"; import { AzureKeyVaultSyncReviewFields } from "./AzureKeyVaultSyncReviewFields"; +import { CamundaSyncReviewFields } from "./CamundaSyncReviewFields"; import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields"; import { GcpSyncReviewFields } from "./GcpSyncReviewFields"; import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; +import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields"; +import { VercelSyncReviewFields } from "./VercelSyncReviewFields"; export const SecretSyncReviewFields = () => { const { watch } = useFormContext(); @@ -72,6 +75,15 @@ export const SecretSyncReviewFields = () => { case SecretSync.Humanitec: DestinationFieldsComponent = ; break; + case SecretSync.TerraformCloud: + DestinationFieldsComponent = ; + break; + case SecretSync.Camunda: + DestinationFieldsComponent = ; + break; + case SecretSync.Vercel: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/TerraformCloudSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/TerraformCloudSyncReviewFields.tsx new file mode 100644 index 000000000..614e3e180 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/TerraformCloudSyncReviewFields.tsx @@ -0,0 +1,28 @@ +import { useFormContext } from "react-hook-form"; + +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { TerraformCloudSyncScope } from "@app/hooks/api/appConnections/terraform-cloud"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const TerraformCloudSyncReviewFields = () => { + const { watch } = useFormContext(); + const orgId = watch("destinationConfig.org"); + const variableSetName = watch("destinationConfig.variableSetName"); + const workspaceName = watch("destinationConfig.workspaceName"); + const scope = watch("destinationConfig.scope"); + const category = watch("destinationConfig.category"); + + return ( + <> + {orgId} + {scope === TerraformCloudSyncScope.VariableSet && ( + {variableSetName} + )} + {scope === TerraformCloudSyncScope.Workspace && ( + {workspaceName} + )} + {category} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/VercelSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/VercelSyncReviewFields.tsx new file mode 100644 index 000000000..43e83cf96 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/VercelSyncReviewFields.tsx @@ -0,0 +1,23 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { VercelEnvironmentType } from "@app/hooks/api/secretSyncs/types/vercel-sync"; + +export const VercelSyncReviewFields = () => { + const { watch } = useFormContext(); + const envId = watch("destinationConfig.env"); + const branchId = watch("destinationConfig.branch"); + const appName = watch("destinationConfig.appName"); + + return ( + <> + {appName} + {envId} + {envId === VercelEnvironmentType.Preview && branchId && ( + {branchId} + )} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/schemas/camunda-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/camunda-sync-destination-schema.ts new file mode 100644 index 000000000..d95156e91 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/camunda-sync-destination-schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const CamundaSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Camunda), + destinationConfig: z.object({ + scope: z.string().trim().min(1, "Camunda scope required"), + clusterUUID: z.string().trim().min(1, "Camunda cluster UUID required"), + clusterName: z.string().optional() + }) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index be2322304..4eb1094ae 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -7,8 +7,11 @@ import { GitHubSyncDestinationSchema } from "@app/components/secret-syncs/forms/ import { AwsParameterStoreSyncDestinationSchema } from "./aws-parameter-store-sync-destination-schema"; import { AzureAppConfigurationSyncDestinationSchema } from "./azure-app-configuration-sync-destination-schema"; import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-destination-schema"; +import { CamundaSyncDestinationSchema } from "./camunda-sync-destination-schema"; import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; +import { TerraformCloudSyncDestinationSchema } from "./terraform-cloud-destination-schema"; +import { VercelSyncDestinationSchema } from "./vercel-sync-destination-schema"; const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ AwsParameterStoreSyncDestinationSchema, @@ -18,7 +21,10 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ AzureKeyVaultSyncDestinationSchema, AzureAppConfigurationSyncDestinationSchema, DatabricksSyncDestinationSchema, - HumanitecSyncDestinationSchema + HumanitecSyncDestinationSchema, + TerraformCloudSyncDestinationSchema, + CamundaSyncDestinationSchema, + VercelSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/components/secret-syncs/forms/schemas/terraform-cloud-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/terraform-cloud-destination-schema.ts new file mode 100644 index 000000000..d818fd29a --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/terraform-cloud-destination-schema.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { + TerraformCloudSyncCategory, + TerraformCloudSyncScope +} from "@app/hooks/api/appConnections/terraform-cloud"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const TerraformCloudSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.TerraformCloud), + destinationConfig: z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal(TerraformCloudSyncScope.VariableSet), + org: z.string().trim().min(1, "Organization required"), + variableSetId: z.string().trim().min(1, "Variable Set required"), + variableSetName: z.string().trim().min(1, "Variable set name required"), + category: z.nativeEnum(TerraformCloudSyncCategory) + }), + z.object({ + scope: z.literal(TerraformCloudSyncScope.Workspace), + org: z.string().trim().min(1, "Organization required"), + workspaceId: z.string().trim().min(1, "Workspace required"), + workspaceName: z.string().trim().min(1, "Workspace name required"), + category: z.nativeEnum(TerraformCloudSyncCategory) + }) + ]) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/vercel-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/vercel-sync-destination-schema.ts new file mode 100644 index 000000000..9d3678803 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/vercel-sync-destination-schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { VercelEnvironmentType } from "@app/hooks/api/secretSyncs/types/vercel-sync"; + +export const VercelSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Vercel), + destinationConfig: z.object({ + app: z.string().trim().min(1, "Project required"), + appName: z.string().trim().min(1, "Project required"), + env: z.nativeEnum(VercelEnvironmentType).or(z.string()), + branch: z.string().trim().optional(), + teamId: z.string().trim() + }) + }) +); diff --git a/frontend/src/components/v2/Editor/Editor.tsx b/frontend/src/components/v2/Editor/Editor.tsx new file mode 100644 index 000000000..e9c49cbac --- /dev/null +++ b/frontend/src/components/v2/Editor/Editor.tsx @@ -0,0 +1,159 @@ +/* eslint-disable no-underscore-dangle */ +import { forwardRef, InputHTMLAttributes } from "react"; +import { InitialConfigType, LexicalComposer } from "@lexical/react/LexicalComposer"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; +import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; +import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; +import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; +import { ReactNode } from "@tanstack/react-router"; +import { cva, VariantProps } from "cva"; +import { EditorState, LexicalEditor } from "lexical"; +import { twMerge } from "tailwind-merge"; + +import { HighlightNode } from "./EditorHighlight"; +import { EditorPlaceholderPlugin } from "./EditorPlaceholderPlugin"; + +// Catch any errors that occur during Lexical updates and log them +// or throw them as needed. If you don't throw them, Lexical will +// try to recover gracefully without losing user data. +function onError(error: Error) { + console.error(error); +} + +const inputVariants = cva( + "input w-full py-[0.375rem] text-gray-400 placeholder:text-sm placeholder-gray-500 placeholder-opacity-50 outline-none focus:ring-2 hover:ring-bunker-400/60 duration-100", + { + variants: { + size: { + xs: ["text-xs"], + sm: ["text-sm"], + md: ["text-md"], + lg: ["text-lg"] + }, + isRounded: { + true: ["rounded-md"], + false: "" + }, + variant: { + filled: ["bg-mineshaft-900", "text-gray-400"], + outline: ["bg-transparent"], + plain: "bg-transparent outline-none" + }, + isError: { + true: "focus:ring-red/50 placeholder-red-300", + false: "focus:ring-primary-400/50 focus:ring-1" + } + }, + compoundVariants: [] + } +); + +const inputParentContainerVariants = cva("inline-flex font-inter items-center border relative", { + variants: { + isRounded: { + true: ["rounded-md"], + false: "" + }, + isError: { + true: "border-red", + false: "border-mineshaft-500" + }, + isFullWidth: { + true: "w-full", + false: "" + }, + variant: { + filled: ["bg-bunker-800", "text-gray-400"], + outline: ["bg-transparent"], + plain: "border-none" + } + } +}); + +type Props = Omit< + InputHTMLAttributes, + "size" | "onChange" | "placeholder" | "aria-placeholder" +> & + VariantProps & { + children?: ReactNode; + namespace?: string; + placeholder?: string; + isFullWidth?: boolean; + isRequired?: boolean; + leftIcon?: ReactNode; + rightIcon?: ReactNode; + isDisabled?: boolean; + isReadOnly?: boolean; + containerClassName?: string; + onChange: (editorState: EditorState, editor: LexicalEditor, tags: Set) => void; + initialValue?: string; + }; + +export const Editor = forwardRef( + ( + { + children, + namespace = "infisical-editor", + className, + containerClassName, + isRounded = true, + isFullWidth = true, + isDisabled, + isError = false, + isRequired, + leftIcon, + rightIcon, + variant = "filled", + size = "md", + isReadOnly, + placeholder, + onChange, + ...props + }, + ref + ) => { + const initialConfig: InitialConfigType = { + namespace, + onError, + nodes: [HighlightNode] + }; + + return ( +
+ {leftIcon && {leftIcon}} + + + } + ErrorBoundary={LexicalErrorBoundary} + /> + + + {children} + + {rightIcon && {rightIcon}} +
+ ); + } +); diff --git a/frontend/src/components/v2/Editor/EditorHighlight.tsx b/frontend/src/components/v2/Editor/EditorHighlight.tsx new file mode 100644 index 000000000..bb57828fb --- /dev/null +++ b/frontend/src/components/v2/Editor/EditorHighlight.tsx @@ -0,0 +1,127 @@ +/* eslint-disable no-underscore-dangle,@typescript-eslint/class-methods-use-this */ +import { useCallback, useEffect } from "react"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { useLexicalTextEntity } from "@lexical/react/useLexicalTextEntity"; +import { + $applyNodeReplacement, + EditorConfig, + LexicalNode, + SerializedTextNode, + Spread, + TextNode +} from "lexical"; + +type HighlightTheme = { contentClassName: string }; +type Trigger = { startTrigger: string; endTrigger: string }; + +export type SerializedHighlightNode = Spread< + { + __highlightTheme: HighlightTheme; + __trigger: Trigger; + }, + SerializedTextNode +>; + +export class HighlightNode extends TextNode { + __highlightTheme: HighlightTheme; + __trigger: Trigger; + + constructor( + text: string, + highlightTheme: HighlightTheme = { + contentClassName: "ph-no-capture text-yellow-200/80" + }, + trigger: Trigger = { startTrigger: "${", endTrigger: "}" }, + key?: string + ) { + super(text, key); + this.__highlightTheme = highlightTheme; + this.__trigger = trigger; + } + + static getType(): string { + return "highlight"; + } + + static clone(node: HighlightNode): HighlightNode { + return new HighlightNode(node.__text, node.__highlightTheme, node.__trigger, node.__key); + } + + static importJSON(serializedNode: SerializedHighlightNode): HighlightNode { + return $applyNodeReplacement(new HighlightNode("")).updateFromJSON(serializedNode); + } + + createDOM(config: EditorConfig): HTMLElement { + const dom = super.createDOM(config); + dom.style.cursor = "default"; + dom.className = this.__highlightTheme.contentClassName; + return dom; + } + + canInsertTextBefore(): boolean { + return false; + } + + canInsertTextAfter(): boolean { + return false; + } + + isTextEntity(): true { + return true; + } +} + +export function $createKeywordNode(keyword: string = ""): HighlightNode { + return $applyNodeReplacement(new HighlightNode(keyword)); +} + +export function $isKeywordNode(node: LexicalNode | null | undefined): boolean { + return node instanceof HighlightNode; +} + +type Props = { + contentClassName?: string; + startTrigger?: string; + endTrigger?: string; +}; + +export const EditorHighlightPlugin = ({ + endTrigger = "}", + startTrigger = "${", + contentClassName = "ph-no-capture text-yellow-200/80" +}: Props) => { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!editor.hasNodes([HighlightNode])) { + throw new Error("HighlightsPlugin: HighlightsNode not registered on editor"); + } + }, [editor]); + + const createKeywordNode = useCallback((textNode: TextNode): HighlightNode => { + return $applyNodeReplacement( + new HighlightNode( + textNode.getTextContent(), + { contentClassName }, + { startTrigger, endTrigger } + ) + ); + }, []); + + const getKeywordMatch = useCallback((text: string) => { + for (let i = 0; i < text.length; i += 1) { + if (text.slice(i, i + 2) === startTrigger) { + const closingBracketIndex = text.indexOf(endTrigger, i + 2); + if (closingBracketIndex !== -1) { + return { start: i, end: closingBracketIndex + 1 }; + } + return null; + } + } + return null; + }, []); + + useLexicalTextEntity(getKeywordMatch, HighlightNode, createKeywordNode); + + return null; +}; diff --git a/frontend/src/components/v2/Editor/EditorPlaceholderPlugin.tsx b/frontend/src/components/v2/Editor/EditorPlaceholderPlugin.tsx new file mode 100644 index 000000000..7bddf17de --- /dev/null +++ b/frontend/src/components/v2/Editor/EditorPlaceholderPlugin.tsx @@ -0,0 +1,22 @@ +import { useEffect } from "react"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { useLexicalIsTextContentEmpty } from "@lexical/react/useLexicalIsTextContentEmpty"; + +export const EditorPlaceholderPlugin = ({ placeholder }: { placeholder: string | undefined }) => { + const [editor] = useLexicalComposerContext(); + const isEmpty = useLexicalIsTextContentEmpty(editor); + + /* Set the placeholder on root. */ + useEffect(() => { + const rootElement = editor.getRootElement() as HTMLElement; + if (rootElement) { + if (isEmpty && placeholder) { + rootElement.setAttribute("placeholder", placeholder); + } else { + rootElement.removeAttribute("placeholder"); + } + } + }, [editor, isEmpty]); // eslint-disable-line + + return null; +}; diff --git a/frontend/src/components/v2/Editor/index.tsx b/frontend/src/components/v2/Editor/index.tsx new file mode 100644 index 000000000..6da88bf75 --- /dev/null +++ b/frontend/src/components/v2/Editor/index.tsx @@ -0,0 +1,2 @@ +export { Editor } from "./Editor"; +export { EditorHighlightPlugin } from "./EditorHighlight"; diff --git a/frontend/src/components/v2/index.tsx b/frontend/src/components/v2/index.tsx index 092fd6697..9dcf72e40 100644 --- a/frontend/src/components/v2/index.tsx +++ b/frontend/src/components/v2/index.tsx @@ -11,6 +11,7 @@ export * from "./DatePicker"; export * from "./DeleteActionModal"; export * from "./Drawer"; export * from "./Dropdown"; +export * from "./Editor"; export * from "./EmailServiceSetupModal"; export * from "./EmptyState"; export * from "./FilterableSelect"; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 656d9466b..27fd0273c 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -30,7 +30,9 @@ export enum ProjectPermissionCmekActions { Edit = "edit", Delete = "delete", Encrypt = "encrypt", - Decrypt = "decrypt" + Decrypt = "decrypt", + Sign = "sign", + Verify = "verify" } export enum ProjectPermissionKmipActions { @@ -75,6 +77,14 @@ export enum ProjectPermissionGroupActions { GrantPrivileges = "grant-privileges" } +export enum ProjectPermissionSshHostActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + IssueHostCert = "issue-host-cert" +} + export enum ProjectPermissionSecretRotationActions { Read = "read", ReadGeneratedCredentials = "read-generated-credentials", @@ -148,6 +158,7 @@ export enum ProjectPermissionSub { SshCertificateAuthorities = "ssh-certificate-authorities", SshCertificateTemplates = "ssh-certificate-templates", SshCertificates = "ssh-certificates", + SshHosts = "ssh-hosts", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", @@ -244,6 +255,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] + | [ProjectPermissionSshHostActions, ProjectPermissionSub.SshHosts] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs] diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index d75b426b7..53e2f5468 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -6,13 +6,16 @@ import { AwsConnectionMethod, AzureAppConfigurationConnectionMethod, AzureKeyVaultConnectionMethod, + CamundaConnectionMethod, DatabricksConnectionMethod, GcpConnectionMethod, GitHubConnectionMethod, HumanitecConnectionMethod, MsSqlConnectionMethod, PostgresConnectionMethod, - TAppConnection + TAppConnection, + TerraformCloudConnectionMethod, + VercelConnectionMethod } from "@app/hooks/api/appConnections/types"; export const APP_CONNECTION_MAP: Record = { @@ -29,8 +32,11 @@ export const APP_CONNECTION_MAP: Record { @@ -49,7 +55,11 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) return { name: "Service Account Impersonation", icon: faUser }; case DatabricksConnectionMethod.ServicePrincipal: return { name: "Service Principal", icon: faUser }; + case CamundaConnectionMethod.ClientCredentials: + return { name: "Client Credentials", icon: faKey }; case HumanitecConnectionMethod.ApiToken: + case TerraformCloudConnectionMethod.ApiToken: + case VercelConnectionMethod.ApiToken: return { name: "API Token", icon: faKey }; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: diff --git a/frontend/src/helpers/kms.ts b/frontend/src/helpers/kms.ts new file mode 100644 index 000000000..64b85dc2c --- /dev/null +++ b/frontend/src/helpers/kms.ts @@ -0,0 +1,27 @@ +import { AsymmetricKeyAlgorithm, KmsKeyUsage, SymmetricKeyAlgorithm } from "@app/hooks/api/cmeks"; + +export const kmsKeyUsageOptions: Record< + KmsKeyUsage, + { + label: string; + tooltip: string; + } +> = { + [KmsKeyUsage.ENCRYPT_DECRYPT]: { + label: "Encrypt/Decrypt", + tooltip: "Use the key only to encrypt and decrypt data." + }, + [KmsKeyUsage.SIGN_VERIFY]: { + label: "Sign/Verify", + tooltip: + "Key pairs for digital signing. Uses the private key for signing and the public key for verification." + } +}; + +export const keyUsageDefaultOption: Record< + KmsKeyUsage, + SymmetricKeyAlgorithm | AsymmetricKeyAlgorithm +> = { + [KmsKeyUsage.ENCRYPT_DECRYPT]: SymmetricKeyAlgorithm.AES_GCM_256, + [KmsKeyUsage.SIGN_VERIFY]: AsymmetricKeyAlgorithm.RSA_4096 +}; diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 1cc076cce..fd7159c02 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -23,6 +23,18 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.AzureKeyVault]: AppConnection.AzureKeyVault, [SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration, [SecretSync.Databricks]: AppConnection.Databricks, - [SecretSync.Humanitec]: AppConnection.Humanitec + [SecretSync.Humanitec]: AppConnection.Humanitec, + [SecretSync.TerraformCloud]: AppConnection.TerraformCloud, + [SecretSync.Camunda]: AppConnection.Camunda, + [SecretSync.Vercel]: AppConnection.Vercel }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index 40da0207a..e2d620fe2 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -79,6 +79,8 @@ export type TAccessApprovalRequest = { member: string; status: string; }[]; + + note?: string; }; export type TAccessApproval = { @@ -119,6 +121,7 @@ export type TProjectUserPrivilege = { export type TCreateAccessRequestDTO = { projectSlug: string; + note?: string; } & Omit; export type TGetAccessApprovalRequestsDTO = { diff --git a/frontend/src/hooks/api/appConnections/camunda/index.ts b/frontend/src/hooks/api/appConnections/camunda/index.ts new file mode 100644 index 000000000..b69c25120 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/camunda/index.ts @@ -0,0 +1 @@ +export * from "./queries"; diff --git a/frontend/src/hooks/api/appConnections/camunda/queries.tsx b/frontend/src/hooks/api/appConnections/camunda/queries.tsx new file mode 100644 index 000000000..e159096e5 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/camunda/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TCamundaCluster } from "./types"; + +const camundaConnectionKeys = { + all: [...appConnectionKeys.all, "camunda"] as const, + listClusters: (connectionId: string) => + [...camundaConnectionKeys.all, "clusters", connectionId] as const +}; + +export const useCamundaConnectionListClusters = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TCamundaCluster[], + unknown, + TCamundaCluster[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: camundaConnectionKeys.listClusters(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get<{ clusters: TCamundaCluster[] }>( + `/api/v1/app-connections/camunda/${connectionId}/clusters` + ); + + return data.clusters; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/camunda/types.ts b/frontend/src/hooks/api/appConnections/camunda/types.ts new file mode 100644 index 000000000..b24988eba --- /dev/null +++ b/frontend/src/hooks/api/appConnections/camunda/types.ts @@ -0,0 +1,4 @@ +export type TCamundaCluster = { + name: string; + uuid: string; +}; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 6891e7e2c..b0830459d 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -6,6 +6,9 @@ export enum AppConnection { AzureAppConfiguration = "azure-app-configuration", Databricks = "databricks", Humanitec = "humanitec", + TerraformCloud = "terraform-cloud", + Vercel = "vercel", Postgres = "postgres", - MsSql = "mssql" + MsSql = "mssql", + Camunda = "camunda" } diff --git a/frontend/src/hooks/api/appConnections/terraform-cloud/index.ts b/frontend/src/hooks/api/appConnections/terraform-cloud/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/terraform-cloud/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/terraform-cloud/queries.tsx b/frontend/src/hooks/api/appConnections/terraform-cloud/queries.tsx new file mode 100644 index 000000000..a5da22a0a --- /dev/null +++ b/frontend/src/hooks/api/appConnections/terraform-cloud/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TTerraformCloudOrganization } from "./types"; + +const terraformCloudConnectionKeys = { + all: [...appConnectionKeys.all, "terraform-cloud"] as const, + listOrganizations: (connectionId: string) => + [...terraformCloudConnectionKeys.all, "organizations", connectionId] as const +}; + +export const useTerraformCloudConnectionListOrganizations = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TTerraformCloudOrganization[], + unknown, + TTerraformCloudOrganization[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: terraformCloudConnectionKeys.listOrganizations(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/terraform-cloud/${connectionId}/organizations` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/terraform-cloud/types.ts b/frontend/src/hooks/api/appConnections/terraform-cloud/types.ts new file mode 100644 index 000000000..7402976f5 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/terraform-cloud/types.ts @@ -0,0 +1,56 @@ +export type TTerraformCloudOrganization = { + name: string; + id: string; + variableSets: TTerraformCloudVariableSet[]; + workspaces: TTerraformCloudWorkspace[]; +}; + +export type TTerraformCloudVariableSet = { + id: string; + name: string; +}; + +export type TTerraformCloudWorkspace = { + id: string; + name: string; +}; + +export type TTerraformCloudConnectionOrganization = { + id: string; + name: string; + variableSets: TTerraformCloudConnectionVariableSet[]; + workspaces: TTerraformCloudConnectionWorkspace[]; +}; + +export type TTerraformCloudConnectionVariableSet = { + id: string; + name: string; + description: string; + global: boolean; +}; + +export type TTerraformCloudConnectionWorkspace = { + id: string; + name: string; +}; + +export enum TerraformCloudSyncScope { + VariableSet = "variable-set", + Workspace = "workspace" +} + +export enum TerraformCloudSyncCategory { + Environment = "env", + Terraform = "terraform" +} + +export const TERRAFORM_CLOUD_SYNC_SCOPES = { + [TerraformCloudSyncScope.VariableSet]: { + name: "Variable Set", + description: "Sync secrets to a specific variable set in Terraform Cloud." + }, + [TerraformCloudSyncScope.Workspace]: { + name: "Workspace", + description: "Sync secrets to a specific workspace in Terraform Cloud." + } +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index d3d376e6e..3bd172283 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -39,6 +39,14 @@ export type THumanitecConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Humanitec; }; +export type TTerraformCloudConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.TerraformCloud; +}; + +export type TVercelConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Vercel; +}; + export type TPostgresConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Postgres; }; @@ -47,6 +55,10 @@ export type TMsSqlConnectionOption = TAppConnectionOptionBase & { app: AppConnection.MsSql; }; +export type TCamundaConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Camunda; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -55,8 +67,11 @@ export type TAppConnectionOption = | TAzureKeyVaultConnectionOption | TDatabricksConnectionOption | THumanitecConnectionOption + | TTerraformCloudConnectionOption + | TVercelConnectionOption | TPostgresConnectionOption - | TMsSqlConnectionOption; + | TMsSqlConnectionOption + | TCamundaConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -66,6 +81,9 @@ export type TAppConnectionOptionMap = { [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnectionOption; [AppConnection.Databricks]: TDatabricksConnectionOption; [AppConnection.Humanitec]: THumanitecConnectionOption; + [AppConnection.TerraformCloud]: TTerraformCloudConnectionOption; + [AppConnection.Vercel]: TVercelConnectionOption; [AppConnection.Postgres]: TPostgresConnectionOption; [AppConnection.MsSql]: TMsSqlConnectionOption; + [AppConnection.Camunda]: TCamundaConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/camunda-connection.ts b/frontend/src/hooks/api/appConnections/types/camunda-connection.ts new file mode 100644 index 000000000..06b6f5b78 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/camunda-connection.ts @@ -0,0 +1,14 @@ +import { AppConnection } from "../enums"; +import { TRootAppConnection } from "./root-connection"; + +export enum CamundaConnectionMethod { + ClientCredentials = "client-credentials" +} + +export type TCamundaConnection = TRootAppConnection & { app: AppConnection.Camunda } & { + method: CamundaConnectionMethod.ClientCredentials; + credentials: { + clientId: string; + clientSecret: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index a241e21a1..eb1b71ebc 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -3,22 +3,28 @@ import { TAppConnectionOption } from "./app-options"; import { TAwsConnection } from "./aws-connection"; import { TAzureAppConfigurationConnection } from "./azure-app-configuration-connection"; import { TAzureKeyVaultConnection } from "./azure-key-vault-connection"; +import { TCamundaConnection } from "./camunda-connection"; import { TDatabricksConnection } from "./databricks-connection"; import { TGcpConnection } from "./gcp-connection"; import { TGitHubConnection } from "./github-connection"; import { THumanitecConnection } from "./humanitec-connection"; import { TMsSqlConnection } from "./mssql-connection"; import { TPostgresConnection } from "./postgres-connection"; +import { TTerraformCloudConnection } from "./terraform-cloud-connection"; +import { TVercelConnection } from "./vercel-connection"; export * from "./aws-connection"; export * from "./azure-app-configuration-connection"; export * from "./azure-key-vault-connection"; +export * from "./camunda-connection"; export * from "./databricks-connection"; export * from "./gcp-connection"; export * from "./github-connection"; export * from "./humanitec-connection"; export * from "./mssql-connection"; export * from "./postgres-connection"; +export * from "./terraform-cloud-connection"; +export * from "./vercel-connection"; export type TAppConnection = | TAwsConnection @@ -28,8 +34,11 @@ export type TAppConnection = | TAzureAppConfigurationConnection | TDatabricksConnection | THumanitecConnection + | TTerraformCloudConnection + | TVercelConnection | TPostgresConnection - | TMsSqlConnection; + | TMsSqlConnection + | TCamundaConnection; export type TAvailableAppConnection = Pick; @@ -64,6 +73,9 @@ export type TAppConnectionMap = { [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnection; [AppConnection.Databricks]: TDatabricksConnection; [AppConnection.Humanitec]: THumanitecConnection; + [AppConnection.TerraformCloud]: TTerraformCloudConnection; + [AppConnection.Vercel]: TVercelConnection; [AppConnection.Postgres]: TPostgresConnection; [AppConnection.MsSql]: TMsSqlConnection; + [AppConnection.Camunda]: TCamundaConnection; }; diff --git a/frontend/src/hooks/api/appConnections/types/terraform-cloud-connection.ts b/frontend/src/hooks/api/appConnections/types/terraform-cloud-connection.ts new file mode 100644 index 000000000..ddc49d776 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/terraform-cloud-connection.ts @@ -0,0 +1,15 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum TerraformCloudConnectionMethod { + ApiToken = "api-token" +} + +export type TTerraformCloudConnection = TRootAppConnection & { + app: AppConnection.TerraformCloud; +} & { + method: TerraformCloudConnectionMethod.ApiToken; + credentials: { + apiToken: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/vercel-connection.ts b/frontend/src/hooks/api/appConnections/types/vercel-connection.ts new file mode 100644 index 000000000..d733639ab --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/vercel-connection.ts @@ -0,0 +1,13 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum VercelConnectionMethod { + ApiToken = "api-token" +} + +export type TVercelConnection = TRootAppConnection & { app: AppConnection.Vercel } & { + method: VercelConnectionMethod.ApiToken; + credentials: { + apiToken: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/vercel/index.ts b/frontend/src/hooks/api/appConnections/vercel/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/vercel/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/vercel/queries.tsx b/frontend/src/hooks/api/appConnections/vercel/queries.tsx new file mode 100644 index 000000000..fa66adcdb --- /dev/null +++ b/frontend/src/hooks/api/appConnections/vercel/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TVercelConnectionOrganization } from "./types"; + +const vercelConnectionKeys = { + all: [...appConnectionKeys.all, "vercel"] as const, + listOrganizations: (connectionId: string) => + [...vercelConnectionKeys.all, "organizations", connectionId] as const +}; + +export const useVercelConnectionListOrganizations = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TVercelConnectionOrganization[], + unknown, + TVercelConnectionOrganization[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: vercelConnectionKeys.listOrganizations(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/vercel/${connectionId}/projects` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/vercel/types.ts b/frontend/src/hooks/api/appConnections/vercel/types.ts new file mode 100644 index 000000000..1e25ce9ee --- /dev/null +++ b/frontend/src/hooks/api/appConnections/vercel/types.ts @@ -0,0 +1,30 @@ +export type TVercelApp = { + id: string; + name: string; + envs: { id: string; name: string }[]; +}; + +export type TVercelConnectionEnvironment = { + id: string; + slug: string; + type: string; + target?: string[]; + gitBranch?: string; + createdAt?: number; + updatedAt?: number; +}; + +export type TVercelConnectionApp = { + id: string; + name: string; + envs?: TVercelConnectionEnvironment[]; + previewBranches?: string[]; + projectId: string; +}; + +export type TVercelConnectionOrganization = { + id: string; + name: string; + slug: string; + apps: TVercelConnectionApp[]; +}; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 464a46aa8..159e840ec 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -106,6 +106,10 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.GET_CMEK]: "Get KMS key", [EventType.CMEK_ENCRYPT]: "Encrypt with KMS key", [EventType.CMEK_DECRYPT]: "Decrypt with KMS key", + [EventType.CMEK_SIGN]: "Sign with KMS key", + [EventType.CMEK_VERIFY]: "Verify with KMS key", + [EventType.CMEK_LIST_SIGNING_ALGORITHMS]: "List signing algorithms for KMS key", + [EventType.CMEK_GET_PUBLIC_KEY]: "Get public key for KMS key", [EventType.UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS]: "Update SSO group to organization role mapping", [EventType.GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS]: "List SSO group to organization role mapping", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index ab287226b..15adb0272 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -110,6 +110,10 @@ export enum EventType { GET_CMEK = "get-cmek", CMEK_ENCRYPT = "cmek-encrypt", CMEK_DECRYPT = "cmek-decrypt", + CMEK_SIGN = "cmek-sign", + CMEK_VERIFY = "cmek-verify", + CMEK_LIST_SIGNING_ALGORITHMS = "cmek-list-signing-algorithms", + CMEK_GET_PUBLIC_KEY = "cmek-get-public-key", UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "update-external-group-org-role-mapping", GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "get-external-group-org-role-mapping", GET_PROJECT_TEMPLATES = "get-project-templates", diff --git a/frontend/src/hooks/api/cmeks/mutations.tsx b/frontend/src/hooks/api/cmeks/mutations.tsx index 41e70c193..b806e2b44 100644 --- a/frontend/src/hooks/api/cmeks/mutations.tsx +++ b/frontend/src/hooks/api/cmeks/mutations.tsx @@ -8,6 +8,10 @@ import { TCmekDecryptResponse, TCmekEncrypt, TCmekEncryptResponse, + TCmekSign, + TCmekSignResponse, + TCmekVerify, + TCmekVerifyResponse, TCreateCmek, TDeleteCmek, TUpdateCmek @@ -74,6 +78,44 @@ export const useCmekEncrypt = () => { }); }; +export const useCmekSign = () => { + return useMutation({ + mutationFn: async ({ + keyId, + data, + signingAlgorithm, + isBase64Encoded + }: TCmekSign & { isBase64Encoded: boolean }) => { + const res = await apiRequest.post(`/api/v1/kms/keys/${keyId}/sign`, { + data: isBase64Encoded ? data : encodeBase64(Buffer.from(data)), + signingAlgorithm + }); + + return res.data; + } + }); +}; + +export const useCmekVerify = () => { + return useMutation({ + mutationFn: async ({ + keyId, + data, + signature, + signingAlgorithm, + isBase64Encoded + }: TCmekVerify & { isBase64Encoded: boolean }) => { + const res = await apiRequest.post(`/api/v1/kms/keys/${keyId}/verify`, { + data: isBase64Encoded ? data : encodeBase64(Buffer.from(data)), + signature, + signingAlgorithm + }); + + return res.data; + } + }); +}; + export const useCmekDecrypt = () => { return useMutation({ mutationFn: async ({ keyId, ciphertext }: TCmekDecrypt) => { diff --git a/frontend/src/hooks/api/cmeks/types.ts b/frontend/src/hooks/api/cmeks/types.ts index c557c1fc2..2f6b8788b 100644 --- a/frontend/src/hooks/api/cmeks/types.ts +++ b/frontend/src/hooks/api/cmeks/types.ts @@ -1,10 +1,18 @@ +import { z } from "zod"; + import { OrderByDirection } from "@app/hooks/api/generic/types"; +export enum KmsKeyUsage { + ENCRYPT_DECRYPT = "encrypt-decrypt", + SIGN_VERIFY = "sign-verify" +} + export type TCmek = { id: string; + keyUsage: KmsKeyUsage; name: string; description?: string; - encryptionAlgorithm: EncryptionAlgorithm; + encryptionAlgorithm: AsymmetricKeyAlgorithm | SymmetricKeyAlgorithm; projectId: string; isDisabled: boolean; isReserved: boolean; @@ -17,7 +25,8 @@ export type TCmek = { type ProjectRef = { projectId: string }; type KeyRef = { keyId: string }; -export type TCreateCmek = Pick & ProjectRef; +export type TCreateCmek = Pick & + ProjectRef; export type TUpdateCmek = KeyRef & Partial> & ProjectRef; @@ -26,6 +35,13 @@ export type TDeleteCmek = KeyRef & ProjectRef; export type TCmekEncrypt = KeyRef & { plaintext: string; isBase64Encoded?: boolean }; export type TCmekDecrypt = KeyRef & { ciphertext: string }; +export type TCmekSign = KeyRef & { data: string; signingAlgorithm: SigningAlgorithm }; +export type TCmekVerify = KeyRef & { + data: string; + signature: string; + signingAlgorithm: SigningAlgorithm; +}; + export type TProjectCmeksList = { keys: TCmek[]; totalCount: number; @@ -44,6 +60,18 @@ export type TCmekEncryptResponse = { ciphertext: string; }; +export type TCmekSignResponse = { + signature: string; + keyId: string; + signingAlgorithm: SigningAlgorithm; +}; + +export type TCmekVerifyResponse = { + signatureValid: boolean; + keyId: string; + signingAlgorithm: SigningAlgorithm; +}; + export type TCmekDecryptResponse = { plaintext: string; }; @@ -52,7 +80,35 @@ export enum CmekOrderBy { Name = "name" } -export enum EncryptionAlgorithm { +export enum AsymmetricKeyAlgorithm { + RSA_4096 = "RSA_4096", + ECC_NIST_P256 = "ECC_NIST_P256" +} + +// Supported symmetric encrypt/decrypt algorithms +export enum SymmetricKeyAlgorithm { AES_GCM_256 = "aes-256-gcm", AES_GCM_128 = "aes-128-gcm" } + +export const AllowedEncryptionKeyAlgorithms = z.enum([ + ...Object.values(SymmetricKeyAlgorithm), + ...Object.values(AsymmetricKeyAlgorithm) +] as [string, ...string[]]).options; + +export enum SigningAlgorithm { + // RSA PSS algorithms + RSASSA_PSS_SHA_256 = "RSASSA_PSS_SHA_256", + RSASSA_PSS_SHA_384 = "RSASSA_PSS_SHA_384", + RSASSA_PSS_SHA_512 = "RSASSA_PSS_SHA_512", + + // RSA PKCS#1 v1.5 algorithms + RSASSA_PKCS1_V1_5_SHA_256 = "RSASSA_PKCS1_V1_5_SHA_256", + RSASSA_PKCS1_V1_5_SHA_384 = "RSASSA_PKCS1_V1_5_SHA_384", + RSASSA_PKCS1_V1_5_SHA_512 = "RSASSA_PKCS1_V1_5_SHA_512", + + // ECDSA algorithms + ECDSA_SHA_256 = "ECDSA_SHA_256", + ECDSA_SHA_384 = "ECDSA_SHA_384", + ECDSA_SHA_512 = "ECDSA_SHA_512" +} diff --git a/frontend/src/hooks/api/identities/index.tsx b/frontend/src/hooks/api/identities/index.tsx index 261556752..f3b9fa012 100644 --- a/frontend/src/hooks/api/identities/index.tsx +++ b/frontend/src/hooks/api/identities/index.tsx @@ -46,5 +46,6 @@ export { useGetIdentityTokenAuth, useGetIdentityTokensTokenAuth, useGetIdentityUniversalAuth, - useGetIdentityUniversalAuthClientSecrets + useGetIdentityUniversalAuthClientSecrets, + useSearchIdentities } from "./queries"; diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index 005882359..6b8a1d1cc 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -15,11 +15,13 @@ import { IdentityMembershipOrg, IdentityOidcAuth, IdentityTokenAuth, - IdentityUniversalAuth + IdentityUniversalAuth, + TSearchIdentitiesDTO } from "./types"; export const identitiesKeys = { getIdentityById: (identityId: string) => [{ identityId }, "identity"] as const, + searchIdentities: (dto: TSearchIdentitiesDTO) => ["identity", "search", dto] as const, getIdentityUniversalAuth: (identityId: string) => [{ identityId }, "identity-universal-auth"] as const, getIdentityUniversalAuthClientSecrets: (identityId: string) => @@ -53,6 +55,26 @@ export const useGetIdentityById = (identityId: string) => { }); }; +export const useSearchIdentities = (dto: TSearchIdentitiesDTO) => { + const { limit, search, offset, orderBy, orderDirection } = dto; + return useQuery({ + queryKey: identitiesKeys.searchIdentities(dto), + queryFn: async () => { + const { data } = await apiRequest.post<{ + identities: IdentityMembershipOrg[]; + totalCount: number; + }>("/api/v1/identities/search", { + limit, + offset, + orderBy, + orderDirection, + search + }); + return data; + } + }); +}; + export const useGetIdentityProjectMemberships = (identityId: string) => { return useQuery({ enabled: Boolean(identityId), diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 35a9870bc..ca06219aa 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -1,3 +1,5 @@ +import { OrderByDirection } from "../generic/types"; +import { OrgIdentityOrderBy } from "../organization/types"; import { TOrgRole } from "../roles/types"; import { ProjectUserMembershipTemporaryMode, Workspace } from "../workspace/types"; import { IdentityAuthMethod, IdentityJwtConfigurationType } from "./enums"; @@ -540,3 +542,14 @@ export type TProjectIdentitiesList = { identityMemberships: IdentityMembership[]; totalCount: number; }; + +export type TSearchIdentitiesDTO = { + limit?: number; + offset?: number; + orderBy?: OrgIdentityOrderBy; + orderDirection?: OrderByDirection; + search: { + name?: { $contains: string }; + role?: { $in: string[] }; + }; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 8311dbf2d..52d6dceb2 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -41,6 +41,7 @@ export * from "./serverDetails"; export * from "./serviceTokens"; export * from "./sshCa"; export * from "./sshCertificateTemplates"; +export * from "./sshHost"; export * from "./ssoConfig"; export * from "./subscriptions"; export * from "./tags"; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 08accba16..449ddf7e0 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -6,7 +6,10 @@ export enum SecretSync { AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", Databricks = "databricks", - Humanitec = "humanitec" + Humanitec = "humanitec", + TerraformCloud = "terraform-cloud", + Camunda = "camunda", + Vercel = "vercel" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/camunda-sync.ts b/frontend/src/hooks/api/secretSyncs/types/camunda-sync.ts new file mode 100644 index 000000000..7332a0a4a --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/camunda-sync.ts @@ -0,0 +1,21 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export enum CamundaSyncScope { + Cluster = "cluster" +} + +export type TCamundaSync = TRootSecretSync & { + destination: SecretSync.Camunda; + destinationConfig: { + scope: string; + clusterUUID: string; + clusterName?: string; + }; + connection: { + app: AppConnection.Camunda; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index a90a8a3ef..c6e5f2762 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -7,8 +7,11 @@ import { DiscriminativePick } from "@app/types"; import { TAwsSecretsManagerSync } from "./aws-secrets-manager-sync"; import { TAzureAppConfigurationSync } from "./azure-app-configuration-sync"; import { TAzureKeyVaultSync } from "./azure-key-vault-sync"; +import { TCamundaSync } from "./camunda-sync"; import { TGcpSync } from "./gcp-sync"; import { THumanitecSync } from "./humanitec-sync"; +import { TTerraformCloudSync } from "./terraform-cloud-sync"; +import { TVercelSync } from "./vercel-sync"; export type TSecretSyncOption = { name: string; @@ -24,7 +27,10 @@ export type TSecretSync = | TAzureKeyVaultSync | TAzureAppConfigurationSync | TDatabricksSync - | THumanitecSync; + | THumanitecSync + | TTerraformCloudSync + | TCamundaSync + | TVercelSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/terraform-cloud-sync.ts b/frontend/src/hooks/api/secretSyncs/types/terraform-cloud-sync.ts new file mode 100644 index 000000000..a8ab23aeb --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/terraform-cloud-sync.ts @@ -0,0 +1,34 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +import { TerraformCloudSyncCategory } from "../../appConnections/terraform-cloud"; + +export type TTerraformCloudSync = TRootSecretSync & { + destination: SecretSync.TerraformCloud; + destinationConfig: + | { + scope: TerraformCloudSyncScope.VariableSet; + org: string; + category: TerraformCloudSyncCategory; + variableSetId: string; + variableSetName: string; + } + | { + scope: TerraformCloudSyncScope.Workspace; + org: string; + category: TerraformCloudSyncCategory; + workspaceId: string; + workspaceName: string; + }; + connection: { + app: AppConnection.TerraformCloud; + name: string; + id: string; + }; +}; + +export enum TerraformCloudSyncScope { + VariableSet = "variable-set", + Workspace = "workspace" +} diff --git a/frontend/src/hooks/api/secretSyncs/types/vercel-sync.ts b/frontend/src/hooks/api/secretSyncs/types/vercel-sync.ts new file mode 100644 index 000000000..ffae61e23 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/vercel-sync.ts @@ -0,0 +1,27 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export const VercelEnvironmentType = { + Development: "development", + Preview: "preview", + Production: "production" +} as const; + +export type VercelEnvironment = (typeof VercelEnvironmentType)[keyof typeof VercelEnvironmentType]; + +export type TVercelSync = TRootSecretSync & { + destination: SecretSync.Vercel; + destinationConfig: { + app: string; + env: VercelEnvironment | string; + branch?: string; + appName?: string; + teamId: string; + }; + connection: { + app: AppConnection.Vercel; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/sshCa/constants.tsx b/frontend/src/hooks/api/sshCa/constants.tsx index 2742a7bfa..05380a239 100644 --- a/frontend/src/hooks/api/sshCa/constants.tsx +++ b/frontend/src/hooks/api/sshCa/constants.tsx @@ -12,3 +12,47 @@ export const sshCertTypeToNameMap: { [K in SshCertType]: string } = { [SshCertType.USER]: "User", [SshCertType.HOST]: "Host" }; + +export enum SshCaKeySource { + INTERNAL = "internal", + EXTERNAL = "external" +} + +export enum SshCertKeyAlgorithm { + RSA_2048 = "RSA_2048", + RSA_4096 = "RSA_4096", + ECDSA_P256 = "EC_prime256v1", + ECDSA_P384 = "EC_secp384r1", + ED25519 = "ED25519" +} + +export const sshCertKeyAlgorithmToNameMap: { [K in SshCertKeyAlgorithm]: string } = { + [SshCertKeyAlgorithm.RSA_2048]: "RSA 2048", + [SshCertKeyAlgorithm.RSA_4096]: "RSA 4096", + [SshCertKeyAlgorithm.ECDSA_P256]: "ECDSA P256", + [SshCertKeyAlgorithm.ECDSA_P384]: "ECDSA P384", + [SshCertKeyAlgorithm.ED25519]: "ED25519" +}; + +export const sshCertKeyAlgorithms = [ + { + label: sshCertKeyAlgorithmToNameMap[SshCertKeyAlgorithm.RSA_2048], + value: SshCertKeyAlgorithm.RSA_2048 + }, + { + label: sshCertKeyAlgorithmToNameMap[SshCertKeyAlgorithm.RSA_4096], + value: SshCertKeyAlgorithm.RSA_4096 + }, + { + label: sshCertKeyAlgorithmToNameMap[SshCertKeyAlgorithm.ECDSA_P256], + value: SshCertKeyAlgorithm.ECDSA_P256 + }, + { + label: sshCertKeyAlgorithmToNameMap[SshCertKeyAlgorithm.ECDSA_P384], + value: SshCertKeyAlgorithm.ECDSA_P384 + }, + { + label: sshCertKeyAlgorithmToNameMap[SshCertKeyAlgorithm.ED25519], + value: SshCertKeyAlgorithm.ED25519 + } +]; diff --git a/frontend/src/hooks/api/sshCa/types.ts b/frontend/src/hooks/api/sshCa/types.ts index 6e5f02c4d..f14533290 100644 --- a/frontend/src/hooks/api/sshCa/types.ts +++ b/frontend/src/hooks/api/sshCa/types.ts @@ -1,5 +1,4 @@ -import { CertKeyAlgorithm } from "../certificates/enums"; -import { SshCaStatus, SshCertType } from "./constants"; +import { SshCaKeySource, SshCaStatus, SshCertKeyAlgorithm, SshCertType } from "./constants"; export type TSshCertificate = { id: string; @@ -18,17 +17,28 @@ export type TSshCertificateAuthority = { projectId: string; status: SshCaStatus; friendlyName: string; - keyAlgorithm: CertKeyAlgorithm; + keyAlgorithm: SshCertKeyAlgorithm; + keySource: SshCaKeySource; createdAt: string; updatedAt: string; publicKey: string; }; -export type TCreateSshCaDTO = { - projectId: string; - friendlyName?: string; - keyAlgorithm: CertKeyAlgorithm; -}; +export type TCreateSshCaDTO = + | { + projectId: string; + friendlyName?: string; + keySource: SshCaKeySource.INTERNAL; + keyAlgorithm: SshCertKeyAlgorithm; + } + | { + projectId: string; + friendlyName?: string; + keySource: SshCaKeySource.EXTERNAL; + keyAlgorithm: SshCertKeyAlgorithm; + publicKey: string; + privateKey: string; + }; export type TUpdateSshCaDTO = { caId: string; @@ -58,7 +68,7 @@ export type TSignSshKeyResponse = { export type TIssueSshCredsDTO = { projectId: string; certificateTemplateId: string; - keyAlgorithm: CertKeyAlgorithm; + keyAlgorithm: SshCertKeyAlgorithm; certType: SshCertType; principals: string[]; ttl?: string; @@ -70,5 +80,5 @@ export type TIssueSshCredsResponse = { signedKey: string; privateKey: string; publicKey: string; - keyAlgorithm: CertKeyAlgorithm; + keyAlgorithm: SshCertKeyAlgorithm; }; diff --git a/frontend/src/hooks/api/sshHost/index.tsx b/frontend/src/hooks/api/sshHost/index.tsx new file mode 100644 index 000000000..a4e4da4e1 --- /dev/null +++ b/frontend/src/hooks/api/sshHost/index.tsx @@ -0,0 +1,2 @@ +export { useCreateSshHost, useDeleteSshHost, useUpdateSshHost } from "./mutations"; +export { fetchSshHostUserCaPublicKey, useGetSshHostById } from "./queries"; diff --git a/frontend/src/hooks/api/sshHost/mutations.tsx b/frontend/src/hooks/api/sshHost/mutations.tsx new file mode 100644 index 000000000..f6b831f3e --- /dev/null +++ b/frontend/src/hooks/api/sshHost/mutations.tsx @@ -0,0 +1,45 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/query-keys"; +import { TCreateSshHostDTO, TDeleteSshHostDTO, TSshHost, TUpdateSshHostDTO } from "./types"; + +export const useCreateSshHost = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data: host } = await apiRequest.post("/api/v1/ssh/hosts", body); + return host; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ queryKey: workspaceKeys.getWorkspaceSshHosts(projectId) }); + } + }); +}; + +export const useUpdateSshHost = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ sshHostId, ...body }) => { + const { data: host } = await apiRequest.patch(`/api/v1/ssh/hosts/${sshHostId}`, body); + return host; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ queryKey: workspaceKeys.getWorkspaceSshHosts(projectId) }); + } + }); +}; + +export const useDeleteSshHost = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ sshHostId }) => { + const { data: host } = await apiRequest.delete(`/api/v1/ssh/hosts/${sshHostId}`); + return host; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ queryKey: workspaceKeys.getWorkspaceSshHosts(projectId) }); + } + }); +}; diff --git a/frontend/src/hooks/api/sshHost/queries.tsx b/frontend/src/hooks/api/sshHost/queries.tsx new file mode 100644 index 000000000..33974e0de --- /dev/null +++ b/frontend/src/hooks/api/sshHost/queries.tsx @@ -0,0 +1,28 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TSshHost } from "./types"; + +export const sshHostKeys = { + getSshHostById: (sshHostId: string) => [{ sshHostId }, "ssh-host"], + getSshHostUserCaPublicKey: (sshHostId: string) => [{ sshHostId }, "ssh-host-user-ca-public-key"] +}; + +export const useGetSshHostById = (sshHostId: string) => { + return useQuery({ + queryKey: sshHostKeys.getSshHostById(sshHostId), + queryFn: async () => { + const { data: sshHost } = await apiRequest.get(`/api/v1/ssh/hosts/${sshHostId}`); + return sshHost; + }, + enabled: Boolean(sshHostId) + }); +}; + +export const fetchSshHostUserCaPublicKey = async (sshHostId: string): Promise => { + const { data } = await apiRequest.get( + `/api/v1/ssh/hosts/${sshHostId}/user-ca-public-key` + ); + return data; +}; diff --git a/frontend/src/hooks/api/sshHost/types.ts b/frontend/src/hooks/api/sshHost/types.ts new file mode 100644 index 000000000..4bb61008c --- /dev/null +++ b/frontend/src/hooks/api/sshHost/types.ts @@ -0,0 +1,43 @@ +export type TSshHost = { + id: string; + projectId: string; + hostname: string; + userCertTtl: string; + hostCertTtl: string; + loginMappings: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; +}; + +export type TCreateSshHostDTO = { + projectId: string; + hostname: string; + userCertTtl?: string; + hostCertTtl?: string; + loginMappings: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; +}; + +export type TUpdateSshHostDTO = { + sshHostId: string; + hostname?: string; + userCertTtl?: string; + hostCertTtl?: string; + loginMappings?: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; +}; + +export type TDeleteSshHostDTO = { + sshHostId: string; +}; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index 3f5209aca..c0f5f027d 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -36,6 +36,7 @@ export { useListWorkspaceSshCas, useListWorkspaceSshCertificates, useListWorkspaceSshCertificateTemplates, + useListWorkspaceSshHosts, useNameWorkspaceSecrets, useSearchProjects, useToggleAutoCapitalization, diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index ae832520e..7d94972ea 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -17,6 +17,7 @@ import { TPkiCollection } from "../pkiCollections/types"; import { EncryptedSecret } from "../secrets/types"; import { TSshCertificate, TSshCertificateAuthority } from "../sshCa/types"; import { TSshCertificateTemplate } from "../sshCertificateTemplates/types"; +import { TSshHost } from "../sshHost/types"; import { userKeys } from "../users/query-keys"; import { TWorkspaceUser } from "../users/types"; import { ProjectSlackConfig } from "../workflowIntegrations/types"; @@ -827,6 +828,19 @@ export const useListWorkspaceSshCas = (projectId: string) => { }); }; +export const useListWorkspaceSshHosts = (projectId: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceSshHosts(projectId), + queryFn: async () => { + const { + data: { hosts } + } = await apiRequest.get<{ hosts: TSshHost[] }>(`/api/v2/workspace/${projectId}/ssh-hosts`); + return hosts; + }, + enabled: Boolean(projectId) + }); +}; + export const useListWorkspaceSshCertificateTemplates = (projectId: string) => { return useQuery({ queryKey: workspaceKeys.getWorkspaceSshCertificateTemplates(projectId), diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index 7ef482a20..539ed2ac7 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -58,6 +58,7 @@ export const workspaceKeys = { getWorkspaceSshCas: (projectId: string) => [{ projectId }, "workspace-ssh-cas"] as const, allWorkspaceSshCertificates: (projectId: string) => [{ projectId }, "workspace-ssh-certificates"] as const, + getWorkspaceSshHosts: (projectId: string) => [{ projectId }, "workspace-ssh-hosts"] as const, specificWorkspaceSshCertificates: ({ offset, limit, diff --git a/frontend/src/index.css b/frontend/src/index.css index 3c9610c04..1c5b0c465 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -191,3 +191,10 @@ html { #nprogress .bar { @apply bg-primary-400; } + +[contentEditable="true"]:before { + content: attr(placeholder); + position: absolute; + top: 0.5rem; + @apply text-sm text-gray-500 opacity-50; +} diff --git a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx index fda82af91..8b0d88ad5 100644 --- a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx +++ b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx @@ -134,18 +134,48 @@ export const ProjectLayout = () => { )} {isSSH && ( - - {({ isActive }) => ( - - Overview - - )} - + <> + + {({ isActive }) => ( + + Hosts + + )} + + {/* + {({ isActive }) => ( + + Certificates + + )} + */} + {/* + {({ isActive }) => ( + + Certificate Authorities + + )} + */} + )} {isSecretManager && ( { + if (typeof str !== "string") { + throw new TypeError("Expected a string"); + } + + if (str === "") return true; + + const regex = base64WithPadding; + + return regex.test(str); +}; diff --git a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx index 3e6ca67f6..a9242c252 100644 --- a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx @@ -33,14 +33,12 @@ import { IdentityPanel } from "@app/pages/admin/OverviewPage/components/Identity import { AuthPanel } from "./components/AuthPanel"; import { EncryptionPanel } from "./components/EncryptionPanel"; import { IntegrationPanel } from "./components/IntegrationPanel"; -import { RateLimitPanel } from "./components/RateLimitPanel"; import { UserPanel } from "./components/UserPanel"; enum TabSections { Settings = "settings", Encryption = "encryption", Auth = "auth", - RateLimit = "rate-limit", Integrations = "integrations", Users = "users", Identities = "identities", @@ -163,7 +161,6 @@ export const OverviewPage = () => { General Encryption Authentication - Rate Limit Integrations User Identities Machine Identities @@ -262,7 +259,6 @@ export const OverviewPage = () => { { - console.log("clearing"); onChange(""); }} > @@ -403,9 +399,6 @@ export const OverviewPage = () => { - - - diff --git a/frontend/src/pages/admin/OverviewPage/components/RateLimitPanel.tsx b/frontend/src/pages/admin/OverviewPage/components/RateLimitPanel.tsx deleted file mode 100644 index 74264132e..000000000 --- a/frontend/src/pages/admin/OverviewPage/components/RateLimitPanel.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { Controller, useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; - -import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; -import { createNotification } from "@app/components/notifications"; -import { Button, ContentLoader, FormControl, Input } from "@app/components/v2"; -import { useSubscription } from "@app/context"; -import { usePopUp } from "@app/hooks"; -import { useGetRateLimit, useUpdateRateLimit } from "@app/hooks/api"; - -const formSchema = z.object({ - readRateLimit: z.number(), - writeRateLimit: z.number(), - secretsRateLimit: z.number(), - authRateLimit: z.number(), - inviteUserRateLimit: z.number(), - mfaRateLimit: z.number(), - publicEndpointLimit: z.number() -}); - -type TRateLimitForm = z.infer; - -export const RateLimitPanel = () => { - const { data: rateLimit, isPending } = useGetRateLimit(); - const { subscription } = useSubscription(); - const { mutateAsync: updateRateLimit } = useUpdateRateLimit(); - const { handlePopUpToggle, handlePopUpOpen, popUp } = usePopUp(["upgradePlan"] as const); - - const { - control, - handleSubmit, - formState: { isSubmitting, isDirty } - } = useForm({ - resolver: zodResolver(formSchema), - values: { - // eslint-disable-next-line - readRateLimit: rateLimit?.readRateLimit ?? 600, - writeRateLimit: rateLimit?.writeRateLimit ?? 200, - secretsRateLimit: rateLimit?.secretsRateLimit ?? 60, - authRateLimit: rateLimit?.authRateLimit ?? 60, - inviteUserRateLimit: rateLimit?.inviteUserRateLimit ?? 30, - mfaRateLimit: rateLimit?.mfaRateLimit ?? 20, - publicEndpointLimit: rateLimit?.publicEndpointLimit ?? 30 - } - }); - - const onRateLimitFormSubmit = async (formData: TRateLimitForm) => { - try { - if (subscription && !subscription.customRateLimits) { - handlePopUpOpen("upgradePlan"); - return; - } - - const { - readRateLimit, - writeRateLimit, - secretsRateLimit, - authRateLimit, - inviteUserRateLimit, - mfaRateLimit, - publicEndpointLimit - } = formData; - - await updateRateLimit({ - readRateLimit, - writeRateLimit, - secretsRateLimit, - authRateLimit, - inviteUserRateLimit, - mfaRateLimit, - publicEndpointLimit - }); - createNotification({ - text: "Rate limits have been successfully updated. Please allow at least 10 minutes for the changes to take effect.", - type: "success" - }); - } catch (e) { - console.error(e); - createNotification({ - type: "error", - text: "Failed to update rate limiting setting." - }); - } - }; - - return isPending ? ( - - ) : ( -
-
-
Configure rate limits
- ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> - ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> - ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> - ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> - ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> - ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> - ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> -
- - handlePopUpToggle("upgradePlan", isOpen)} - text="You can configure custom rate limits if you switch to Infisical's Enterprise plan." - /> - - ); -}; diff --git a/frontend/src/pages/kms/OverviewPage/components/CmekModal.tsx b/frontend/src/pages/kms/OverviewPage/components/CmekModal.tsx index 7598d8e80..c43b6c8d0 100644 --- a/frontend/src/pages/kms/OverviewPage/components/CmekModal.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/CmekModal.tsx @@ -15,13 +15,23 @@ import { TextArea } from "@app/components/v2"; import { useWorkspace } from "@app/context"; -import { EncryptionAlgorithm, TCmek, useCreateCmek, useUpdateCmek } from "@app/hooks/api/cmeks"; +import { keyUsageDefaultOption, kmsKeyUsageOptions } from "@app/helpers/kms"; +import { + AllowedEncryptionKeyAlgorithms, + AsymmetricKeyAlgorithm, + KmsKeyUsage, + SymmetricKeyAlgorithm, + TCmek, + useCreateCmek, + useUpdateCmek +} from "@app/hooks/api/cmeks"; import { slugSchema } from "@app/lib/schemas"; const formSchema = z.object({ name: slugSchema({ min: 1, max: 32, field: "Name" }), description: z.string().max(500).optional(), - encryptionAlgorithm: z.nativeEnum(EncryptionAlgorithm) + encryptionAlgorithm: z.enum(AllowedEncryptionKeyAlgorithms), + keyUsage: z.nativeEnum(KmsKeyUsage) }); export type FormData = z.infer; @@ -47,24 +57,33 @@ const CmekForm = ({ onComplete, cmek }: FormProps) => { control, handleSubmit, register, + setValue, + watch, formState: { isSubmitting, errors } } = useForm({ resolver: zodResolver(formSchema), defaultValues: { name: cmek?.name, description: cmek?.description, - encryptionAlgorithm: EncryptionAlgorithm.AES_GCM_256 + encryptionAlgorithm: SymmetricKeyAlgorithm.AES_GCM_256, + keyUsage: KmsKeyUsage.ENCRYPT_DECRYPT } }); - const handleCreateCmek = async ({ encryptionAlgorithm, name, description }: FormData) => { + const handleCreateCmek = async ({ + encryptionAlgorithm, + name, + description, + keyUsage + }: FormData) => { const mutation = isUpdate ? updateCmek.mutateAsync({ keyId: cmek.id, projectId, name, description }) : createCmek.mutateAsync({ projectId, - encryptionAlgorithm, name, - description + description, + keyUsage, + encryptionAlgorithm: encryptionAlgorithm as AsymmetricKeyAlgorithm | SymmetricKeyAlgorithm }); try { @@ -83,6 +102,8 @@ const CmekForm = ({ onComplete, cmek }: FormProps) => { } }; + const selectedKeyUsage = watch("keyUsage"); + return (
{ > - {!isUpdate && ( - ( - - - - )} - /> - )} +
+ {!isUpdate && ( + <> + ( + + {Object.entries(KmsKeyUsage).map(([key, value]) => ( +
+

{kmsKeyUsageOptions[value].label}

+

{kmsKeyUsageOptions[value].tooltip}

+
+ ))} +
+ } + label="Key Usage" + errorText={error?.message} + isError={Boolean(error)} + > + + + )} + /> + ( + + + + )} + /> + + )} + ; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + cmek: TCmek; +}; + +type FormProps = Pick; + +const SignForm = ({ cmek }: FormProps) => { + const cmekSign = useCmekSign(); + + const { + handleSubmit, + register, + control, + formState: { isSubmitting, errors } + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + signingAlgorithm: cmek?.encryptionAlgorithm?.startsWith("RSA") + ? SigningAlgorithm.RSASSA_PSS_SHA_512 + : SigningAlgorithm.ECDSA_SHA_256, + isBase64Encoded: false + } + }); + + const [copySignature, isCopyingSignature, setCopySignature] = useTimedReset({ + initialState: "Copy to Clipboard" + }); + + const handleSignData = async (formData: FormData) => { + try { + await cmekSign.mutateAsync({ ...formData, keyId: cmek.id }); + createNotification({ + text: "Successfully signed data", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to sign data", + type: "error" + }); + } + }; + + const signature = cmekSign.data?.signature; + + const handleCopyToClipboard = () => { + navigator.clipboard.writeText(signature ?? ""); + + setCopySignature("Copied to Clipboard"); + }; + + const allowedSigningAlgorithms = Object.values(SigningAlgorithm).filter((a) => + cmek?.encryptionAlgorithm?.startsWith("RSA") + ? a.toLowerCase().startsWith("rsa") + : a.toLowerCase().startsWith("ecdsa") + ); + + return ( + + {signature ? ( + +