diff --git a/.env.example b/.env.example index b54f09921..3f3664f2e 100644 --- a/.env.example +++ b/.env.example @@ -88,3 +88,20 @@ PLAIN_WISH_LABEL_IDS= SSL_CLIENT_CERTIFICATE_HEADER_KEY= ENABLE_MSSQL_SECRET_ROTATION_ENCRYPT=true + +# App Connections + +# aws assume-role +INF_APP_CONNECTION_AWS_ACCESS_KEY_ID= +INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY= + +# github oauth +INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID= +INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET= + +#github app +INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID= +INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET= +INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY= +INF_APP_CONNECTION_GITHUB_APP_SLUG= +INF_APP_CONNECTION_GITHUB_APP_ID= \ No newline at end of file diff --git a/Dockerfile.fips.standalone-infisical b/Dockerfile.fips.standalone-infisical index 34cd3eed8..75e95eae8 100644 --- a/Dockerfile.fips.standalone-infisical +++ b/Dockerfile.fips.standalone-infisical @@ -137,6 +137,7 @@ RUN apt-get update && apt-get install -y \ freetds-dev \ freetds-bin \ tdsodbc \ + openssh \ && rm -rf /var/lib/apt/lists/* # Configure ODBC in production diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index cd5477083..98370b2a8 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -139,7 +139,8 @@ RUN apk --update add \ freetds-dev \ bash \ curl \ - git + git \ + openssh # Configure ODBC in production RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/libtdsodbc.so\nSetup = /usr/lib/libtdsodbc.so\nFileUsage = 1\n" > /etc/odbcinst.ini diff --git a/backend/Dockerfile b/backend/Dockerfile index 0bb358ee0..5db874e75 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,7 +7,8 @@ WORKDIR /app RUN apk --update add \ python3 \ make \ - g++ + g++ \ + openssh # install dependencies for TDS driver (required for SAP ASE dynamic secrets) RUN apk add --no-cache \ diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index 3eda2ad03..0d1c16574 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -17,7 +17,8 @@ RUN apk --update add \ openssl-dev \ python3 \ make \ - g++ + g++ \ + openssh # install dependencies for TDS driver (required for SAP ASE dynamic secrets) RUN apk add --no-cache \ diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 8ff12069a..02159fa9c 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -31,9 +31,12 @@ import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-ap import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service"; import { TSecretScanningServiceFactory } from "@app/ee/services/secret-scanning/secret-scanning-service"; 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 { 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"; +import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TAuthLoginFactory } from "@app/services/auth/auth-login-service"; import { TAuthPasswordFactory } from "@app/services/auth/auth-password-service"; import { TAuthSignupFactory } from "@app/services/auth/auth-signup-service"; @@ -177,6 +180,8 @@ declare module "fastify" { auditLogStream: TAuditLogStreamServiceFactory; certificate: TCertificateServiceFactory; certificateTemplate: TCertificateTemplateServiceFactory; + sshCertificateAuthority: TSshCertificateAuthorityServiceFactory; + sshCertificateTemplate: TSshCertificateTemplateServiceFactory; certificateAuthority: TCertificateAuthorityServiceFactory; certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; certificateEst: TCertificateEstServiceFactory; @@ -204,6 +209,7 @@ declare module "fastify" { externalGroupOrgRoleMapping: TExternalGroupOrgRoleMappingServiceFactory; projectTemplate: TProjectTemplateServiceFactory; totp: TTotpServiceFactory; + appConnection: TAppConnectionServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 31258dc30..aaa2014b8 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -320,6 +320,21 @@ import { TSlackIntegrations, TSlackIntegrationsInsert, TSlackIntegrationsUpdate, + TSshCertificateAuthorities, + TSshCertificateAuthoritiesInsert, + TSshCertificateAuthoritiesUpdate, + TSshCertificateAuthoritySecrets, + TSshCertificateAuthoritySecretsInsert, + TSshCertificateAuthoritySecretsUpdate, + TSshCertificateBodies, + TSshCertificateBodiesInsert, + TSshCertificateBodiesUpdate, + TSshCertificates, + TSshCertificatesInsert, + TSshCertificatesUpdate, + TSshCertificateTemplates, + TSshCertificateTemplatesInsert, + TSshCertificateTemplatesUpdate, TSuperAdmin, TSuperAdminInsert, TSuperAdminUpdate, @@ -351,6 +366,7 @@ import { TWorkflowIntegrationsInsert, TWorkflowIntegrationsUpdate } from "@app/db/schemas"; +import { TAppConnections, TAppConnectionsInsert, TAppConnectionsUpdate } from "@app/db/schemas/app-connections"; import { TExternalGroupOrgRoleMappings, TExternalGroupOrgRoleMappingsInsert, @@ -381,6 +397,31 @@ declare module "knex/types/tables" { interface Tables { [TableName.Users]: KnexOriginal.CompositeTableType; [TableName.Groups]: KnexOriginal.CompositeTableType; + [TableName.SshCertificateAuthority]: KnexOriginal.CompositeTableType< + TSshCertificateAuthorities, + TSshCertificateAuthoritiesInsert, + TSshCertificateAuthoritiesUpdate + >; + [TableName.SshCertificateAuthoritySecret]: KnexOriginal.CompositeTableType< + TSshCertificateAuthoritySecrets, + TSshCertificateAuthoritySecretsInsert, + TSshCertificateAuthoritySecretsUpdate + >; + [TableName.SshCertificateTemplate]: KnexOriginal.CompositeTableType< + TSshCertificateTemplates, + TSshCertificateTemplatesInsert, + TSshCertificateTemplatesUpdate + >; + [TableName.SshCertificate]: KnexOriginal.CompositeTableType< + TSshCertificates, + TSshCertificatesInsert, + TSshCertificatesUpdate + >; + [TableName.SshCertificateBody]: KnexOriginal.CompositeTableType< + TSshCertificateBodies, + TSshCertificateBodiesInsert, + TSshCertificateBodiesUpdate + >; [TableName.CertificateAuthority]: KnexOriginal.CompositeTableType< TCertificateAuthorities, TCertificateAuthoritiesInsert, @@ -854,5 +895,10 @@ declare module "knex/types/tables" { TResourceMetadataInsert, TResourceMetadataUpdate >; + [TableName.AppConnection]: KnexOriginal.CompositeTableType< + TAppConnections, + TAppConnectionsInsert, + TAppConnectionsUpdate + >; } } diff --git a/backend/src/db/migrations/20241216013357_ssh-mgmt.ts b/backend/src/db/migrations/20241216013357_ssh-mgmt.ts new file mode 100644 index 000000000..92831d382 --- /dev/null +++ b/backend/src/db/migrations/20241216013357_ssh-mgmt.ts @@ -0,0 +1,99 @@ +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.SshCertificateAuthority))) { + await knex.schema.createTable(TableName.SshCertificateAuthority, (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("status").notNullable(); // active / disabled + t.string("friendlyName").notNullable(); + t.string("keyAlgorithm").notNullable(); + }); + await createOnUpdateTrigger(knex, TableName.SshCertificateAuthority); + } + + if (!(await knex.schema.hasTable(TableName.SshCertificateAuthoritySecret))) { + await knex.schema.createTable(TableName.SshCertificateAuthoritySecret, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshCaId").notNullable().unique(); + t.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE"); + t.binary("encryptedPrivateKey").notNullable(); + }); + await createOnUpdateTrigger(knex, TableName.SshCertificateAuthoritySecret); + } + + if (!(await knex.schema.hasTable(TableName.SshCertificateTemplate))) { + await knex.schema.createTable(TableName.SshCertificateTemplate, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshCaId").notNullable(); + t.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE"); + t.string("status").notNullable(); // active / disabled + t.string("name").notNullable(); + t.string("ttl").notNullable(); + t.string("maxTTL").notNullable(); + t.specificType("allowedUsers", "text[]").notNullable(); + t.specificType("allowedHosts", "text[]").notNullable(); + t.boolean("allowUserCertificates").notNullable(); + t.boolean("allowHostCertificates").notNullable(); + t.boolean("allowCustomKeyIds").notNullable(); + }); + await createOnUpdateTrigger(knex, TableName.SshCertificateTemplate); + } + + if (!(await knex.schema.hasTable(TableName.SshCertificate))) { + await knex.schema.createTable(TableName.SshCertificate, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshCaId").notNullable(); + t.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("SET NULL"); + t.uuid("sshCertificateTemplateId"); + t.foreign("sshCertificateTemplateId") + .references("id") + .inTable(TableName.SshCertificateTemplate) + .onDelete("SET NULL"); + t.string("serialNumber").notNullable().unique(); + t.string("certType").notNullable(); // user or host + t.specificType("principals", "text[]").notNullable(); + t.string("keyId").notNullable(); + t.datetime("notBefore").notNullable(); + t.datetime("notAfter").notNullable(); + }); + await createOnUpdateTrigger(knex, TableName.SshCertificate); + } + + if (!(await knex.schema.hasTable(TableName.SshCertificateBody))) { + await knex.schema.createTable(TableName.SshCertificateBody, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshCertId").notNullable().unique(); + t.foreign("sshCertId").references("id").inTable(TableName.SshCertificate).onDelete("CASCADE"); + t.binary("encryptedCertificate").notNullable(); + }); + + await createOnUpdateTrigger(knex, TableName.SshCertificateBody); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SshCertificateBody); + await dropOnUpdateTrigger(knex, TableName.SshCertificateBody); + + await knex.schema.dropTableIfExists(TableName.SshCertificate); + await dropOnUpdateTrigger(knex, TableName.SshCertificate); + + await knex.schema.dropTableIfExists(TableName.SshCertificateTemplate); + await dropOnUpdateTrigger(knex, TableName.SshCertificateTemplate); + + await knex.schema.dropTableIfExists(TableName.SshCertificateAuthoritySecret); + await dropOnUpdateTrigger(knex, TableName.SshCertificateAuthoritySecret); + + await knex.schema.dropTableIfExists(TableName.SshCertificateAuthority); + await dropOnUpdateTrigger(knex, TableName.SshCertificateAuthority); +} diff --git a/backend/src/db/migrations/20241218181018_app-connection.ts b/backend/src/db/migrations/20241218181018_app-connection.ts new file mode 100644 index 000000000..d09907ae1 --- /dev/null +++ b/backend/src/db/migrations/20241218181018_app-connection.ts @@ -0,0 +1,28 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.AppConnection))) { + await knex.schema.createTable(TableName.AppConnection, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name", 32).notNullable(); + t.string("description"); + t.string("app").notNullable(); + t.string("method").notNullable(); + t.binary("encryptedCredentials").notNullable(); + t.integer("version").defaultTo(1).notNullable(); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.AppConnection); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.AppConnection); + await dropOnUpdateTrigger(knex, TableName.AppConnection); +} diff --git a/backend/src/db/schemas/app-connections.ts b/backend/src/db/schemas/app-connections.ts new file mode 100644 index 000000000..8c9dff236 --- /dev/null +++ b/backend/src/db/schemas/app-connections.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const AppConnectionsSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable().optional(), + app: z.string(), + method: z.string(), + encryptedCredentials: zodBuffer, + version: z.number().default(1), + orgId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAppConnections = z.infer; +export type TAppConnectionsInsert = Omit, TImmutableDBKeys>; +export type TAppConnectionsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index e6d845b7f..9bcfdd49f 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -108,6 +108,11 @@ export * from "./secrets"; export * from "./secrets-v2"; export * from "./service-tokens"; export * from "./slack-integrations"; +export * from "./ssh-certificate-authorities"; +export * from "./ssh-certificate-authority-secrets"; +export * from "./ssh-certificate-bodies"; +export * from "./ssh-certificate-templates"; +export * from "./ssh-certificates"; export * from "./super-admin"; export * from "./totp-configs"; export * from "./trusted-ips"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index dc9420c9e..dac713cd9 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -2,6 +2,11 @@ import { z } from "zod"; export enum TableName { Users = "users", + SshCertificateAuthority = "ssh_certificate_authorities", + SshCertificateAuthoritySecret = "ssh_certificate_authority_secrets", + SshCertificateTemplate = "ssh_certificate_templates", + SshCertificate = "ssh_certificates", + SshCertificateBody = "ssh_certificate_bodies", CertificateAuthority = "certificate_authorities", CertificateTemplateEstConfig = "certificate_template_est_configs", CertificateAuthorityCert = "certificate_authority_certs", @@ -125,7 +130,8 @@ export enum TableName { KmsKeyVersion = "kms_key_versions", WorkflowIntegrations = "workflow_integrations", SlackIntegrations = "slack_integrations", - ProjectSlackConfigs = "project_slack_configs" + ProjectSlackConfigs = "project_slack_configs", + AppConnection = "app_connections" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; @@ -206,5 +212,6 @@ export enum IdentityAuthMethod { export enum ProjectType { SecretManager = "secret-manager", CertificateManager = "cert-manager", - KMS = "kms" + KMS = "kms", + SSH = "ssh" } diff --git a/backend/src/db/schemas/ssh-certificate-authorities.ts b/backend/src/db/schemas/ssh-certificate-authorities.ts new file mode 100644 index 000000000..81e789288 --- /dev/null +++ b/backend/src/db/schemas/ssh-certificate-authorities.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 SshCertificateAuthoritiesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + projectId: z.string(), + status: z.string(), + friendlyName: z.string(), + keyAlgorithm: z.string() +}); + +export type TSshCertificateAuthorities = z.infer; +export type TSshCertificateAuthoritiesInsert = Omit, TImmutableDBKeys>; +export type TSshCertificateAuthoritiesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/ssh-certificate-authority-secrets.ts b/backend/src/db/schemas/ssh-certificate-authority-secrets.ts new file mode 100644 index 000000000..934c10ab2 --- /dev/null +++ b/backend/src/db/schemas/ssh-certificate-authority-secrets.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SshCertificateAuthoritySecretsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshCaId: z.string().uuid(), + encryptedPrivateKey: zodBuffer +}); + +export type TSshCertificateAuthoritySecrets = z.infer; +export type TSshCertificateAuthoritySecretsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TSshCertificateAuthoritySecretsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/ssh-certificate-bodies.ts b/backend/src/db/schemas/ssh-certificate-bodies.ts new file mode 100644 index 000000000..baafb773b --- /dev/null +++ b/backend/src/db/schemas/ssh-certificate-bodies.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SshCertificateBodiesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshCertId: z.string().uuid(), + encryptedCertificate: zodBuffer +}); + +export type TSshCertificateBodies = z.infer; +export type TSshCertificateBodiesInsert = Omit, TImmutableDBKeys>; +export type TSshCertificateBodiesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/ssh-certificate-templates.ts b/backend/src/db/schemas/ssh-certificate-templates.ts new file mode 100644 index 000000000..6c16c3942 --- /dev/null +++ b/backend/src/db/schemas/ssh-certificate-templates.ts @@ -0,0 +1,30 @@ +// 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 SshCertificateTemplatesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshCaId: z.string().uuid(), + status: z.string(), + name: z.string(), + ttl: z.string(), + maxTTL: z.string(), + allowedUsers: z.string().array(), + allowedHosts: z.string().array(), + allowUserCertificates: z.boolean(), + allowHostCertificates: z.boolean(), + allowCustomKeyIds: z.boolean() +}); + +export type TSshCertificateTemplates = z.infer; +export type TSshCertificateTemplatesInsert = Omit, TImmutableDBKeys>; +export type TSshCertificateTemplatesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/ssh-certificates.ts b/backend/src/db/schemas/ssh-certificates.ts new file mode 100644 index 000000000..6fe5bc261 --- /dev/null +++ b/backend/src/db/schemas/ssh-certificates.ts @@ -0,0 +1,26 @@ +// 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 SshCertificatesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshCaId: z.string().uuid(), + 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() +}); + +export type TSshCertificates = z.infer; +export type TSshCertificatesInsert = Omit, TImmutableDBKeys>; +export type TSshCertificatesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 5e3a0eafe..6a5ddf51e 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -25,6 +25,9 @@ import { registerSecretRotationRouter } from "./secret-rotation-router"; import { registerSecretScanningRouter } from "./secret-scanning-router"; import { registerSecretVersionRouter } from "./secret-version-router"; 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 { registerTrustedIpRouter } from "./trusted-ip-router"; import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router"; @@ -68,6 +71,15 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { { prefix: "/pki" } ); + await server.register( + async (sshRouter) => { + await sshRouter.register(registerSshCaRouter, { prefix: "/ca" }); + await sshRouter.register(registerSshCertRouter, { prefix: "/certificates" }); + await sshRouter.register(registerSshCertificateTemplateRouter, { prefix: "/certificate-templates" }); + }, + { prefix: "/ssh" } + ); + await server.register( async (ssoRouter) => { await ssoRouter.register(registerSamlRouter); diff --git a/backend/src/ee/routes/v1/org-role-router.ts b/backend/src/ee/routes/v1/org-role-router.ts index 30f31c545..3a0ad47da 100644 --- a/backend/src/ee/routes/v1/org-role-router.ts +++ b/backend/src/ee/routes/v1/org-role-router.ts @@ -23,7 +23,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { "Please choose a different slug, the slug you have entered is reserved" ), name: z.string().trim(), - description: z.string().trim().optional(), + description: z.string().trim().nullish(), permissions: z.any().array() }), response: { @@ -95,7 +95,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { ) .optional(), name: z.string().trim().optional(), - description: z.string().trim().optional(), + description: z.string().trim().nullish(), permissions: z.any().array().optional() }), response: { diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index 0fa35ab1d..469460491 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -39,7 +39,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { ) .describe(PROJECT_ROLE.CREATE.slug), name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name), - description: z.string().trim().optional().describe(PROJECT_ROLE.CREATE.description), + description: z.string().trim().nullish().describe(PROJECT_ROLE.CREATE.description), permissions: ProjectPermissionV1Schema.array().describe(PROJECT_ROLE.CREATE.permissions) }), response: { @@ -95,7 +95,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { .describe(PROJECT_ROLE.UPDATE.slug) .optional(), name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), - description: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.description), + description: z.string().trim().nullish().describe(PROJECT_ROLE.UPDATE.description), permissions: ProjectPermissionV1Schema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional() }), response: { diff --git a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts new file mode 100644 index 000000000..ab80888d7 --- /dev/null +++ b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts @@ -0,0 +1,279 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-schema"; +import { SshCaStatus } from "@app/ee/services/ssh/ssh-certificate-authority-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({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + 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) + }), + response: { + 200: z.object({ + ca: sanitizedSshCa.extend({ + publicKey: z.string() + }) + }) + } + }, + handler: async (req) => { + const ca = await server.services.sshCertificateAuthority.createSshCa({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.CREATE_SSH_CA, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "GET", + url: "/:sshCaId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.GET.sshCaId) + }), + response: { + 200: z.object({ + ca: sanitizedSshCa.extend({ + publicKey: z.string() + }) + }) + } + }, + handler: async (req) => { + const ca = await server.services.sshCertificateAuthority.getSshCaById({ + caId: req.params.sshCaId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_SSH_CA, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "GET", + url: "/:sshCaId/public-key", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get public key of SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.GET_PUBLIC_KEY.sshCaId) + }), + response: { + 200: z.string() + } + }, + handler: async (req) => { + const publicKey = await server.services.sshCertificateAuthority.getSshCaPublicKey({ + caId: req.params.sshCaId + }); + + return publicKey; + } + }); + + server.route({ + method: "PATCH", + url: "/:sshCaId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.sshCaId) + }), + body: z.object({ + friendlyName: z.string().optional().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.friendlyName), + status: z.nativeEnum(SshCaStatus).optional().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.status) + }), + response: { + 200: z.object({ + ca: sanitizedSshCa.extend({ + publicKey: z.string() + }) + }) + } + }, + handler: async (req) => { + const ca = await server.services.sshCertificateAuthority.updateSshCaById({ + caId: req.params.sshCaId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.UPDATE_SSH_CA, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName, + status: ca.status as SshCaStatus + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "DELETE", + url: "/:sshCaId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.DELETE.sshCaId) + }), + response: { + 200: z.object({ + ca: sanitizedSshCa + }) + } + }, + handler: async (req) => { + const ca = await server.services.sshCertificateAuthority.deleteSshCaById({ + caId: req.params.sshCaId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.DELETE_SSH_CA, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "GET", + url: "/:sshCaId/certificate-templates", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get list of certificate templates for the SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.GET_CERTIFICATE_TEMPLATES.sshCaId) + }), + response: { + 200: z.object({ + certificateTemplates: sanitizedSshCertificateTemplate.array() + }) + } + }, + handler: async (req) => { + const { certificateTemplates, ca } = await server.services.sshCertificateAuthority.getSshCaCertificateTemplates({ + caId: req.params.sshCaId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_SSH_CA_CERTIFICATE_TEMPLATES, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName + } + } + }); + + return { + certificateTemplates + }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/ssh-certificate-router.ts b/backend/src/ee/routes/v1/ssh-certificate-router.ts new file mode 100644 index 000000000..5c135c3f1 --- /dev/null +++ b/backend/src/ee/routes/v1/ssh-certificate-router.ts @@ -0,0 +1,164 @@ +import ms from "ms"; +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 { SSH_CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +export const registerSshCertRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/sign", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Sign SSH public key", + body: z.object({ + certificateTemplateId: z + .string() + .trim() + .min(1) + .describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.certificateTemplateId), + publicKey: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.publicKey), + certType: z + .nativeEnum(SshCertType) + .default(SshCertType.USER) + .describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.certType), + principals: z + .array(z.string().transform((val) => val.trim())) + .nonempty("Principals array must not be empty") + .describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.principals), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.ttl), + keyId: z.string().trim().max(50).optional().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.keyId) + }), + response: { + 200: z.object({ + serialNumber: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.serialNumber), + signedKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.signedKey) + }) + } + }, + handler: async (req) => { + const { serialNumber, signedPublicKey, certificateTemplate, ttl, keyId } = + await server.services.sshCertificateAuthority.signSshKey({ + 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, + orgId: req.permission.orgId, + event: { + type: EventType.SIGN_SSH_KEY, + metadata: { + certificateTemplateId: certificateTemplate.id, + certType: req.body.certType, + principals: req.body.principals, + ttl: String(ttl), + keyId + } + } + }); + + return { + serialNumber, + signedKey: signedPublicKey + }; + } + }); + + server.route({ + method: "POST", + url: "/issue", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Issue SSH credentials (certificate + key)", + body: z.object({ + certificateTemplateId: z + .string() + .trim() + .min(1) + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.certificateTemplateId), + keyAlgorithm: z + .nativeEnum(CertKeyAlgorithm) + .default(CertKeyAlgorithm.RSA_2048) + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyAlgorithm), + certType: z + .nativeEnum(SshCertType) + .default(SshCertType.USER) + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.certType), + principals: z + .array(z.string().transform((val) => val.trim())) + .nonempty("Principals array must not be empty") + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.principals), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.ttl), + keyId: z.string().trim().max(50).optional().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyId) + }), + response: { + 200: z.object({ + serialNumber: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.serialNumber), + signedKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.signedKey), + 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) + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyAlgorithm) + }) + } + }, + handler: async (req) => { + const { serialNumber, signedPublicKey, privateKey, publicKey, certificateTemplate, ttl, keyId } = + await server.services.sshCertificateAuthority.issueSshCreds({ + 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, + orgId: req.permission.orgId, + event: { + type: EventType.ISSUE_SSH_CREDS, + metadata: { + certificateTemplateId: certificateTemplate.id, + keyAlgorithm: req.body.keyAlgorithm, + certType: req.body.certType, + principals: req.body.principals, + ttl: String(ttl), + keyId + } + } + }); + + return { + serialNumber, + signedKey: signedPublicKey, + privateKey, + publicKey, + keyAlgorithm: req.body.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 new file mode 100644 index 000000000..f94b9d50a --- /dev/null +++ b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts @@ -0,0 +1,258 @@ +import slugify from "@sindresorhus/slugify"; +import ms from "ms"; +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; +import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; +import { + isValidHostPattern, + isValidUserPattern +} from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-validators"; +import { SSH_CERTIFICATE_TEMPLATES } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerSshCertificateTemplateRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:certificateTemplateId", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + certificateTemplateId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.GET.certificateTemplateId) + }), + response: { + 200: sanitizedSshCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.sshCertificateTemplate.getSshCertTemplate({ + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.GET_SSH_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + body: z + .object({ + sshCaId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.sshCaId), + name: z + .string() + .min(1) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Name must be a valid slug" + }) + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.name), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .default("1h") + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.ttl), + maxTTL: z + .string() + .refine((val) => ms(val) > 0, "Max TTL must be a positive number") + .default("30d") + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.maxTTL), + allowedUsers: z + .array(z.string().refine(isValidUserPattern, "Invalid user pattern")) + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowedUsers), + allowedHosts: z + .array(z.string().refine(isValidHostPattern, "Invalid host pattern")) + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowedHosts), + allowUserCertificates: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowUserCertificates), + 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", + path: ["maxTTL"] + }), + response: { + 200: sanitizedSshCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplate, ca } = await server.services.sshCertificateTemplate.createSshCertTemplate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.CREATE_SSH_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + sshCaId: ca.id, + name: certificateTemplate.name, + ttl: certificateTemplate.ttl, + maxTTL: certificateTemplate.maxTTL, + allowedUsers: certificateTemplate.allowedUsers, + allowedHosts: certificateTemplate.allowedHosts, + allowUserCertificates: certificateTemplate.allowUserCertificates, + allowHostCertificates: certificateTemplate.allowHostCertificates, + allowCustomKeyIds: certificateTemplate.allowCustomKeyIds + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "PATCH", + url: "/:certificateTemplateId", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + status: z.nativeEnum(SshCertTemplateStatus).optional(), + name: z + .string() + .min(1) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.name), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.ttl), + maxTTL: z + .string() + .refine((val) => ms(val) > 0, "Max TTL must be a positive number") + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.maxTTL), + allowedUsers: z + .array(z.string().refine(isValidUserPattern, "Invalid user pattern")) + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowedUsers), + allowedHosts: z + .array(z.string().refine(isValidHostPattern, "Invalid host pattern")) + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowedHosts), + allowUserCertificates: z.boolean().optional().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowUserCertificates), + allowHostCertificates: z.boolean().optional().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowHostCertificates), + allowCustomKeyIds: z.boolean().optional().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowCustomKeyIds) + }), + params: z.object({ + certificateTemplateId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.certificateTemplateId) + }), + response: { + 200: sanitizedSshCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplate, projectId } = await server.services.sshCertificateTemplate.updateSshCertTemplate({ + ...req.body, + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.UPDATE_SSH_CERTIFICATE_TEMPLATE, + metadata: { + status: certificateTemplate.status as SshCertTemplateStatus, + certificateTemplateId: certificateTemplate.id, + sshCaId: certificateTemplate.sshCaId, + name: certificateTemplate.name, + ttl: certificateTemplate.ttl, + maxTTL: certificateTemplate.maxTTL, + allowedUsers: certificateTemplate.allowedUsers, + allowedHosts: certificateTemplate.allowedHosts, + allowUserCertificates: certificateTemplate.allowUserCertificates, + allowHostCertificates: certificateTemplate.allowHostCertificates, + allowCustomKeyIds: certificateTemplate.allowCustomKeyIds + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "DELETE", + url: "/:certificateTemplateId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + certificateTemplateId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.DELETE.certificateTemplateId) + }), + response: { + 200: sanitizedSshCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.sshCertificateTemplate.deleteSshCertTemplate({ + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.DELETE_SSH_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id + } + } + }); + + return certificateTemplate; + } + }); +}; diff --git a/backend/src/ee/routes/v2/project-role-router.ts b/backend/src/ee/routes/v2/project-role-router.ts index 0152104c6..2d3b1984d 100644 --- a/backend/src/ee/routes/v2/project-role-router.ts +++ b/backend/src/ee/routes/v2/project-role-router.ts @@ -36,7 +36,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { ) .describe(PROJECT_ROLE.CREATE.slug), name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name), - description: z.string().trim().optional().describe(PROJECT_ROLE.CREATE.description), + description: z.string().trim().nullish().describe(PROJECT_ROLE.CREATE.description), permissions: ProjectPermissionV2Schema.array().describe(PROJECT_ROLE.CREATE.permissions) }), response: { @@ -91,7 +91,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { .optional() .describe(PROJECT_ROLE.UPDATE.slug), name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), - description: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.description), + description: z.string().trim().nullish().describe(PROJECT_ROLE.UPDATE.description), permissions: ProjectPermissionV2Schema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional() }), response: { 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 d3b865665..a8d98977f 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -2,9 +2,14 @@ import { TCreateProjectTemplateDTO, TUpdateProjectTemplateDTO } from "@app/ee/services/project-template/project-template-types"; +import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; import { SymmetricEncryption } from "@app/lib/crypto/cipher"; 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"; import { ActorType } from "@app/services/auth/auth-type"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types"; import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; import { PkiItemType } from "@app/services/pki-collection/pki-collection-types"; @@ -143,6 +148,17 @@ export enum EventType { SECRET_APPROVAL_REQUEST = "secret-approval-request", SECRET_APPROVAL_CLOSED = "secret-approval-closed", SECRET_APPROVAL_REOPENED = "secret-approval-reopened", + SIGN_SSH_KEY = "sign-ssh-key", + ISSUE_SSH_CREDS = "issue-ssh-creds", + CREATE_SSH_CA = "create-ssh-certificate-authority", + GET_SSH_CA = "get-ssh-certificate-authority", + UPDATE_SSH_CA = "update-ssh-certificate-authority", + DELETE_SSH_CA = "delete-ssh-certificate-authority", + GET_SSH_CA_CERTIFICATE_TEMPLATES = "get-ssh-certificate-authority-certificate-templates", + CREATE_SSH_CERTIFICATE_TEMPLATE = "create-ssh-certificate-template", + 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_CA = "create-certificate-authority", GET_CA = "get-certificate-authority", UPDATE_CA = "update-certificate-authority", @@ -208,7 +224,12 @@ export enum EventType { CREATE_PROJECT_TEMPLATE = "create-project-template", UPDATE_PROJECT_TEMPLATE = "update-project-template", DELETE_PROJECT_TEMPLATE = "delete-project-template", - APPLY_PROJECT_TEMPLATE = "apply-project-template" + APPLY_PROJECT_TEMPLATE = "apply-project-template", + GET_APP_CONNECTIONS = "get-app-connections", + GET_APP_CONNECTION = "get-app-connection", + CREATE_APP_CONNECTION = "create-app-connection", + UPDATE_APP_CONNECTION = "update-app-connection", + DELETE_APP_CONNECTION = "delete-app-connection" } interface UserActorMetadata { @@ -1206,6 +1227,117 @@ interface SecretApprovalRequest { }; } +interface SignSshKey { + type: EventType.SIGN_SSH_KEY; + metadata: { + certificateTemplateId: string; + certType: SshCertType; + principals: string[]; + ttl: string; + keyId: string; + }; +} + +interface IssueSshCreds { + type: EventType.ISSUE_SSH_CREDS; + metadata: { + certificateTemplateId: string; + keyAlgorithm: CertKeyAlgorithm; + certType: SshCertType; + principals: string[]; + ttl: string; + keyId: string; + }; +} + +interface CreateSshCa { + type: EventType.CREATE_SSH_CA; + metadata: { + sshCaId: string; + friendlyName: string; + }; +} + +interface GetSshCa { + type: EventType.GET_SSH_CA; + metadata: { + sshCaId: string; + friendlyName: string; + }; +} + +interface UpdateSshCa { + type: EventType.UPDATE_SSH_CA; + metadata: { + sshCaId: string; + friendlyName: string; + status: SshCaStatus; + }; +} + +interface DeleteSshCa { + type: EventType.DELETE_SSH_CA; + metadata: { + sshCaId: string; + friendlyName: string; + }; +} + +interface GetSshCaCertificateTemplates { + type: EventType.GET_SSH_CA_CERTIFICATE_TEMPLATES; + metadata: { + sshCaId: string; + friendlyName: string; + }; +} + +interface CreateSshCertificateTemplate { + type: EventType.CREATE_SSH_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + sshCaId: string; + name: string; + ttl: string; + maxTTL: string; + allowedUsers: string[]; + allowedHosts: string[]; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowCustomKeyIds: boolean; + }; +} + +interface GetSshCertificateTemplate { + type: EventType.GET_SSH_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + }; +} + +interface UpdateSshCertificateTemplate { + type: EventType.UPDATE_SSH_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + sshCaId: string; + name: string; + status: SshCertTemplateStatus; + ttl: string; + maxTTL: string; + allowedUsers: string[]; + allowedHosts: string[]; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowCustomKeyIds: boolean; + }; +} + +interface DeleteSshCertificateTemplate { + type: EventType.DELETE_SSH_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + }; +} + interface CreateCa { type: EventType.CREATE_CA; metadata: { @@ -1742,6 +1874,39 @@ interface ApplyProjectTemplateEvent { }; } +interface GetAppConnectionsEvent { + type: EventType.GET_APP_CONNECTIONS; + metadata: { + app?: AppConnection; + count: number; + connectionIds: string[]; + }; +} + +interface GetAppConnectionEvent { + type: EventType.GET_APP_CONNECTION; + metadata: { + connectionId: string; + }; +} + +interface CreateAppConnectionEvent { + type: EventType.CREATE_APP_CONNECTION; + metadata: Omit & { connectionId: string }; +} + +interface UpdateAppConnectionEvent { + type: EventType.UPDATE_APP_CONNECTION; + metadata: Omit & { connectionId: string; credentialsUpdated: boolean }; +} + +interface DeleteAppConnectionEvent { + type: EventType.DELETE_APP_CONNECTION; + metadata: { + connectionId: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -1837,6 +2002,17 @@ export type Event = | SecretApprovalClosed | SecretApprovalRequest | SecretApprovalReopened + | SignSshKey + | IssueSshCreds + | CreateSshCa + | GetSshCa + | UpdateSshCa + | DeleteSshCa + | GetSshCaCertificateTemplates + | CreateSshCertificateTemplate + | UpdateSshCertificateTemplate + | GetSshCertificateTemplate + | DeleteSshCertificateTemplate | CreateCa | GetCa | UpdateCa @@ -1902,4 +2078,9 @@ export type Event = | CreateProjectTemplateEvent | UpdateProjectTemplateEvent | DeleteProjectTemplateEvent - | ApplyProjectTemplateEvent; + | ApplyProjectTemplateEvent + | GetAppConnectionsEvent + | GetAppConnectionEvent + | CreateAppConnectionEvent + | UpdateAppConnectionEvent + | DeleteAppConnectionEvent; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 70c299564..69daa8514 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -49,7 +49,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ }, pkiEst: false, enforceMfa: false, - projectTemplates: false + projectTemplates: false, + appConnections: false }); export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 622b0e06b..381044f82 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -67,6 +67,7 @@ export type TFeatureSet = { pkiEst: boolean; enforceMfa: boolean; projectTemplates: false; + appConnections: false; // TODO: remove once live }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index aac45b2d5..487d2155c 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -27,7 +27,8 @@ export enum OrgPermissionSubjects { Kms = "kms", AdminConsole = "organization-admin-console", AuditLogs = "audit-logs", - ProjectTemplates = "project-templates" + ProjectTemplates = "project-templates", + AppConnections = "app-connections" } export type OrgPermissionSet = @@ -46,6 +47,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] + | [OrgPermissionActions, OrgPermissionSubjects.AppConnections] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]; const buildAdminPermission = () => { @@ -123,6 +125,11 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.ProjectTemplates); can(OrgPermissionActions.Delete, OrgPermissionSubjects.ProjectTemplates); + can(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); + can(OrgPermissionActions.Create, OrgPermissionSubjects.AppConnections); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.AppConnections); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.AppConnections); + can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole); return rules; @@ -153,6 +160,8 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); + can(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); + return rules; }; diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index f6d7f715f..47142054e 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -54,6 +54,9 @@ export enum ProjectPermissionSub { CertificateAuthorities = "certificate-authorities", Certificates = "certificates", CertificateTemplates = "certificate-templates", + SshCertificateAuthorities = "ssh-certificate-authorities", + SshCertificates = "ssh-certificates", + SshCertificateTemplates = "ssh-certificate-templates", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", @@ -132,6 +135,9 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek] @@ -338,6 +344,28 @@ const GeneralPermissionSchema = [ "Describe what action an entity can take." ) }), + z.object({ + subject: z + .literal(ProjectPermissionSub.SshCertificateAuthorities) + .describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.SshCertificates).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z + .literal(ProjectPermissionSub.SshCertificateTemplates) + .describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), z.object({ subject: z.literal(ProjectPermissionSub.PkiAlerts).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( @@ -480,7 +508,10 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.Certificates, ProjectPermissionSub.CertificateTemplates, ProjectPermissionSub.PkiAlerts, - ProjectPermissionSub.PkiCollections + ProjectPermissionSub.PkiCollections, + ProjectPermissionSub.SshCertificateAuthorities, + ProjectPermissionSub.SshCertificates, + ProjectPermissionSub.SshCertificateTemplates ].forEach((el) => { can( [ @@ -665,6 +696,11 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); + can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateAuthorities); + can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificates); + can([ProjectPermissionActions.Create], ProjectPermissionSub.SshCertificates); + can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateTemplates); + can( [ ProjectPermissionCmekActions.Create, @@ -707,6 +743,9 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); can(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateAuthorities); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates); return rules; }; diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-dal.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-dal.ts new file mode 100644 index 000000000..b8afa0df2 --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-dal.ts @@ -0,0 +1,66 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TSshCertificateTemplateDALFactory = ReturnType; + +export const sshCertificateTemplateDALFactory = (db: TDbClient) => { + const sshCertificateTemplateOrm = ormify(db, TableName.SshCertificateTemplate); + + const getById = async (id: string, tx?: Knex) => { + try { + const certTemplate = await (tx || db.replicaNode())(TableName.SshCertificateTemplate) + .join( + TableName.SshCertificateAuthority, + `${TableName.SshCertificateAuthority}.id`, + `${TableName.SshCertificateTemplate}.sshCaId` + ) + .join(TableName.Project, `${TableName.Project}.id`, `${TableName.SshCertificateAuthority}.projectId`) + .where(`${TableName.SshCertificateTemplate}.id`, "=", id) + .select(selectAllTableCols(TableName.SshCertificateTemplate)) + .select( + db.ref("projectId").withSchema(TableName.SshCertificateAuthority), + db.ref("friendlyName").as("caName").withSchema(TableName.SshCertificateAuthority), + db.ref("status").as("caStatus").withSchema(TableName.SshCertificateAuthority) + ) + .first(); + + return certTemplate; + } catch (error) { + throw new DatabaseError({ error, name: "Get SSH certificate template by ID" }); + } + }; + + /** + * Returns the SSH certificate template named [name] within project with id [projectId] + */ + const getByName = async (name: string, projectId: string, tx?: Knex) => { + try { + const certTemplate = await (tx || db.replicaNode())(TableName.SshCertificateTemplate) + .join( + TableName.SshCertificateAuthority, + `${TableName.SshCertificateAuthority}.id`, + `${TableName.SshCertificateTemplate}.sshCaId` + ) + .join(TableName.Project, `${TableName.Project}.id`, `${TableName.SshCertificateAuthority}.projectId`) + .where(`${TableName.SshCertificateTemplate}.name`, "=", name) + .where(`${TableName.Project}.id`, "=", projectId) + .select(selectAllTableCols(TableName.SshCertificateTemplate)) + .select( + db.ref("projectId").withSchema(TableName.SshCertificateAuthority), + db.ref("friendlyName").as("caName").withSchema(TableName.SshCertificateAuthority), + db.ref("status").as("caStatus").withSchema(TableName.SshCertificateAuthority) + ) + .first(); + + return certTemplate; + } catch (error) { + throw new DatabaseError({ error, name: "Get SSH certificate template by name" }); + } + }; + + return { ...sshCertificateTemplateOrm, getById, getByName }; +}; diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-schema.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-schema.ts new file mode 100644 index 000000000..fb7a95203 --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-schema.ts @@ -0,0 +1,15 @@ +import { SshCertificateTemplatesSchema } from "@app/db/schemas"; + +export const sanitizedSshCertificateTemplate = SshCertificateTemplatesSchema.pick({ + id: true, + sshCaId: true, + status: true, + name: true, + ttl: true, + maxTTL: true, + allowedUsers: true, + allowedHosts: true, + allowCustomKeyIds: true, + allowUserCertificates: true, + allowHostCertificates: true +}); diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-service.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-service.ts new file mode 100644 index 000000000..a31b9b858 --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-service.ts @@ -0,0 +1,249 @@ +import { ForbiddenError } from "@casl/ability"; +import ms from "ms"; + +import { ProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; + +import { TSshCertificateAuthorityDALFactory } from "../ssh/ssh-certificate-authority-dal"; +import { TSshCertificateTemplateDALFactory } from "./ssh-certificate-template-dal"; +import { + SshCertTemplateStatus, + TCreateSshCertTemplateDTO, + TDeleteSshCertTemplateDTO, + TGetSshCertTemplateDTO, + TUpdateSshCertTemplateDTO +} from "./ssh-certificate-template-types"; + +type TSshCertificateTemplateServiceFactoryDep = { + sshCertificateTemplateDAL: Pick< + TSshCertificateTemplateDALFactory, + "transaction" | "getByName" | "create" | "updateById" | "deleteById" | "getById" + >; + sshCertificateAuthorityDAL: Pick; + permissionService: Pick; +}; + +export type TSshCertificateTemplateServiceFactory = ReturnType; + +export const sshCertificateTemplateServiceFactory = ({ + sshCertificateTemplateDAL, + sshCertificateAuthorityDAL, + permissionService +}: TSshCertificateTemplateServiceFactoryDep) => { + const createSshCertTemplate = async ({ + sshCaId, + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateSshCertTemplateDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(sshCaId); + if (!ca) { + throw new NotFoundError({ + message: `SSH CA with ID ${sshCaId} not found` + }); + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SshCertificateTemplates + ); + + if (ms(ttl) > ms(maxTTL)) { + throw new BadRequestError({ + message: "TTL cannot be greater than max TTL" + }); + } + + const newCertificateTemplate = await sshCertificateTemplateDAL.transaction(async (tx) => { + const existingTemplate = await sshCertificateTemplateDAL.getByName(name, ca.projectId, tx); + if (existingTemplate) { + throw new BadRequestError({ + message: `SSH certificate template with name ${name} already exists` + }); + } + + const certificateTemplate = await sshCertificateTemplateDAL.create( + { + sshCaId, + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds, + status: SshCertTemplateStatus.ACTIVE + }, + tx + ); + + return certificateTemplate; + }); + + return { certificateTemplate: newCertificateTemplate, ca }; + }; + + const updateSshCertTemplate = async ({ + id, + status, + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateSshCertTemplateDTO) => { + const certTemplate = await sshCertificateTemplateDAL.getById(id); + if (!certTemplate) { + throw new NotFoundError({ + message: `SSH certificate template with ID ${id} not found` + }); + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + certTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.SshCertificateTemplates + ); + + const updatedCertificateTemplate = await sshCertificateTemplateDAL.transaction(async (tx) => { + if (name) { + const existingTemplate = await sshCertificateTemplateDAL.getByName(name, certTemplate.projectId, tx); + if (existingTemplate && existingTemplate.id !== id) { + throw new BadRequestError({ + message: `SSH certificate template with name ${name} already exists` + }); + } + } + + if (ms(ttl || certTemplate.ttl) > ms(maxTTL || certTemplate.maxTTL)) { + throw new BadRequestError({ + message: "TTL cannot be greater than max TTL" + }); + } + + const certificateTemplate = await sshCertificateTemplateDAL.updateById( + id, + { + status, + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds + }, + tx + ); + + return certificateTemplate; + }); + + return { + certificateTemplate: updatedCertificateTemplate, + projectId: certTemplate.projectId + }; + }; + + const deleteSshCertTemplate = async ({ + id, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TDeleteSshCertTemplateDTO) => { + const certificateTemplate = await sshCertificateTemplateDAL.getById(id); + if (!certificateTemplate) { + throw new NotFoundError({ + message: `SSH certificate template with ID ${id} not found` + }); + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + certificateTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.SshCertificateTemplates + ); + + await sshCertificateTemplateDAL.deleteById(certificateTemplate.id); + + return certificateTemplate; + }; + + const getSshCertTemplate = async ({ id, actorId, actorAuthMethod, actor, actorOrgId }: TGetSshCertTemplateDTO) => { + const certTemplate = await sshCertificateTemplateDAL.getById(id); + if (!certTemplate) { + throw new NotFoundError({ + message: `SSH certificate template with ID ${id} not found` + }); + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + certTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateTemplates + ); + + return certTemplate; + }; + + return { + createSshCertTemplate, + updateSshCertTemplate, + deleteSshCertTemplate, + getSshCertTemplate + }; +}; diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-types.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-types.ts new file mode 100644 index 000000000..64de1bf0c --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-types.ts @@ -0,0 +1,39 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum SshCertTemplateStatus { + ACTIVE = "active", + DISABLED = "disabled" +} + +export type TCreateSshCertTemplateDTO = { + sshCaId: string; + name: string; + ttl: string; + maxTTL: string; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowedUsers: string[]; + allowedHosts: string[]; + allowCustomKeyIds: boolean; +} & Omit; + +export type TUpdateSshCertTemplateDTO = { + id: string; + status?: SshCertTemplateStatus; + name?: string; + ttl?: string; + maxTTL?: string; + allowUserCertificates?: boolean; + allowHostCertificates?: boolean; + allowedUsers?: string[]; + allowedHosts?: string[]; + allowCustomKeyIds?: boolean; +} & Omit; + +export type TGetSshCertTemplateDTO = { + id: string; +} & Omit; + +export type TDeleteSshCertTemplateDTO = { + id: string; +} & Omit; diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts new file mode 100644 index 000000000..373bbc640 --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts @@ -0,0 +1,14 @@ +// Validates usernames or wildcard (*) +export const isValidUserPattern = (value: string): boolean => { + // Matches valid Linux usernames or a wildcard (*) + const userRegex = /^(?:\*|[a-z_][a-z0-9_-]{0,31})$/; + return userRegex.test(value); +}; + +// Validates hostnames, wildcard domains, or IP addresses +export const isValidHostPattern = (value: string): boolean => { + // Matches FQDNs, wildcard domains (*.example.com), IPv4, and IPv6 addresses + const hostRegex = + /^(?:\*|\*\.[a-z0-9-]+(?:\.[a-z0-9-]+)*|[a-z0-9-]+(?:\.[a-z0-9-]+)*|\d{1,3}(\.\d{1,3}){3}|([a-fA-F0-9:]+:+)+[a-fA-F0-9]+(?:%[a-zA-Z0-9]+)?)$/; + return hostRegex.test(value); +}; diff --git a/backend/src/ee/services/ssh-certificate/ssh-certificate-body-dal.ts b/backend/src/ee/services/ssh-certificate/ssh-certificate-body-dal.ts new file mode 100644 index 000000000..c3d16a39e --- /dev/null +++ b/backend/src/ee/services/ssh-certificate/ssh-certificate-body-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TSshCertificateBodyDALFactory = ReturnType; + +export const sshCertificateBodyDALFactory = (db: TDbClient) => { + const sshCertificateBodyOrm = ormify(db, TableName.SshCertificateBody); + return sshCertificateBodyOrm; +}; diff --git a/backend/src/ee/services/ssh-certificate/ssh-certificate-dal.ts b/backend/src/ee/services/ssh-certificate/ssh-certificate-dal.ts new file mode 100644 index 000000000..9c5bd1d3e --- /dev/null +++ b/backend/src/ee/services/ssh-certificate/ssh-certificate-dal.ts @@ -0,0 +1,38 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TSshCertificateDALFactory = ReturnType; + +export const sshCertificateDALFactory = (db: TDbClient) => { + const sshCertificateOrm = ormify(db, TableName.SshCertificate); + + const countSshCertificatesInProject = async (projectId: string) => { + try { + interface CountResult { + count: string; + } + + const query = db + .replicaNode()(TableName.SshCertificate) + .join( + TableName.SshCertificateAuthority, + `${TableName.SshCertificate}.sshCaId`, + `${TableName.SshCertificateAuthority}.id` + ) + .join(TableName.Project, `${TableName.SshCertificateAuthority}.projectId`, `${TableName.Project}.id`) + .where(`${TableName.Project}.id`, projectId); + + const count = await query.count("*").first(); + + return parseInt((count as unknown as CountResult).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count all SSH certificates in project" }); + } + }; + return { + ...sshCertificateOrm, + countSshCertificatesInProject + }; +}; diff --git a/backend/src/ee/services/ssh-certificate/ssh-certificate-schema.ts b/backend/src/ee/services/ssh-certificate/ssh-certificate-schema.ts new file mode 100644 index 000000000..9c3b7a392 --- /dev/null +++ b/backend/src/ee/services/ssh-certificate/ssh-certificate-schema.ts @@ -0,0 +1,14 @@ +import { SshCertificatesSchema } from "@app/db/schemas"; + +export const sanitizedSshCertificate = SshCertificatesSchema.pick({ + id: true, + sshCaId: true, + sshCertificateTemplateId: true, + serialNumber: true, + certType: true, + publicKey: true, + principals: true, + keyId: true, + notBefore: true, + notAfter: true +}); diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-dal.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-dal.ts new file mode 100644 index 000000000..c906efa91 --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-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 TSshCertificateAuthorityDALFactory = ReturnType; + +export const sshCertificateAuthorityDALFactory = (db: TDbClient) => { + const sshCaOrm = ormify(db, TableName.SshCertificateAuthority); + return sshCaOrm; +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts new file mode 100644 index 000000000..f59a5d5bf --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts @@ -0,0 +1,376 @@ +import { execFile } from "child_process"; +import crypto from "crypto"; +import { promises as fs } from "fs"; +import ms from "ms"; +import os from "os"; +import path from "path"; +import { promisify } from "util"; + +import { TSshCertificateTemplates } from "@app/db/schemas"; +import { BadRequestError } from "@app/lib/errors"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +import { + isValidHostPattern, + isValidUserPattern +} from "../ssh-certificate-template/ssh-certificate-template-validators"; +import { SshCertType, TCreateSshCertDTO } from "./ssh-certificate-authority-types"; + +const execFileAsync = promisify(execFile); + +/* eslint-disable no-bitwise */ +export const createSshCertSerialNumber = () => { + const randomBytes = crypto.randomBytes(8); // 8 bytes = 64 bits + randomBytes[0] &= 0x7f; // Ensure the most significant bit is 0 (to stay within unsigned range) + return BigInt(`0x${randomBytes.toString("hex")}`).toString(10); // Convert to decimal +}; + +/** + * 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) => { + 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; + + switch (keyAlgorithm) { + case CertKeyAlgorithm.RSA_2048: + keyType = "rsa"; + keyBits = "2048"; + break; + case CertKeyAlgorithm.RSA_4096: + keyType = "rsa"; + keyBits = "4096"; + break; + case CertKeyAlgorithm.ECDSA_P256: + keyType = "ecdsa"; + keyBits = "256"; + break; + case CertKeyAlgorithm.ECDSA_P384: + keyType = "ecdsa"; + keyBits = "384"; + break; + default: + throw new BadRequestError({ + message: "Failed to produce SSH CA key pair generation command due to unrecognized key algorithm" + }); + } + + try { + // 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", ""]); + + // Read the generated keys + const publicKey = await fs.readFile(publicKeyFile, "utf8"); + const privateKey = await fs.readFile(privateKeyFile, "utf8"); + + return { publicKey, privateKey }; + } finally { + // Cleanup the temporary directory and all its contents + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +}; + +/** + * Return the SSH public key for the given SSH private key. + */ +export const getSshPublicKey = async (privateKey: string) => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-key-")); + const privateKeyFile = path.join(tempDir, "id_key"); + try { + await fs.writeFile(privateKeyFile, privateKey, { mode: 0o600 }); + + // Run ssh-keygen to extract the public key + const { stdout } = await execFileAsync("ssh-keygen", ["-y", "-f", privateKeyFile], { encoding: "utf8" }); + return stdout.trim(); + } finally { + // Ensure that files and the temporary directory are cleaned up + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +}; + +/** + * Validate the requested SSH certificate type based on the SSH certificate template configuration. + */ +export const validateSshCertificateType = (template: TSshCertificateTemplates, certType: SshCertType) => { + if (!template.allowUserCertificates && certType === SshCertType.USER) { + throw new BadRequestError({ message: "Failed to validate user certificate type due to template restriction" }); + } + + if (!template.allowHostCertificates && certType === SshCertType.HOST) { + throw new BadRequestError({ message: "Failed to validate host certificate type due to template restriction" }); + } +}; + +/** + * Validate the requested SSH certificate principals based on the SSH certificate template configuration. + */ +export const validateSshCertificatePrincipals = ( + certType: SshCertType, + template: TSshCertificateTemplates, + principals: string[] +) => { + /** + * Validate and sanitize a principal string + */ + const validatePrincipal = (principal: string) => { + const sanitized = principal.trim(); + + // basic checks for empty or control characters + if (sanitized.length === 0) { + throw new BadRequestError({ + message: "Principal cannot be an empty string." + }); + } + + if (/\r|\n|\t|\0/.test(sanitized)) { + throw new BadRequestError({ + message: `Principal '${sanitized}' contains invalid whitespace or control characters.` + }); + } + + // disallow whitespace anywhere + if (/\s/.test(sanitized)) { + throw new BadRequestError({ + message: `Principal '${sanitized}' cannot contain whitespace.` + }); + } + + // restrict allowed characters to letters, digits, dot, underscore, and hyphen + if (!/^[A-Za-z0-9._-]+$/.test(sanitized)) { + throw new BadRequestError({ + message: `Principal '${sanitized}' contains invalid characters. Allowed: alphanumeric, '.', '_', '-'.` + }); + } + + // disallow leading hyphen to avoid potential argument-like inputs + if (sanitized.startsWith("-")) { + throw new BadRequestError({ + message: `Principal '${sanitized}' cannot start with a hyphen.` + }); + } + + // length restriction (adjust as needed) + if (sanitized.length > 64) { + throw new BadRequestError({ + message: `Principal '${sanitized}' is too long.` + }); + } + + return sanitized; + }; + + // Sanitize and validate all principals using the helper + const sanitizedPrincipals = principals.map(validatePrincipal); + + switch (certType) { + case SshCertType.USER: { + if (template.allowedUsers.length === 0) { + throw new BadRequestError({ + message: "No allowed users are configured in the SSH certificate template." + }); + } + + const allowsAllUsers = template.allowedUsers.includes("*") ?? false; + + sanitizedPrincipals.forEach((principal) => { + if (principal === "*") { + throw new BadRequestError({ + message: `Principal '*' is not allowed for user certificates.` + }); + } + if (allowsAllUsers && !isValidUserPattern(principal)) { + throw new BadRequestError({ + message: `Principal '${principal}' does not match a valid user pattern.` + }); + } + if (!allowsAllUsers && !template.allowedUsers.includes(principal)) { + throw new BadRequestError({ + message: `Principal '${principal}' is not in the list of allowed users.` + }); + } + }); + break; + } + case SshCertType.HOST: { + if (template.allowedHosts.length === 0) { + throw new BadRequestError({ + message: "No allowed hosts are configured in the SSH certificate template." + }); + } + + const allowsAllHosts = template.allowedHosts.includes("*") ?? false; + + sanitizedPrincipals.forEach((principal) => { + if (principal.includes("*")) { + throw new BadRequestError({ + message: `Principal '${principal}' with wildcards is not allowed for host certificates.` + }); + } + if (allowsAllHosts && !isValidHostPattern(principal)) { + throw new BadRequestError({ + message: `Principal '${principal}' does not match a valid host pattern.` + }); + } + + if ( + !allowsAllHosts && + !template.allowedHosts.some((allowedHost) => { + if (allowedHost.startsWith("*.")) { + const baseDomain = allowedHost.slice(2); // Remove the leading "*." + return principal.endsWith(`.${baseDomain}`); + } + return principal === allowedHost; + }) + ) { + throw new BadRequestError({ + message: `Principal '${principal}' is not in the list of allowed hosts or domains.` + }); + } + }); + break; + } + default: + throw new BadRequestError({ + message: "Failed to validate SSH certificate principals due to unrecognized requested certificate type" + }); + } +}; + +/** + * Validate the requested SSH certificate TTL based on the SSH certificate template configuration. + */ +export const validateSshCertificateTtl = (template: TSshCertificateTemplates, ttl?: string) => { + if (!ttl) { + // use default template ttl + return Math.ceil(ms(template.ttl) / 1000); + } + + if (ms(ttl) > ms(template.maxTTL)) { + throw new BadRequestError({ + message: "Failed TTL validation due to TTL being greater than configured max TTL on template" + }); + } + + return Math.ceil(ms(ttl) / 1000); +}; + +/** + * Validate the requested SSH certificate key ID to ensure + * that it only contains alphanumeric characters with no spaces. + */ +export const validateSshCertificateKeyId = (keyId: string) => { + const regex = /^[A-Za-z0-9-]+$/; + if (!regex.test(keyId)) { + throw new BadRequestError({ + message: + "Failed to validate Key ID because it can only contain alphanumeric characters and hyphens, with no spaces." + }); + } + + if (keyId.length > 50) { + throw new BadRequestError({ + message: "keyId can only be up to 50 characters long." + }); + } +}; + +/** + * Validate the format of the SSH public key + */ +const validateSshPublicKey = async (publicKey: string) => { + const validPrefixes = ["ssh-rsa", "ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"]; + const startsWithValidPrefix = validPrefixes.some((prefix) => publicKey.startsWith(`${prefix} `)); + if (!startsWithValidPrefix) { + throw new BadRequestError({ message: "Failed to validate SSH public key format: unsupported key type." }); + } + + // write the key to a temp file and run `ssh-keygen -l -f` + // check to see if OpenSSH can read/interpret the public key + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-pubkey-")); + const pubKeyFile = path.join(tempDir, "key.pub"); + + try { + await fs.writeFile(pubKeyFile, publicKey, { mode: 0o600 }); + await execFileAsync("ssh-keygen", ["-l", "-f", pubKeyFile]); + } catch (error) { + throw new BadRequestError({ + message: "Failed to validate SSH public key format: could not be parsed." + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +}; + +/** + * Create an SSH certificate for a user or host. + */ +export const createSshCert = async ({ + template, + caPrivateKey, + clientPublicKey, + keyId, + principals, + requestedTtl, + certType +}: TCreateSshCertDTO) => { + // 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 + const ttl = validateSshCertificateTtl(template, requestedTtl); + + validateSshCertificateKeyId(keyId); + await validateSshPublicKey(clientPublicKey); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-cert-")); + + const publicKeyFile = path.join(tempDir, "user_key.pub"); + const privateKeyFile = path.join(tempDir, "ca_key"); + const signedPublicKeyFile = path.join(tempDir, "user_key-cert.pub"); + + const serialNumber = createSshCertSerialNumber(); + + // Build `ssh-keygen` arguments for signing + // Using an array avoids shell injection issues + const sshKeygenArgs = [ + certType === "host" ? "-h" : null, // host certificate if needed + "-s", + privateKeyFile, // path to SSH CA private key + "-I", + keyId, // identity (key ID) + "-n", + principals.join(","), // principals + "-V", + `+${ttl}s`, // validity (TTL in seconds) + "-z", + serialNumber, // serial number + publicKeyFile // public key file to sign + ].filter(Boolean) as string[]; + + try { + // Write public and private keys to the temp directory + await fs.writeFile(publicKeyFile, clientPublicKey, { mode: 0o600 }); + await fs.writeFile(privateKeyFile, caPrivateKey, { mode: 0o600 }); + + // Execute the signing process + await execFileAsync("ssh-keygen", sshKeygenArgs, { encoding: "utf8" }); + + // Read the signed public key from the generated cert file + const signedPublicKey = await fs.readFile(signedPublicKeyFile, "utf8"); + + return { serialNumber, signedPublicKey, ttl }; + } finally { + // Cleanup the temporary directory and all its contents + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts new file mode 100644 index 000000000..9ff76efbc --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts @@ -0,0 +1,9 @@ +import { SshCertificateAuthoritiesSchema } from "@app/db/schemas"; + +export const sanitizedSshCa = SshCertificateAuthoritiesSchema.pick({ + id: true, + projectId: true, + friendlyName: true, + status: true, + keyAlgorithm: true +}); diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-secret-dal.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-secret-dal.ts new file mode 100644 index 000000000..9423a0ff2 --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-secret-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TSshCertificateAuthoritySecretDALFactory = ReturnType; + +export const sshCertificateAuthoritySecretDALFactory = (db: TDbClient) => { + const sshCaSecretOrm = ormify(db, TableName.SshCertificateAuthoritySecret); + return sshCaSecretOrm; +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts new file mode 100644 index 000000000..0b408c9dd --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts @@ -0,0 +1,523 @@ +import { ForbiddenError } from "@casl/ability"; + +import { ProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, 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 { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +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 { + SshCaStatus, + TCreateSshCaDTO, + TDeleteSshCaDTO, + TGetSshCaCertificateTemplatesDTO, + TGetSshCaDTO, + TGetSshCaPublicKeyDTO, + TIssueSshCredsDTO, + TSignSshKeyDTO, + TUpdateSshCaDTO +} from "./ssh-certificate-authority-types"; + +type TSshCertificateAuthorityServiceFactoryDep = { + sshCertificateAuthorityDAL: Pick< + TSshCertificateAuthorityDALFactory, + "transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" + >; + sshCertificateAuthoritySecretDAL: Pick; + sshCertificateTemplateDAL: Pick; + sshCertificateDAL: Pick; + sshCertificateBodyDAL: Pick; + kmsService: Pick< + TKmsServiceFactory, + "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "getOrgKmsKeyId" | "createCipherPairWithDataKey" + >; + permissionService: Pick; +}; + +export type TSshCertificateAuthorityServiceFactory = ReturnType; + +export const sshCertificateAuthorityServiceFactory = ({ + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + sshCertificateTemplateDAL, + sshCertificateDAL, + sshCertificateBodyDAL, + kmsService, + permissionService +}: TSshCertificateAuthorityServiceFactoryDep) => { + /** + * Generates a new SSH CA + */ + const createSshCa = async ({ + projectId, + friendlyName, + keyAlgorithm, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateSshCaDTO) => { + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + 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 }; + }); + + return newCa; + }; + + /** + * Return SSH CA with id [caId] + */ + const getSshCaById = async ({ caId, actor, actorId, actorAuthMethod, actorOrgId }: TGetSshCaDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateAuthorities + ); + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: ca.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + + return { ...ca, publicKey }; + }; + + /** + * Return public key of SSH CA with id [caId] + */ + const getSshCaPublicKey = async ({ caId }: TGetSshCaPublicKeyDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: ca.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + + return publicKey; + }; + + /** + * Update SSH CA with id [caId] + * Note: Used to enable/disable CA + */ + const updateSshCaById = async ({ + caId, + friendlyName, + status, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TUpdateSshCaDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.SshCertificateAuthorities + ); + + const updatedCa = await sshCertificateAuthorityDAL.updateById(caId, { friendlyName, status }); + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: ca.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + + return { ...updatedCa, publicKey }; + }; + + /** + * Delete SSH CA with id [caId] + */ + const deleteSshCaById = async ({ caId, actor, actorId, actorAuthMethod, actorOrgId }: TDeleteSshCaDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.SshCertificateAuthorities + ); + + const deletedCa = await sshCertificateAuthorityDAL.deleteById(caId); + + return deletedCa; + }; + + /** + * 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]. + */ + const issueSshCreds = async ({ + certificateTemplateId, + keyAlgorithm, + certType, + principals, + ttl: requestedTtl, + keyId: requestedKeyId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TIssueSshCredsDTO) => { + const sshCertificateTemplate = await sshCertificateTemplateDAL.getById(certificateTemplateId); + if (!sshCertificateTemplate) { + throw new NotFoundError({ + message: "No SSH certificate template found with specified name" + }); + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + sshCertificateTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SshCertificates + ); + + if (sshCertificateTemplate.caStatus === SshCaStatus.DISABLED) { + throw new BadRequestError({ + message: "SSH CA is disabled" + }); + } + + if (sshCertificateTemplate.status === SshCertTemplateStatus.DISABLED) { + throw new BadRequestError({ + message: "SSH certificate template is disabled" + }); + } + + // set [keyId] depending on if [allowCustomKeyIds] is true or false + const keyId = sshCertificateTemplate.allowCustomKeyIds + ? requestedKeyId ?? `${actor}-${actorId}` + : `${actor}-${actorId}`; + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: sshCertificateTemplate.sshCaId }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: sshCertificateTemplate.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + // create user key pair + const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm); + + const { serialNumber, signedPublicKey, ttl } = await createSshCert({ + template: sshCertificateTemplate, + caPrivateKey: decryptedCaPrivateKey.toString("utf8"), + clientPublicKey: publicKey, + keyId, + principals, + requestedTtl, + certType + }); + + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: sshCertificateTemplate.projectId + }); + + const encryptedCertificate = secretManagerEncryptor({ + plainText: Buffer.from(signedPublicKey, "utf8") + }).cipherTextBlob; + + await sshCertificateDAL.transaction(async (tx) => { + const cert = await sshCertificateDAL.create( + { + sshCaId: sshCertificateTemplate.sshCaId, + sshCertificateTemplateId: sshCertificateTemplate.id, + serialNumber, + certType, + principals, + keyId, + notBefore: new Date(), + notAfter: new Date(Date.now() + ttl * 1000) + }, + tx + ); + + await sshCertificateBodyDAL.create( + { + sshCertId: cert.id, + encryptedCertificate + }, + tx + ); + }); + + return { + serialNumber, + signedPublicKey, + privateKey, + publicKey, + certificateTemplate: sshCertificateTemplate, + ttl, + keyId + }; + }; + + /** + * Return SSH certificate by signing SSH public key [publicKey] + * using CA behind SSH certificate template with name [templateName] + */ + const signSshKey = async ({ + certificateTemplateId, + publicKey, + certType, + principals, + ttl: requestedTtl, + keyId: requestedKeyId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TSignSshKeyDTO) => { + const sshCertificateTemplate = await sshCertificateTemplateDAL.getById(certificateTemplateId); + if (!sshCertificateTemplate) { + throw new NotFoundError({ + message: "No SSH certificate template found with specified name" + }); + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + sshCertificateTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SshCertificates + ); + + if (sshCertificateTemplate.caStatus === SshCaStatus.DISABLED) { + throw new BadRequestError({ + message: "SSH CA is disabled" + }); + } + + if (sshCertificateTemplate.status === SshCertTemplateStatus.DISABLED) { + throw new BadRequestError({ + message: "SSH certificate template is disabled" + }); + } + + // set [keyId] depending on if [allowCustomKeyIds] is true or false + const keyId = sshCertificateTemplate.allowCustomKeyIds + ? requestedKeyId ?? `${actor}-${actorId}` + : `${actor}-${actorId}`; + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: sshCertificateTemplate.sshCaId }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: sshCertificateTemplate.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const { serialNumber, signedPublicKey, ttl } = await createSshCert({ + template: sshCertificateTemplate, + caPrivateKey: decryptedCaPrivateKey.toString("utf8"), + clientPublicKey: publicKey, + keyId, + principals, + requestedTtl, + certType + }); + + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: sshCertificateTemplate.projectId + }); + + const encryptedCertificate = secretManagerEncryptor({ + plainText: Buffer.from(signedPublicKey, "utf8") + }).cipherTextBlob; + + await sshCertificateDAL.transaction(async (tx) => { + const cert = await sshCertificateDAL.create( + { + sshCaId: sshCertificateTemplate.sshCaId, + sshCertificateTemplateId: sshCertificateTemplate.id, + serialNumber, + certType, + principals, + keyId, + notBefore: new Date(), + notAfter: new Date(Date.now() + ttl * 1000) + }, + tx + ); + + await sshCertificateBodyDAL.create( + { + sshCertId: cert.id, + encryptedCertificate + }, + tx + ); + }); + + return { serialNumber, signedPublicKey, certificateTemplate: sshCertificateTemplate, ttl, keyId }; + }; + + const getSshCaCertificateTemplates = async ({ + caId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TGetSshCaCertificateTemplatesDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateTemplates + ); + + const certificateTemplates = await sshCertificateTemplateDAL.find({ sshCaId: caId }); + + return { + certificateTemplates, + ca + }; + }; + + return { + issueSshCreds, + signSshKey, + createSshCa, + getSshCaById, + getSshCaPublicKey, + updateSshCaById, + deleteSshCaById, + getSshCaCertificateTemplates + }; +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts new file mode 100644 index 000000000..3f202ebf0 --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts @@ -0,0 +1,68 @@ +import { TSshCertificateTemplates } from "@app/db/schemas"; +import { TProjectPermission } from "@app/lib/types"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +export enum SshCaStatus { + ACTIVE = "active", + DISABLED = "disabled" +} + +export enum SshCertType { + USER = "user", + HOST = "host" +} + +export type TCreateSshCaDTO = { + friendlyName: string; + keyAlgorithm: CertKeyAlgorithm; +} & TProjectPermission; + +export type TGetSshCaDTO = { + caId: string; +} & Omit; + +export type TGetSshCaPublicKeyDTO = { + caId: string; +}; + +export type TUpdateSshCaDTO = { + caId: string; + friendlyName?: string; + status?: SshCaStatus; +} & Omit; + +export type TDeleteSshCaDTO = { + caId: string; +} & Omit; + +export type TIssueSshCredsDTO = { + certificateTemplateId: string; + keyAlgorithm: CertKeyAlgorithm; + certType: SshCertType; + principals: string[]; + ttl?: string; + keyId?: string; +} & Omit; + +export type TSignSshKeyDTO = { + certificateTemplateId: string; + publicKey: string; + certType: SshCertType; + principals: string[]; + ttl?: string; + keyId?: string; +} & Omit; + +export type TGetSshCaCertificateTemplatesDTO = { + caId: string; +} & Omit; + +export type TCreateSshCertDTO = { + template: TSshCertificateTemplates; + caPrivateKey: string; + clientPublicKey: string; + keyId: string; + principals: string[]; + requestedTtl?: string; + certType: SshCertType; +}; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 08b33e10a..57816dc24 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1,3 +1,6 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; + export const GROUPS = { CREATE: { name: "The name of the group to create.", @@ -492,6 +495,17 @@ export const PROJECTS = { LIST_INTEGRATION_AUTHORIZATION: { workspaceId: "The ID of the project to list integration auths for." }, + LIST_SSH_CAS: { + projectId: "The ID of the project to list SSH CAs 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.", + limit: "The number of SSH certificates to return." + }, + LIST_SSH_CERTIFICATE_TEMPLATES: { + projectId: "The ID of the project to list SSH certificate templates for." + }, LIST_CAS: { slug: "The slug of the project to list CAs for.", status: "The status of the CA to filter by.", @@ -1188,6 +1202,84 @@ export const AUDIT_LOG_STREAMS = { } }; +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." + }, + GET: { + sshCaId: "The ID of the SSH CA to get." + }, + GET_PUBLIC_KEY: { + sshCaId: "The ID of the SSH CA to get the public key for." + }, + UPDATE: { + sshCaId: "The ID of the SSH CA to update.", + friendlyName: "A friendly name for the SSH CA to update to.", + status: "The status of the SSH CA to update to. This can be one of active or disabled." + }, + DELETE: { + sshCaId: "The ID of the SSH CA to delete." + }, + GET_CERTIFICATE_TEMPLATES: { + sshCaId: "The ID of the SSH CA to get the certificate templates for." + }, + SIGN_SSH_KEY: { + certificateTemplateId: "The ID of the SSH certificate template to sign the SSH public key with.", + publicKey: "The SSH public key to sign.", + certType: "The type of certificate to issue. This can be one of user or host.", + principals: "The list of principals (usernames, hostnames) to include in the certificate.", + ttl: "The time to live for the certificate such as 1m, 1h, 1d, ... If not specified, the default TTL for the template will be used.", + keyId: "The key ID to include in the certificate. If not specified, a default key ID will be generated.", + serialNumber: "The serial number of the issued SSH certificate.", + signedKey: "The SSH certificate or signed SSH public key." + }, + ISSUE_SSH_CREDENTIALS: { + certificateTemplateId: "The ID of the SSH certificate template to issue the SSH credentials with.", + keyAlgorithm: "The type of public key algorithm and size, in bits, of the key pair for the SSH CA.", + certType: "The type of certificate to issue. This can be one of user or host.", + principals: "The list of principals (usernames, hostnames) to include in the certificate.", + ttl: "The time to live for the certificate such as 1m, 1h, 1d, ... If not specified, the default TTL for the template will be used.", + keyId: "The key ID to include in the certificate. If not specified, a default key ID will be generated.", + 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." + } +}; + +export const SSH_CERTIFICATE_TEMPLATES = { + GET: { + certificateTemplateId: "The ID of the SSH certificate template to get." + }, + CREATE: { + sshCaId: "The ID of the SSH CA to associate the certificate template with.", + name: "The name of the certificate template.", + ttl: "The default time to live for issued certificates such as 1m, 1h, 1d, 1y, ...", + maxTTL: "The maximum time to live for issued certificates such as 1m, 1h, 1d, 1y, ...", + allowedUsers: "The list of allowed users for certificates issued under this template.", + allowedHosts: "The list of allowed hosts for certificates issued under this template.", + allowUserCertificates: "Whether or not to allow user certificates to be issued under this template.", + allowHostCertificates: "Whether or not to allow host certificates to be issued under this template.", + allowCustomKeyIds: "Whether or not to allow custom key IDs for certificates issued under this template." + }, + UPDATE: { + certificateTemplateId: "The ID of the SSH certificate template to update.", + name: "The name of the certificate template.", + ttl: "The default time to live for issued certificates such as 1m, 1h, 1d, 1y, ...", + maxTTL: "The maximum time to live for issued certificates such as 1m, 1h, 1d, 1y, ...", + allowedUsers: "The list of allowed users for certificates issued under this template.", + allowedHosts: "The list of allowed hosts for certificates issued under this template.", + allowUserCertificates: "Whether or not to allow user certificates to be issued under this template.", + allowHostCertificates: "Whether or not to allow host certificates to be issued under this template.", + allowCustomKeyIds: "Whether or not to allow custom key IDs for certificates issued under this template." + }, + DELETE: { + certificateTemplateId: "The ID of the SSH certificate template to delete." + } +}; + export const CERTIFICATE_AUTHORITIES = { CREATE: { projectSlug: "Slug of the project to create the CA in.", @@ -1517,3 +1609,34 @@ export const ProjectTemplates = { templateId: "The ID of the project template to be deleted." } }; + +export const AppConnections = { + GET_BY_ID: (app: AppConnection) => ({ + connectionId: `The ID of the ${APP_CONNECTION_NAME_MAP[app]} Connection to retrieve.` + }), + GET_BY_NAME: (app: AppConnection) => ({ + connectionName: `The name of the ${APP_CONNECTION_NAME_MAP[app]} Connection to retrieve.` + }), + CREATE: (app: AppConnection) => { + const appName = APP_CONNECTION_NAME_MAP[app]; + return { + name: `The name of the ${appName} Connection to create. Must be slug-friendly.`, + description: `An optional description for the ${appName} Connection.`, + credentials: `The credentials used to connect with ${appName}.`, + method: `The method used to authenticate with ${appName}.` + }; + }, + UPDATE: (app: AppConnection) => { + const appName = APP_CONNECTION_NAME_MAP[app]; + return { + connectionId: `The ID of the ${appName} Connection to be updated.`, + name: `The updated name of the ${appName} Connection. Must be slug-friendly.`, + description: `The updated description of the ${appName} Connection.`, + credentials: `The credentials used to connect with ${appName}.`, + method: `The method used to authenticate with ${appName}.` + }; + }, + DELETE: (app: AppConnection) => ({ + connectionId: `The ID of the ${APP_CONNECTION_NAME_MAP[app]} connection to be deleted.` + }) +}; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 7bb95468a..7b6f356fd 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -180,7 +180,24 @@ const envSchema = z HSM_SLOT: z.coerce.number().optional().default(0), USE_PG_QUEUE: zodStrBool.default("false"), - SHOULD_INIT_PG_QUEUE: zodStrBool.default("false") + SHOULD_INIT_PG_QUEUE: zodStrBool.default("false"), + + /* App Connections ----------------------------------------------------------------------------- */ + + // aws + INF_APP_CONNECTION_AWS_ACCESS_KEY_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY: zpStr(z.string().optional()), + + // github oauth + INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET: zpStr(z.string().optional()), + + // github app + INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_APP_SLUG: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_APP_ID: zpStr(z.string().optional()) }) // To ensure that basic encryption is always possible. .refine( diff --git a/backend/src/lib/fn/string.ts b/backend/src/lib/fn/string.ts index 26e8f27df..1dc2bbfed 100644 --- a/backend/src/lib/fn/string.ts +++ b/backend/src/lib/fn/string.ts @@ -14,3 +14,5 @@ export const prefixWithSlash = (str: string) => { if (str.startsWith("/")) return str; return `/${str}`; }; + +export const startsWithVowel = (str: string) => /^[aeiou]/i.test(str); diff --git a/backend/src/lib/types/index.ts b/backend/src/lib/types/index.ts index 6ebf91f36..b8b272017 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -43,6 +43,8 @@ export type RequiredKeys = { export type PickRequired = Pick>; +export type DiscriminativePick = T extends unknown ? Pick : never; + export enum EnforcementLevel { Hard = "hard", Soft = "soft" diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index ac4803c98..7f9e16197 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -1,8 +1,10 @@ import { ForbiddenError, PureAbility } from "@casl/ability"; +import opentelemetry from "@opentelemetry/api"; import fastifyPlugin from "fastify-plugin"; import jwt from "jsonwebtoken"; import { ZodError } from "zod"; +import { getConfig } from "@app/lib/config/env"; import { BadRequestError, DatabaseError, @@ -35,8 +37,30 @@ enum HttpStatusCodes { } export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + + const apiMeter = opentelemetry.metrics.getMeter("API"); + const errorHistogram = apiMeter.createHistogram("API_errors", { + description: "API errors by type, status code, and name", + unit: "1" + }); + server.setErrorHandler((error, req, res) => { req.log.error(error); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + const { method } = req; + const route = req.routerPath; + const errorType = + error instanceof jwt.JsonWebTokenError ? "TokenError" : error.constructor.name || "UnknownError"; + + errorHistogram.record(1, { + route, + method, + type: errorType, + name: error.name + }); + } + if (error instanceof BadRequestError) { void res .status(HttpStatusCodes.BadRequest) @@ -52,13 +76,20 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider message: error.message, error: error.name }); - } else if (error instanceof DatabaseError || error instanceof InternalServerError) { + } else if (error instanceof DatabaseError) { void res.status(HttpStatusCodes.InternalServerError).send({ reqId: req.id, statusCode: HttpStatusCodes.InternalServerError, message: "Something went wrong", error: error.name }); + } else if (error instanceof InternalServerError) { + void res.status(HttpStatusCodes.InternalServerError).send({ + reqId: req.id, + statusCode: HttpStatusCodes.InternalServerError, + message: error.message ?? "Something went wrong", + error: error.name + }); } else if (error instanceof GatewayTimeoutError) { void res.status(HttpStatusCodes.GatewayTimeout).send({ reqId: req.id, diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 4cb88688e..05575a63f 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -75,6 +75,13 @@ import { snapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-da import { snapshotFolderDALFactory } from "@app/ee/services/secret-snapshot/snapshot-folder-dal"; import { snapshotSecretDALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-dal"; import { snapshotSecretV2DALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-v2-dal"; +import { sshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; +import { sshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal"; +import { sshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service"; +import { sshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-body-dal"; +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 { 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"; @@ -84,6 +91,8 @@ import { readLimit } from "@app/server/config/rateLimiter"; import { accessTokenQueueServiceFactory } from "@app/services/access-token-queue/access-token-queue"; import { apiKeyDALFactory } from "@app/services/api-key/api-key-dal"; import { apiKeyServiceFactory } from "@app/services/api-key/api-key-service"; +import { appConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { appConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { authDALFactory } from "@app/services/auth/auth-dal"; import { authLoginServiceFactory } from "@app/services/auth/auth-login-service"; import { authPaswordServiceFactory } from "@app/services/auth/auth-password-service"; @@ -308,6 +317,7 @@ export const registerRoutes = async ( const auditLogStreamDAL = auditLogStreamDALFactory(db); const trustedIpDAL = trustedIpDALFactory(db); const telemetryDAL = telemetryDALFactory(db); + const appConnectionDAL = appConnectionDALFactory(db); // ee db layer ops const permissionDAL = permissionDALFactory(db); @@ -346,6 +356,12 @@ export const registerRoutes = async ( const dynamicSecretDAL = dynamicSecretDALFactory(db); const dynamicSecretLeaseDAL = dynamicSecretLeaseDALFactory(db); + const sshCertificateDAL = sshCertificateDALFactory(db); + const sshCertificateBodyDAL = sshCertificateBodyDALFactory(db); + const sshCertificateAuthorityDAL = sshCertificateAuthorityDALFactory(db); + const sshCertificateAuthoritySecretDAL = sshCertificateAuthoritySecretDALFactory(db); + const sshCertificateTemplateDAL = sshCertificateTemplateDALFactory(db); + const kmsDAL = kmskeyDALFactory(db); const internalKmsDAL = internalKmsDALFactory(db); const externalKmsDAL = externalKmsDALFactory(db); @@ -714,6 +730,22 @@ export const registerRoutes = async ( queueService }); + const sshCertificateAuthorityService = sshCertificateAuthorityServiceFactory({ + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + sshCertificateTemplateDAL, + sshCertificateDAL, + sshCertificateBodyDAL, + kmsService, + permissionService + }); + + const sshCertificateTemplateService = sshCertificateTemplateServiceFactory({ + sshCertificateTemplateDAL, + sshCertificateAuthorityDAL, + permissionService + }); + const certificateAuthorityService = certificateAuthorityServiceFactory({ certificateAuthorityDAL, certificateAuthorityCertDAL, @@ -851,6 +883,9 @@ export const registerRoutes = async ( certificateDAL, pkiAlertDAL, pkiCollectionDAL, + sshCertificateAuthorityDAL, + sshCertificateDAL, + sshCertificateTemplateDAL, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, keyStore, @@ -1328,6 +1363,13 @@ export const registerRoutes = async ( externalGroupOrgRoleMappingDAL }); + const appConnectionService = appConnectionServiceFactory({ + appConnectionDAL, + permissionService, + kmsService, + licenseService + }); + await superAdminService.initServerCfg(); // setup the communication with license key server @@ -1396,6 +1438,8 @@ export const registerRoutes = async ( auditLog: auditLogService, auditLogStream: auditLogStreamService, certificate: certificateService, + sshCertificateAuthority: sshCertificateAuthorityService, + sshCertificateTemplate: sshCertificateTemplateService, certificateAuthority: certificateAuthorityService, certificateTemplate: certificateTemplateService, certificateAuthorityCrl: certificateAuthorityCrlService, @@ -1422,7 +1466,8 @@ export const registerRoutes = async ( migration: migrationService, externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService, projectTemplate: projectTemplateService, - totp: totpService + totp: totpService, + appConnection: appConnectionService }); const cronJobs: CronJob[] = []; 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 new file mode 100644 index 000000000..d18639786 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AwsConnectionListItemSchema, SanitizedAwsConnectionSchema } from "@app/services/app-connection/aws"; +import { GitHubConnectionListItemSchema, SanitizedGitHubConnectionSchema } from "@app/services/app-connection/github"; +import { AuthMode } from "@app/services/auth/auth-type"; + +// can't use discriminated due to multiple schemas for certain apps +const SanitizedAppConnectionSchema = z.union([ + ...SanitizedAwsConnectionSchema.options, + ...SanitizedGitHubConnectionSchema.options +]); + +const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ + AwsConnectionListItemSchema, + GitHubConnectionListItemSchema +]); + +export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/options", + config: { + rateLimit: readLimit + }, + schema: { + description: "List the available App Connection Options.", + response: { + 200: z.object({ + appConnectionOptions: AppConnectionOptionsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: () => { + const appConnectionOptions = server.services.appConnection.listAppConnectionOptions(); + return { appConnectionOptions }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "List all the App Connections for the current organization.", + response: { + 200: z.object({ appConnections: SanitizedAppConnectionSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const appConnections = await server.services.appConnection.listAppConnectionsByOrg(req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GET_APP_CONNECTIONS, + metadata: { + count: appConnections.length, + connectionIds: appConnections.map((connection) => connection.id) + } + } + }); + + return { appConnections }; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/apps/app-connection-endpoints.ts b/backend/src/server/routes/v1/app-connection-routers/apps/app-connection-endpoints.ts new file mode 100644 index 000000000..ec3b633a1 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/apps/app-connection-endpoints.ts @@ -0,0 +1,274 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { AppConnections } from "@app/lib/api-docs"; +import { startsWithVowel } from "@app/lib/fn"; +import { readLimit, writeLimit } 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 { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; +import { TAppConnection, TAppConnectionInput } from "@app/services/app-connection/app-connection-types"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerAppConnectionEndpoints = ({ + server, + app, + createSchema, + updateSchema, + responseSchema +}: { + app: AppConnection; + server: FastifyZodProvider; + createSchema: z.ZodType<{ + name: string; + method: I["method"]; + credentials: I["credentials"]; + description?: string | null; + }>; + updateSchema: z.ZodType<{ name?: string; credentials?: I["credentials"]; description?: string | null }>; + responseSchema: z.ZodTypeAny; +}) => { + const appName = APP_CONNECTION_NAME_MAP[app]; + + server.route({ + method: "GET", + url: `/`, + config: { + rateLimit: readLimit + }, + schema: { + description: `List the ${appName} Connections for the current organization.`, + response: { + 200: z.object({ appConnections: responseSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const appConnections = (await server.services.appConnection.listAppConnectionsByOrg(req.permission, app)) as T[]; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GET_APP_CONNECTIONS, + metadata: { + app, + count: appConnections.length, + connectionIds: appConnections.map((connection) => connection.id) + } + } + }); + + return { appConnections }; + } + }); + + server.route({ + method: "GET", + url: "/:connectionId", + config: { + rateLimit: readLimit + }, + schema: { + description: `Get the specified ${appName} Connection by ID.`, + params: z.object({ + connectionId: z.string().uuid().describe(AppConnections.GET_BY_ID(app).connectionId) + }), + response: { + 200: z.object({ appConnection: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { connectionId } = req.params; + + const appConnection = (await server.services.appConnection.findAppConnectionById( + app, + connectionId, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GET_APP_CONNECTION, + metadata: { + connectionId + } + } + }); + + return { appConnection }; + } + }); + + server.route({ + method: "GET", + url: `/name/:connectionName`, + config: { + rateLimit: readLimit + }, + schema: { + description: `Get the specified ${appName} Connection by name.`, + params: z.object({ + connectionName: z + .string() + .min(0, "Connection name required") + .describe(AppConnections.GET_BY_NAME(app).connectionName) + }), + response: { + 200: z.object({ appConnection: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { connectionName } = req.params; + + const appConnection = (await server.services.appConnection.findAppConnectionByName( + app, + connectionName, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GET_APP_CONNECTION, + metadata: { + connectionId: appConnection.id + } + } + }); + + return { appConnection }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: `Create ${ + startsWithVowel(appName) ? "an" : "a" + } ${appName} Connection for the current organization.`, + body: createSchema, + response: { + 200: z.object({ appConnection: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { name, method, credentials, description } = req.body; + + const appConnection = (await server.services.appConnection.createAppConnection( + { name, method, app, credentials, description }, + req.permission + )) as TAppConnection; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.CREATE_APP_CONNECTION, + metadata: { + name, + method, + app, + connectionId: appConnection.id + } + } + }); + + return { appConnection }; + } + }); + + server.route({ + method: "PATCH", + url: "/:connectionId", + config: { + rateLimit: writeLimit + }, + schema: { + description: `Update the specified ${appName} Connection.`, + params: z.object({ + connectionId: z.string().uuid().describe(AppConnections.UPDATE(app).connectionId) + }), + body: updateSchema, + response: { + 200: z.object({ appConnection: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { name, credentials, description } = req.body; + const { connectionId } = req.params; + + const appConnection = (await server.services.appConnection.updateAppConnection( + { name, credentials, connectionId, description }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.UPDATE_APP_CONNECTION, + metadata: { + name, + description, + credentialsUpdated: Boolean(credentials), + connectionId + } + } + }); + + return { appConnection }; + } + }); + + server.route({ + method: "DELETE", + url: `/:connectionId`, + config: { + rateLimit: writeLimit + }, + schema: { + description: `Delete the specified ${appName} Connection.`, + params: z.object({ + connectionId: z.string().uuid().describe(AppConnections.DELETE(app).connectionId) + }), + response: { + 200: z.object({ appConnection: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { connectionId } = req.params; + + const appConnection = (await server.services.appConnection.deleteAppConnection( + app, + connectionId, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.DELETE_APP_CONNECTION, + metadata: { + connectionId + } + } + }); + + return { appConnection }; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/apps/aws-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/apps/aws-connection-router.ts new file mode 100644 index 000000000..189ca4fbd --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/apps/aws-connection-router.ts @@ -0,0 +1,17 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateAwsConnectionSchema, + SanitizedAwsConnectionSchema, + UpdateAwsConnectionSchema +} from "@app/services/app-connection/aws"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerAwsConnectionRouter = async (server: FastifyZodProvider) => + registerAppConnectionEndpoints({ + app: AppConnection.AWS, + server, + responseSchema: SanitizedAwsConnectionSchema, + createSchema: CreateAwsConnectionSchema, + updateSchema: UpdateAwsConnectionSchema + }); diff --git a/backend/src/server/routes/v1/app-connection-routers/apps/github-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/apps/github-connection-router.ts new file mode 100644 index 000000000..273d4b9e1 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/apps/github-connection-router.ts @@ -0,0 +1,17 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateGitHubConnectionSchema, + SanitizedGitHubConnectionSchema, + UpdateGitHubConnectionSchema +} from "@app/services/app-connection/github"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerGitHubConnectionRouter = async (server: FastifyZodProvider) => + registerAppConnectionEndpoints({ + app: AppConnection.GitHub, + server, + responseSchema: SanitizedGitHubConnectionSchema, + createSchema: CreateGitHubConnectionSchema, + updateSchema: UpdateGitHubConnectionSchema + }); diff --git a/backend/src/server/routes/v1/app-connection-routers/apps/index.ts b/backend/src/server/routes/v1/app-connection-routers/apps/index.ts new file mode 100644 index 000000000..b56a65f50 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/apps/index.ts @@ -0,0 +1,8 @@ +import { registerAwsConnectionRouter } from "@app/server/routes/v1/app-connection-routers/apps/aws-connection-router"; +import { registerGitHubConnectionRouter } from "@app/server/routes/v1/app-connection-routers/apps/github-connection-router"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const APP_CONNECTION_REGISTER_MAP: Record Promise> = { + [AppConnection.AWS]: registerAwsConnectionRouter, + [AppConnection.GitHub]: registerGitHubConnectionRouter +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts new file mode 100644 index 000000000..720057449 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -0,0 +1,2 @@ +export * from "./app-connection-router"; +export * from "./apps"; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index a04f77b7a..7fae1d1f9 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -1,3 +1,4 @@ +import { APP_CONNECTION_REGISTER_MAP, registerAppConnectionRouter } from "@app/server/routes/v1/app-connection-routers"; import { registerCmekRouter } from "@app/server/routes/v1/cmek-router"; import { registerDashboardRouter } from "@app/server/routes/v1/dashboard-router"; @@ -110,4 +111,14 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerDashboardRouter, { prefix: "/dashboard" }); await server.register(registerCmekRouter, { prefix: "/kms" }); await server.register(registerExternalGroupOrgRoleMappingRouter, { prefix: "/external-group-mappings" }); + + await server.register( + async (appConnectionsRouter) => { + await appConnectionsRouter.register(registerAppConnectionRouter); + for await (const [app, router] of Object.entries(APP_CONNECTION_REGISTER_MAP)) { + await appConnectionsRouter.register(router, { prefix: `/${app}` }); + } + }, + { prefix: "/app-connections" } + ); }; diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 761020d74..68d13842c 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -137,7 +137,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { .enum(["true", "false"]) .default("false") .transform((value) => value === "true"), - type: z.enum([ProjectType.SecretManager, ProjectType.KMS, ProjectType.CertificateManager, "all"]).optional() + type: z + .enum([ProjectType.SecretManager, ProjectType.KMS, ProjectType.CertificateManager, ProjectType.SSH, "all"]) + .optional() }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 0e9da8d17..84d2ee6cd 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -10,6 +10,9 @@ import { } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types"; +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 { PROJECTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; @@ -500,4 +503,101 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return { certificateTemplates }; } }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-certificates", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_CAS.projectId) + }), + querystring: z.object({ + offset: z.coerce.number().default(0).describe(PROJECTS.LIST_SSH_CERTIFICATES.offset), + limit: z.coerce.number().default(25).describe(PROJECTS.LIST_SSH_CERTIFICATES.limit) + }), + response: { + 200: z.object({ + certificates: z.array(sanitizedSshCertificate), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificates, totalCount } = await server.services.project.listProjectSshCertificates({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId, + offset: req.query.offset, + limit: req.query.limit + }); + + return { certificates, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-certificate-templates", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_CERTIFICATE_TEMPLATES.projectId) + }), + response: { + 200: z.object({ + certificateTemplates: z.array(sanitizedSshCertificateTemplate) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplates } = await server.services.project.listProjectSshCertificateTemplates({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId + }); + + return { certificateTemplates }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-cas", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_CAS.projectId) + }), + response: { + 200: z.object({ + cas: z.array(sanitizedSshCa) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const cas = await server.services.project.listProjectSshCas({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId + }); + + return { cas }; + } + }); }; diff --git a/backend/src/services/app-connection/app-connection-dal.ts b/backend/src/services/app-connection/app-connection-dal.ts new file mode 100644 index 000000000..f74f7cf06 --- /dev/null +++ b/backend/src/services/app-connection/app-connection-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 TAppConnectionDALFactory = ReturnType; + +export const appConnectionDALFactory = (db: TDbClient) => { + const appConnectionOrm = ormify(db, TableName.AppConnection); + + return { ...appConnectionOrm }; +}; diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts new file mode 100644 index 000000000..d69b7dec1 --- /dev/null +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -0,0 +1,4 @@ +export enum AppConnection { + GitHub = "github", + AWS = "aws" +} diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts new file mode 100644 index 000000000..787839cf7 --- /dev/null +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -0,0 +1,92 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { TAppConnectionServiceFactoryDep } from "@app/services/app-connection/app-connection-service"; +import { TAppConnection, TAppConnectionConfig } from "@app/services/app-connection/app-connection-types"; +import { + AwsConnectionMethod, + getAwsAppConnectionListItem, + validateAwsConnectionCredentials +} from "@app/services/app-connection/aws"; +import { + getGitHubConnectionListItem, + GitHubConnectionMethod, + validateGitHubConnectionCredentials +} from "@app/services/app-connection/github"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +export const listAppConnectionOptions = () => { + return [getAwsAppConnectionListItem(), getGitHubConnectionListItem()].sort((a, b) => a.name.localeCompare(b.name)); +}; + +export const encryptAppConnectionCredentials = async ({ + orgId, + credentials, + kmsService +}: { + orgId: string; + credentials: TAppConnection["credentials"]; + kmsService: TAppConnectionServiceFactoryDep["kmsService"]; +}) => { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(credentials)) + }); + + return encryptedCredentialsBlob; +}; + +export const decryptAppConnectionCredentials = async ({ + orgId, + encryptedCredentials, + kmsService +}: { + orgId: string; + encryptedCredentials: Buffer; + kmsService: TAppConnectionServiceFactoryDep["kmsService"]; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedCredentials + }); + + return JSON.parse(decryptedPlainTextBlob.toString()) as TAppConnection["credentials"]; +}; + +export const validateAppConnectionCredentials = async ( + appConnection: TAppConnectionConfig +): Promise => { + const { app } = appConnection; + switch (app) { + case AppConnection.AWS: { + return validateAwsConnectionCredentials(appConnection); + } + case AppConnection.GitHub: + return validateGitHubConnectionCredentials(appConnection); + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unhandled App Connection ${app}`); + } +}; + +export const getAppConnectionMethodName = (method: TAppConnection["method"]) => { + switch (method) { + case GitHubConnectionMethod.App: + return "GitHub App"; + case GitHubConnectionMethod.OAuth: + return "OAuth"; + case AwsConnectionMethod.AccessKey: + return "Access Key"; + case AwsConnectionMethod.AssumeRole: + return "Assume Role"; + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unhandled App Connection Method: ${method}`); + } +}; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts new file mode 100644 index 000000000..f473b1e38 --- /dev/null +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -0,0 +1,6 @@ +import { AppConnection } from "./app-connection-enums"; + +export const APP_CONNECTION_NAME_MAP: Record = { + [AppConnection.AWS]: "AWS", + [AppConnection.GitHub]: "GitHub" +}; diff --git a/backend/src/services/app-connection/app-connection-schemas.ts b/backend/src/services/app-connection/app-connection-schemas.ts new file mode 100644 index 000000000..ce5e877fd --- /dev/null +++ b/backend/src/services/app-connection/app-connection-schemas.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +import { AppConnectionsSchema } from "@app/db/schemas/app-connections"; +import { AppConnections } from "@app/lib/api-docs"; +import { slugSchema } from "@app/server/lib/schemas"; + +import { AppConnection } from "./app-connection-enums"; + +export const BaseAppConnectionSchema = AppConnectionsSchema.omit({ + encryptedCredentials: true, + app: true, + method: true +}); + +export const GenericCreateAppConnectionFieldsSchema = (app: AppConnection) => + z.object({ + name: slugSchema({ field: "name" }).describe(AppConnections.CREATE(app).name), + description: z + .string() + .trim() + .max(256, "Description cannot exceed 256 characters") + .nullish() + .describe(AppConnections.CREATE(app).description) + }); + +export const GenericUpdateAppConnectionFieldsSchema = (app: AppConnection) => + z.object({ + name: slugSchema({ field: "name" }).describe(AppConnections.UPDATE(app).name).optional(), + description: z + .string() + .trim() + .max(256, "Description cannot exceed 256 characters") + .nullish() + .describe(AppConnections.UPDATE(app).description) + }); diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts new file mode 100644 index 000000000..9b9f16626 --- /dev/null +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -0,0 +1,360 @@ +import { ForbiddenError } from "@casl/ability"; + +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { DiscriminativePick, OrgServiceActor } from "@app/lib/types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + decryptAppConnectionCredentials, + encryptAppConnectionCredentials, + getAppConnectionMethodName, + listAppConnectionOptions, + validateAppConnectionCredentials +} from "@app/services/app-connection/app-connection-fns"; +import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; +import { + TAppConnection, + TAppConnectionConfig, + TCreateAppConnectionDTO, + TUpdateAppConnectionDTO, + TValidateAppConnectionCredentials +} from "@app/services/app-connection/app-connection-types"; +import { ValidateAwsConnectionCredentialsSchema } from "@app/services/app-connection/aws"; +import { ValidateGitHubConnectionCredentialsSchema } from "@app/services/app-connection/github"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +import { TAppConnectionDALFactory } from "./app-connection-dal"; + +export type TAppConnectionServiceFactoryDep = { + appConnectionDAL: TAppConnectionDALFactory; + permissionService: Pick; + kmsService: Pick; + licenseService: Pick; // TODO: remove once launched +}; + +export type TAppConnectionServiceFactory = ReturnType; + +const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record = { + [AppConnection.AWS]: ValidateAwsConnectionCredentialsSchema, + [AppConnection.GitHub]: ValidateGitHubConnectionCredentialsSchema +}; + +export const appConnectionServiceFactory = ({ + appConnectionDAL, + permissionService, + kmsService, + licenseService +}: TAppConnectionServiceFactoryDep) => { + // app connections are disabled for public until launch + const checkAppServicesAvailability = async (orgId: string) => { + const subscription = await licenseService.getPlan(orgId); + + if (!subscription.appConnections) throw new BadRequestError({ message: "App Connections are not available yet." }); + }; + + const listAppConnectionsByOrg = async (actor: OrgServiceActor, app?: AppConnection) => { + await checkAppServicesAvailability(actor.orgId); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); + + const appConnections = await appConnectionDAL.find( + app + ? { orgId: actor.orgId, app } + : { + orgId: actor.orgId + } + ); + + return Promise.all( + appConnections + .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) + .map(async ({ encryptedCredentials, ...connection }) => { + const credentials = await decryptAppConnectionCredentials({ + encryptedCredentials, + kmsService, + orgId: connection.orgId + }); + + return { + ...connection, + credentials + } as TAppConnection; + }) + ); + }; + + const findAppConnectionById = async (app: AppConnection, connectionId: string, actor: OrgServiceActor) => { + await checkAppServicesAvailability(actor.orgId); + + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + appConnection.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); + + if (appConnection.app !== app) + throw new BadRequestError({ message: `App Connection with ID ${connectionId} is not for App "${app}"` }); + + return { + ...appConnection, + credentials: await decryptAppConnectionCredentials({ + encryptedCredentials: appConnection.encryptedCredentials, + orgId: appConnection.orgId, + kmsService + }) + } as TAppConnection; + }; + + const findAppConnectionByName = async (app: AppConnection, connectionName: string, actor: OrgServiceActor) => { + await checkAppServicesAvailability(actor.orgId); + + const appConnection = await appConnectionDAL.findOne({ name: connectionName, orgId: actor.orgId }); + + if (!appConnection) + throw new NotFoundError({ message: `Could not find App Connection with name ${connectionName}` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + appConnection.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); + + if (appConnection.app !== app) + throw new BadRequestError({ message: `App Connection with name ${connectionName} is not for App "${app}"` }); + + return { + ...appConnection, + credentials: await decryptAppConnectionCredentials({ + encryptedCredentials: appConnection.encryptedCredentials, + orgId: appConnection.orgId, + kmsService + }) + } as TAppConnection; + }; + + const createAppConnection = async ( + { method, app, credentials, ...params }: TCreateAppConnectionDTO, + actor: OrgServiceActor + ) => { + await checkAppServicesAvailability(actor.orgId); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.AppConnections); + + const appConnection = await appConnectionDAL.transaction(async (tx) => { + const isConflictingName = Boolean( + await appConnectionDAL.findOne( + { + name: params.name, + orgId: actor.orgId + }, + tx + ) + ); + + if (isConflictingName) + throw new BadRequestError({ + message: `An App Connection with the name "${params.name}" already exists` + }); + + const validatedCredentials = await validateAppConnectionCredentials({ + app, + credentials, + method, + orgId: actor.orgId + } as TAppConnectionConfig); + + const encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: validatedCredentials, + orgId: actor.orgId, + kmsService + }); + + const connection = await appConnectionDAL.create( + { + orgId: actor.orgId, + encryptedCredentials, + method, + app, + ...params + }, + tx + ); + + return { + ...connection, + credentials: validatedCredentials + }; + }); + + return appConnection; + }; + + const updateAppConnection = async ( + { connectionId, credentials, ...params }: TUpdateAppConnectionDTO, + actor: OrgServiceActor + ) => { + await checkAppServicesAvailability(actor.orgId); + + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + appConnection.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.AppConnections); + + const updatedAppConnection = await appConnectionDAL.transaction(async (tx) => { + if (params.name && appConnection.name !== params.name) { + const isConflictingName = Boolean( + await appConnectionDAL.findOne( + { + name: params.name, + orgId: appConnection.orgId + }, + tx + ) + ); + + if (isConflictingName) + throw new BadRequestError({ + message: `An App Connection with the name "${params.name}" already exists` + }); + } + + let encryptedCredentials: undefined | Buffer; + + if (credentials) { + const { app, method } = appConnection as DiscriminativePick; + + if ( + !VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[app].safeParse({ + method, + credentials + }).success + ) + throw new BadRequestError({ + message: `Invalid credential format for ${ + APP_CONNECTION_NAME_MAP[app] + } Connection with method ${getAppConnectionMethodName(method)}` + }); + + const validatedCredentials = await validateAppConnectionCredentials({ + app, + orgId: actor.orgId, + credentials, + method + } as TAppConnectionConfig); + + if (!validatedCredentials) + throw new BadRequestError({ message: "Unable to validate connection - check credentials" }); + + encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: validatedCredentials, + orgId: actor.orgId, + kmsService + }); + } + + const updatedConnection = await appConnectionDAL.updateById( + connectionId, + { + orgId: actor.orgId, + encryptedCredentials, + ...params + }, + tx + ); + + return updatedConnection; + }); + + return { + ...updatedAppConnection, + credentials: await decryptAppConnectionCredentials({ + encryptedCredentials: updatedAppConnection.encryptedCredentials, + orgId: updatedAppConnection.orgId, + kmsService + }) + } as TAppConnection; + }; + + const deleteAppConnection = async (app: AppConnection, connectionId: string, actor: OrgServiceActor) => { + await checkAppServicesAvailability(actor.orgId); + + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + appConnection.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.AppConnections); + + if (appConnection.app !== app) + throw new BadRequestError({ message: `App Connection with ID ${connectionId} is not for App "${app}"` }); + + // TODO: specify delete error message if due to existing dependencies + + const deletedAppConnection = await appConnectionDAL.deleteById(connectionId); + + return { + ...deletedAppConnection, + credentials: await decryptAppConnectionCredentials({ + encryptedCredentials: deletedAppConnection.encryptedCredentials, + orgId: deletedAppConnection.orgId, + kmsService + }) + } as TAppConnection; + }; + + return { + listAppConnectionOptions, + listAppConnectionsByOrg, + findAppConnectionById, + findAppConnectionByName, + createAppConnection, + updateAppConnection, + deleteAppConnection + }; +}; diff --git a/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts new file mode 100644 index 000000000..e3983cf91 --- /dev/null +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -0,0 +1,31 @@ +import { + TAwsConnection, + TAwsConnectionConfig, + TAwsConnectionInput, + TValidateAwsConnectionCredentials +} from "@app/services/app-connection/aws"; +import { + TGitHubConnection, + TGitHubConnectionConfig, + TGitHubConnectionInput, + TValidateGitHubConnectionCredentials +} from "@app/services/app-connection/github"; + +export type TAppConnection = { id: string } & (TAwsConnection | TGitHubConnection); + +export type TAppConnectionInput = { id: string } & (TAwsConnectionInput | TGitHubConnectionInput); + +export type TCreateAppConnectionDTO = Pick< + TAppConnectionInput, + "credentials" | "method" | "name" | "app" | "description" +>; + +export type TUpdateAppConnectionDTO = Partial> & { + connectionId: string; +}; + +export type TAppConnectionConfig = TAwsConnectionConfig | TGitHubConnectionConfig; + +export type TValidateAppConnectionCredentials = + | TValidateAwsConnectionCredentials + | TValidateGitHubConnectionCredentials; diff --git a/backend/src/services/app-connection/aws/aws-connection-enums.ts b/backend/src/services/app-connection/aws/aws-connection-enums.ts new file mode 100644 index 000000000..0b571de0c --- /dev/null +++ b/backend/src/services/app-connection/aws/aws-connection-enums.ts @@ -0,0 +1,4 @@ +export enum AwsConnectionMethod { + AssumeRole = "assume-role", + AccessKey = "access-key" +} diff --git a/backend/src/services/app-connection/aws/aws-connection-fns.ts b/backend/src/services/app-connection/aws/aws-connection-fns.ts new file mode 100644 index 000000000..36008bc58 --- /dev/null +++ b/backend/src/services/app-connection/aws/aws-connection-fns.ts @@ -0,0 +1,105 @@ +import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; +import AWS from "aws-sdk"; +import { randomUUID } from "crypto"; + +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { AwsConnectionMethod } from "./aws-connection-enums"; +import { TAwsConnectionConfig } from "./aws-connection-types"; + +export const getAwsAppConnectionListItem = () => { + const { INF_APP_CONNECTION_AWS_ACCESS_KEY_ID } = getConfig(); + + return { + name: "AWS" as const, + app: AppConnection.AWS as const, + methods: Object.values(AwsConnectionMethod) as [AwsConnectionMethod.AssumeRole, AwsConnectionMethod.AccessKey], + accessKeyId: INF_APP_CONNECTION_AWS_ACCESS_KEY_ID + }; +}; + +export const getAwsConnectionConfig = async (appConnection: TAwsConnectionConfig, region = "us-east-1") => { + const appCfg = getConfig(); + + let accessKeyId: string; + let secretAccessKey: string; + let sessionToken: undefined | string; + + const { method, credentials, orgId } = appConnection; + + switch (method) { + case AwsConnectionMethod.AssumeRole: { + const client = new STSClient({ + region, + credentials: + appCfg.INF_APP_CONNECTION_AWS_ACCESS_KEY_ID && appCfg.INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY + ? { + accessKeyId: appCfg.INF_APP_CONNECTION_AWS_ACCESS_KEY_ID, + secretAccessKey: appCfg.INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY + } + : undefined // if hosting on AWS + }); + + const command = new AssumeRoleCommand({ + RoleArn: credentials.roleArn, + RoleSessionName: `infisical-app-connection-${randomUUID()}`, + DurationSeconds: 900, // 15 mins + ExternalId: orgId + }); + + const assumeRes = await client.send(command); + + if (!assumeRes.Credentials?.AccessKeyId || !assumeRes.Credentials?.SecretAccessKey) { + throw new BadRequestError({ message: "Failed to assume role - verify credentials and role configuration" }); + } + + accessKeyId = assumeRes.Credentials.AccessKeyId; + secretAccessKey = assumeRes.Credentials.SecretAccessKey; + sessionToken = assumeRes.Credentials?.SessionToken; + break; + } + case AwsConnectionMethod.AccessKey: { + accessKeyId = credentials.accessKeyId; + secretAccessKey = credentials.secretAccessKey; + break; + } + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new InternalServerError({ message: `Unsupported AWS connection method: ${method}` }); + } + + return new AWS.Config({ + region, + credentials: { + accessKeyId, + secretAccessKey, + sessionToken + } + }); +}; + +export const validateAwsConnectionCredentials = async (appConnection: TAwsConnectionConfig) => { + const awsConfig = await getAwsConnectionConfig(appConnection); + const sts = new AWS.STS(awsConfig); + let resp: Awaited["promise"]>>; + + try { + resp = await sts.getCallerIdentity().promise(); + } catch (e: unknown) { + throw new BadRequestError({ + message: `Unable to validate connection - verify credentials` + }); + } + + if (resp.$response.httpResponse.statusCode !== 200) + throw new InternalServerError({ + message: `Unable to validate credentials: ${ + resp.$response.error?.message ?? + `AWS responded with a status code of ${resp.$response.httpResponse.statusCode}. Verify credentials and try again.` + }` + }); + + return appConnection.credentials; +}; diff --git a/backend/src/services/app-connection/aws/aws-connection-schemas.ts b/backend/src/services/app-connection/aws/aws-connection-schemas.ts new file mode 100644 index 000000000..914e92671 --- /dev/null +++ b/backend/src/services/app-connection/aws/aws-connection-schemas.ts @@ -0,0 +1,82 @@ +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 { AwsConnectionMethod } from "./aws-connection-enums"; + +export const AwsConnectionAssumeRoleCredentialsSchema = z.object({ + roleArn: z.string().trim().min(1, "Role ARN required") +}); + +export const AwsConnectionAccessTokenCredentialsSchema = z.object({ + accessKeyId: z.string().trim().min(1, "Access Key ID required"), + secretAccessKey: z.string().trim().min(1, "Secret Access Key required") +}); + +const BaseAwsConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.AWS) }); + +export const AwsConnectionSchema = z.intersection( + BaseAwsConnectionSchema, + z.discriminatedUnion("method", [ + z.object({ + method: z.literal(AwsConnectionMethod.AssumeRole), + credentials: AwsConnectionAssumeRoleCredentialsSchema + }), + z.object({ + method: z.literal(AwsConnectionMethod.AccessKey), + credentials: AwsConnectionAccessTokenCredentialsSchema + }) + ]) +); + +export const SanitizedAwsConnectionSchema = z.discriminatedUnion("method", [ + BaseAwsConnectionSchema.extend({ + method: z.literal(AwsConnectionMethod.AssumeRole), + credentials: AwsConnectionAssumeRoleCredentialsSchema.omit({ roleArn: true }) + }), + BaseAwsConnectionSchema.extend({ + method: z.literal(AwsConnectionMethod.AccessKey), + credentials: AwsConnectionAccessTokenCredentialsSchema.omit({ secretAccessKey: true }) + }) +]); + +export const ValidateAwsConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(AwsConnectionMethod.AssumeRole).describe(AppConnections?.CREATE(AppConnection.AWS).method), + credentials: AwsConnectionAssumeRoleCredentialsSchema.describe(AppConnections.CREATE(AppConnection.AWS).credentials) + }), + z.object({ + method: z.literal(AwsConnectionMethod.AccessKey).describe(AppConnections?.CREATE(AppConnection.AWS).method), + credentials: AwsConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AWS).credentials + ) + }) +]); + +export const CreateAwsConnectionSchema = ValidateAwsConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.AWS) +); + +export const UpdateAwsConnectionSchema = z + .object({ + credentials: z + .union([AwsConnectionAccessTokenCredentialsSchema, AwsConnectionAssumeRoleCredentialsSchema]) + .optional() + .describe(AppConnections.UPDATE(AppConnection.AWS).credentials) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AWS)); + +export const AwsConnectionListItemSchema = z.object({ + name: z.literal("AWS"), + app: z.literal(AppConnection.AWS), + // the below is preferable but currently breaks mintlify + // methods: z.tuple([z.literal(AwsConnectionMethod.AssumeRole), z.literal(AwsConnectionMethod.AccessKey)]), + methods: z.nativeEnum(AwsConnectionMethod).array(), + accessKeyId: z.string().optional() +}); diff --git a/backend/src/services/app-connection/aws/aws-connection-types.ts b/backend/src/services/app-connection/aws/aws-connection-types.ts new file mode 100644 index 000000000..a0b74c3d0 --- /dev/null +++ b/backend/src/services/app-connection/aws/aws-connection-types.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { + AwsConnectionSchema, + CreateAwsConnectionSchema, + ValidateAwsConnectionCredentialsSchema +} from "./aws-connection-schemas"; + +export type TAwsConnection = z.infer; + +export type TAwsConnectionInput = z.infer & { + app: AppConnection.AWS; +}; + +export type TValidateAwsConnectionCredentials = typeof ValidateAwsConnectionCredentialsSchema; + +export type TAwsConnectionConfig = DiscriminativePick & { + orgId: string; +}; diff --git a/backend/src/services/app-connection/aws/index.ts b/backend/src/services/app-connection/aws/index.ts new file mode 100644 index 000000000..4608a3483 --- /dev/null +++ b/backend/src/services/app-connection/aws/index.ts @@ -0,0 +1,4 @@ +export * from "./aws-connection-enums"; +export * from "./aws-connection-fns"; +export * from "./aws-connection-schemas"; +export * from "./aws-connection-types"; diff --git a/backend/src/services/app-connection/github/github-connection-enums.ts b/backend/src/services/app-connection/github/github-connection-enums.ts new file mode 100644 index 000000000..77a4eebac --- /dev/null +++ b/backend/src/services/app-connection/github/github-connection-enums.ts @@ -0,0 +1,4 @@ +export enum GitHubConnectionMethod { + OAuth = "oauth", + App = "github-app" +} diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts new file mode 100644 index 000000000..01fa7846f --- /dev/null +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -0,0 +1,129 @@ +import { AxiosResponse } from "axios"; + +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; +import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors"; +import { getAppConnectionMethodName } from "@app/services/app-connection/app-connection-fns"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { AppConnection } from "../app-connection-enums"; +import { GitHubConnectionMethod } from "./github-connection-enums"; +import { TGitHubConnectionConfig } from "./github-connection-types"; + +export const getGitHubConnectionListItem = () => { + const { INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, INF_APP_CONNECTION_GITHUB_APP_SLUG } = getConfig(); + + return { + name: "GitHub" as const, + app: AppConnection.GitHub as const, + methods: Object.values(GitHubConnectionMethod) as [GitHubConnectionMethod.App, GitHubConnectionMethod.OAuth], + oauthClientId: INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, + appClientSlug: INF_APP_CONNECTION_GITHUB_APP_SLUG + }; +}; + +type TokenRespData = { + access_token: string; + scope: string; + token_type: string; +}; + +export const validateGitHubConnectionCredentials = async (config: TGitHubConnectionConfig) => { + const { credentials, method } = config; + + const { + INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, + INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET, + INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID, + INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET, + SITE_URL + } = getConfig(); + + const { clientId, clientSecret } = + method === GitHubConnectionMethod.App + ? { + clientId: INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID, + clientSecret: INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET + } + : // oauth + { + clientId: INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, + clientSecret: INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET + }; + + if (!clientId || !clientSecret) { + throw new InternalServerError({ + message: `GitHub ${getAppConnectionMethodName(method)} environment variables have not been configured` + }); + } + + let tokenResp: AxiosResponse; + + try { + tokenResp = await request.get("https://github.com/login/oauth/access_token", { + params: { + client_id: clientId, + client_secret: clientSecret, + code: credentials.code, + redirect_uri: `${SITE_URL}/app-connections/github/oauth/callback` + }, + headers: { + Accept: "application/json", + "Accept-Encoding": "application/json" + } + }); + } catch (e: unknown) { + throw new BadRequestError({ + message: `Unable to validate connection - verify credentials` + }); + } + + if (tokenResp.status !== 200) { + throw new BadRequestError({ + message: `Unable to validate credentials: GitHub responded with a status code of ${tokenResp.status} (${tokenResp.statusText}). Verify credentials and try again.` + }); + } + + if (method === GitHubConnectionMethod.App) { + const installationsResp = await request.get<{ + installations: { + id: number; + account: { + login: string; + }; + }[]; + }>(IntegrationUrls.GITHUB_USER_INSTALLATIONS, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${tokenResp.data.access_token}`, + "Accept-Encoding": "application/json" + } + }); + + const matchingInstallation = installationsResp.data.installations.find( + (installation) => installation.id === +credentials.installationId + ); + + if (!matchingInstallation) { + throw new ForbiddenRequestError({ + message: "User does not have access to the provided installation" + }); + } + } + + switch (method) { + case GitHubConnectionMethod.App: + return { + // access token not needed for GitHub App + installationId: credentials.installationId + }; + case GitHubConnectionMethod.OAuth: + return { + accessToken: tokenResp.data.access_token + }; + default: + throw new InternalServerError({ + message: `Unhandled GitHub connection method: ${method as GitHubConnectionMethod}` + }); + } +}; diff --git a/backend/src/services/app-connection/github/github-connection-schemas.ts b/backend/src/services/app-connection/github/github-connection-schemas.ts new file mode 100644 index 000000000..5adb211ba --- /dev/null +++ b/backend/src/services/app-connection/github/github-connection-schemas.ts @@ -0,0 +1,93 @@ +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 { GitHubConnectionMethod } from "./github-connection-enums"; + +export const GitHubConnectionOAuthInputCredentialsSchema = z.object({ + code: z.string().trim().min(1, "OAuth code required") +}); + +export const GitHubConnectionAppInputCredentialsSchema = z.object({ + code: z.string().trim().min(1, "GitHub App code required"), + installationId: z.string().min(1, "GitHub App Installation ID required") +}); + +export const GitHubConnectionOAuthOutputCredentialsSchema = z.object({ + accessToken: z.string() +}); + +export const GitHubConnectionAppOutputCredentialsSchema = z.object({ + installationId: z.string() +}); + +export const ValidateGitHubConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(GitHubConnectionMethod.App).describe(AppConnections.CREATE(AppConnection.GitHub).method), + credentials: GitHubConnectionAppInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.GitHub).credentials + ) + }), + z.object({ + method: z.literal(GitHubConnectionMethod.OAuth).describe(AppConnections.CREATE(AppConnection.GitHub).method), + credentials: GitHubConnectionOAuthInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.GitHub).credentials + ) + }) +]); + +export const CreateGitHubConnectionSchema = ValidateGitHubConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.GitHub) +); + +export const UpdateGitHubConnectionSchema = z + .object({ + credentials: z + .union([GitHubConnectionAppInputCredentialsSchema, GitHubConnectionOAuthInputCredentialsSchema]) + .optional() + .describe(AppConnections.UPDATE(AppConnection.GitHub).credentials) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.GitHub)); + +const BaseGitHubConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.GitHub) }); + +export const GitHubAppConnectionSchema = z.intersection( + BaseGitHubConnectionSchema, + z.discriminatedUnion("method", [ + z.object({ + method: z.literal(GitHubConnectionMethod.App), + credentials: GitHubConnectionAppOutputCredentialsSchema + }), + z.object({ + method: z.literal(GitHubConnectionMethod.OAuth), + credentials: GitHubConnectionOAuthOutputCredentialsSchema + }) + ]) +); + +export const SanitizedGitHubConnectionSchema = z.discriminatedUnion("method", [ + BaseGitHubConnectionSchema.extend({ + method: z.literal(GitHubConnectionMethod.App), + credentials: GitHubConnectionAppOutputCredentialsSchema.omit({ installationId: true }) + }), + BaseGitHubConnectionSchema.extend({ + method: z.literal(GitHubConnectionMethod.OAuth), + credentials: GitHubConnectionOAuthOutputCredentialsSchema.omit({ accessToken: true }) + }) +]); + +export const GitHubConnectionListItemSchema = z.object({ + name: z.literal("GitHub"), + app: z.literal(AppConnection.GitHub), + // the below is preferable but currently breaks mintlify + // methods: z.tuple([z.literal(GitHubConnectionMethod.GitHubApp), z.literal(GitHubConnectionMethod.OAuth)]), + methods: z.nativeEnum(GitHubConnectionMethod).array(), + oauthClientId: z.string().optional(), + appClientSlug: z.string().optional() +}); diff --git a/backend/src/services/app-connection/github/github-connection-types.ts b/backend/src/services/app-connection/github/github-connection-types.ts new file mode 100644 index 000000000..5a9b13c00 --- /dev/null +++ b/backend/src/services/app-connection/github/github-connection-types.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateGitHubConnectionSchema, + GitHubAppConnectionSchema, + ValidateGitHubConnectionCredentialsSchema +} from "./github-connection-schemas"; + +export type TGitHubConnection = z.infer; + +export type TGitHubConnectionInput = z.infer & { + app: AppConnection.GitHub; +}; + +export type TValidateGitHubConnectionCredentials = typeof ValidateGitHubConnectionCredentialsSchema; + +export type TGitHubConnectionConfig = DiscriminativePick; diff --git a/backend/src/services/app-connection/github/index.ts b/backend/src/services/app-connection/github/index.ts new file mode 100644 index 000000000..35915046b --- /dev/null +++ b/backend/src/services/app-connection/github/index.ts @@ -0,0 +1,4 @@ +export * from "./github-connection-enums"; +export * from "./github-connection-fns"; +export * from "./github-connection-schemas"; +export * from "./github-connection-types"; diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts index efb582d88..d2c87e772 100644 --- a/backend/src/services/certificate-authority/certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -15,7 +15,7 @@ import { /* eslint-disable no-bitwise */ export const createSerialNumber = () => { - const randomBytes = crypto.randomBytes(20); + const randomBytes = crypto.randomBytes(20); // 20 bytes = 160 bits randomBytes[0] &= 0x7f; // ensure the first bit is 0 return randomBytes.toString("hex"); }; diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 0519b70d2..203e30abb 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -1406,14 +1406,24 @@ const syncSecretsHeroku = async ({ * Sync/push [secrets] to Vercel project named [integration.app] */ const syncSecretsVercel = async ({ + createManySecretsRawFn, integration, integrationAuth, - secrets, + secrets: infisicalSecrets, accessToken }: { - integration: TIntegrations; + createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; + integration: TIntegrations & { + projectId: string; + environment: { + id: string; + name: string; + slug: string; + }; + secretPath: string; + }; integrationAuth: TIntegrationAuths; - secrets: Record; + secrets: Record; accessToken: string; }) => { interface VercelSecret { @@ -1486,80 +1496,119 @@ const syncSecretsVercel = async ({ } } - const updateSecrets: VercelSecret[] = []; - const deleteSecrets: VercelSecret[] = []; - const newSecrets: VercelSecret[] = []; + const metadata = IntegrationMetadataSchema.parse(integration.metadata); - // Identify secrets to create - Object.keys(secrets).forEach((key) => { - if (!(key in res)) { - // case: secret has been created - newSecrets.push({ - key, - value: secrets[key].value, - type: "encrypted", - target: [integration.targetEnvironment as string], - ...(integration.path - ? { - gitBranch: integration.path - } - : {}) - }); + // Default to overwrite target for old integrations that doesn't have a initial sync behavior set. + if (!metadata.initialSyncBehavior) { + metadata.initialSyncBehavior = IntegrationInitialSyncBehavior.OVERWRITE_TARGET; + } + + const secretsToAddToInfisical: { [key: string]: VercelSecret } = {}; + + Object.keys(res).forEach((vercelKey) => { + if (!integration.lastUsed) { + // first time using integration + // -> apply initial sync behavior + switch (metadata.initialSyncBehavior) { + // Override all the secrets in Vercel + case IntegrationInitialSyncBehavior.OVERWRITE_TARGET: { + if (!(vercelKey in infisicalSecrets)) infisicalSecrets[vercelKey] = null; + break; + } + case IntegrationInitialSyncBehavior.PREFER_SOURCE: { + // if the vercel secret is not in infisical, we need to add it to infisical + if (!(vercelKey in infisicalSecrets)) { + infisicalSecrets[vercelKey] = { + value: res[vercelKey].value + }; + secretsToAddToInfisical[vercelKey] = res[vercelKey]; + } + break; + } + default: { + throw new Error(`Invalid initial sync behavior: ${metadata.initialSyncBehavior}`); + } + } + } else if (!(vercelKey in infisicalSecrets)) { + infisicalSecrets[vercelKey] = null; } }); - // Identify secrets to update and delete - Object.keys(res).forEach((key) => { - if (key in secrets) { - if (res[key].value !== secrets[key].value) { - // case: secret value has changed - updateSecrets.push({ - id: res[key].id, - key, - value: secrets[key].value, - type: res[key].type, - target: res[key].target.includes(integration.targetEnvironment as string) - ? [...res[key].target] - : [...res[key].target, integration.targetEnvironment as string], - ...(integration.path - ? { - gitBranch: integration.path - } - : {}) - }); - } - } else { - // case: secret has been deleted - deleteSecrets.push({ - id: res[key].id, - key, - value: res[key].value, - type: "encrypted", // value doesn't matter - target: [integration.targetEnvironment as string], - ...(integration.path - ? { - gitBranch: integration.path - } - : {}) - }); - } - }); - - // Sync/push new secrets - if (newSecrets.length > 0) { - await request.post(`${IntegrationUrls.VERCEL_API_URL}/v10/projects/${integration.app}/env`, newSecrets, { - params, - headers: { - Authorization: `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" - } + if (Object.keys(secretsToAddToInfisical).length) { + await createManySecretsRawFn({ + projectId: integration.projectId, + environment: integration.environment.slug, + path: integration.secretPath, + secrets: Object.keys(secretsToAddToInfisical).map((key) => ({ + secretName: key, + secretValue: secretsToAddToInfisical[key].value, + type: SecretType.Shared, + secretComment: "" + })) }); } - for await (const secret of updateSecrets) { - if (secret.type !== "sensitive") { - const { id, ...updatedSecret } = secret; - await request.patch(`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${integration.app}/env/${id}`, updatedSecret, { + // update and create logic + for await (const key of Object.keys(infisicalSecrets)) { + if (!(key in res) || infisicalSecrets[key]?.value !== res[key].value) { + // if the key is not in the vercel res, we need to create it + if (!(key in res)) { + await request.post( + `${IntegrationUrls.VERCEL_API_URL}/v10/projects/${integration.app}/env`, + { + key, + value: infisicalSecrets[key]?.value, + type: "encrypted", + target: [integration.targetEnvironment as string], + ...(integration.path + ? { + gitBranch: integration.path + } + : {}) + }, + { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + // Else if the key already exists and its not sensitive, we need to update it + } else if (res[key].type !== "sensitive") { + await request.patch( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${integration.app}/env/${res[key].id}`, + { + key, + value: infisicalSecrets[key]?.value, + type: res[key].type, + target: res[key].target.includes(integration.targetEnvironment as string) + ? [...res[key].target] + : [...res[key].target, integration.targetEnvironment as string], + ...(integration.path + ? { + gitBranch: integration.path + } + : {}) + }, + { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + } + } + } + + // delete logic + for await (const key of Object.keys(res)) { + if (infisicalSecrets[key] === null) { + // case: delete secret + await request.delete(`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${integration.app}/env/${res[key].id}`, { params, headers: { Authorization: `Bearer ${accessToken}`, @@ -1568,16 +1617,6 @@ const syncSecretsVercel = async ({ }); } } - - for await (const secret of deleteSecrets) { - await request.delete(`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, { - params, - headers: { - Authorization: `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" - } - }); - } }; /** @@ -4480,7 +4519,8 @@ export const syncIntegrationSecrets = async ({ integration, integrationAuth, secrets, - accessToken + accessToken, + createManySecretsRawFn }); break; case Integrations.NETLIFY: diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 2dc6953ed..fc302c4c8 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -8,6 +8,9 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, 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 { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; +import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; @@ -57,6 +60,9 @@ import { TListProjectCertificateTemplatesDTO, TListProjectCertsDTO, TListProjectsDTO, + TListProjectSshCasDTO, + TListProjectSshCertificatesDTO, + TListProjectSshCertificateTemplatesDTO, TLoadProjectKmsBackupDTO, TToggleProjectAutoCapitalizationDTO, TUpdateAuditLogsRetentionDTO, @@ -97,6 +103,9 @@ type TProjectServiceFactoryDep = { certificateTemplateDAL: Pick; pkiAlertDAL: Pick; pkiCollectionDAL: Pick; + sshCertificateAuthorityDAL: Pick; + sshCertificateDAL: Pick; + sshCertificateTemplateDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; licenseService: Pick; @@ -146,6 +155,9 @@ export const projectServiceFactory = ({ certificateTemplateDAL, pkiCollectionDAL, pkiAlertDAL, + sshCertificateAuthorityDAL, + sshCertificateDAL, + sshCertificateTemplateDAL, keyStore, kmsService, projectBotDAL, @@ -923,6 +935,118 @@ export const projectServiceFactory = ({ }; }; + /** + * Return list of SSH CAs for project + */ + const listProjectSshCas = async ({ + actorId, + actorOrgId, + actorAuthMethod, + actor, + projectId + }: TListProjectSshCasDTO) => { + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateAuthorities + ); + + const cas = await sshCertificateAuthorityDAL.find( + { + projectId + }, + { sort: [["updatedAt", "desc"]] } + ); + + return cas; + }; + + /** + * Return list of SSH certificates for project + */ + const listProjectSshCertificates = async ({ + limit = 25, + offset = 0, + actorId, + actorOrgId, + actorAuthMethod, + actor, + projectId + }: TListProjectSshCertificatesDTO) => { + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); + + const cas = await sshCertificateAuthorityDAL.find({ + projectId + }); + + const certificates = await sshCertificateDAL.find( + { + $in: { + sshCaId: cas.map((ca) => ca.id) + } + }, + { offset, limit, sort: [["updatedAt", "desc"]] } + ); + + const count = await sshCertificateDAL.countSshCertificatesInProject(projectId); + + return { certificates, totalCount: count }; + }; + + /** + * Return list of SSH certificate templates for project + */ + const listProjectSshCertificateTemplates = async ({ + actorId, + actorOrgId, + actorAuthMethod, + actor, + projectId + }: TListProjectSshCertificateTemplatesDTO) => { + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateTemplates + ); + + const cas = await sshCertificateAuthorityDAL.find({ + projectId + }); + + const certificateTemplates = await sshCertificateTemplateDAL.find({ + $in: { + sshCaId: cas.map((ca) => ca.id) + } + }); + + return { certificateTemplates }; + }; + const updateProjectKmsKey = async ({ projectId, kms, @@ -1156,6 +1280,9 @@ export const projectServiceFactory = ({ listProjectAlerts, listProjectPkiCollections, listProjectCertificateTemplates, + listProjectSshCas, + listProjectSshCertificates, + listProjectSshCertificateTemplates, updateVersionLimit, updateAuditLogsRetention, updateProjectKmsKey, diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 29261da4f..2c6b8e2da 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -132,6 +132,13 @@ export type TGetProjectKmsKey = TProjectPermission; export type TListProjectCertificateTemplatesDTO = TProjectPermission; +export type TListProjectSshCasDTO = TProjectPermission; +export type TListProjectSshCertificateTemplatesDTO = TProjectPermission; +export type TListProjectSshCertificatesDTO = { + offset: number; + limit: number; +} & TProjectPermission; + export type TGetProjectSlackConfig = TProjectPermission; export type TUpdateProjectSlackConfig = { diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 0f61e8c48..8bb1e03d2 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -1,4 +1,5 @@ /* eslint-disable no-await-in-loop */ +import opentelemetry from "@opentelemetry/api"; import { AxiosError } from "axios"; import { @@ -167,6 +168,12 @@ export const secretQueueFactory = ({ projectKeyDAL, resourceMetadataDAL }: TSecretQueueFactoryDep) => { + const integrationMeter = opentelemetry.metrics.getMeter("Integrations"); + const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", { + description: "Integration secret sync errors", + unit: "1" + }); + const removeSecretReminder = async (dto: TRemoveSecretReminderDTO) => { const appCfg = getConfig(); await queueService.stopRepeatableJob( @@ -951,6 +958,19 @@ export const secretQueueFactory = ({ `Secret integration sync error [projectId=${job.data.projectId}] [environment=${environment}] [secretPath=${job.data.secretPath}]` ); + const appCfg = getConfig(); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + errorHistogram.record(1, { + version: 1, + integration: integration.integration, + integrationId: integration.id, + type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError", + status: err instanceof AxiosError ? err.response?.status : undefined, + name: err instanceof Error ? err.name : undefined, + projectId: integration.projectId + }); + } + const message = // eslint-disable-next-line no-nested-ternary (err instanceof AxiosError diff --git a/cli/go.mod b/cli/go.mod index b55a49c63..637bd904d 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -10,7 +10,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.4.3 + github.com/infisical/go-sdk v0.4.7 github.com/mattn/go-isatty v0.0.20 github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a github.com/muesli/mango-cobra v1.2.0 @@ -23,8 +23,8 @@ require ( github.com/spf13/cobra v1.6.1 github.com/spf13/viper v1.8.1 github.com/stretchr/testify v1.9.0 - golang.org/x/crypto v0.25.0 - golang.org/x/term v0.22.0 + golang.org/x/crypto v0.31.0 + golang.org/x/term v0.27.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -93,9 +93,9 @@ require ( go.opentelemetry.io/otel/trace v1.24.0 // indirect golang.org/x/net v0.27.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.5.0 // indirect google.golang.org/api v0.188.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 // indirect diff --git a/cli/go.sum b/cli/go.sum index 537a146cb..79fe1fd7f 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -265,8 +265,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.4.3 h1:O5ZJ2eCBAZDE9PIAfBPq9Utb2CgQKrhmj9R0oFTRu4U= -github.com/infisical/go-sdk v0.4.3/go.mod h1:6fWzAwTPIoKU49mQ2Oxu+aFnJu9n7k2JcNrZjzhHM2M= +github.com/infisical/go-sdk v0.4.7 h1:+cxIdDfciMh0Syxbxbqjhvz9/ShnN1equ2zqlVQYGtw= +github.com/infisical/go-sdk v0.4.7/go.mod h1:6fWzAwTPIoKU49mQ2Oxu+aFnJu9n7k2JcNrZjzhHM2M= github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -453,8 +453,8 @@ golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= -golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -564,8 +564,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -620,16 +620,16 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= -golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -643,8 +643,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/cli/packages/cmd/ssh.go b/cli/packages/cmd/ssh.go new file mode 100644 index 000000000..d7c1f1e26 --- /dev/null +++ b/cli/packages/cmd/ssh.go @@ -0,0 +1,609 @@ +/* +Copyright (c) 2023 Infisical Inc. +*/ +package cmd + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/config" + "github.com/Infisical/infisical-merge/packages/util" + infisicalSdk "github.com/infisical/go-sdk" + infisicalSdkUtil "github.com/infisical/go-sdk/packages/util" + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +var sshCmd = &cobra.Command{ + Example: `infisical ssh`, + Short: "Used to issue SSH credentials", + Use: "ssh", + DisableFlagsInUseLine: true, + Args: cobra.NoArgs, +} + +var sshIssueCredentialsCmd = &cobra.Command{ + Example: `ssh issue-credentials`, + Short: "Used to issue SSH credentials against a certificate template", + Use: "issue-credentials", + DisableFlagsInUseLine: true, + Args: cobra.NoArgs, + Run: issueCredentials, +} + +var sshSignKeyCmd = &cobra.Command{ + Example: `ssh sign-key`, + Short: "Used to sign a SSH public key against a certificate template", + Use: "sign-key", + DisableFlagsInUseLine: true, + Args: cobra.NoArgs, + Run: signKey, +} + +var algoToFileName = map[infisicalSdkUtil.CertKeyAlgorithm]string{ + infisicalSdkUtil.RSA2048: "id_rsa_2048", + infisicalSdkUtil.RSA4096: "id_rsa_4096", + infisicalSdkUtil.ECDSAP256: "id_ecdsa_p256", + infisicalSdkUtil.ECDSAP384: "id_ecdsa_p384", +} + +func isValidKeyAlgorithm(algo infisicalSdkUtil.CertKeyAlgorithm) bool { + _, exists := algoToFileName[algo] + return exists +} + +func isValidCertType(certType infisicalSdkUtil.SshCertType) bool { + switch certType { + case infisicalSdkUtil.UserCert, infisicalSdkUtil.HostCert: + return true + default: + return false + } +} + +func writeToFile(filePath string, content string, perm os.FileMode) error { + // Ensure the directory exists + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + // Write the content to the file + err := os.WriteFile(filePath, []byte(content), perm) + if err != nil { + return fmt.Errorf("failed to write to file %s: %w", filePath, err) + } + + return nil +} + +func addCredentialsToAgent(privateKeyContent, certContent string) error { + // Parse the private key + privateKey, err := ssh.ParseRawPrivateKey([]byte(privateKeyContent)) + if err != nil { + return fmt.Errorf("failed to parse private key: %w", err) + } + + // Parse the certificate + pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certContent)) + if err != nil { + return fmt.Errorf("failed to parse certificate: %w", err) + } + + cert, ok := pubKey.(*ssh.Certificate) + if !ok { + return fmt.Errorf("parsed key is not a certificate") + } + // Calculate LifetimeSecs based on certificate's valid-to time + validUntil := time.Unix(int64(cert.ValidBefore), 0) + now := time.Now() + + // Handle ValidBefore as either a timestamp or an enumeration + // SSH certificates use ValidBefore as a timestamp unless set to 0 or ~0 + if cert.ValidBefore == ssh.CertTimeInfinity { + // If certificate never expires, set default lifetime to 1 year (can adjust as needed) + validUntil = now.Add(365 * 24 * time.Hour) + } + + // Calculate the duration until expiration + lifetime := validUntil.Sub(now) + if lifetime <= 0 { + return fmt.Errorf("certificate is already expired") + } + + // Convert duration to seconds + lifetimeSecs := uint32(lifetime.Seconds()) + + // Connect to the SSH agent + socket := os.Getenv("SSH_AUTH_SOCK") + if socket == "" { + return fmt.Errorf("SSH_AUTH_SOCK not set") + } + + conn, err := net.Dial("unix", socket) + if err != nil { + return fmt.Errorf("failed to connect to SSH agent: %w", err) + } + defer conn.Close() + + agentClient := agent.NewClient(conn) + + // Add the key with certificate to the agent + err = agentClient.Add(agent.AddedKey{ + PrivateKey: privateKey, + Certificate: cert, + Comment: "Added via Infisical CLI", + LifetimeSecs: lifetimeSecs, + }) + if err != nil { + return fmt.Errorf("failed to add key to agent: %w", err) + } + + return nil +} + +func issueCredentials(cmd *cobra.Command, args []string) { + + token, err := util.GetInfisicalToken(cmd) + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + 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] and try again") + } + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken + } + + certificateTemplateId, err := cmd.Flags().GetString("certificateTemplateId") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + if certificateTemplateId == "" { + util.PrintErrorMessageAndExit("You must set the --certificateTemplateId flag") + } + + principalsStr, err := cmd.Flags().GetString("principals") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + // Check if the input string is empty before splitting + if principalsStr == "" { + util.HandleError(fmt.Errorf("no principals provided"), "The 'principals' flag cannot be empty") + } + + // Convert the comma-delimited string into a slice of strings + principals := strings.Split(principalsStr, ",") + for i, principal := range principals { + principals[i] = strings.TrimSpace(principal) + } + + keyAlgorithm, err := cmd.Flags().GetString("keyAlgorithm") + if err != nil { + util.HandleError(err, "Unable to parse keyAlgorithm flag") + } + + if !isValidKeyAlgorithm(infisicalSdkUtil.CertKeyAlgorithm(keyAlgorithm)) { + util.HandleError(fmt.Errorf("invalid keyAlgorithm: %s", keyAlgorithm), + "Valid values: RSA_2048, RSA_4096, EC_prime256v1, EC_secp384r1") + } + + certType, err := cmd.Flags().GetString("certType") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + if !isValidCertType(infisicalSdkUtil.SshCertType(certType)) { + util.HandleError(fmt.Errorf("invalid certType: %s", certType), + "Valid values: user, host") + } + + ttl, err := cmd.Flags().GetString("ttl") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + keyId, err := cmd.Flags().GetString("keyId") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + outFilePath, err := cmd.Flags().GetString("outFilePath") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + addToAgent, err := cmd.Flags().GetBool("addToAgent") + if err != nil { + util.HandleError(err, "Unable to parse addToAgent flag") + } + + if outFilePath == "" && addToAgent == false { + util.PrintErrorMessageAndExit("You must provide either --outFilePath or --addToAgent flag to use this command") + } + + var ( + outputDir string + privateKeyPath string + publicKeyPath string + signedKeyPath string + ) + + if outFilePath != "" { + // Expand ~ to home directory if present + if strings.HasPrefix(outFilePath, "~") { + homeDir, err := os.UserHomeDir() + if err != nil { + util.HandleError(err, "Failed to resolve home directory") + } + outFilePath = strings.Replace(outFilePath, "~", homeDir, 1) + } + + // Check if outFilePath ends with "-cert.pub" + if strings.HasSuffix(outFilePath, "-cert.pub") { + // Treat outFilePath as the signed key path + signedKeyPath = outFilePath + + // Derive the base name by removing "-cert.pub" + baseName := strings.TrimSuffix(filepath.Base(outFilePath), "-cert.pub") + + // Set the output directory + outputDir = filepath.Dir(outFilePath) + + // Define private and public key paths + privateKeyPath = filepath.Join(outputDir, baseName) + publicKeyPath = filepath.Join(outputDir, baseName+".pub") + } else { + // Treat outFilePath as a directory + outputDir = outFilePath + + // Check if the directory exists; if not, create it + info, err := os.Stat(outputDir) + if os.IsNotExist(err) { + err = os.MkdirAll(outputDir, 0755) + if err != nil { + util.HandleError(err, "Failed to create output directory") + } + } else if err != nil { + util.HandleError(err, "Failed to access output directory") + } else if !info.IsDir() { + util.PrintErrorMessageAndExit("The provided --outFilePath is not a directory") + } + } + } + + // Define file names based on key algorithm + fileName := algoToFileName[infisicalSdkUtil.CertKeyAlgorithm(keyAlgorithm)] + + // Define file paths + privateKeyPath = filepath.Join(outputDir, fileName) + publicKeyPath = filepath.Join(outputDir, fileName+".pub") + signedKeyPath = filepath.Join(outputDir, fileName+"-cert.pub") + + // If outFilePath ends with "-cert.pub", ensure the signedKeyPath is set + if strings.HasSuffix(outFilePath, "-cert.pub") { + // Ensure the signedKeyPath was set + if signedKeyPath == "" { + util.HandleError(fmt.Errorf("signedKeyPath is not set correctly"), "Internal error") + } + } else { + // Ensure all paths are set + if privateKeyPath == "" || publicKeyPath == "" || signedKeyPath == "" { + util.HandleError(fmt.Errorf("file paths are not set correctly"), "Internal error") + } + } + + infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ + SiteUrl: config.INFISICAL_URL, + UserAgent: api.USER_AGENT, + AutoTokenRefresh: false, + }) + infisicalClient.Auth().SetAccessToken(infisicalToken) + + creds, err := infisicalClient.Ssh().IssueCredentials(infisicalSdk.IssueSshCredsOptions{ + CertificateTemplateID: certificateTemplateId, + Principals: principals, + KeyAlgorithm: infisicalSdkUtil.CertKeyAlgorithm(keyAlgorithm), + CertType: infisicalSdkUtil.SshCertType(certType), + TTL: ttl, + KeyID: keyId, + }) + + if err != nil { + util.HandleError(err, "Failed to issue SSH credentials") + } + + if outFilePath != "" { + // If signedKeyPath wasn't set in the directory scenario, set it now + if signedKeyPath == "" { + fileName := algoToFileName[infisicalSdkUtil.CertKeyAlgorithm(keyAlgorithm)] + signedKeyPath = filepath.Join(outputDir, fileName+"-cert.pub") + } + + if privateKeyPath == "" { + privateKeyPath = filepath.Join(outputDir, algoToFileName[infisicalSdkUtil.CertKeyAlgorithm(keyAlgorithm)]) + } + err = writeToFile(privateKeyPath, creds.PrivateKey, 0600) + if err != nil { + util.HandleError(err, "Failed to write Private Key to file") + } + + if publicKeyPath == "" { + publicKeyPath = privateKeyPath + ".pub" + } + err = writeToFile(publicKeyPath, creds.PublicKey, 0644) + if err != nil { + util.HandleError(err, "Failed to write Public Key to file") + } + + err = writeToFile(signedKeyPath, creds.SignedKey, 0644) + if err != nil { + util.HandleError(err, "Failed to write Signed Key to file") + } + + fmt.Println("Successfully wrote SSH certificate to:", signedKeyPath) + } + + // Add SSH credentials to the SSH agent if needed + if addToAgent { + // Call the helper function to handle add-to-agent flow + err := addCredentialsToAgent(creds.PrivateKey, creds.SignedKey) + if err != nil { + util.HandleError(err, "Failed to add keys to SSH agent") + } else { + fmt.Println("The SSH key and certificate have been successfully added to your ssh-agent.") + } + } +} + +func signKey(cmd *cobra.Command, args []string) { + + token, err := util.GetInfisicalToken(cmd) + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + 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] and try again") + } + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken + } + + certificateTemplateId, err := cmd.Flags().GetString("certificateTemplateId") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + if certificateTemplateId == "" { + util.PrintErrorMessageAndExit("You must set the --certificateTemplateId flag") + } + + publicKey, err := cmd.Flags().GetString("publicKey") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + publicKeyFilePath, err := cmd.Flags().GetString("publicKeyFilePath") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + if publicKey == "" && publicKeyFilePath == "" { + util.HandleError(fmt.Errorf("either --publicKey or --publicKeyFilePath must be provided"), "Invalid input") + } + + if publicKey != "" && publicKeyFilePath != "" { + util.HandleError(fmt.Errorf("only one of --publicKey or --publicKeyFile can be provided"), "Invalid input") + } + + if publicKeyFilePath != "" { + if strings.HasPrefix(publicKeyFilePath, "~") { + // Expand the tilde (~) to the user's home directory + homeDir, err := os.UserHomeDir() + if err != nil { + util.HandleError(err, "Failed to resolve home directory") + } + publicKeyFilePath = strings.Replace(publicKeyFilePath, "~", homeDir, 1) + } + + // Ensure the file has a .pub extension + if !strings.HasSuffix(publicKeyFilePath, ".pub") { + util.HandleError(fmt.Errorf("public key file must have a .pub extension"), "Invalid input") + } + + content, err := os.ReadFile(publicKeyFilePath) + if err != nil { + util.HandleError(err, "Failed to read public key file") + } + + publicKey = strings.TrimSpace(string(content)) + } + + if strings.TrimSpace(publicKey) == "" { + util.HandleError(fmt.Errorf("Public key is empty"), "Invalid input") + } + + principalsStr, err := cmd.Flags().GetString("principals") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + // Check if the input string is empty before splitting + if principalsStr == "" { + util.HandleError(fmt.Errorf("no principals provided"), "The 'principals' flag cannot be empty") + } + + // Convert the comma-delimited string into a slice of strings + principals := strings.Split(principalsStr, ",") + for i, principal := range principals { + principals[i] = strings.TrimSpace(principal) + } + + certType, err := cmd.Flags().GetString("certType") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + if !isValidCertType(infisicalSdkUtil.SshCertType(certType)) { + util.HandleError(fmt.Errorf("invalid certType: %s", certType), + "Valid values: user, host") + } + + ttl, err := cmd.Flags().GetString("ttl") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + keyId, err := cmd.Flags().GetString("keyId") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + outFilePath, err := cmd.Flags().GetString("outFilePath") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + var ( + outputDir string + signedKeyPath string + ) + + if outFilePath == "" { + // Use current working directory + if err != nil { + util.HandleError(err, "Failed to get current working directory") + } + + // check if public key path exists + if publicKeyFilePath == "" { + util.PrintErrorMessageAndExit("--outFilePath must be specified when --publicKeyFilePath is not provided") + } + + outputDir = filepath.Dir(publicKeyFilePath) + // Derive the base name by removing "-cert.pub" + baseName := strings.TrimSuffix(filepath.Base(publicKeyFilePath), ".pub") + signedKeyPath = filepath.Join(outputDir, baseName+"-cert.pub") + } else { + // Expand ~ to home directory if present + if strings.HasPrefix(outFilePath, "~") { + homeDir, err := os.UserHomeDir() + if err != nil { + util.HandleError(err, "Failed to resolve home directory") + } + outFilePath = strings.Replace(outFilePath, "~", homeDir, 1) + } + + // Check if outFilePath ends with "-cert.pub" + if !strings.HasSuffix(outFilePath, "-cert.pub") { + util.PrintErrorMessageAndExit("--outFilePath must end with -cert.pub") + } + + // Extract the directory from outFilePath + outputDir = filepath.Dir(outFilePath) + + // Validate the output directory + info, err := os.Stat(outputDir) + if os.IsNotExist(err) { + // Directory does not exist; attempt to create it + err = os.MkdirAll(outputDir, 0755) + if err != nil { + util.HandleError(err, "Failed to create output directory") + } + } else if err != nil { + // Other errors accessing the directory + util.HandleError(err, "Failed to access output directory") + } else if !info.IsDir() { + // Path exists but is not a directory + util.PrintErrorMessageAndExit("The provided --outFilePath's directory is not valid") + } + + signedKeyPath = outFilePath + } + + infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ + SiteUrl: config.INFISICAL_URL, + UserAgent: api.USER_AGENT, + AutoTokenRefresh: false, + }) + infisicalClient.Auth().SetAccessToken(infisicalToken) + + creds, err := infisicalClient.Ssh().SignKey(infisicalSdk.SignSshPublicKeyOptions{ + CertificateTemplateID: certificateTemplateId, + PublicKey: publicKey, + Principals: principals, + CertType: infisicalSdkUtil.SshCertType(certType), + TTL: ttl, + KeyID: keyId, + }) + + if err != nil { + util.HandleError(err, "Failed to sign SSH public key") + } + + err = writeToFile(signedKeyPath, creds.SignedKey, 0644) + if err != nil { + util.HandleError(err, "Failed to write Signed Key to file") + } + + fmt.Println("Successfully wrote SSH certificate to:", signedKeyPath) +} + +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") + sshSignKeyCmd.Flags().String("publicKey", "", "The public key to sign") + sshSignKeyCmd.Flags().String("publicKeyFilePath", "", "The file path to the public key file to sign") + sshSignKeyCmd.Flags().String("outFilePath", "", "The path to write the SSH certificate to such as ~/.ssh/id_rsa-cert.pub. If not provided, the credentials will be saved to the directory of the specified public key file path or the current working directory") + sshSignKeyCmd.Flags().String("principals", "", "The principals that the certificate should be signed for") + sshSignKeyCmd.Flags().String("certType", string(infisicalSdkUtil.UserCert), "The cert type for the created certificate") + sshSignKeyCmd.Flags().String("ttl", "", "The ttl for the created certificate") + sshSignKeyCmd.Flags().String("keyId", "", "The keyId that the created certificate should have") + sshCmd.AddCommand(sshSignKeyCmd) + + sshIssueCredentialsCmd.Flags().String("token", "", "Issue SSH credentials using machine identity access token") + sshIssueCredentialsCmd.Flags().String("certificateTemplateId", "", "The ID of the SSH certificate template to issue SSH credentials for") + sshIssueCredentialsCmd.Flags().String("principals", "", "The principals to issue SSH credentials for") + sshIssueCredentialsCmd.Flags().String("keyAlgorithm", string(infisicalSdkUtil.RSA2048), "The key algorithm to issue SSH credentials for") + sshIssueCredentialsCmd.Flags().String("certType", string(infisicalSdkUtil.UserCert), "The cert type to issue SSH credentials for") + sshIssueCredentialsCmd.Flags().String("ttl", "", "The ttl to issue SSH credentials for") + sshIssueCredentialsCmd.Flags().String("keyId", "", "The keyId to issue SSH credentials for") + 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) + rootCmd.AddCommand(sshCmd) +} diff --git a/docs/api-reference/endpoints/app-connections/aws/create.mdx b/docs/api-reference/endpoints/app-connections/aws/create.mdx new file mode 100644 index 000000000..2fd1602ed --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/aws/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/aws" +--- + + + Check out the configuration docs for [AWS Connections](/integrations/app-connections/aws) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/aws/delete.mdx b/docs/api-reference/endpoints/app-connections/aws/delete.mdx new file mode 100644 index 000000000..e6030257f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/aws/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/aws/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/aws/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/aws/get-by-id.mdx new file mode 100644 index 000000000..0a057cc1b --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/aws/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/aws/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/aws/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/aws/get-by-name.mdx new file mode 100644 index 000000000..d18994f7c --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/aws/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/aws/name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/aws/list.mdx b/docs/api-reference/endpoints/app-connections/aws/list.mdx new file mode 100644 index 000000000..5ea0c50a0 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/aws/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/aws" +--- diff --git a/docs/api-reference/endpoints/app-connections/aws/update.mdx b/docs/api-reference/endpoints/app-connections/aws/update.mdx new file mode 100644 index 000000000..4fd3a4a00 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/aws/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/aws/{connectionId}" +--- + + + Check out the configuration docs for [AWS Connections](/integrations/app-connections/aws) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/github/create.mdx b/docs/api-reference/endpoints/app-connections/github/create.mdx new file mode 100644 index 000000000..1e06fd64f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/github" +--- + + + GitHub Connections must be created through the Infisical UI. + Check out the configuration docs for [GitHub Connections](/integrations/app-connections/github) for a step-by-step + guide. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/github/delete.mdx b/docs/api-reference/endpoints/app-connections/github/delete.mdx new file mode 100644 index 000000000..6b4f2e676 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/github/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/github/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/github/get-by-id.mdx new file mode 100644 index 000000000..c85d41d37 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/github/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/github/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/github/get-by-name.mdx new file mode 100644 index 000000000..95ddbd6e9 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/github/name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/github/list.mdx b/docs/api-reference/endpoints/app-connections/github/list.mdx new file mode 100644 index 000000000..c4b13b8eb --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/github" +--- diff --git a/docs/api-reference/endpoints/app-connections/github/update.mdx b/docs/api-reference/endpoints/app-connections/github/update.mdx new file mode 100644 index 000000000..7e2326c60 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/github/{connectionId}" +--- + + + GitHub Connections must be updated through the Infisical UI. + Check out the configuration docs for [GitHub Connections](/integrations/app-connections/github) for a step-by-step + guide. + diff --git a/docs/api-reference/endpoints/app-connections/list.mdx b/docs/api-reference/endpoints/app-connections/list.mdx new file mode 100644 index 000000000..e7ee6b009 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections" +--- diff --git a/docs/api-reference/endpoints/app-connections/options.mdx b/docs/api-reference/endpoints/app-connections/options.mdx new file mode 100644 index 000000000..7cc03aca3 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/options.mdx @@ -0,0 +1,4 @@ +--- +title: "Options" +openapi: "GET /api/v1/app-connections/options" +--- diff --git a/docs/api-reference/endpoints/ssh/ca/create.mdx b/docs/api-reference/endpoints/ssh/ca/create.mdx new file mode 100644 index 000000000..b053d0133 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/ca/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/ssh/ca" +--- diff --git a/docs/api-reference/endpoints/ssh/ca/delete.mdx b/docs/api-reference/endpoints/ssh/ca/delete.mdx new file mode 100644 index 000000000..989fd1c4b --- /dev/null +++ b/docs/api-reference/endpoints/ssh/ca/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/ssh/ca/{sshCaId}" +--- diff --git a/docs/api-reference/endpoints/ssh/ca/list-certificate-templates.mdx b/docs/api-reference/endpoints/ssh/ca/list-certificate-templates.mdx new file mode 100644 index 000000000..632a9f8c0 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/ca/list-certificate-templates.mdx @@ -0,0 +1,4 @@ +--- +title: "List templates" +openapi: "GET /api/v1/ssh/ca/{sshCaId}/certificate-templates" +--- diff --git a/docs/api-reference/endpoints/ssh/ca/list.mdx b/docs/api-reference/endpoints/ssh/ca/list.mdx new file mode 100644 index 000000000..c31dd4099 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/ca/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/workspace/{projectId}/ssh-cas" +--- diff --git a/docs/api-reference/endpoints/ssh/ca/public-key.mdx b/docs/api-reference/endpoints/ssh/ca/public-key.mdx new file mode 100644 index 000000000..1f9b570d1 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/ca/public-key.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve public key" +openapi: "GET /api/v1/ssh/ca/{sshCaId}/public-key" +--- diff --git a/docs/api-reference/endpoints/ssh/ca/read.mdx b/docs/api-reference/endpoints/ssh/ca/read.mdx new file mode 100644 index 000000000..9f5eda90a --- /dev/null +++ b/docs/api-reference/endpoints/ssh/ca/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/ssh/ca/{sshCaId}" +--- diff --git a/docs/api-reference/endpoints/ssh/ca/update.mdx b/docs/api-reference/endpoints/ssh/ca/update.mdx new file mode 100644 index 000000000..8ec2dc7ad --- /dev/null +++ b/docs/api-reference/endpoints/ssh/ca/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/ssh/ca/{sshCaId}" +--- diff --git a/docs/api-reference/endpoints/ssh/certificate-templates/create.mdx b/docs/api-reference/endpoints/ssh/certificate-templates/create.mdx new file mode 100644 index 000000000..6e3beef1a --- /dev/null +++ b/docs/api-reference/endpoints/ssh/certificate-templates/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/ssh/certificate-templates" +--- diff --git a/docs/api-reference/endpoints/ssh/certificate-templates/delete.mdx b/docs/api-reference/endpoints/ssh/certificate-templates/delete.mdx new file mode 100644 index 000000000..1fa776276 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/certificate-templates/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/ssh/certificate-templates/{certificateTemplateId}" +--- diff --git a/docs/api-reference/endpoints/ssh/certificate-templates/list.mdx b/docs/api-reference/endpoints/ssh/certificate-templates/list.mdx new file mode 100644 index 000000000..4331db1de --- /dev/null +++ b/docs/api-reference/endpoints/ssh/certificate-templates/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/workspace/{projectId}/ssh-certificate-templates" +--- diff --git a/docs/api-reference/endpoints/ssh/certificate-templates/read.mdx b/docs/api-reference/endpoints/ssh/certificate-templates/read.mdx new file mode 100644 index 000000000..13a356688 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/certificate-templates/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/ssh/certificate-templates/{certificateTemplateId}" +--- diff --git a/docs/api-reference/endpoints/ssh/certificate-templates/update.mdx b/docs/api-reference/endpoints/ssh/certificate-templates/update.mdx new file mode 100644 index 000000000..f566d7535 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/certificate-templates/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/ssh/certificate-templates/{certificateTemplateId}" +--- diff --git a/docs/api-reference/endpoints/ssh/certificates/issue-credentials.mdx b/docs/api-reference/endpoints/ssh/certificates/issue-credentials.mdx new file mode 100644 index 000000000..4a6da70b3 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/certificates/issue-credentials.mdx @@ -0,0 +1,4 @@ +--- +title: "Issue SSH Credentials" +openapi: "POST /api/v1/ssh/certificates/issue" +--- diff --git a/docs/api-reference/endpoints/ssh/certificates/sign-key.mdx b/docs/api-reference/endpoints/ssh/certificates/sign-key.mdx new file mode 100644 index 000000000..0843b34a2 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/certificates/sign-key.mdx @@ -0,0 +1,4 @@ +--- +title: "Sign SSH Public Key" +openapi: "POST /api/v1/ssh/certificates/sign" +--- diff --git a/docs/documentation/platform/ssh.mdx b/docs/documentation/platform/ssh.mdx new file mode 100644 index 000000000..1bcad484a --- /dev/null +++ b/docs/documentation/platform/ssh.mdx @@ -0,0 +1,209 @@ +--- +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 certificates 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 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. + +
+ +```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 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. + + + + 1.1. Start by creating a SSH project in the SSH tab of your organization. + + ![ssh project create](/images/platform/ssh/ssh-project.png) + + 1.2. Next, create a CA in the **Certificate Authorities** tab of the + project. + + ![ssh create ca](/images/platform/ssh/ssh-create-ca-1.png) + + ![ssh create ca popup](/images/platform/ssh/ssh-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 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`. + + 1.3. 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 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. + + + + + 2.1. Begin by downloading the CA's public key from the CA's details section. + + ![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`. + + 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. + + ```bash + TrustedUserCAKeys /etc/ssh/ca.pub + + PubkeyAcceptedKeyTypes=+ssh-rsa,ssh-rsa-cert-v01@openssh.com + ``` + + 2.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 + +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. + + + + + + +```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. + + + + 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. + diff --git a/docs/images/app-connections/aws/access-key-connection.png b/docs/images/app-connections/aws/access-key-connection.png new file mode 100644 index 000000000..9c70da623 Binary files /dev/null and b/docs/images/app-connections/aws/access-key-connection.png differ diff --git a/docs/images/app-connections/aws/assume-role-connection.png b/docs/images/app-connections/aws/assume-role-connection.png new file mode 100644 index 000000000..c01f2e016 Binary files /dev/null and b/docs/images/app-connections/aws/assume-role-connection.png differ diff --git a/docs/images/app-connections/aws/create-access-key-method.png b/docs/images/app-connections/aws/create-access-key-method.png new file mode 100644 index 000000000..a82cca038 Binary files /dev/null and b/docs/images/app-connections/aws/create-access-key-method.png differ diff --git a/docs/images/app-connections/aws/create-assume-role-method.png b/docs/images/app-connections/aws/create-assume-role-method.png new file mode 100644 index 000000000..4b422222d Binary files /dev/null and b/docs/images/app-connections/aws/create-assume-role-method.png differ diff --git a/docs/images/app-connections/aws/parameter-store-permissions.png b/docs/images/app-connections/aws/parameter-store-permissions.png new file mode 100644 index 000000000..1fb2b8118 Binary files /dev/null and b/docs/images/app-connections/aws/parameter-store-permissions.png differ diff --git a/docs/images/app-connections/aws/secrets-manager-permissions.png b/docs/images/app-connections/aws/secrets-manager-permissions.png new file mode 100644 index 000000000..57d2eb2e2 Binary files /dev/null and b/docs/images/app-connections/aws/secrets-manager-permissions.png differ diff --git a/docs/images/app-connections/aws/select-aws-connection.png b/docs/images/app-connections/aws/select-aws-connection.png new file mode 100644 index 000000000..0cd51bb7f Binary files /dev/null and b/docs/images/app-connections/aws/select-aws-connection.png differ diff --git a/docs/images/app-connections/general/add-connection.png b/docs/images/app-connections/general/add-connection.png new file mode 100644 index 000000000..97718065a Binary files /dev/null and b/docs/images/app-connections/general/add-connection.png differ diff --git a/docs/images/app-connections/github/create-github-app-method.png b/docs/images/app-connections/github/create-github-app-method.png new file mode 100644 index 000000000..640fb0213 Binary files /dev/null and b/docs/images/app-connections/github/create-github-app-method.png differ diff --git a/docs/images/app-connections/github/create-oauth-method.png b/docs/images/app-connections/github/create-oauth-method.png new file mode 100644 index 000000000..4898a0de0 Binary files /dev/null and b/docs/images/app-connections/github/create-oauth-method.png differ diff --git a/docs/images/app-connections/github/github-app-connection.png b/docs/images/app-connections/github/github-app-connection.png new file mode 100644 index 000000000..3d81bc182 Binary files /dev/null and b/docs/images/app-connections/github/github-app-connection.png differ diff --git a/docs/images/app-connections/github/install-github-app.png b/docs/images/app-connections/github/install-github-app.png new file mode 100644 index 000000000..3b09ed485 Binary files /dev/null and b/docs/images/app-connections/github/install-github-app.png differ diff --git a/docs/images/app-connections/github/oauth-connection.png b/docs/images/app-connections/github/oauth-connection.png new file mode 100644 index 000000000..bf907256c Binary files /dev/null and b/docs/images/app-connections/github/oauth-connection.png differ diff --git a/docs/images/app-connections/github/select-github-connection.png b/docs/images/app-connections/github/select-github-connection.png new file mode 100644 index 000000000..2856d0a39 Binary files /dev/null and b/docs/images/app-connections/github/select-github-connection.png differ diff --git a/docs/images/platform/ssh/ssh-ca-public-key.png b/docs/images/platform/ssh/ssh-ca-public-key.png new file mode 100644 index 000000000..42653df4a Binary files /dev/null and b/docs/images/platform/ssh/ssh-ca-public-key.png differ diff --git a/docs/images/platform/ssh/ssh-create-ca-1.png b/docs/images/platform/ssh/ssh-create-ca-1.png new file mode 100644 index 000000000..e9a5b7f06 Binary files /dev/null and b/docs/images/platform/ssh/ssh-create-ca-1.png differ diff --git a/docs/images/platform/ssh/ssh-create-ca-2.png b/docs/images/platform/ssh/ssh-create-ca-2.png new file mode 100644 index 000000000..63025025f Binary files /dev/null and b/docs/images/platform/ssh/ssh-create-ca-2.png differ diff --git a/docs/images/platform/ssh/ssh-create-template-1.png b/docs/images/platform/ssh/ssh-create-template-1.png new file mode 100644 index 000000000..9d9420948 Binary files /dev/null and b/docs/images/platform/ssh/ssh-create-template-1.png differ diff --git a/docs/images/platform/ssh/ssh-create-template-2.png b/docs/images/platform/ssh/ssh-create-template-2.png new file mode 100644 index 000000000..7b93e6d80 Binary files /dev/null and b/docs/images/platform/ssh/ssh-create-template-2.png differ diff --git a/docs/images/platform/ssh/ssh-project.png b/docs/images/platform/ssh/ssh-project.png new file mode 100644 index 000000000..0b57f9245 Binary files /dev/null and b/docs/images/platform/ssh/ssh-project.png differ diff --git a/docs/integrations/app-connections/aws.mdx b/docs/integrations/app-connections/aws.mdx new file mode 100644 index 000000000..65af1bcdc --- /dev/null +++ b/docs/integrations/app-connections/aws.mdx @@ -0,0 +1,354 @@ +--- +title: "AWS Connection" +description: "Learn how to configure an AWS Connection for Infisical." +--- + +Infisical supports two methods for connecting to AWS. + + + + Infisical will assume the provided role in your AWS account securely, without the need to share any credentials. + + **Prerequisites:** + + - Set up and add envars to [Infisical Cloud](https://app.infisical.com) + + + To connect your self-hosted Infisical instance with AWS, you need to set up an AWS IAM User account that can assume the configured AWS IAM Role. + + If your instance is deployed on AWS, the aws-sdk will automatically retrieve the credentials. Ensure that you assign the provided permission policy to your deployed instance, such as ECS or EC2. + + The following steps are for instances not deployed on AWS: + + + Navigate to [Create IAM User](https://console.aws.amazon.com/iamv2/home#/users/create) in your AWS Console. + + + Attach the following inline permission policy to the IAM User to allow it to assume any IAM Roles: + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowAssumeAnyRole", + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam::*:role/*" + } + ] + } + ``` + + + Obtain the AWS access key ID and secret access key for your IAM User by navigating to **IAM > Users > [Your User] > Security credentials > Access keys**. + + ![Access Key Step 1](/images/integrations/aws/integrations-aws-access-key-1.png) + ![Access Key Step 2](/images/integrations/aws/integrations-aws-access-key-2.png) + ![Access Key Step 3](/images/integrations/aws/integrations-aws-access-key-3.png) + + + 1. Set the access key as **INF_APP_CONNECTION_AWS_CLIENT_ID**. + 2. Set the secret key as **INF_APP_CONNECTION_AWS_CLIENT_SECRET**. + + + + + + + 1. Navigate to the [Create IAM Role](https://console.aws.amazon.com/iamv2/home#/roles/create?step=selectEntities) page in your AWS Console. + ![IAM Role Creation](/images/integrations/aws/integration-aws-iam-assume-role.png) + + 2. Select **AWS Account** as the **Trusted Entity Type**. + 3. Choose **Another AWS Account** and enter **381492033652** (Infisical AWS Account ID). This restricts the role to be assumed only by Infisical. If self-hosting, provide your AWS account number instead. + 4. Optionally, enable **Require external ID** and enter your **Organization ID** to further enhance security. + + + + Depending on your use case, add one or more of the following policies to your IAM Role: + + + + + + Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Secrets Manager: + + ![IAM Role Secrets Manager Permissions](/images/app-connections/aws/secrets-manager-permissions.png) + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowSecretsManagerAccess", + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue", + "secretsmanager:CreateSecret", + "secretsmanager:UpdateSecret", + "secretsmanager:DescribeSecret", + "secretsmanager:TagResource", + "secretsmanager:UntagResource", + "kms:ListKeys", + "kms:ListAliases", + "kms:Encrypt", + "kms:Decrypt" + ], + "Resource": "*" + } + ] + } + ``` + + + Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Parameter Store: + + ![IAM Role Secrets Manager Permissions](/images/app-connections/aws/parameter-store-permissions.png) + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowSSMAccess", + "Effect": "Allow", + "Action": [ + "ssm:PutParameter", + "ssm:DeleteParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath", + "ssm:DescribeParameters", + "ssm:DeleteParameters", + "ssm:AddTagsToResource", // if you need to add tags to secrets + "kms:ListKeys", // if you need to specify the KMS key + "kms:ListAliases", // if you need to specify the KMS key + "kms:Encrypt", // if you need to specify the KMS key + "kms:Decrypt" // if you need to specify the KMS key + ], + "Resource": "*" + } + ] + } + ``` + + + + + + + + ![Copy IAM Role ARN](/images/integrations/aws/integration-aws-iam-assume-arn.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 **AWS Connection** option. + ![Select AWS Connection](/images/app-connections/aws/select-aws-connection.png) + + 3. Select the **Assume Role** method option and provide the **AWS IAM Role ARN** obtained from the previous step and press **Connect to AWS**. + ![Create AWS Connection](/images/app-connections/aws/create-assume-role-method.png) + + 4. Your **AWS Connection** is now available for use. + ![Assume Role AWS Connection](/images/app-connections/aws/assume-role-connection.png) + + + To create an AWS Connection, make an API request to the [Create AWS + Connection](/api-reference/endpoints/app-connections/aws/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/aws \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-aws-connection", + "method": "assume-role", + "credentials": { + "roleArn": "...", + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-aws-connection", + "version": 123, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "app": "aws", + "method": "assume-role", + "credentials": {} + } + } + ``` + + + + + + + + Infisical will use the provided **Access Key ID** and **Secret Key** to connect to your AWS instance. + + **Prerequisites:** + + - Set up and add envars to [Infisical Cloud](https://app.infisical.com) + + + + 1. Navigate to the [Create IAM Role](https://console.aws.amazon.com/iamv2/home#/roles/create?step=selectEntities) page in your AWS Console. + ![IAM Role Creation](/images/integrations/aws/integration-aws-iam-assume-role.png) + + 2. Select **AWS Account** as the **Trusted Entity Type**. + 3. Choose **Another AWS Account** and enter **381492033652** (Infisical AWS Account ID). This restricts the role to be assumed only by Infisical. If self-hosting, provide your AWS account number instead. + 4. Optionally, enable **Require external ID** and enter your **Organization ID** to further enhance security. + + + + Depending on your use case, add one or more of the following policies to your IAM Role: + + + + + + Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Secrets Manager: + + ![IAM Role Secrets Manager Permissions](/images/app-connections/aws/secrets-manager-permissions.png) + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowSecretsManagerAccess", + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue", + "secretsmanager:CreateSecret", + "secretsmanager:UpdateSecret", + "secretsmanager:DescribeSecret", + "secretsmanager:TagResource", + "secretsmanager:UntagResource", + "kms:ListKeys", + "kms:ListAliases", + "kms:Encrypt", + "kms:Decrypt" + ], + "Resource": "*" + } + ] + } + ``` + + + Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Parameter Store: + + ![IAM Role Secrets Manager Permissions](/images/app-connections/aws/parameter-store-permissions.png) + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowSSMAccess", + "Effect": "Allow", + "Action": [ + "ssm:PutParameter", + "ssm:DeleteParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath", + "ssm:DescribeParameters", + "ssm:DeleteParameters", + "ssm:AddTagsToResource", // if you need to add tags to secrets + "kms:ListKeys", // if you need to specify the KMS key + "kms:ListAliases", // if you need to specify the KMS key + "kms:Encrypt", // if you need to specify the KMS key + "kms:Decrypt" // if you need to specify the KMS key + ], + "Resource": "*" + } + ] + } + ``` + + + + + + + Retrieve an AWS **Access Key ID** and a **Secret Key** for your IAM user in **IAM > Users > User > Security credentials > Access keys**. + + ![access key 1](/images/integrations/aws/integrations-aws-access-key-1.png) + ![access key 2](/images/integrations/aws/integrations-aws-access-key-2.png) + ![access key 3](/images/integrations/aws/integrations-aws-access-key-3.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 **AWS Connection** option. + ![Select AWS Connection](/images/app-connections/aws/select-aws-connection.png) + + 3. Select the **Access Key** method option and provide the **Access Key ID** and **Secret Key** obtained from the previous step and press **Connect to AWS**. + ![Create AWS Connection](/images/app-connections/aws/create-access-key-method.png) + + 4. Your **AWS Connection** is now available for use. + ![Assume Role AWS Connection](/images/app-connections/aws/access-key-connection.png) + + + To create an AWS Connection, make an API request to the [Create AWS + Connection](/api-reference/endpoints/app-connections/aws/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/aws \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-aws-connection", + "method": "access-key", + "credentials": { + "accessKeyId": "...", + "secretKey": "..." + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-aws-connection", + "version": 123, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "app": "aws", + "method": "access-key", + "credentials": { + "accessKeyId": "..." + } + } + } + ``` + + + + + + + diff --git a/docs/integrations/app-connections/github.mdx b/docs/integrations/app-connections/github.mdx new file mode 100644 index 000000000..18f702bb6 --- /dev/null +++ b/docs/integrations/app-connections/github.mdx @@ -0,0 +1,169 @@ +--- +title: "GitHub Connection" +description: "Learn how to configure a GitHub Connection for Infisical." +--- + +Infisical supports two methods for connecting to GitHub. + + + + Infisical will use a GitHub App with finely grained permissions to connect to GitHub. + + **Prerequisites:** + + - Set up and add envars to [Infisical Cloud](https://app.infisical.com) + + + Using the GitHub integration with app authentication on a self-hosted instance of Infisical requires configuring an application on GitHub + and registering your instance with it. + + + + Navigate to the GitHub app settings [here](https://github.com/settings/apps). Click **New GitHub App**. + + ![integrations github app create](/images/integrations/github/app/self-hosted-github-app-create.png) + + Give the application a name, a homepage URL (your self-hosted domain i.e. `https://your-domain.com`), and a callback URL (i.e. `https://your-domain.com/app-connections/github/oauth/callback`). + + ![integrations github app basic details](/images/integrations/github/app/self-hosted-github-app-basic-details.png) + + Enable request user authorization during app installation. + ![integrations github app enable auth](/images/integrations/github/app/self-hosted-github-app-enable-oauth.png) + + Disable webhook by unchecking the Active checkbox. + ![integrations github app webhook](/images/integrations/github/app/self-hosted-github-app-webhook.png) + + Set the repository permissions as follows: Metadata: Read-only, Secrets: Read and write, Environments: Read and write, Actions: Read. + ![integrations github app repository](/images/integrations/github/app/self-hosted-github-app-repository.png) + + Similarly, set the organization permissions as follows: Secrets: Read and write. + ![integrations github app organization](/images/integrations/github/app/self-hosted-github-app-organization.png) + + Create the Github application. + ![integrations github app create confirm](/images/integrations/github/app/self-hosted-github-app-create-confirm.png) + + + If you have a GitHub organization, you can create an application under it + in your organization Settings > Developer settings > GitHub Apps > New GitHub App. + + + + Generate a new **Client Secret** for your GitHub application. + ![integrations github app create secret](/images/integrations/github/app/self-hosted-github-app-secret.png) + + Generate a new **Private Key** for your Github application. + ![integrations github app create private key](/images/integrations/github/app/self-hosted-github-app-private-key.png) + + Obtain the necessary Github application credentials. This would be the application slug, client ID, app ID, client secret, and private key. + ![integrations github app credentials](/images/integrations/github/app/self-hosted-github-app-credentials.png) + + Back in your Infisical instance, add the five new environment variables for the credentials of your GitHub application: + + - `INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID`: The **Client ID** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET`: The **Client Secret** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_APP_CLIENT_SLUG`: The **Slug** of your GitHub application. This is the one found in the URL. + - `INF_APP_CONNECTION_GITHUB_APP_CLIENT_APP_ID`: The **App ID** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_APP_CLIENT_PRIVATE_KEY`: The **Private Key** of your GitHub application. + + Once added, restart your Infisical instance and use the GitHub integration via app authentication. + + + + + ## Setup GitHub 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 **GitHub Connection** option from the connection options modal. + ![Select GitHub Connection](/images/app-connections/github/select-github-connection.png) + + + Select the **GitHub App** method and click **Connect to GitHub**. + ![Connect via GitHub App](/images/app-connections/github/create-github-app-method.png) + + + You will then be redirected to the GitHub app installation page. + + Install and authorize the GitHub application. This will redirect you back to Infisical's App Connections page. + ![Install GitHub App](/images/app-connections/github/install-github-app.png) + + + Your **GitHub Connection** is now available for use. + ![Assume Role AWS Connection](/images/app-connections/github/github-app-connection.png) + + + + + Infisical will use an OAuth App to connect to GitHub. + + **Prerequisites:** + + - Set up and add envars to [Infisical Cloud](https://app.infisical.com) + + + Using the GitHub integration on a self-hosted instance of Infisical requires configuring an OAuth application in GitHub + and registering your instance with it. + + + Navigate to your user Settings > Developer settings > OAuth Apps to create a new GitHub OAuth application. + + ![integrations github config](../../images/integrations/github/integrations-github-config-settings.png) + ![integrations github config](../../images/integrations/github/integrations-github-config-dev-settings.png) + ![integrations github config](../../images/integrations/github/integrations-github-config-new-app.png) + + Create the OAuth application. As part of the form, set the **Homepage URL** to your self-hosted domain `https://your-domain.com` + and the **Authorization callback URL** to `https://your-domain.com/app-connections/github/oauth/callback`. + + ![integrations github config](../../images/integrations/github/integrations-github-config-new-app-form.png) + + + If you have a GitHub organization, you can create an OAuth application under it + in your organization Settings > Developer settings > OAuth Apps > New Org OAuth App. + + + + Obtain the **Client ID** and generate a new **Client Secret** for your GitHub OAuth application. + + ![integrations github config](../../images/integrations/github/integrations-github-config-credentials.png) + + Back in your Infisical instance, add two new environment variables for the credentials of your GitHub OAuth application: + + - `INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID`: The **Client ID** of your GitHub OAuth application. + - `INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET`: The **Client Secret** of your GitHub OAuth application. + + Once added, restart your Infisical instance and use the GitHub integration. + + + + + ## Setup GitHub 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 **GitHub Connection** option from the connection options modal. + ![Select GitHub Connection](/images/app-connections/github/select-github-connection.png) + + + Select the **OAuth** method and click **Connect to GitHub**. + ![Connect via GitHub App](/images/app-connections/github/create-oauth-method.png) + + + You will then be redirected to the GitHub to grant Infisical access to your GitHub account (organization and repo privileges). + Once granted, you will redirect you back to Infisical's App Connections page. + ![GitHub Authorization](/images/integrations/github/integrations-github-auth.png) + + + Your **GitHub Connection** is now available for use. + ![Assume Role AWS Connection](/images/app-connections/github/oauth-connection.png) + + + + diff --git a/docs/integrations/app-connections/overview.mdx b/docs/integrations/app-connections/overview.mdx new file mode 100644 index 000000000..64f3616de --- /dev/null +++ b/docs/integrations/app-connections/overview.mdx @@ -0,0 +1,77 @@ +--- +sidebarTitle: "Overview" +description: "Learn how to manage and configure third-party app connections with Infisical." +--- + +App Connections enable your organization to integrate Infisical with third-party services in a secure and versatile way. + +## Concept + +App Connections are an organization-level resource used to establish connections with third-party applications +that can be used across Infisical projects. Example use cases include syncing secrets, generating dynamic secrets, and more. + +
+ +
+ + ```mermaid + %%{init: {'flowchart': {'curve': 'linear'} } }%% + graph TD + A[AWS] + B[AWS Connection] + C[Project 1 Secret Sync] + D[Project 2 Secret Sync] + E[Project 3 Generate Dynamic Secret] + + B --> A + C --> B + D --> B + E --> B + + classDef default fill:#ffffff,stroke:#666,stroke-width:2px,rx:10px,color:black + classDef aws fill:#FFF2B2,stroke:#E6C34A,stroke-width:2px,color:black,rx:15px + classDef project fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px + classDef connection fill:#F4FFE6,stroke:#96D600,stroke-width:2px,color:black,rx:15px + + class A aws + class B connection + class C,D,E project + ``` + +
+ +## Workflow + +App Connections require initial setup in both your third-party application and Infisical. Follow these steps to establish a secure connection: + + + For step-by-step guides specific to each application, refer to the App Connections section in the Navigation Bar. + + +1. Create Access Entity: If necessary, create an entity such as a service account or role within the third-party application you want to connect to. Be sure +to limit the access of this entity to the minimal permission set required to perform the operations you need. For example: + - For secret syncing: Read/write permissions to specific secret stores + - For dynamic secrets: Permissions to create temporary credentials + + + Whenever possible, Infisical encourages creating a designated service account for your App Connection to limit the scope of permissions based on your use-case. + + +2. Generate Authentication Credentials: Obtain the required credentials from your third-party application. These can vary between applications and might be: + - an API key or access token + - A client ID and secret pair + - other credentials, etc. + +3. Create App Connection: Configure the connection in Infisical using your generated credentials through either the UI or API. + + + Some App Connections can only be created via the UI such as connections using OAuth. + + +4. Utilize the Connection: Use your App Connection for various features across Infisical such as our Secrets Sync by selecting it via the dropdown menu +in the UI or by passing the associated `connectionId` when generating resources via the API. + + + Infisical is continuously expanding its third-party application support. If your desired application isn't listed, + you can still use previous methods of connecting to it such as our Native Integrations. + \ No newline at end of file diff --git a/docs/mint.json b/docs/mint.json index 030a0b4dc..7eb28336b 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -114,6 +114,7 @@ "documentation/platform/pki/alerting" ] }, + "documentation/platform/ssh", { "group": "Key Management (KMS)", "pages": [ @@ -340,6 +341,14 @@ "cli/faq" ] }, + { + "group": "App Connections", + "pages": [ + "integrations/app-connections/overview", + "integrations/app-connections/aws", + "integrations/app-connections/github" + ] + }, { "group": "Infrastructure Integrations", "pages": [ @@ -756,6 +765,33 @@ "api-reference/endpoints/identity-specific-privilege/list" ] }, + { + "group": "App Connections", + "pages": [ + "api-reference/endpoints/app-connections/list", + "api-reference/endpoints/app-connections/options", + { "group": "AWS", + "pages": [ + "api-reference/endpoints/app-connections/aws/list", + "api-reference/endpoints/app-connections/aws/get-by-id", + "api-reference/endpoints/app-connections/aws/get-by-name", + "api-reference/endpoints/app-connections/aws/create", + "api-reference/endpoints/app-connections/aws/update", + "api-reference/endpoints/app-connections/aws/delete" + ] + }, + { "group": "GitHub", + "pages": [ + "api-reference/endpoints/app-connections/github/list", + "api-reference/endpoints/app-connections/github/get-by-id", + "api-reference/endpoints/app-connections/github/get-by-name", + "api-reference/endpoints/app-connections/github/create", + "api-reference/endpoints/app-connections/github/update", + "api-reference/endpoints/app-connections/github/delete" + ] + } + ] + }, { "group": "Integrations", "pages": [ @@ -846,6 +882,40 @@ } ] }, + { + "group": "Infisical SSH", + "pages": [ + { + "group": "Certificates", + "pages": [ + "api-reference/endpoints/ssh/certificates/issue-credentials", + "api-reference/endpoints/ssh/certificates/sign-key" + ] + }, + { + "group": "Certificate Authorities", + "pages": [ + "api-reference/endpoints/ssh/ca/list", + "api-reference/endpoints/ssh/ca/create", + "api-reference/endpoints/ssh/ca/read", + "api-reference/endpoints/ssh/ca/update", + "api-reference/endpoints/ssh/ca/delete", + "api-reference/endpoints/ssh/ca/public-key", + "api-reference/endpoints/ssh/ca/list-certificate-templates" + ] + }, + { + "group": "Certificate Templates", + "pages": [ + "api-reference/endpoints/ssh/certificate-templates/list", + "api-reference/endpoints/ssh/certificate-templates/create", + "api-reference/endpoints/ssh/certificate-templates/read", + "api-reference/endpoints/ssh/certificate-templates/update", + "api-reference/endpoints/ssh/certificate-templates/delete" + ] + } + ] + }, { "group": "Infisical KMS", "pages": [ diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 2e21410a8..8f902c506 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -418,7 +418,53 @@ When set, all visits to the Infisical login page will automatically redirect use information. -## Native secret integrations +## App Connections + +You can configure third-party app connections for re-use across Infisical Projects. + + + + The AWS IAM User access key ID for assuming roles + + + + The AWS IAM User secret key for assuming roles + + + + + + The ID of the GitHub App + + + + The slug of the GitHub App + + + + The client ID for the GitHub App + + + + The client secret for the GitHub App + + + + The private key for the GitHub App + + + + + + The OAuth2 client ID for GitHub OAuth Connection + + + + The OAuth2 client secret for GitHub OAuth Connection + + + +## Native Secret Integrations To help you sync secrets from Infisical to services such as Github and Gitlab, Infisical provides native integrations out of the box. @@ -492,7 +538,7 @@ To help you sync secrets from Infisical to services such as Github and Gitlab, I - + The AWS IAM User access key for assuming roles. diff --git a/frontend/public/images/integrations/Amazon Web Services.png b/frontend/public/images/integrations/Amazon Web Services.png index 65b4a6ee8..d4025224e 100644 Binary files a/frontend/public/images/integrations/Amazon Web Services.png and b/frontend/public/images/integrations/Amazon Web Services.png differ diff --git a/frontend/public/lotties/system-regular-126-verified-hover-verified.json b/frontend/public/lotties/system-regular-126-verified-hover-verified.json new file mode 100644 index 000000000..ce8777c0a --- /dev/null +++ b/frontend/public/lotties/system-regular-126-verified-hover-verified.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":60,"w":500,"h":500,"nm":"system-regular-126-verified","ddd":0,"assets":[{"id":"comp_1","nm":"hover-verified","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.004,250.003,0],"ix":2,"l":2},"a":{"a":0,"k":[250.004,250.003,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],[6.109,-6.116],[-6.115,-6.108],[0,0],[-4.002,0],[-3.055,3.051],[0,0],[6.107,6.115],[6.115,-6.107],[0,0]],"o":[[-6.115,-6.107],[-6.107,6.116],[0,0],[3.056,3.052],[4.002,0],[0,0],[6.115,-6.108],[-6.109,-6.116],[0,0],[0,0]],"v":[[-69.803,-8.539],[-91.936,-8.526],[-91.922,13.607],[-39.704,65.762],[-28.644,70.339],[-17.584,65.762],[91.922,-43.616],[91.936,-65.749],[69.803,-65.762],[-28.644,32.57]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-126-verified').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":[263.158,242.187],"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},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[73.521,-47.165],[0,0],[0,0],[11.506,86.895],[0,0],[-39.84,20.453],[-33.146,-7.159]],"o":[[-11.51,86.919],[0,0],[0,0],[-73.503,-47.153],[0,0],[33.146,-7.159],[39.84,20.453],[0,0]],"v":[[149.277,-67.68],[15.49,143.298],[0,153.266],[-15.509,143.287],[-149.274,-67.655],[-154.479,-107.449],[0,-153.936],[154.479,-107.449]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[8.073,1.615],[32.173,20.107],[5.074,-3.172],[35.445,-7.092],[-1.068,-8.164],[0,0],[-81.252,-52.124],[0,0],[-2.944,0],[-2.579,1.66],[0,0],[-12.724,96.093],[0,0]],"o":[[-35.445,-7.092],[-5.074,-3.172],[-32.173,20.107],[-8.073,1.615],[0,0],[12.72,96.068],[0,0],[2.579,1.66],[2.944,0],[0,0],[81.27,-52.136],[0,0],[1.068,-8.164]],"v":[[174.946,-135.138],[8.294,-185.147],[-8.294,-185.147],[-174.946,-135.138],[-187.394,-117.763],[-180.307,-63.572],[-32.428,169.62],[-8.469,185.036],[0,187.526],[8.469,185.036],[32.409,169.632],[180.31,-63.596],[187.394,-117.763]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-126-verified').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":[250.004,250.003],"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":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":60,"op":300,"st":1,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.004,250.003,0],"ix":2,"l":2},"a":{"a":0,"k":[250.004,250.003,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],[6.109,-6.116],[-6.115,-6.108],[0,0],[-4.002,0],[-3.055,3.051],[0,0],[6.107,6.115],[6.115,-6.107],[0,0]],"o":[[-6.115,-6.107],[-6.107,6.116],[0,0],[3.056,3.052],[4.002,0],[0,0],[6.115,-6.108],[-6.109,-6.116],[0,0],[0,0]],"v":[[-69.803,-8.539],[-91.936,-8.526],[-91.922,13.607],[-39.704,65.762],[-28.644,70.339],[-17.584,65.762],[91.922,-43.616],[91.936,-65.749],[69.803,-65.762],[-28.644,32.57]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-126-verified').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":[263.158,242.187],"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},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[73.521,-47.165],[0,0],[0,0],[11.506,86.895],[0,0],[-39.84,20.453],[-33.146,-7.159]],"o":[[-11.51,86.919],[0,0],[0,0],[-73.503,-47.153],[0,0],[33.146,-7.159],[39.84,20.453],[0,0]],"v":[[149.277,-67.68],[15.49,143.298],[0,153.266],[-15.509,143.287],[-149.274,-67.655],[-154.479,-107.449],[0,-153.936],[154.479,-107.449]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[8.073,1.615],[32.173,20.107],[5.074,-3.172],[35.445,-7.092],[-1.068,-8.164],[0,0],[-81.252,-52.124],[0,0],[-2.944,0],[-2.579,1.66],[0,0],[-12.724,96.093],[0,0]],"o":[[-35.445,-7.092],[-5.074,-3.172],[-32.173,20.107],[-8.073,1.615],[0,0],[12.72,96.068],[0,0],[2.579,1.66],[2.944,0],[0,0],[81.27,-52.136],[0,0],[1.068,-8.164]],"v":[[174.946,-135.138],[8.294,-185.147],[-8.294,-185.147],[-174.946,-135.138],[-187.394,-117.763],[-180.307,-63.572],[-32.428,169.62],[-8.469,185.036],[0,187.526],[8.469,185.036],[32.409,169.632],[180.31,-63.596],[187.394,-117.763]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-126-verified').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":[250.004,250.003],"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":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.131],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.628],"y":[0]},"t":30,"s":[27]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":46,"s":[-11]},{"t":60,"s":[0]}],"ix":10},"p":{"a":0,"k":[249.998,250.004,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.131,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[{"i":[[0,0],[0,0],[77.333,-49.716],[0,0],[0,0],[0,0],[11.993,91.145],[0,0],[-41.666,26.039]],"o":[[0,0],[-11.99,91.149],[0,0],[0,0],[0,0],[-77.33,-49.714],[0,0],[0,0],[41.667,26.039]],"v":[[171.875,-119.795],[164.746,-65.6],[23.905,156.51],[0.007,171.873],[0,171.875],[-23.904,156.508],[-164.743,-65.592],[-171.875,-119.795],[0,-171.875]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.628,"y":0},"t":21,"s":[{"i":[[0,0],[0,0],[99.436,-63.925],[0,0],[0,0],[0,0],[15.421,117.196],[0,0],[-53.575,33.481]],"o":[[0,0],[-15.417,117.201],[0,0],[0,0],[0,0],[-99.432,-63.922],[0,0],[0,0],[53.576,33.481]],"v":[[191.412,-122.573],[182.245,-52.889],[1.15,232.704],[-29.579,252.458],[-29.588,252.46],[-60.324,232.701],[-241.417,-52.879],[-250.588,-122.573],[-29.588,-189.539]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":41.801,"s":[{"i":[[0,0],[0,0],[56.69,-36.445],[0,0],[0,0],[0,0],[8.792,66.816],[0,0],[-30.544,19.088]],"o":[[0,0],[-8.79,66.819],[0,0],[0,0],[0,0],[-56.688,-36.443],[0,0],[0,0],[30.545,19.088]],"v":[[125.996,-87.818],[120.77,-48.09],[17.524,114.733],[0.005,125.995],[0,125.996],[-17.523,114.731],[-120.768,-48.083],[-125.996,-87.818],[0,-125.996]],"c":true}]},{"t":60,"s":[{"i":[[0,0],[0,0],[77.333,-49.716],[0,0],[0,0],[0,0],[11.993,91.145],[0,0],[-41.666,26.039]],"o":[[0,0],[-11.99,91.149],[0,0],[0,0],[0,0],[-77.33,-49.714],[0,0],[0,0],[41.667,26.039]],"v":[[171.875,-119.795],[164.746,-65.6],[23.905,156.51],[0.007,171.873],[0,171.875],[-23.904,156.508],[-164.743,-65.592],[-171.875,-119.795],[0,-171.875]],"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('system-regular-126-verified').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"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":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":-239,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":".primary.design","cl":"primary design","parent":3,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0.006,-0.001,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.157,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[153.363,-119.689],[-14.644,46.689],[-66.863,-5.466]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.6,"y":0},"t":21,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[167.916,-123.188],[-50.252,92.864],[-118.061,25.138]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":42,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[114.144,-89.081],[-10.899,34.749],[-49.764,-4.068]],"c":false}]},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[153.363,-119.689],[-14.644,46.689],[-66.863,-5.466]],"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('system-regular-126-verified').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"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":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[100]},{"t":20,"s":[100],"h":1},{"i":{"x":[0.1],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":28,"s":[0]},{"t":60,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[26.2]},{"t":20,"s":[100],"h":1},{"i":{"x":[0.1],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":28,"s":[0]},{"t":60,"s":[26.2]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":1,"op":60,"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":"primary","np":3,"mn":"ADBE Color Control","ix":1,"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-verified","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":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":500,"h":500,"ip":0,"op":70,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-verified","dr":60}],"props":{}} \ No newline at end of file diff --git a/frontend/src/components/navigation/RegionSelect.tsx b/frontend/src/components/navigation/RegionSelect.tsx index 44f2336cd..51a033244 100644 --- a/frontend/src/components/navigation/RegionSelect.tsx +++ b/frontend/src/components/navigation/RegionSelect.tsx @@ -3,6 +3,7 @@ import { faCheck } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Modal, ModalContent, ModalTrigger, Select, SelectItem } from "@app/components/v2"; +import { isInfisicalCloud } from "@app/helpers/platform"; enum Region { US = "us", @@ -79,10 +80,7 @@ export const RegionSelect = () => { }; const shouldDisplay = - window.location.origin.includes("https://app.infisical.com") || - window.location.origin.includes("https://us.infisical.com") || - window.location.origin.includes("https://eu.infisical.com") || - window.location.origin.includes("http://localhost:8080"); + isInfisicalCloud() || window.location.origin.includes("http://localhost:8080"); // only display region select for cloud if (!shouldDisplay) return null; diff --git a/frontend/src/components/v2/FormControl/FormControl.tsx b/frontend/src/components/v2/FormControl/FormControl.tsx index 8651422a1..4d5446519 100644 --- a/frontend/src/components/v2/FormControl/FormControl.tsx +++ b/frontend/src/components/v2/FormControl/FormControl.tsx @@ -44,7 +44,7 @@ export const FormLabel = ({ )} {tooltipText && ( - + )} diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 41a2e7e3c..4480bbad8 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -23,7 +23,8 @@ export enum OrgPermissionSubjects { Kms = "kms", AdminConsole = "organization-admin-console", AuditLogs = "audit-logs", - ProjectTemplates = "project-templates" + ProjectTemplates = "project-templates", + AppConnections = "app-connections" } export enum OrgPermissionAdminConsoleAction { @@ -47,6 +48,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole] | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] - | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]; + | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] + | [OrgPermissionActions, OrgPermissionSubjects.AppConnections]; export type TOrgPermission = MongoAbility; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 673b2b41a..b0c3aa463 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -85,6 +85,9 @@ export enum ProjectPermissionSub { CertificateAuthorities = "certificate-authorities", Certificates = "certificates", CertificateTemplates = "certificate-templates", + SshCertificateAuthorities = "ssh-certificate-authorities", + SshCertificateTemplates = "ssh-certificate-templates", + SshCertificates = "ssh-certificates", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", @@ -165,6 +168,9 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts new file mode 100644 index 000000000..9d52fb14e --- /dev/null +++ b/frontend/src/helpers/appConnections.ts @@ -0,0 +1,29 @@ +import { faGithub } from "@fortawesome/free-brands-svg-icons"; +import { faKey, faPassport, faUser } from "@fortawesome/free-solid-svg-icons"; + +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + AwsConnectionMethod, + GitHubConnectionMethod, + TAppConnection +} from "@app/hooks/api/appConnections/types"; + +export const APP_CONNECTION_MAP: Record = { + [AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" }, + [AppConnection.GitHub]: { name: "GitHub", image: "GitHub.png" } +}; + +export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { + switch (method) { + case GitHubConnectionMethod.App: + return { name: "GitHub App", icon: faGithub }; + case GitHubConnectionMethod.OAuth: + return { name: "OAuth", icon: faPassport }; + case AwsConnectionMethod.AccessKey: + return { name: "Access Key", icon: faKey }; + case AwsConnectionMethod.AssumeRole: + return { name: "Assume Role", icon: faUser }; + default: + throw new Error(`Unhandled App Connection Method: ${method}`); + } +}; diff --git a/frontend/src/helpers/platform.ts b/frontend/src/helpers/platform.ts new file mode 100644 index 000000000..821febcb8 --- /dev/null +++ b/frontend/src/helpers/platform.ts @@ -0,0 +1,4 @@ +export const isInfisicalCloud = () => + window.location.origin.includes("https://app.infisical.com") || + window.location.origin.includes("https://us.infisical.com") || + window.location.origin.includes("https://eu.infisical.com"); diff --git a/frontend/src/helpers/project.ts b/frontend/src/helpers/project.ts index 8898f00c9..0734335e7 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -69,14 +69,19 @@ export const getProjectHomePage = (workspace: Workspace) => { return `/${workspace.type}/${workspace.id}/certificates`; } - return `/${workspace.type}/${workspace.id}/kms`; + if (workspace.type === ProjectType.KMS) { + return `/${workspace.type}/${workspace.id}/kms`; + } + + return `/${workspace.type}/${workspace.id}/ssh`; }; export const getProjectTitle = (type: ProjectType) => { const titleConvert = { [ProjectType.SecretManager]: "Secret Management", [ProjectType.KMS]: "Key Management", - [ProjectType.CertificateManager]: "Cert Management" + [ProjectType.CertificateManager]: "Cert Management", + [ProjectType.SSH]: "SSH" }; return titleConvert[type]; }; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts new file mode 100644 index 000000000..3c1a409a4 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -0,0 +1,4 @@ +export enum AppConnection { + AWS = "aws", + GitHub = "github" +} diff --git a/frontend/src/hooks/api/appConnections/index.ts b/frontend/src/hooks/api/appConnections/index.ts new file mode 100644 index 000000000..177955438 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/index.ts @@ -0,0 +1,3 @@ +export * from "./mutations"; +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/mutations.tsx b/frontend/src/hooks/api/appConnections/mutations.tsx new file mode 100644 index 000000000..d9e2912d7 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/mutations.tsx @@ -0,0 +1,58 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { appConnectionKeys } from "@app/hooks/api/appConnections/queries"; +import { + TAppConnectionResponse, + TCreateAppConnectionDTO, + TDeleteAppConnectionDTO, + TUpdateAppConnectionDTO +} from "@app/hooks/api/appConnections/types"; + +export const useCreateAppConnection = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ app, ...params }: TCreateAppConnectionDTO) => { + const { data } = await apiRequest.post( + `/api/v1/app-connections/${app}`, + params + ); + + return data.appConnection; + }, + onSuccess: () => queryClient.invalidateQueries(appConnectionKeys.list()) + }); +}; + +export const useUpdateAppConnection = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ connectionId, app, ...params }: TUpdateAppConnectionDTO) => { + const { data } = await apiRequest.patch( + `/api/v1/app-connections/${app}/${connectionId}`, + params + ); + + return data.appConnection; + }, + onSuccess: (_, { connectionId, app }) => { + queryClient.invalidateQueries(appConnectionKeys.list()); + queryClient.invalidateQueries(appConnectionKeys.byId(app, connectionId)); + } + }); +}; + +export const useDeleteAppConnection = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ connectionId, app }: TDeleteAppConnectionDTO) => { + const { data } = await apiRequest.delete(`/api/v1/app-connections/${app}/${connectionId}`); + + return data; + }, + onSuccess: (_, { connectionId, app }) => { + queryClient.invalidateQueries(appConnectionKeys.list()); + queryClient.invalidateQueries(appConnectionKeys.byId(app, connectionId)); + } + }); +}; diff --git a/frontend/src/hooks/api/appConnections/queries.tsx b/frontend/src/hooks/api/appConnections/queries.tsx new file mode 100644 index 000000000..10e2601ed --- /dev/null +++ b/frontend/src/hooks/api/appConnections/queries.tsx @@ -0,0 +1,136 @@ +import { useMemo } from "react"; +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + TAppConnection, + TAppConnectionMap, + TAppConnectionOptions, + TGetAppConnection, + TListAppConnections +} from "@app/hooks/api/appConnections/types"; +import { + TAppConnectionOption, + TAppConnectionOptionMap +} from "@app/hooks/api/appConnections/types/app-options"; + +export const appConnectionKeys = { + all: ["app-connection"] as const, + options: () => [...appConnectionKeys.all, "options"] as const, + list: () => [...appConnectionKeys.all, "list"] as const, + listByApp: (app: AppConnection) => [...appConnectionKeys.list(), app], + byId: (app: AppConnection, templateId: string) => + [...appConnectionKeys.all, app, "by-id", templateId] as const +}; + +export const useAppConnectionOptions = ( + options?: Omit< + UseQueryOptions< + TAppConnectionOption[], + unknown, + TAppConnectionOption[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: appConnectionKeys.options(), + queryFn: async () => { + const { data } = await apiRequest.get( + "/api/v1/app-connections/options" + ); + + return data.appConnectionOptions; + }, + ...options + }); +}; + +export const useGetAppConnectionOption = (app: T) => { + const { data: options = [], isLoading } = useAppConnectionOptions(); + + return useMemo( + () => ({ + option: (options.find((opt) => opt.app === app) as TAppConnectionOptionMap[T]) ?? {}, + isLoading + }), + [options, app] + ); +}; + +export const useListAppConnections = ( + options?: Omit< + UseQueryOptions< + TAppConnection[], + unknown, + TAppConnection[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: appConnectionKeys.list(), + queryFn: async () => { + const { data } = await apiRequest.get>( + "/api/v1/app-connections" + ); + + return data.appConnections; + }, + ...options + }); +}; + +export const useListAppConnectionsByApp = ( + app: T, + options?: Omit< + UseQueryOptions< + TAppConnectionMap[T][], + unknown, + TAppConnectionMap[T][], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: appConnectionKeys.listByApp(app), + queryFn: async () => { + const { data } = await apiRequest.get>( + `/api/v1/app-connections/${app}` + ); + + return data.appConnections; + }, + ...options + }); +}; + +export const useGetAppConnectionById = ( + app: T, + connectionId: string, + options?: Omit< + UseQueryOptions< + TAppConnectionMap[T], + unknown, + TAppConnectionMap[T], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: appConnectionKeys.byId(app, connectionId), + queryFn: async () => { + const { data } = await apiRequest.get>( + `/api/v1/app-connections/${app}/${connectionId}` + ); + + return data.appConnection; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts new file mode 100644 index 000000000..bfc9e5903 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -0,0 +1,24 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +export type TAppConnectionOptionBase = { + name: string; + methods: string[]; +}; + +export type TAwsConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.AWS; + accessKeyId?: string; +}; + +export type TGitHubConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.GitHub; + oauthClientId?: string; + appClientSlug?: string; +}; + +export type TAppConnectionOption = TAwsConnectionOption | TGitHubConnectionOption; + +export type TAppConnectionOptionMap = { + [AppConnection.AWS]: TAwsConnectionOption; + [AppConnection.GitHub]: TGitHubConnectionOption; +}; diff --git a/frontend/src/hooks/api/appConnections/types/aws-connection.ts b/frontend/src/hooks/api/appConnections/types/aws-connection.ts new file mode 100644 index 000000000..86074ca35 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/aws-connection.ts @@ -0,0 +1,23 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum AwsConnectionMethod { + AssumeRole = "assume-role", + AccessKey = "access-key" +} + +export type TAwsConnection = TRootAppConnection & { app: AppConnection.AWS } & ( + | { + method: AwsConnectionMethod.AccessKey; + credentials: { + accessKeyId: string; + secretAccessKey: string; + }; + } + | { + method: AwsConnectionMethod.AssumeRole; + credentials: { + roleArn: string; + }; + } + ); diff --git a/frontend/src/hooks/api/appConnections/types/github-connection.ts b/frontend/src/hooks/api/appConnections/types/github-connection.ts new file mode 100644 index 000000000..d00936cda --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/github-connection.ts @@ -0,0 +1,23 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum GitHubConnectionMethod { + App = "github-app", + OAuth = "oauth" +} + +export type TGitHubConnection = TRootAppConnection & { app: AppConnection.GitHub } & ( + | { + method: GitHubConnectionMethod.OAuth; + credentials: { + code: string; + }; + } + | { + method: GitHubConnectionMethod.App; + credentials: { + code: string; + installationId: string; + }; + } + ); diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts new file mode 100644 index 000000000..fcec4a1df --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -0,0 +1,36 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TAppConnectionOption } from "@app/hooks/api/appConnections/types/app-options"; +import { TAwsConnection } from "@app/hooks/api/appConnections/types/aws-connection"; +import { TGitHubConnection } from "@app/hooks/api/appConnections/types/github-connection"; + +export * from "./aws-connection"; +export * from "./github-connection"; + +export type TAppConnection = TAwsConnection | TGitHubConnection; + +export type TListAppConnections = { appConnections: T[] }; +export type TGetAppConnection = { appConnection: T }; +export type TAppConnectionOptions = { appConnectionOptions: TAppConnectionOption[] }; +export type TAppConnectionResponse = { appConnection: TAppConnection }; + +export type TCreateAppConnectionDTO = Pick< + TAppConnection, + "name" | "credentials" | "method" | "app" | "description" +>; + +export type TUpdateAppConnectionDTO = Partial< + Pick +> & { + connectionId: string; + app: AppConnection; +}; + +export type TDeleteAppConnectionDTO = { + app: AppConnection; + connectionId: string; +}; + +export type TAppConnectionMap = { + [AppConnection.AWS]: TAwsConnection; + [AppConnection.GitHub]: TGitHubConnection; +}; diff --git a/frontend/src/hooks/api/appConnections/types/root-connection.ts b/frontend/src/hooks/api/appConnections/types/root-connection.ts new file mode 100644 index 000000000..0dc4a616f --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/root-connection.ts @@ -0,0 +1,9 @@ +export type TRootAppConnection = { + id: string; + name: string; + description?: string | null; + version: number; + orgId: string; + createdAt: string; + updatedAt: string; +}; diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index 9bb7b89d5..bbc75f90d 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -1,3 +1,6 @@ +import { SshCaStatus } from "@app/hooks/api/ssh-ca"; +import { SshCertTemplateStatus } from "@app/hooks/api/sshCertificateTemplates"; + import { CaStatus, CaType } from "./enums"; export const caTypeToNameMap: { [K in CaType]: string } = { @@ -11,7 +14,7 @@ export const caStatusToNameMap: { [K in CaStatus]: string } = { [CaStatus.PENDING_CERTIFICATE]: "Pending Certificate" }; -export const getCaStatusBadgeVariant = (status: CaStatus) => { +export const getCaStatusBadgeVariant = (status: CaStatus | SshCaStatus | SshCertTemplateStatus) => { switch (status) { case CaStatus.ACTIVE: return "success"; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 551822f09..19a6fcc06 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -38,6 +38,8 @@ export * from "./secretSharing"; export * from "./secretSnapshots"; export * from "./serverDetails"; export * from "./serviceTokens"; +export * from "./ssh-ca"; +export * from "./sshCertificateTemplates"; export * from "./ssoConfig"; export * from "./subscriptions"; export * from "./tags"; diff --git a/frontend/src/hooks/api/roles/types.ts b/frontend/src/hooks/api/roles/types.ts index 0a48c9f97..20c65190c 100644 --- a/frontend/src/hooks/api/roles/types.ts +++ b/frontend/src/hooks/api/roles/types.ts @@ -56,7 +56,7 @@ export type TGetUserProjectPermissionDTO = { export type TCreateOrgRoleDTO = { orgId: string; name: string; - description?: string; + description?: string | null; slug: string; permissions: TPermission[]; }; @@ -74,7 +74,7 @@ export type TDeleteOrgRoleDTO = { export type TCreateProjectRoleDTO = { projectId: string; name: string; - description?: string; + description?: string | null; slug: string; permissions: TProjectPermission[]; }; diff --git a/frontend/src/hooks/api/ssh-ca/constants.tsx b/frontend/src/hooks/api/ssh-ca/constants.tsx new file mode 100644 index 000000000..2742a7bfa --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/constants.tsx @@ -0,0 +1,14 @@ +export enum SshCaStatus { + ACTIVE = "active", + DISABLED = "disabled" +} + +export enum SshCertType { + USER = "user", + HOST = "host" +} + +export const sshCertTypeToNameMap: { [K in SshCertType]: string } = { + [SshCertType.USER]: "User", + [SshCertType.HOST]: "Host" +}; diff --git a/frontend/src/hooks/api/ssh-ca/index.tsx b/frontend/src/hooks/api/ssh-ca/index.tsx new file mode 100644 index 000000000..8fc57654b --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/index.tsx @@ -0,0 +1,9 @@ +export { SshCaStatus } from "./constants"; +export { + useCreateSshCa, + useDeleteSshCa, + useIssueSshCreds, + useSignSshKey, + useUpdateSshCa +} from "./mutations"; +export { useGetSshCaById, useGetSshCaCertTemplates } from "./queries"; diff --git a/frontend/src/hooks/api/ssh-ca/mutations.tsx b/frontend/src/hooks/api/ssh-ca/mutations.tsx new file mode 100644 index 000000000..ceddd65b4 --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/mutations.tsx @@ -0,0 +1,97 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/query-keys"; +import { + TCreateSshCaDTO, + TDeleteSshCaDTO, + TIssueSshCredsDTO, + TIssueSshCredsResponse, + TSignSshKeyDTO, + TSignSshKeyResponse, + TSshCertificateAuthority, + TUpdateSshCaDTO +} from "./types"; + +export const sshCaKeys = { + getSshCaById: (caId: string) => [{ caId }, "ssh-ca"] +}; + +export const useCreateSshCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { + data: { ca } + } = await apiRequest.post<{ ca: TSshCertificateAuthority }>("/api/v1/ssh/ca/", body); + return ca; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceSshCas(projectId)); + } + }); +}; + +export const useUpdateSshCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId, ...body }) => { + const { + data: { ca } + } = await apiRequest.patch<{ ca: TSshCertificateAuthority }>(`/api/v1/ssh/ca/${caId}`, body); + return ca; + }, + onSuccess: ({ projectId }, { caId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceSshCas(projectId)); + queryClient.invalidateQueries(sshCaKeys.getSshCaById(caId)); + } + }); +}; + +export const useDeleteSshCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId }) => { + const { + data: { ca } + } = await apiRequest.delete<{ ca: TSshCertificateAuthority }>(`/api/v1/ssh/ca/${caId}`); + return ca; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceSshCas(projectId)); + } + }); +}; + +export const useSignSshKey = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + "/api/v1/ssh/certificates/sign", + body + ); + return data; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries(workspaceKeys.allWorkspaceSshCertificates(projectId)); + } + }); +}; + +export const useIssueSshCreds = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + "/api/v1/ssh/certificates/issue", + body + ); + return data; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries(workspaceKeys.allWorkspaceSshCertificates(projectId)); + } + }); +}; diff --git a/frontend/src/hooks/api/ssh-ca/queries.tsx b/frontend/src/hooks/api/ssh-ca/queries.tsx new file mode 100644 index 000000000..b85bbcb35 --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TSshCertificateTemplate } from "../sshCertificateTemplates/types"; +import { TSshCertificateAuthority } from "./types"; + +export const sshCaKeys = { + getSshCaById: (caId: string) => [{ caId }, "ssh-ca"], + getSshCaCertTemplates: (caId: string) => [{ caId }, "ssh-ca-cert-templates"] +}; + +export const useGetSshCaById = (caId: string) => { + return useQuery({ + queryKey: sshCaKeys.getSshCaById(caId), + queryFn: async () => { + const { + data: { ca } + } = await apiRequest.get<{ ca: TSshCertificateAuthority }>(`/api/v1/ssh/ca/${caId}`); + return ca; + }, + enabled: Boolean(caId) + }); +}; + +export const useGetSshCaCertTemplates = (caId: string) => { + return useQuery({ + queryKey: sshCaKeys.getSshCaCertTemplates(caId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateTemplates: TSshCertificateTemplate[]; + }>(`/api/v1/ssh/ca/${caId}/certificate-templates`); + return data; + }, + enabled: Boolean(caId) + }); +}; diff --git a/frontend/src/hooks/api/ssh-ca/types.ts b/frontend/src/hooks/api/ssh-ca/types.ts new file mode 100644 index 000000000..6e5f02c4d --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/types.ts @@ -0,0 +1,74 @@ +import { CertKeyAlgorithm } from "../certificates/enums"; +import { SshCaStatus, SshCertType } from "./constants"; + +export type TSshCertificate = { + id: string; + sshCaId: string; + sshCertificateTemplateId: string; + serialNumber: string; + certType: SshCertType; + principals: string[]; + keyId: string; + notBefore: string; + notAfter: string; +}; + +export type TSshCertificateAuthority = { + id: string; + projectId: string; + status: SshCaStatus; + friendlyName: string; + keyAlgorithm: CertKeyAlgorithm; + createdAt: string; + updatedAt: string; + publicKey: string; +}; + +export type TCreateSshCaDTO = { + projectId: string; + friendlyName?: string; + keyAlgorithm: CertKeyAlgorithm; +}; + +export type TUpdateSshCaDTO = { + caId: string; + friendlyName?: string; + status?: SshCaStatus; +}; + +export type TDeleteSshCaDTO = { + caId: string; +}; + +export type TSignSshKeyDTO = { + projectId: string; + certificateTemplateId: string; + publicKey?: string; + certType: SshCertType; + principals: string[]; + ttl?: string; + keyId?: string; +}; + +export type TSignSshKeyResponse = { + serialNumber: string; + signedKey: string; +}; + +export type TIssueSshCredsDTO = { + projectId: string; + certificateTemplateId: string; + keyAlgorithm: CertKeyAlgorithm; + certType: SshCertType; + principals: string[]; + ttl?: string; + keyId?: string; +}; + +export type TIssueSshCredsResponse = { + serialNumber: string; + signedKey: string; + privateKey: string; + publicKey: string; + keyAlgorithm: CertKeyAlgorithm; +}; diff --git a/frontend/src/hooks/api/sshCertificateTemplates/index.tsx b/frontend/src/hooks/api/sshCertificateTemplates/index.tsx new file mode 100644 index 000000000..9efe99c81 --- /dev/null +++ b/frontend/src/hooks/api/sshCertificateTemplates/index.tsx @@ -0,0 +1,7 @@ +export { + useCreateSshCertTemplate, + useDeleteSshCertTemplate, + useUpdateSshCertTemplate +} from "./mutations"; +export { useGetSshCertTemplate } from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/sshCertificateTemplates/mutations.tsx b/frontend/src/hooks/api/sshCertificateTemplates/mutations.tsx new file mode 100644 index 000000000..91fb5eb53 --- /dev/null +++ b/frontend/src/hooks/api/sshCertificateTemplates/mutations.tsx @@ -0,0 +1,59 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { sshCaKeys } from "../ssh-ca/queries"; +import { + TCreateSshCertificateTemplateDTO, + TDeleteSshCertificateTemplateDTO, + TSshCertificateTemplate, + TUpdateSshCertificateTemplateDTO +} from "./types"; + +export const useCreateSshCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const { data: certificateTemplate } = await apiRequest.post( + "/api/v1/ssh/certificate-templates", + data + ); + return certificateTemplate; + }, + onSuccess: ({ sshCaId }) => { + queryClient.invalidateQueries(sshCaKeys.getSshCaCertTemplates(sshCaId)); + } + }); +}; + +export const useUpdateSshCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const { data: certificateTemplate } = await apiRequest.patch( + `/api/v1/ssh/certificate-templates/${data.id}`, + data + ); + + return certificateTemplate; + }, + onSuccess: ({ sshCaId }) => { + queryClient.invalidateQueries(sshCaKeys.getSshCaCertTemplates(sshCaId)); + } + }); +}; + +export const useDeleteSshCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const { data: certificateTemplate } = await apiRequest.delete( + `/api/v1/ssh/certificate-templates/${data.id}` + ); + return certificateTemplate; + }, + onSuccess: ({ sshCaId }) => { + queryClient.invalidateQueries(sshCaKeys.getSshCaCertTemplates(sshCaId)); + } + }); +}; diff --git a/frontend/src/hooks/api/sshCertificateTemplates/queries.tsx b/frontend/src/hooks/api/sshCertificateTemplates/queries.tsx new file mode 100644 index 000000000..b9b535916 --- /dev/null +++ b/frontend/src/hooks/api/sshCertificateTemplates/queries.tsx @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TSshCertificateTemplate } from "./types"; + +export const certTemplateKeys = { + getSshCertTemplateById: (id: string) => [{ id }, "ssh-cert-template"] +}; + +export const useGetSshCertTemplate = (id: string) => { + return useQuery({ + queryKey: certTemplateKeys.getSshCertTemplateById(id), + queryFn: async () => { + const { data: certificateTemplate } = await apiRequest.get( + `/api/v1/ssh/certificate-templates/${id}` + ); + return certificateTemplate; + }, + enabled: Boolean(id) + }); +}; diff --git a/frontend/src/hooks/api/sshCertificateTemplates/types.ts b/frontend/src/hooks/api/sshCertificateTemplates/types.ts new file mode 100644 index 000000000..4099b4b8a --- /dev/null +++ b/frontend/src/hooks/api/sshCertificateTemplates/types.ts @@ -0,0 +1,47 @@ +export enum SshCertTemplateStatus { + ACTIVE = "active", + DISABLED = "disabled" +} + +export type TSshCertificateTemplate = { + id: string; + sshCaId: string; + status: SshCertTemplateStatus; + name: string; + ttl: string; + maxTTL: string; + allowedUsers: string[]; + allowedHosts: string[]; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowCustomKeyIds: boolean; +}; + +export type TCreateSshCertificateTemplateDTO = { + sshCaId: string; + name: string; + ttl: string; + maxTTL: string; + allowedUsers: string[]; + allowedHosts: string[]; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowCustomKeyIds: boolean; +}; + +export type TUpdateSshCertificateTemplateDTO = { + id: string; + status?: SshCertTemplateStatus; + name?: string; + ttl?: string; + maxTTL?: string; + allowedUsers?: string[]; + allowedHosts?: string[]; + allowUserCertificates?: boolean; + allowHostCertificates?: boolean; + allowCustomKeyIds?: boolean; +}; + +export type TDeleteSshCertificateTemplateDTO = { + id: string; +}; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index b1c4e224d..44b0a34fb 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -45,4 +45,5 @@ export type SubscriptionPlan = { pkiEst: boolean; enforceMfa: boolean; projectTemplates: boolean; + appConnections: boolean; // TODO: remove once released }; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index cd652970a..665462e7d 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -32,6 +32,9 @@ export { useListWorkspaceGroups, useListWorkspacePkiAlerts, useListWorkspacePkiCollections, + useListWorkspaceSshCas, + useListWorkspaceSshCertificates, + useListWorkspaceSshCertificateTemplates, useNameWorkspaceSecrets, useToggleAutoCapitalization, useUpdateIdentityWorkspaceRole, diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 9fdce5b67..45524bdb3 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -15,6 +15,8 @@ import { TIntegration } from "../integrations/types"; import { TPkiAlert } from "../pkiAlerts/types"; import { TPkiCollection } from "../pkiCollections/types"; import { EncryptedSecret } from "../secrets/types"; +import { TSshCertificate, TSshCertificateAuthority } from "../ssh-ca/types"; +import { TSshCertificateTemplate } from "../sshCertificateTemplates/types"; import { userKeys } from "../users/query-keys"; import { TWorkspaceUser } from "../users/types"; import { ProjectSlackConfig } from "../workflowIntegrations/types"; @@ -731,6 +733,67 @@ export const useListWorkspaceCertificateTemplates = ({ workspaceId }: { workspac }); }; +export const useListWorkspaceSshCertificates = ({ + offset, + limit, + projectId +}: { + offset: number; + limit: number; + projectId: string; +}) => { + return useQuery({ + queryKey: workspaceKeys.specificWorkspaceSshCertificates({ + offset, + limit, + projectId + }), + queryFn: async () => { + const params = new URLSearchParams({ + offset: String(offset), + limit: String(limit) + }); + + const { data } = await apiRequest.get<{ + certificates: TSshCertificate[]; + totalCount: number; + }>(`/api/v2/workspace/${projectId}/ssh-certificates`, { + params + }); + return data; + }, + enabled: Boolean(projectId) + }); +}; + +export const useListWorkspaceSshCas = (projectId: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceSshCas(projectId), + queryFn: async () => { + const { + data: { cas } + } = await apiRequest.get<{ cas: Omit[] }>( + `/api/v2/workspace/${projectId}/ssh-cas` + ); + return cas; + }, + enabled: Boolean(projectId) + }); +}; + +export const useListWorkspaceSshCertificateTemplates = (projectId: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceSshCertificateTemplates(projectId), + queryFn: async () => { + const { data } = await apiRequest.get<{ certificateTemplates: TSshCertificateTemplate[] }>( + `/api/v2/workspace/${projectId}/ssh-certificate-templates` + ); + return data; + }, + enabled: Boolean(projectId) + }); +}; + export const useGetWorkspaceSlackConfig = ({ workspaceId }: { workspaceId: string }) => { return useQuery({ queryKey: workspaceKeys.getWorkspaceSlackConfig(workspaceId), diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index a1c5770e9..1005fe8bc 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -53,5 +53,19 @@ export const workspaceKeys = { getWorkspaceCertificateTemplates: (workspaceId: string) => [{ workspaceId }, "workspace-certificate-templates"] as const, getWorkspaceSlackConfig: (workspaceId: string) => - [{ workspaceId }, "workspace-slack-config"] as const + [{ workspaceId }, "workspace-slack-config"] as const, + getWorkspaceSshCas: (projectId: string) => [{ projectId }, "workspace-ssh-cas"] as const, + allWorkspaceSshCertificates: (projectId: string) => + [{ projectId }, "workspace-ssh-certificates"] as const, + specificWorkspaceSshCertificates: ({ + offset, + limit, + projectId + }: { + offset: number; + limit: number; + projectId: string; + }) => [...workspaceKeys.allWorkspaceSshCertificates(projectId), { offset, limit }] as const, + getWorkspaceSshCertificateTemplates: (projectId: string) => + [{ projectId }, "workspace-ssh-certificate-templates"] as const }; diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 558982d80..0510bdbe7 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -11,7 +11,8 @@ export enum ProjectVersion { export enum ProjectType { SecretManager = "secret-manager", CertificateManager = "cert-manager", - KMS = "kms" + KMS = "kms", + SSH = "ssh" } export enum ProjectUserMembershipTemporaryMode { diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index c084683cb..693f6e342 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -327,6 +327,7 @@ export const AppLayout = ({ children }: LayoutProps) => { )} {!router.asPath.includes("org") && + !router.asPath.includes("app-connections") && (!router.asPath.includes("personal") && currentWorkspace ? ( ) : ( @@ -339,7 +340,8 @@ export const AppLayout = ({ children }: LayoutProps) => { ))}
- {router.pathname.startsWith("/org") && ( + {(router.pathname.startsWith("/org") || + router.pathname.startsWith("/app-connections")) && ( { + + + + SSH + + + { if ( !currentWorkspace || router.asPath.startsWith("personal") || - router.asPath.startsWith("integrations") + router.asPath.startsWith("integrations") || + router.asPath.startsWith("/app-connections") ) { return
; } @@ -32,6 +33,7 @@ export const ProjectSidebarItem = () => { const isSecretManager = currentWorkspace?.type === ProjectType.SecretManager; const isCertManager = currentWorkspace?.type === ProjectType.CertificateManager; const isCmek = currentWorkspace?.type === ProjectType.KMS; + const isSsh = currentWorkspace?.type === ProjectType.SSH; return ( @@ -82,6 +84,18 @@ export const ProjectSidebarItem = () => { )} + {isSsh && ( + + + + Overview + + + + )} = T extends unknown ? Pick : never; diff --git a/frontend/src/pages/app-connections/github/oauth/callback.tsx b/frontend/src/pages/app-connections/github/oauth/callback.tsx new file mode 100644 index 000000000..79827acaa --- /dev/null +++ b/frontend/src/pages/app-connections/github/oauth/callback.tsx @@ -0,0 +1,134 @@ +import { useEffect } from "react"; +import { useRouter } from "next/router"; +import queryString from "query-string"; + +import { createNotification } from "@app/components/notifications"; +import { ContentLoader } from "@app/components/v2"; +import { + GitHubConnectionMethod, + TAppConnection, + TGitHubConnection, + useCreateAppConnection, + useUpdateAppConnection +} from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +type FormData = Pick & { + returnUrl?: string; + connectionId?: string; +}; + +export default function GitHubOAuthCallbackPage() { + const router = useRouter(); + const updateAppConnection = useUpdateAppConnection(); + const createAppConnection = useCreateAppConnection(); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { + code, + state, + installation_id: installationId + } = queryString.parse(router.asPath.split("?")[1]); + + useEffect(() => { + (async () => { + let formData: FormData; + + try { + formData = JSON.parse(localStorage.getItem("githubConnectionFormData") ?? "{}") as FormData; + } catch (e) { + createNotification({ + type: "error", + text: "Invalid form state, redirecting..." + }); + router.push(window.location.origin); + return; + } + + // validate state + if (state !== localStorage.getItem("latestCSRFToken")) { + createNotification({ + type: "error", + text: "Invalid state, redirecting..." + }); + router.push(window.location.origin); + return; + } + + localStorage.removeItem("githubConnectionFormData"); + localStorage.removeItem("latestCSRFToken"); + + const { connectionId, name, description, returnUrl } = formData; + + let appConnection: TAppConnection; + + try { + if (connectionId) { + appConnection = await updateAppConnection.mutateAsync({ + app: AppConnection.GitHub, + ...(installationId + ? { + connectionId, + credentials: { + code: code as string, + installationId: installationId as string + } + } + : { + connectionId, + credentials: { + code: code as string + } + }) + }); + } else { + appConnection = await createAppConnection.mutateAsync({ + app: AppConnection.GitHub, + name, + description, + ...(installationId + ? { + method: GitHubConnectionMethod.App, + credentials: { + code: code as string, + installationId: installationId as string + } + } + : { + method: GitHubConnectionMethod.OAuth, + credentials: { + code: code as string + } + }) + }); + } + } catch (e: any) { + createNotification({ + title: `Failed to ${connectionId ? "update" : "add"} GitHub Connection`, + text: e.message, + type: "error" + }); + router.push( + returnUrl ?? + `/org/${localStorage.getItem("orgData.id")}/settings?selectedTab=app-connections` + ); + return; + } + + createNotification({ + text: `Successfully ${connectionId ? "updated" : "added"} GitHub Connection`, + type: "success" + }); + + router.push(returnUrl ?? `/org/${appConnection.orgId}/settings?selectedTab=app-connections`); + })(); + }, []); + + return ( +
+ +
+ ); +} + +GitHubOAuthCallbackPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/vercel/create.tsx b/frontend/src/pages/integrations/vercel/create.tsx index 813ac1e73..7dc525f90 100644 --- a/frontend/src/pages/integrations/vercel/create.tsx +++ b/frontend/src/pages/integrations/vercel/create.tsx @@ -13,6 +13,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import queryString from "query-string"; import { useCreateIntegration } from "@app/hooks/api"; +import { IntegrationSyncBehavior } from "@app/hooks/api/integrations/types"; import { Button, @@ -36,12 +37,26 @@ const vercelEnvironments = [ { name: "Production", slug: "production" } ]; +const initialSyncBehaviors = [ + { + label: "No Import - Overwrite all values in Vercel", + value: IntegrationSyncBehavior.OVERWRITE_TARGET + }, + { + label: "Import - Prefer values from Infisical", + value: IntegrationSyncBehavior.PREFER_SOURCE + } +]; + export default function VercelCreateIntegrationPage() { const router = useRouter(); const { mutateAsync } = useCreateIntegration(); const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); const [secretPath, setSecretPath] = useState("/"); + const [initialSyncBehavior, setInitialSyncBehavior] = useState( + IntegrationSyncBehavior.PREFER_SOURCE + ); const [targetAppId, setTargetAppId] = useState(""); const [targetEnvironment, setTargetEnvironment] = useState(""); const [targetBranch, setTargetBranch] = useState(""); @@ -104,7 +119,10 @@ export default function VercelCreateIntegrationPage() { sourceEnvironment: selectedSourceEnvironment, targetEnvironment, path, - secretPath + secretPath, + metadata: { + initialSyncBehavior + } }); setIsLoading(false); @@ -231,6 +249,21 @@ export default function VercelCreateIntegrationPage() { )} + + + + + +
+

{data.friendlyName}

+ + +
+ + + +
+
+ + + {(isAllowed) => ( + + handlePopUpOpen("deleteSshCa", { + caId: data.id + }) + } + disabled={!isAllowed} + > + Delete SSH CA + + )} + + +
+
+
+
+ +
+
+ +
+
+
+ )} + + handlePopUpToggle("deleteSshCa", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveCaSubmit((popUp?.deleteSshCa?.data as { caId: string })?.caId) + } + /> +
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.SshCertificateAuthorities } +); diff --git a/frontend/src/views/Project/SshCaPage/components/SshCaDetailsSection.tsx b/frontend/src/views/Project/SshCaPage/components/SshCaDetailsSection.tsx new file mode 100644 index 000000000..812ac02fe --- /dev/null +++ b/frontend/src/views/Project/SshCaPage/components/SshCaDetailsSection.tsx @@ -0,0 +1,124 @@ +import { faCheck, faCopy, faDownload, faPencil } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import FileSaver from "file-saver"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { IconButton, Tooltip } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { useTimedReset } from "@app/hooks"; +import { useGetSshCaById } from "@app/hooks/api"; +import { caStatusToNameMap } from "@app/hooks/api/ca/constants"; +import { certKeyAlgorithmToNameMap } from "@app/hooks/api/certificates/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + caId: string; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["sshCa"]>, data?: {}) => void; +}; + +export const SshCaDetailsSection = ({ caId, handlePopUpOpen }: Props) => { + const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset({ + initialState: "Copy ID to clipboard" + }); + const [downloadText, isDownloading, setDownloadText] = useTimedReset({ + initialState: "Save public key" + }); + + const { data: ca } = useGetSshCaById(caId); + + const downloadTxtFile = (filename: string, content: string) => { + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); + FileSaver.saveAs(blob, filename); + }; + + return ca ? ( +
+
+

SSH CA Details

+ + {(isAllowed) => { + return ( + + { + e.stopPropagation(); + handlePopUpOpen("sshCa", { + caId: ca.id + }); + }} + > + + + + ); + }} + +
+
+
+

SSH CA ID

+
+

{ca.id}

+
+ + { + navigator.clipboard.writeText(ca.id); + setCopyTextId("Copied"); + }} + > + + + +
+
+
+
+

Friendly Name

+

{ca.friendlyName}

+
+
+

Status

+

{caStatusToNameMap[ca.status]}

+
+
+

Key Algorithm

+

{certKeyAlgorithmToNameMap[ca.keyAlgorithm]}

+
+
+

Public Key

+
+

{ca.publicKey.substring(0, 20)}...

+
+ + { + setDownloadText("Saved"); + downloadTxtFile("ssh_ca.pub", ca.publicKey); + }} + > + + + +
+
+
+
+
+ ) : ( +
+ ); +}; diff --git a/frontend/src/views/Project/SshCaPage/components/SshCertificateContent.tsx b/frontend/src/views/Project/SshCaPage/components/SshCertificateContent.tsx new file mode 100644 index 000000000..83aff5730 --- /dev/null +++ b/frontend/src/views/Project/SshCaPage/components/SshCertificateContent.tsx @@ -0,0 +1,174 @@ +import { faCheck, faCopy, faDownload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import FileSaver from "file-saver"; + +import { IconButton, Tooltip } from "@app/components/v2"; +import { useTimedReset } from "@app/hooks"; + +type Props = { + serialNumber: string; + signedKey: string; + privateKey?: string; + publicKey?: string; +}; + +export const SshCertificateContent = ({ + serialNumber, + signedKey, + privateKey, + publicKey +}: Props) => { + const [copyTextSerialNumber, isCopyingSerialNumber, setCopyTextSerialNumber] = + useTimedReset({ + initialState: "Copy to clipboard" + }); + const [copyTextCertificate, isCopyingCertificate, setCopyTextCertificate] = useTimedReset( + { + initialState: "Copy to clipboard" + } + ); + + const [copyTextCertificateSk, isCopyingCertificateSk, setCopyTextCertificateSk] = + useTimedReset({ + initialState: "Copy to clipboard" + }); + + const [copyTextCertificatePk, isCopyingCertificatePk, setCopyTextCertificatePk] = + useTimedReset({ + initialState: "Copy to clipboard" + }); + + const downloadTxtFile = (filename: string, content: string) => { + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); + FileSaver.saveAs(blob, filename); + }; + + return ( +
+

Serial Number

+
+

{serialNumber}

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

SSH Certificate / Signed Key

+
+ + { + navigator.clipboard.writeText(signedKey); + setCopyTextCertificate("Copied"); + }} + > + + + + + { + downloadTxtFile("user_key-cert.pub", signedKey); + }} + > + + + +
+
+
+

{signedKey}

+
+ {privateKey && ( + <> +
+

Private Key

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

{privateKey}

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

Public Key

+
+ + { + navigator.clipboard.writeText(publicKey); + setCopyTextCertificatePk("Copied"); + }} + > + + + + + { + downloadTxtFile("user_key.pub", publicKey); + }} + > + + + +
+
+
+

{publicKey}

+
+ + )} +
+ ); +}; diff --git a/frontend/src/views/Project/SshCaPage/components/SshCertificateModal.tsx b/frontend/src/views/Project/SshCaPage/components/SshCertificateModal.tsx new file mode 100644 index 000000000..878a6a181 --- /dev/null +++ b/frontend/src/views/Project/SshCaPage/components/SshCertificateModal.tsx @@ -0,0 +1,373 @@ +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + SshCertTemplateStatus, + useGetSshCertTemplate, + useIssueSshCreds, + useListWorkspaceSshCertificateTemplates, + useSignSshKey +} from "@app/hooks/api"; +import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants"; +import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums"; +import { SshCertType } from "@app/hooks/api/ssh-ca/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { SshCertificateContent } from "./SshCertificateContent"; + +const schema = z.object({ + templateId: z.string(), + publicKey: z.string().optional(), + keyAlgorithm: z.enum([ + CertKeyAlgorithm.RSA_2048, + CertKeyAlgorithm.RSA_4096, + CertKeyAlgorithm.ECDSA_P256, + CertKeyAlgorithm.ECDSA_P384 + ]), + certType: z.nativeEnum(SshCertType), + principals: z.string(), + ttl: z + .string() + .trim() + .refine((val) => ms(val) > 0, "TTL must be a valid time string such as 2 days, 1d, 2h 1y, ...") + .optional(), + keyId: z.string().optional() +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["sshCertificate"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["sshCertificate"]>, state?: boolean) => void; +}; + +type TSshCertificateDetails = { + serialNumber: string; + signedKey: string; + privateKey?: string; + publicKey?: string; +}; + +enum SshCertificateOperation { + SIGN_SSH_KEY = "sign-ssh-key", + ISSUE_SSH_CREDS = "issue-ssh-creds" +} + +export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; + const [operation, setOperation] = useState( + SshCertificateOperation.SIGN_SSH_KEY + ); + const [certificateDetails, setCertificateDetails] = useState(null); + + const { mutateAsync: signSshKey } = useSignSshKey(); + const { mutateAsync: issueSshCreds } = useIssueSshCreds(); + + const popUpData = popUp?.sshCertificate?.data as { + sshCaId: string; + templateId: string; + }; + + const { data: templatesData } = useListWorkspaceSshCertificateTemplates(projectId); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting }, + setValue, + watch + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + keyAlgorithm: CertKeyAlgorithm.RSA_2048, + certType: SshCertType.USER + } + }); + + const templateId = watch("templateId"); + const { data: templateData } = useGetSshCertTemplate(templateId); + + useEffect(() => { + if (popUpData) { + setValue("templateId", popUpData.templateId); + } else if (templatesData && templatesData.certificateTemplates.length > 0) { + setValue("templateId", templatesData.certificateTemplates[0].id); + } + }, [popUpData]); + + const onFormSubmit = async ({ + keyAlgorithm, + certType, + publicKey: existingPublicKey, + principals, + ttl, + keyId + }: FormData) => { + try { + if (!templateData) return; + if (!projectId) return; + + switch (operation) { + case SshCertificateOperation.SIGN_SSH_KEY: { + const { serialNumber, signedKey } = await signSshKey({ + projectId, + certificateTemplateId: templateData.id, + publicKey: existingPublicKey, + certType, + principals: principals.split(",").map((user) => user.trim()), + ttl, + keyId + }); + + setCertificateDetails({ + serialNumber, + signedKey + }); + break; + } + case SshCertificateOperation.ISSUE_SSH_CREDS: { + const { serialNumber, publicKey, privateKey, signedKey } = await issueSshCreds({ + projectId, + certificateTemplateId: templateData.id, + keyAlgorithm, + certType, + principals: principals.split(",").map((user) => user.trim()), + ttl, + keyId + }); + + setCertificateDetails({ + serialNumber, + privateKey, + publicKey, + signedKey + }); + break; + } + default: { + break; + } + } + + reset(); + + createNotification({ + text: "Successfully created SSH certificate", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create SSH certificate", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("sshCertificate", isOpen); + reset(); + setCertificateDetails(null); + }} + > + + {!certificateDetails ? ( +
+ ( + + + + )} + /> + + + + ( + + + + )} + /> + {operation === SshCertificateOperation.SIGN_SSH_KEY && ( + ( + + + + )} + /> + )} + {operation === SshCertificateOperation.ISSUE_SSH_CREDS && ( + ( + + + + )} + /> + )} + ( + + + + )} + /> + + ( + + + + )} + /> + {templateData && templateData.allowCustomKeyIds && ( + ( + + + + )} + /> + )} +
+ + +
+ + ) : ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplateModal.tsx b/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplateModal.tsx new file mode 100644 index 000000000..b193ae257 --- /dev/null +++ b/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplateModal.tsx @@ -0,0 +1,411 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import slugify from "@sindresorhus/slugify"; +import ms from "ms"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem, + Switch +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + useCreateSshCertTemplate, + useGetSshCaById, + useGetSshCertTemplate, + useListWorkspaceSshCas, + useUpdateSshCertTemplate +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +// Validates usernames or wildcard (*) +export const isValidUserPattern = (value: string): boolean => { + // Matches valid Linux usernames or a wildcard (*) + const userRegex = /^(?:\*|[a-z_][a-z0-9_-]{0,31})$/; + return userRegex.test(value); +}; + +// Validates hostnames, wildcard domains, or IP addresses +export const isValidHostPattern = (value: string): boolean => { + // Matches FQDNs, wildcard domains (*.example.com), IPv4, and IPv6 addresses + const hostRegex = + /^(?:\*|\*\.[a-z0-9-]+(?:\.[a-z0-9-]+)*|[a-z0-9-]+(?:\.[a-z0-9-]+)*|\d{1,3}(\.\d{1,3}){3}|([a-fA-F0-9:]+:+)+[a-fA-F0-9]+(?:%[a-zA-Z0-9]+)?)$/; + return hostRegex.test(value); +}; + +const schema = z + .object({ + sshCaId: z.string(), + name: z + .string() + .trim() + .toLowerCase() + .min(1) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Invalid name. Name can only contain alphanumeric characters and hyphens." + }), + ttl: z + .string() + .trim() + .refine( + (val) => ms(val) > 0, + "TTL must be a valid time string such as 2 days, 1d, 2h 1y, ..." + ) + .default("1h"), + maxTTL: z + .string() + .trim() + .refine( + (val) => ms(val) > 0, + "Max TTL must be a valid time string such as 2 days, 1d, 2h 1y, ..." + ) + .default("30d"), + allowedUsers: z.string().refine( + (val) => { + const trimmed = val.trim(); + if (trimmed === "") return true; + const users = trimmed.split(",").map((u) => u.trim()); + return users.every(isValidUserPattern); + }, + { + message: "Invalid user pattern in allowedUsers" + } + ), + allowedHosts: z.string().refine( + (val) => { + const trimmed = val.trim(); + if (trimmed === "") return true; + const users = trimmed.split(",").map((u) => u.trim()); + return users.every(isValidHostPattern); + }, + { + message: "Invalid host pattern in allowedHosts" + } + ), + allowUserCertificates: z.boolean().optional().default(false), + allowHostCertificates: z.boolean().optional().default(false), + allowCustomKeyIds: z.boolean().optional().default(false) + }) + .refine((data) => ms(data.maxTTL) > ms(data.ttl), { + message: "Max TLL must be greater than TTL", + path: ["maxTTL"] + }); + +export type FormData = z.infer; + +type Props = { + sshCaId: string; + popUp: UsePopUpState<["sshCertificateTemplate"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["sshCertificateTemplate"]>, + state?: boolean + ) => void; +}; + +export const SshCertificateTemplateModal = ({ popUp, handlePopUpToggle, sshCaId }: Props) => { + const { currentWorkspace } = useWorkspace(); + + const { data: ca } = useGetSshCaById(sshCaId); + + const { data: certTemplate } = useGetSshCertTemplate( + (popUp?.sshCertificateTemplate?.data as { id: string })?.id || "" + ); + + const { data: cas } = useListWorkspaceSshCas(currentWorkspace?.id || ""); + + const { mutateAsync: createSshCertTemplate } = useCreateSshCertTemplate(); + const { mutateAsync: updateSshCertTemplate } = useUpdateSshCertTemplate(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: {} + }); + + useEffect(() => { + if (certTemplate) { + reset({ + sshCaId: certTemplate.sshCaId, + name: certTemplate.name, + ttl: certTemplate.ttl, + maxTTL: certTemplate.maxTTL, + allowedUsers: certTemplate.allowedUsers.join(", "), + allowedHosts: certTemplate.allowedHosts.join(", "), + allowUserCertificates: certTemplate.allowUserCertificates, + allowHostCertificates: certTemplate.allowHostCertificates, + allowCustomKeyIds: certTemplate.allowCustomKeyIds + }); + } else { + reset({ + sshCaId, + name: "", + ttl: "1h", + maxTTL: "30d", + allowedUsers: "", + allowedHosts: "", + allowUserCertificates: false, + allowHostCertificates: false, + allowCustomKeyIds: false + }); + } + }, [certTemplate, ca]); + + const onFormSubmit = async ({ + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds + }: FormData) => { + try { + if (certTemplate) { + await updateSshCertTemplate({ + id: certTemplate.id, + name, + ttl, + maxTTL, + allowedUsers: allowedUsers ? allowedUsers.split(",").map((user) => user.trim()) : [], + allowedHosts: allowedHosts ? allowedHosts.split(",").map((host) => host.trim()) : [], + allowUserCertificates, + allowHostCertificates, + allowCustomKeyIds + }); + + createNotification({ + text: "Successfully updated SSH certificate template", + type: "success" + }); + } else { + await createSshCertTemplate({ + sshCaId, + name, + ttl, + maxTTL, + allowedUsers: allowedUsers ? allowedUsers.split(",").map((user) => user.trim()) : [], + allowedHosts: allowedHosts ? allowedHosts.split(",").map((host) => host.trim()) : [], + allowUserCertificates, + allowHostCertificates, + allowCustomKeyIds + }); + + createNotification({ + text: "Successfully created SSH certificate template", + type: "success" + }); + } + + reset(); + handlePopUpToggle("sshCertificateTemplate", false); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to save changes", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("sshCertificateTemplate", isOpen); + reset(); + }} + > + +
+ {certTemplate && ( + + + + )} + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Allow User Certificates

+
+
+ ); + }} + /> + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Allow Host Certificates

+
+
+ ); + }} + /> + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Allow Custom Key IDs

+
+
+ ); + }} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesSection.tsx b/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesSection.tsx new file mode 100644 index 000000000..f3fa88c93 --- /dev/null +++ b/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesSection.tsx @@ -0,0 +1,161 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { DeleteActionModal, IconButton } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { + SshCertTemplateStatus, + useDeleteSshCertTemplate, + useUpdateSshCertTemplate +} from "@app/hooks/api"; + +import { SshCertificateModal } from "./SshCertificateModal"; +import { SshCertificateTemplateModal } from "./SshCertificateTemplateModal"; +import { SshCertificateTemplatesTable } from "./SshCertificateTemplatesTable"; + +type Props = { + caId: string; +}; + +export const SshCertificateTemplatesSection = ({ caId }: Props) => { + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "sshCertificateTemplate", + "sshCertificateTemplateStatus", + "sshCertificate", + "deleteSshCertificateTemplate", + "upgradePlan" + ] as const); + + const { mutateAsync: deleteSshCertTemplate } = useDeleteSshCertTemplate(); + const { mutateAsync: updateSshCertTemplate } = useUpdateSshCertTemplate(); + + const onRemoveSshCertificateTemplateSubmit = async (id: string) => { + try { + await deleteSshCertTemplate({ + id + }); + + await createNotification({ + text: "Successfully deleted SSH certificate template", + type: "success" + }); + + handlePopUpClose("deleteSshCertificateTemplate"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete SSH certificate template", + type: "error" + }); + } + }; + + const onUpdateSshCaStatus = async ({ + templateId, + status + }: { + templateId: string; + status: SshCertTemplateStatus; + }) => { + try { + await updateSshCertTemplate({ id: templateId, status }); + + await createNotification({ + text: `Successfully ${ + status === SshCertTemplateStatus.ACTIVE ? "enabled" : "disabled" + } SSH certificate template`, + type: "success" + }); + + handlePopUpClose("sshCertificateTemplateStatus"); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${ + status === SshCertTemplateStatus.ACTIVE ? "enabled" : "disabled" + } SSH certificate template`, + type: "error" + }); + } + }; + + return ( +
+
+

Certificate Templates

+ + {(isAllowed) => ( + handlePopUpOpen("sshCertificateTemplate")} + isDisabled={!isAllowed} + > + + + )} + +
+
+ +
+ + + handlePopUpToggle("deleteSshCertificateTemplate", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveSshCertificateTemplateSubmit( + (popUp?.deleteSshCertificateTemplate?.data as { id: string })?.id + ) + } + /> + handlePopUpToggle("sshCertificateTemplateStatus", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onUpdateSshCaStatus( + popUp?.sshCertificateTemplateStatus?.data as { + templateId: string; + status: SshCertTemplateStatus; + } + ) + } + buttonText={ + (popUp?.sshCertificateTemplateStatus?.data as { status: string })?.status === + SshCertTemplateStatus.ACTIVE + ? "Enable" + : "Disable" + } + /> +
+ ); +}; diff --git a/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesTable.tsx b/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesTable.tsx new file mode 100644 index 000000000..9a41340e9 --- /dev/null +++ b/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesTable.tsx @@ -0,0 +1,193 @@ +import { + faBan, + faCertificate, + faEllipsis, + faFileAlt, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Badge, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { SshCertTemplateStatus, useGetSshCaCertTemplates } from "@app/hooks/api"; +import { caStatusToNameMap, getCaStatusBadgeVariant } from "@app/hooks/api/ca/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + sshCaId: string; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState< + [ + "sshCertificateTemplate", + "sshCertificateTemplateStatus", + "sshCertificate", + "deleteSshCertificateTemplate", + "upgradePlan" + ] + >, + data?: { + id?: string; + name?: string; + sshCaId?: string; + status?: SshCertTemplateStatus; + templateId?: string; + } + ) => void; +}; + +export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props) => { + const { data, isLoading } = useGetSshCaCertTemplates(sshCaId); + return ( +
+ + + + + + + + + + {isLoading && } + {!isLoading && + data?.certificateTemplates.map((certificateTemplate) => { + return ( + + + + + + ); + })} + +
NameStatus +
{certificateTemplate.name} + + {caStatusToNameMap[certificateTemplate.status]} + + + + +
+ + + +
+
+ + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("sshCertificateTemplateStatus", { + templateId: certificateTemplate.id, + status: + certificateTemplate.status === SshCertTemplateStatus.ACTIVE + ? SshCertTemplateStatus.DISABLED + : SshCertTemplateStatus.ACTIVE + }); + }} + disabled={!isAllowed} + icon={} + > + {`${ + certificateTemplate.status === SshCertTemplateStatus.ACTIVE + ? "Disable" + : "Enable" + } Template`} + + )} + + + { + handlePopUpOpen("sshCertificate", { + sshCaId, + templateId: certificateTemplate.id + }); + }} + icon={ + + } + > + Issue Certificate + + + + + handlePopUpOpen("sshCertificateTemplate", { + id: certificateTemplate.id + }) + } + icon={} + > + Edit Template + + + + {(isAllowed) => ( + } + onClick={() => + handlePopUpOpen("deleteSshCertificateTemplate", { + id: certificateTemplate.id, + name: certificateTemplate.name + }) + } + > + Delete Template + + )} + + +
+
+ {!isLoading && !data?.certificateTemplates?.length && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Project/SshCaPage/components/index.tsx b/frontend/src/views/Project/SshCaPage/components/index.tsx new file mode 100644 index 000000000..9466ecc8f --- /dev/null +++ b/frontend/src/views/Project/SshCaPage/components/index.tsx @@ -0,0 +1,3 @@ +export { SshCaDetailsSection } from "./SshCaDetailsSection"; +export { SshCertificateModal } from "./SshCertificateModal"; +export { SshCertificateTemplatesSection } from "./SshCertificateTemplatesSection"; diff --git a/frontend/src/views/Project/SshCaPage/index.tsx b/frontend/src/views/Project/SshCaPage/index.tsx new file mode 100644 index 000000000..18da81c2b --- /dev/null +++ b/frontend/src/views/Project/SshCaPage/index.tsx @@ -0,0 +1 @@ +export { SshCaPage } from "./SshCaPage"; diff --git a/frontend/src/views/Project/SshPage/SshPage.tsx b/frontend/src/views/Project/SshPage/SshPage.tsx new file mode 100644 index 000000000..294170fb5 --- /dev/null +++ b/frontend/src/views/Project/SshPage/SshPage.tsx @@ -0,0 +1,53 @@ +import { motion } from "framer-motion"; + +import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; + +import { SshCaSection, SshCertificatesSection } from "./components"; + +enum TabSections { + SshCa = "ssh-certificate-authorities", + SshCertificates = "ssh-certificates" +} + +export const SshPage = withProjectPermission( + () => { + return ( +
+
+

SSH

+ + + SSH Certificates + Certificate Authorities + + + + + + + + + + + + +
+
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.SshCertificateAuthorities } +); diff --git a/frontend/src/views/Project/SshPage/components/SshCaModal.tsx b/frontend/src/views/Project/SshPage/components/SshCaModal.tsx new file mode 100644 index 000000000..47a58bfdf --- /dev/null +++ b/frontend/src/views/Project/SshPage/components/SshCaModal.tsx @@ -0,0 +1,192 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useRouter } from "next/router"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useCreateSshCa, useGetSshCaById, useUpdateSshCa } from "@app/hooks/api"; +import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants"; +import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums"; +import { ProjectType } from "@app/hooks/api/workspace/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["sshCa"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["sshCa"]>, state?: boolean) => void; +}; + +const schema = z + .object({ + friendlyName: z.string(), + keyAlgorithm: z.enum([ + CertKeyAlgorithm.RSA_2048, + CertKeyAlgorithm.RSA_4096, + CertKeyAlgorithm.ECDSA_P256, + CertKeyAlgorithm.ECDSA_P384 + ]) + }) + .required(); + +export type FormData = z.infer; + +export const SshCaModal = ({ popUp, handlePopUpToggle }: Props) => { + const router = useRouter(); + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; + const { data: ca } = useGetSshCaById((popUp?.sshCa?.data as { caId: string })?.caId || ""); + + const { mutateAsync: createMutateAsync } = useCreateSshCa(); + const { mutateAsync: updateMutateAsync } = useUpdateSshCa(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + friendlyName: "", + keyAlgorithm: CertKeyAlgorithm.RSA_2048 + } + }); + + useEffect(() => { + if (ca) { + reset({ + friendlyName: ca.friendlyName, + keyAlgorithm: ca.keyAlgorithm + }); + } else { + reset({ + friendlyName: "", + keyAlgorithm: CertKeyAlgorithm.RSA_2048 + }); + } + }, [ca]); + + const onFormSubmit = async ({ friendlyName, keyAlgorithm }: FormData) => { + try { + if (!projectId) return; + + if (ca) { + await updateMutateAsync({ + caId: ca.id, + friendlyName + }); + } else { + const { id: newCaId } = await createMutateAsync({ + projectId, + friendlyName, + keyAlgorithm + }); + + router.push(`/${ProjectType.SSH}/${projectId}/ca/${newCaId}`); + } + + reset(); + handlePopUpToggle("sshCa", false); + + createNotification({ + text: `Successfully ${ca ? "updated" : "created"} SSH CA`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create SSH CA", + type: "error" + }); + } + }; + + return ( + { + reset(); + handlePopUpToggle("sshCa", isOpen); + }} + > + +
+ {ca && ( + + + + )} + ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Project/SshPage/components/SshCaSection.tsx b/frontend/src/views/Project/SshPage/components/SshCaSection.tsx new file mode 100644 index 000000000..98855a450 --- /dev/null +++ b/frontend/src/views/Project/SshPage/components/SshCaSection.tsx @@ -0,0 +1,120 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { SshCaStatus, useDeleteSshCa, useUpdateSshCa } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { SshCaModal } from "./SshCaModal"; +import { SshCaTable } from "./SshCaTable"; + +export const SshCaSection = () => { + const { mutateAsync: deleteSshCa } = useDeleteSshCa(); + const { mutateAsync: updateSshCa } = useUpdateSshCa(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "sshCa", + "deleteSshCa", + "sshCaStatus", // enable / disable + "upgradePlan" + ] as const); + + const onRemoveSshCaSubmit = async (caId: string) => { + try { + await deleteSshCa({ caId }); + + await createNotification({ + text: "Successfully deleted SSH CA", + type: "success" + }); + + handlePopUpClose("deleteSshCa"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete SSH CA", + type: "error" + }); + } + }; + + const onUpdateSshCaStatus = async ({ caId, status }: { caId: string; status: SshCaStatus }) => { + try { + await updateSshCa({ caId, status }); + + await createNotification({ + text: `Successfully ${status === SshCaStatus.ACTIVE ? "enabled" : "disabled"} SSH CA`, + type: "success" + }); + + handlePopUpClose("sshCaStatus"); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${status === SshCaStatus.ACTIVE ? "enabled" : "disabled"} SSH CA`, + type: "error" + }); + } + }; + + return ( +
+
+

Certificate Authorities

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("deleteSshCa", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveSshCaSubmit((popUp?.deleteSshCa?.data as { caId: string })?.caId) + } + /> + handlePopUpToggle("sshCaStatus", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onUpdateSshCaStatus(popUp?.sshCaStatus?.data as { caId: string; status: SshCaStatus }) + } + /> + {/* handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> */} +
+ ); +}; diff --git a/frontend/src/views/Project/SshPage/components/SshCaTable.tsx b/frontend/src/views/Project/SshPage/components/SshCaTable.tsx new file mode 100644 index 000000000..0f29b3711 --- /dev/null +++ b/frontend/src/views/Project/SshPage/components/SshCaTable.tsx @@ -0,0 +1,154 @@ +import { useRouter } from "next/router"; +import { faBan, faCertificate, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Badge, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { SshCaStatus, useListWorkspaceSshCas } from "@app/hooks/api"; +import { caStatusToNameMap, getCaStatusBadgeVariant } from "@app/hooks/api/ca/constants"; +import { ProjectType } from "@app/hooks/api/workspace/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteSshCa", "sshCaStatus"]>, + data?: {} + ) => void; +}; + +export const SshCaTable = ({ handlePopUpOpen }: Props) => { + const router = useRouter(); + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useListWorkspaceSshCas(currentWorkspace?.id || ""); + + return ( +
+ + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map((ca) => { + return ( + + router.push(`/${ProjectType.SSH}/${currentWorkspace?.id}/ca/${ca.id}`) + } + > + + + + + ); + })} + +
Friendly NameStatus +
{ca.friendlyName} + + {caStatusToNameMap[ca.status]} + + + + +
+ + + +
+
+ + {(ca.status === SshCaStatus.ACTIVE || + ca.status === SshCaStatus.DISABLED) && ( + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("sshCaStatus", { + caId: ca.id, + status: + ca.status === SshCaStatus.ACTIVE + ? SshCaStatus.DISABLED + : SshCaStatus.ACTIVE + }); + }} + disabled={!isAllowed} + icon={} + > + {`${ + ca.status === SshCaStatus.ACTIVE ? "Disable" : "Enable" + } SSH CA`} + + )} + + )} + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("deleteSshCa", { + caId: ca.id + }); + }} + disabled={!isAllowed} + icon={} + > + Delete SSH CA + + )} + + +
+
+ {!isLoading && data?.length === 0 && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Project/SshPage/components/SshCertificatesSection.tsx b/frontend/src/views/Project/SshPage/components/SshCertificatesSection.tsx new file mode 100644 index 000000000..5d2e7f5fe --- /dev/null +++ b/frontend/src/views/Project/SshPage/components/SshCertificatesSection.tsx @@ -0,0 +1,39 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { SshCertificateModal } from "../../SshCaPage/components/SshCertificateModal"; +import { SshCertificatesTable } from "./SshCertificatesTable"; + +export const SshCertificatesSection = () => { + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["sshCertificate"] as const); + return ( +
+
+

Certificates

+ + {(isAllowed) => ( + + )} + +
+ + +
+ ); +}; diff --git a/frontend/src/views/Project/SshPage/components/SshCertificatesTable.tsx b/frontend/src/views/Project/SshPage/components/SshCertificatesTable.tsx new file mode 100644 index 000000000..c8310cc71 --- /dev/null +++ b/frontend/src/views/Project/SshPage/components/SshCertificatesTable.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import { faCertificate } from "@fortawesome/free-solid-svg-icons"; +import { format } from "date-fns"; + +import { + Badge, + EmptyState, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useListWorkspaceSshCertificates } from "@app/hooks/api"; + +import { getSshCertStatusBadgeDetails } from "./SshCertificatesTable.utils"; + +const PER_PAGE_INIT = 25; + +export const SshCertificatesTable = () => { + const { currentWorkspace } = useWorkspace(); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(PER_PAGE_INIT); + + const { data, isLoading } = useListWorkspaceSshCertificates({ + projectId: currentWorkspace?.id || "", + offset: (page - 1) * perPage, + limit: perPage + }); + + return ( + + + + + + + + + + + + {isLoading && } + {!isLoading && + data?.certificates?.map((certificate) => { + const { variant, label } = getSshCertStatusBadgeDetails(certificate.notAfter); + return ( + + + + + + + ); + })} + +
PrincipalsStatusNot BeforeNot After
{certificate.principals.join(", ")} + {label} + + {certificate.notBefore + ? format(new Date(certificate.notBefore), "yyyy-MM-dd") + : "-"} + + {certificate.notAfter + ? format(new Date(certificate.notAfter), "yyyy-MM-dd") + : "-"} +
+ {!isLoading && data?.totalCount !== undefined && data.totalCount >= PER_PAGE_INIT && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isLoading && !data?.certificates?.length && ( + + )} +
+ ); +}; diff --git a/frontend/src/views/Project/SshPage/components/SshCertificatesTable.utils.ts b/frontend/src/views/Project/SshPage/components/SshCertificatesTable.utils.ts new file mode 100644 index 000000000..12b75aa08 --- /dev/null +++ b/frontend/src/views/Project/SshPage/components/SshCertificatesTable.utils.ts @@ -0,0 +1,17 @@ +export const getSshCertStatusBadgeDetails = (notAfter: string) => { + const currentDate = new Date().getTime(); + const notAfterDate = new Date(notAfter).getTime(); + + let variant: "success" | "primary" | "danger" = "success"; + let label = "Active"; + + if (notAfterDate > currentDate) { + variant = "success"; + label = "Active"; + } else { + variant = "danger"; + label = "Expired"; + } + + return { variant, label }; +}; diff --git a/frontend/src/views/Project/SshPage/components/index.tsx b/frontend/src/views/Project/SshPage/components/index.tsx new file mode 100644 index 000000000..efb307725 --- /dev/null +++ b/frontend/src/views/Project/SshPage/components/index.tsx @@ -0,0 +1,2 @@ +export { SshCaSection } from "./SshCaSection"; +export { SshCertificatesSection } from "./SshCertificatesSection"; diff --git a/frontend/src/views/Project/SshPage/index.tsx b/frontend/src/views/Project/SshPage/index.tsx new file mode 100644 index 000000000..080bae38d --- /dev/null +++ b/frontend/src/views/Project/SshPage/index.tsx @@ -0,0 +1 @@ +export { SshPage } from "./SshPage"; diff --git a/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx b/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx index b5ed6d26a..cfabf2ebd 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx @@ -7,7 +7,7 @@ export const OrgSettingsPage = () => { return (
-
+

{t("settings.org.title")}

diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/AppConnectionsTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/AppConnectionsTab.tsx new file mode 100644 index 000000000..f0aa81160 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/AppConnectionsTab.tsx @@ -0,0 +1,99 @@ +import Link from "next/link"; +import { + faArrowUpRightFromSquare, + faBookOpen, + faPlus, + faWrench +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context"; +import { withPermission } from "@app/hoc"; +import { usePopUp } from "@app/hooks"; + +import { AddAppConnectionModal, AppConnectionsTable } from "./components"; + +export const AppConnectionsTab = withPermission( + () => { + const { subscription } = useSubscription(); + + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addConnection"] as const); + + // TODO: remove once live + if (!subscription?.appConnections) + return ( +
+ +
+
+ App Connections are currently unavailable. +
+ Check back soon. +
+
+ ); + + return ( +
+ ); + }, + { + action: OrgPermissionActions.Read, + subject: OrgPermissionSubjects.AppConnections + } +); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AddAppConnectionModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AddAppConnectionModal.tsx new file mode 100644 index 000000000..c74985a3f --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AddAppConnectionModal.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; + +import { Modal, ModalContent } from "@app/components/v2"; +import { TAppConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { AppConnectionForm } from "./AppConnectionForm"; +import { AppConnectionsSelect } from "./AppConnectionList"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + onComplete: (appConnection: TAppConnection) => void; +}; + +const Content = ({ onComplete }: ContentProps) => { + const [selectedApp, setSelectedApp] = useState(null); + + if (selectedApp) { + return ( + setSelectedApp(null)} + app={selectedApp} + /> + ); + } + + return ; +}; + +export const AddAppConnectionModal = ({ isOpen, onOpenChange }: Props) => { + return ( + + + onOpenChange(false)} /> + + + ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AppConnectionForm.tsx new file mode 100644 index 000000000..ba0f343f9 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AppConnectionForm.tsx @@ -0,0 +1,117 @@ +import { createNotification } from "@app/components/notifications"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { + TAppConnection, + useCreateAppConnection, + useUpdateAppConnection +} from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnectionHeader } from "../AppConnectionHeader"; +import { AwsConnectionForm } from "./AwsConnectionForm"; +import { GitHubConnectionForm } from "./GitHubConnectionForm"; + +type FormProps = { + onComplete: (appConnection: TAppConnection) => void; +} & ({ appConnection: TAppConnection } | { app: AppConnection }); + +type CreateFormProps = FormProps & { app: AppConnection }; +type UpdateFormProps = FormProps & { + appConnection: TAppConnection; +}; + +const CreateForm = ({ app, onComplete }: CreateFormProps) => { + const createAppConnection = useCreateAppConnection(); + const { name: appName } = APP_CONNECTION_MAP[app]; + + const onSubmit = async ( + formData: DiscriminativePick + ) => { + try { + const connection = await createAppConnection.mutateAsync(formData); + createNotification({ + text: `Successfully added ${appName} Connection`, + type: "success" + }); + onComplete(connection); + } catch (err: any) { + console.error(err); + createNotification({ + title: `Failed to add ${appName} Connection`, + text: err.message, + type: "error" + }); + } + }; + + switch (app) { + case AppConnection.AWS: + return ; + case AppConnection.GitHub: + return ; + default: + throw new Error(`Unhandled App ${app}`); + } +}; + +const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { + const updateAppConnection = useUpdateAppConnection(); + const { name: appName } = APP_CONNECTION_MAP[appConnection.app]; + + const onSubmit = async ( + formData: DiscriminativePick + ) => { + try { + const connection = await updateAppConnection.mutateAsync({ + connectionId: appConnection.id, + ...formData + }); + createNotification({ + text: `Successfully updated ${appName} Connection`, + type: "success" + }); + onComplete(connection); + } catch (err: any) { + console.error(err); + createNotification({ + title: `Failed to update ${appName} Connection`, + text: err.message, + type: "error" + }); + } + }; + + switch (appConnection.app) { + case AppConnection.AWS: + return ; + case AppConnection.GitHub: + return ; + default: + throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); + } +}; + +type Props = { onBack?: () => void } & Pick & + ( + | { app: AppConnection; appConnection?: undefined } + | { app?: undefined; appConnection: TAppConnection } + ); +export const AppConnectionForm = ({ onBack, ...props }: Props) => { + const { app, appConnection } = props; + + return ( +
+ + {appConnection ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AwsConnectionForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AwsConnectionForm.tsx new file mode 100644 index 000000000..d1137a887 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AwsConnectionForm.tsx @@ -0,0 +1,187 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + Input, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { AwsConnectionMethod, TAwsConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TAwsConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.AWS) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(AwsConnectionMethod.AssumeRole), + credentials: z.object({ + roleArn: z.string().trim().min(1, "Role ARN required") + }) + }), + rootSchema.extend({ + method: z.literal(AwsConnectionMethod.AccessKey), + credentials: z.object({ + accessKeyId: z.string().trim().min(1, "Access Key ID required"), + secretAccessKey: z.string().trim().min(1, "Secret Access Key required") + }) + }) +]); + +type FormData = z.infer; + +export const AwsConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.AWS, + method: AwsConnectionMethod.AssumeRole + } + }); + + const { + handleSubmit, + control, + watch, + formState: { isSubmitting, isDirty } + } = form; + + const selectedMethod = watch("method"); + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + {selectedMethod === AwsConnectionMethod.AssumeRole ? ( + ( + + onChange(e.target.value)} + /> + + )} + /> + ) : ( + <> + ( + + onChange(e.target.value)} + /> + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> + + )} +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GenericAppConnectionFields.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GenericAppConnectionFields.tsx new file mode 100644 index 000000000..d9e25a0a7 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GenericAppConnectionFields.tsx @@ -0,0 +1,42 @@ +import { useFormContext } from "react-hook-form"; +import { z } from "zod"; + +import { FormControl, Input, TextArea } from "@app/components/v2"; +import { slugSchema } from "@app/lib/schemas"; + +export const genericAppConnectionFieldsSchema = z.object({ + name: slugSchema({ min: 1, max: 32, field: "Name" }), + description: z.string().trim().max(256, "Description cannot exceed 256 characters").nullish() +}); + +export const GenericAppConnectionsFields = () => { + const { + register, + formState: { errors } + } = useFormContext<{ name: string; description?: string | null }>(); + + return ( + <> + + + + +